Compare commits
68
Commits
5d038488fb
..
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 | ||
|
|
20c8fdcdf3 | ||
|
|
9f82d3cf31 | ||
|
|
3e18bd2150 | ||
|
|
665bad76bf | ||
|
|
04d63cccdb | ||
|
|
5b6bd86d70 | ||
|
|
b05ff14b3b |
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,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Http\Controllers\VideoController;
|
||||
use App\Models\Video;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Renders the 1200×630 VS composition PNG for a match video's social-share
|
||||
* preview. Runs the same helper used by VideoController@ogImage but from a
|
||||
* CLI process context, because Chromium reliably fails to launch under
|
||||
* PHP-FPM ("Failed to launch the browser process: Code: null") on this
|
||||
* host. Invoke detached via `nohup` from either the ogImage endpoint (on
|
||||
* cache miss) or SportsMatchController on save.
|
||||
*/
|
||||
class RenderMatchOgImage extends Command
|
||||
{
|
||||
protected $signature = 'og:render-match {video}';
|
||||
protected $description = 'Render the OG social-share PNG for a match video';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$video = Video::find($this->argument('video'));
|
||||
if (! $video) {
|
||||
$this->error("Video {$this->argument('video')} not found.");
|
||||
return 1;
|
||||
}
|
||||
if ($video->type !== 'match' || ! $video->sportsMatch) {
|
||||
$this->error("Video {$video->id} is not a match with a sports record.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$controller = new VideoController();
|
||||
$r = new \ReflectionMethod($controller, 'renderMatchOgImage');
|
||||
$r->setAccessible(true);
|
||||
$png = $r->invoke($controller, $video);
|
||||
|
||||
if ($png === null) {
|
||||
$this->error("Render failed. See laravel.log.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info("Rendered " . strlen($png) . " bytes for video {$video->id}.");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
->withoutOverlapping()
|
||||
->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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,18 @@ class Countries
|
||||
* All countries keyed by ISO2 code.
|
||||
* Fields: name, iso2, iso3, flag, dial_code, timezone, currency
|
||||
*/
|
||||
/**
|
||||
* Resolve any ISO2 code (case-insensitive) to its full country name.
|
||||
* Falls back to the uppercased code, then to null for empty input.
|
||||
* Use this instead of printing raw two-letter codes to end users.
|
||||
*/
|
||||
public static function name(?string $iso2): ?string
|
||||
{
|
||||
$code = strtoupper(trim((string) $iso2));
|
||||
if ($code === '') return null;
|
||||
return self::all()[$code]['name'] ?? $code;
|
||||
}
|
||||
|
||||
public static function all(): array
|
||||
{
|
||||
// Return lowercase ISO2 code — used as the fi fi-{code} CSS class (flag-icons library)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Data;
|
||||
|
||||
class CountryLanguages
|
||||
{
|
||||
private const MAP = [
|
||||
'AE' => 'ar', 'AF' => 'ps', 'AL' => 'sq', 'AM' => 'hy', 'AO' => 'pt',
|
||||
'AR' => 'es', 'AT' => 'de', 'AU' => 'en', 'AZ' => 'az',
|
||||
'BA' => 'bs', 'BD' => 'bn', 'BE' => 'nl', 'BG' => 'bg', 'BH' => 'ar',
|
||||
'BO' => 'es', 'BR' => 'pt', 'BY' => 'be',
|
||||
'CA' => 'en', 'CH' => 'de', 'CL' => 'es', 'CN' => 'zh', 'CO' => 'es',
|
||||
'CR' => 'es', 'CU' => 'es', 'CZ' => 'cs',
|
||||
'DE' => 'de', 'DK' => 'da', 'DO' => 'es', 'DZ' => 'ar',
|
||||
'EC' => 'es', 'EE' => 'et', 'EG' => 'ar', 'ES' => 'es', 'ET' => 'am',
|
||||
'FI' => 'fi', 'FR' => 'fr',
|
||||
'GB' => 'en', 'GE' => 'ka', 'GR' => 'el', 'GT' => 'es',
|
||||
'HK' => 'zh', 'HN' => 'es', 'HR' => 'hr', 'HU' => 'hu',
|
||||
'ID' => 'id', 'IE' => 'en', 'IL' => 'he', 'IN' => 'hi', 'IQ' => 'ar',
|
||||
'IR' => 'fa', 'IS' => 'is', 'IT' => 'it',
|
||||
'JO' => 'ar', 'JP' => 'ja',
|
||||
'KE' => 'sw', 'KG' => 'ky', 'KH' => 'km', 'KR' => 'ko', 'KW' => 'ar',
|
||||
'KZ' => 'kk',
|
||||
'LA' => 'lo', 'LB' => 'ar', 'LK' => 'si', 'LT' => 'lt', 'LU' => 'fr',
|
||||
'LV' => 'lv', 'LY' => 'ar',
|
||||
'MA' => 'ar', 'MD' => 'ro', 'ME' => 'sr', 'MK' => 'mk', 'MM' => 'my',
|
||||
'MN' => 'mn', 'MT' => 'mt', 'MX' => 'es', 'MY' => 'ms',
|
||||
'NG' => 'en', 'NI' => 'es', 'NL' => 'nl', 'NO' => 'no', 'NP' => 'ne',
|
||||
'NZ' => 'en',
|
||||
'OM' => 'ar',
|
||||
'PA' => 'es', 'PE' => 'es', 'PH' => 'tl', 'PK' => 'ur', 'PL' => 'pl',
|
||||
'PR' => 'es', 'PS' => 'ar', 'PT' => 'pt', 'PY' => 'es',
|
||||
'QA' => 'ar',
|
||||
'RO' => 'ro', 'RS' => 'sr', 'RU' => 'ru',
|
||||
'SA' => 'ar', 'SD' => 'ar', 'SE' => 'sv', 'SG' => 'en', 'SI' => 'sl',
|
||||
'SK' => 'sk', 'SO' => 'so', 'SV' => 'es', 'SY' => 'ar',
|
||||
'TH' => 'th', 'TJ' => 'tg', 'TN' => 'ar', 'TR' => 'tr', 'TW' => 'zh',
|
||||
'TZ' => 'sw',
|
||||
'UA' => 'uk', 'UG' => 'en', 'US' => 'en', 'UY' => 'es', 'UZ' => 'uz',
|
||||
'VE' => 'es', 'VN' => 'vi',
|
||||
'YE' => 'ar',
|
||||
'ZA' => 'en', 'ZW' => 'en',
|
||||
];
|
||||
|
||||
public static function primary(?string $iso2): ?string
|
||||
{
|
||||
if (! $iso2) return null;
|
||||
return self::MAP[strtoupper($iso2)] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,12 @@ namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\User;
|
||||
use App\Notifications\BruteForceLoginAlert;
|
||||
use App\Support\SafeNotifier;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class AuthenticatedSessionController extends Controller
|
||||
{
|
||||
@@ -50,11 +54,57 @@ class AuthenticatedSessionController extends Controller
|
||||
'details' => ['email' => $credentials['email']],
|
||||
]);
|
||||
|
||||
$this->detectBruteForce($request, $credentials['email']);
|
||||
|
||||
return back()->withErrors([
|
||||
'email' => 'The provided credentials do not match our records.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire one alert to super admins when the same email has racked up
|
||||
* BRUTE_FORCE_THRESHOLD failures in BRUTE_FORCE_WINDOW_MINUTES. A Cache
|
||||
* key with the same TTL prevents the alert from re-firing on every
|
||||
* subsequent failed attempt within the same burst.
|
||||
*/
|
||||
private const BRUTE_FORCE_THRESHOLD = 5;
|
||||
private const BRUTE_FORCE_WINDOW_MINUTES = 10;
|
||||
|
||||
private function detectBruteForce(Request $request, string $email): void
|
||||
{
|
||||
$window = self::BRUTE_FORCE_WINDOW_MINUTES;
|
||||
$threshold = self::BRUTE_FORCE_THRESHOLD;
|
||||
|
||||
// SQLite stores JSON-cast `details` as text; a LIKE-based match is
|
||||
// database-portable and avoids per-driver JSON operator syntax.
|
||||
$pattern = '%"email":"' . addslashes($email) . '"%';
|
||||
|
||||
$recent = AuditLog::where('action', 'user.login.failed')
|
||||
->where('created_at', '>=', now()->subMinutes($window))
|
||||
->where('details', 'like', $pattern)
|
||||
->count();
|
||||
|
||||
if ($recent < $threshold) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cacheKey = 'brute_force_alert:' . sha1($email);
|
||||
if (Cache::has($cacheKey)) {
|
||||
return;
|
||||
}
|
||||
Cache::put($cacheKey, 1, now()->addMinutes($window));
|
||||
|
||||
$ip = $request->header('CF-Connecting-IP')
|
||||
?? $request->header('X-Forwarded-For')
|
||||
?? $request->ip()
|
||||
?? 'unknown';
|
||||
|
||||
$notification = new BruteForceLoginAlert($email, (string) $ip, $recent, $window);
|
||||
User::where('role', 'super_admin')->each(function (User $admin) use ($notification) {
|
||||
SafeNotifier::send($admin, $notification);
|
||||
});
|
||||
}
|
||||
|
||||
public function destroy(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
@@ -6,6 +6,8 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Notifications\NewUserRegistered;
|
||||
use App\Rules\NotDisposableEmail;
|
||||
use App\Rules\ReservedIdentifier;
|
||||
use App\Support\SafeNotifier;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
@@ -27,10 +29,14 @@ class RegisteredUserController extends Controller
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users', new NotDisposableEmail],
|
||||
'name' => ['required', 'string', 'max:255', new ReservedIdentifier('name')],
|
||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users', new NotDisposableEmail, new ReservedIdentifier('email')],
|
||||
'password' => ['required', 'confirmed', Password::defaults()],
|
||||
'birthday' => ['required', 'date', 'before:today'],
|
||||
// Age gate: must be at least 13 on sign-up day. The front-end
|
||||
// date picker already caps the year dropdown, but the server
|
||||
// must enforce it so a client bypass doesn't let a 10-year-old
|
||||
// through.
|
||||
'birthday' => ['required', 'date', 'before:' . now()->subYears(13)->toDateString()],
|
||||
'gender' => ['required', 'in:male,female'],
|
||||
'nationality' => ['required', 'string', 'size:2'],
|
||||
]);
|
||||
@@ -48,7 +54,7 @@ class RegisteredUserController extends Controller
|
||||
|
||||
// Notify all super admins of the new registration
|
||||
User::where('role', 'super_admin')->each(function (User $admin) use ($user) {
|
||||
$admin->notify(new NewUserRegistered($user));
|
||||
SafeNotifier::send($admin, new NewUserRegistered($user));
|
||||
});
|
||||
|
||||
auth()->login($user);
|
||||
|
||||
@@ -8,6 +8,8 @@ use App\Models\Video;
|
||||
use App\Notifications\NewCommentLikeNotification;
|
||||
use App\Notifications\NewCommentNotification;
|
||||
use App\Notifications\NewReplyNotification;
|
||||
use App\Services\LlmLyricsService;
|
||||
use App\Support\SafeNotifier;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
@@ -15,7 +17,7 @@ class CommentController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth')->except(['index']);
|
||||
$this->middleware('auth')->except(['index', 'translate']);
|
||||
}
|
||||
|
||||
public function index(Video $video)
|
||||
@@ -34,12 +36,66 @@ class CommentController extends Controller
|
||||
|
||||
$commenter = Auth::user();
|
||||
|
||||
// Idempotency guard: a client that retries (duplicate listeners after
|
||||
// an SPA swap, fast double-click, flaky connection) can POST the same
|
||||
// comment twice. We dedupe on two signals:
|
||||
//
|
||||
// 1. X-Idempotency-Key — the fetch() client sends a per-attempt
|
||||
// UUID; the browser's own retry logic reuses the same value.
|
||||
// This catches the deliberate-retry case before body comparison.
|
||||
//
|
||||
// 2. Identical (user, video, parent, body) within 60s — covers
|
||||
// the duplicate-listener case where two independent fetch()es
|
||||
// fire with different idempotency keys for the same user intent.
|
||||
//
|
||||
// Either match returns the first-written comment unchanged.
|
||||
$idemKey = (string) $request->header('X-Idempotency-Key', '');
|
||||
$cacheKey = $idemKey !== ''
|
||||
? 'comment-idem:' . $commenter->id . ':' . $video->id . ':' . substr(hash('sha256', $idemKey), 0, 40)
|
||||
: null;
|
||||
|
||||
if ($cacheKey) {
|
||||
$existingId = \Illuminate\Support\Facades\Cache::get($cacheKey);
|
||||
if ($existingId && $existing = Comment::find($existingId)) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'comment' => $existing->load('user:id,name,avatar'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$existing = $video->comments()
|
||||
->where('user_id', $commenter->id)
|
||||
->where('body', $request->body)
|
||||
->where(function ($q) use ($request) {
|
||||
$request->parent_id
|
||||
? $q->where('parent_id', $request->parent_id)
|
||||
: $q->whereNull('parent_id');
|
||||
})
|
||||
->where('created_at', '>=', now()->subSeconds(60))
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
if ($cacheKey) {
|
||||
\Illuminate\Support\Facades\Cache::put($cacheKey, $existing->id, now()->addMinutes(5));
|
||||
}
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'comment' => $existing->load('user:id,name,avatar'),
|
||||
]);
|
||||
}
|
||||
|
||||
$comment = $video->comments()->create([
|
||||
'user_id' => $commenter->id,
|
||||
'body' => $request->body,
|
||||
'parent_id' => $request->parent_id,
|
||||
]);
|
||||
|
||||
if ($cacheKey) {
|
||||
\Illuminate\Support\Facades\Cache::put($cacheKey, $comment->id, now()->addMinutes(5));
|
||||
}
|
||||
|
||||
$comment->load('user:id,name,avatar');
|
||||
|
||||
// Fire notifications (never notify yourself)
|
||||
@@ -47,12 +103,12 @@ class CommentController extends Controller
|
||||
// Reply — notify the parent comment's author
|
||||
$parent = Comment::find($request->parent_id);
|
||||
if ($parent && $parent->user_id !== $commenter->id) {
|
||||
$parent->user->notify(new NewReplyNotification($video, $comment, $commenter));
|
||||
SafeNotifier::send($parent->user, new NewReplyNotification($video, $comment, $commenter));
|
||||
}
|
||||
} else {
|
||||
// Top-level comment — notify the video owner
|
||||
if ($video->user_id !== $commenter->id) {
|
||||
$video->user->notify(new NewCommentNotification($video, $comment, $commenter));
|
||||
SafeNotifier::send($video->user, new NewCommentNotification($video, $comment, $commenter));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +145,37 @@ class CommentController extends Controller
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function translate(Request $request, Comment $comment, LlmLyricsService $llm)
|
||||
{
|
||||
if (! $llm->isEnabled()) {
|
||||
return response()->json(['error' => 'Translation is not configured'], 503);
|
||||
}
|
||||
|
||||
$target = (string) $request->input('target', 'en');
|
||||
|
||||
// Derive a source-language hint from the comment author's most-frequent
|
||||
// access country (video_views + profile_visits, falling back to their
|
||||
// nationality setting). Never trust the client for this — it's used
|
||||
// to disambiguate scripts the LLM might otherwise misidentify.
|
||||
$topCountries = \App\Models\User::topCountriesFor([$comment->user_id]);
|
||||
$sourceCountry = $topCountries[$comment->user_id] ?? null;
|
||||
$sourceLang = \App\Data\CountryLanguages::primary($sourceCountry);
|
||||
|
||||
$translated = $llm->translate((string) $comment->body, $target, $sourceLang);
|
||||
|
||||
if ($translated === '') {
|
||||
return response()->json(['error' => 'Translation failed'], 502);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'translated' => $translated,
|
||||
'target' => $target,
|
||||
'source_hint' => $sourceLang,
|
||||
'author_country'=> $sourceCountry,
|
||||
]);
|
||||
}
|
||||
|
||||
public function like(Comment $comment)
|
||||
{
|
||||
$userId = Auth::id();
|
||||
@@ -107,7 +194,8 @@ class CommentController extends Controller
|
||||
if ($comment->user_id !== $userId) {
|
||||
$video = $comment->video;
|
||||
if ($video) {
|
||||
$comment->user->notify(
|
||||
SafeNotifier::send(
|
||||
$comment->user,
|
||||
new NewCommentLikeNotification($video, $comment, Auth::user())
|
||||
);
|
||||
}
|
||||
@@ -118,4 +206,56 @@ class CommentController extends Controller
|
||||
|
||||
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' => [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\VideoController;
|
||||
use App\Models\SportsMatch;
|
||||
use App\Services\NasSyncService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -34,6 +35,23 @@ class SportsMatchController extends Controller
|
||||
$this->handleImages($match, $request, $nas);
|
||||
$match->save();
|
||||
|
||||
if ($match->video && $match->video->title !== $match->title) {
|
||||
$match->video->title = $match->title;
|
||||
$match->video->save();
|
||||
}
|
||||
|
||||
// Prefetch the OG social-share PNG so WhatsApp/Facebook crawlers
|
||||
// get the composed VS card on first share, not the fallback frame.
|
||||
if ($match->video) {
|
||||
// Blocks the response by ~3–5s while the VS PNG renders. That
|
||||
// wait is intentional: the user typically shares the URL right
|
||||
// after saving, and WhatsApp/Facebook cache whatever they see
|
||||
// on first crawl. If the render hasn't finished when the
|
||||
// crawler hits, we'd be stuck on the thumbnail fallback in
|
||||
// every future share.
|
||||
(new VideoController())->renderMatchOgSync($match->video->fresh(['sportsMatch']));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => 'Match saved as draft.',
|
||||
@@ -51,6 +69,21 @@ class SportsMatchController extends Controller
|
||||
$this->handleImages($sportsMatch, $request, $nas);
|
||||
$sportsMatch->save();
|
||||
|
||||
// Keep the parent Video's display title in sync with the match title
|
||||
// so the match page header, share metadata, and search results all
|
||||
// reflect the edited value.
|
||||
if ($sportsMatch->video && $sportsMatch->video->title !== $sportsMatch->title) {
|
||||
$sportsMatch->video->title = $sportsMatch->title;
|
||||
$sportsMatch->video->save();
|
||||
}
|
||||
|
||||
if ($sportsMatch->video) {
|
||||
// Synchronous re-render on edit: guarantees the fresh VS card
|
||||
// exists at the new versioned URL before the JSON response
|
||||
// returns and the user can share the link. See renderMatchOgSync.
|
||||
(new VideoController())->renderMatchOgSync($sportsMatch->video->fresh(['sportsMatch']));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => 'Match updated.',
|
||||
|
||||
@@ -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();
|
||||
|
||||
// ── 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');
|
||||
$sizeThumbnails = $this->dirSize($disk, 'thumbnails');
|
||||
$sizeAvatars = $this->dirSize($disk, 'avatars');
|
||||
$sizeImages = $this->dirSize($disk, 'images');
|
||||
$totalPublicSize = $sizeVideos + $sizeThumbnails + $sizeAvatars + $sizeImages;
|
||||
// Images are variable but ~120 KB is a solid conservative estimate
|
||||
// for a web-sized JPEG/PNG upload.
|
||||
$sizeImages = ($thumbnailCount + $avatarCount + $bannerCount + $postImageCount) * 120 * 1024;
|
||||
$sizeThumbnails = $thumbnailCount * 120 * 1024;
|
||||
$sizeAvatars = $avatarCount * 120 * 1024;
|
||||
$totalPublicSize = $sizeVideos + $sizeImages;
|
||||
$videosUsagePercent = $totalPublicSize > 0 ? round(($sizeVideos / $totalPublicSize) * 100, 1) : 0;
|
||||
|
||||
// Convert to MB
|
||||
$toMb = fn($b) => round($b / 1024 / 1024, 1);
|
||||
$storage = [
|
||||
'videos' => $toMb($sizeVideos),
|
||||
@@ -506,7 +517,7 @@ class SuperAdminController extends Controller
|
||||
foreach ($uniqueViewers as $viewer) {
|
||||
if (!$viewer->country) continue;
|
||||
if (!isset($countryMap[$viewer->country])) {
|
||||
$countryMap[$viewer->country] = ['country' => $viewer->country, 'country_name' => $viewer->country_name, 'total' => 0];
|
||||
$countryMap[$viewer->country] = ['country' => $viewer->country, 'country_name' => $viewer->country_name ?: \App\Data\Countries::name($viewer->country), 'total' => 0];
|
||||
}
|
||||
$countryMap[$viewer->country]['total']++;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ class UserController extends Controller
|
||||
{
|
||||
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)
|
||||
@@ -246,7 +246,14 @@ class UserController extends Controller
|
||||
|
||||
switch ($sort) {
|
||||
case 'popular':
|
||||
$baseQuery->withCount('viewers')->orderByDesc('viewers_count');
|
||||
// Views first, likes as the tiebreak, created_at as the final
|
||||
// tiebreak so two videos with the same view+like counts don't
|
||||
// flip order between page loads.
|
||||
$baseQuery
|
||||
->withCount(['viewers as viewers_count', 'likes as likes_count'])
|
||||
->orderByDesc('viewers_count')
|
||||
->orderByDesc('likes_count')
|
||||
->latest();
|
||||
break;
|
||||
case 'oldest':
|
||||
$baseQuery->oldest();
|
||||
@@ -262,8 +269,16 @@ class UserController extends Controller
|
||||
? $user->playlists()->with(['videos' => $withFirstVideo])->orderBy('created_at', 'desc')->get()
|
||||
: $user->playlists()->public()->where('is_default', false)->with(['videos' => $withFirstVideo])->orderBy('created_at', 'desc')->get();
|
||||
|
||||
// Header stats must not leak unlisted/private video view counts to
|
||||
// visitors. Owners (and ?preview=1 is handled above by setting
|
||||
// $isOwner=false) see totals across every video they uploaded;
|
||||
// visitors see totals across public videos only — matching what
|
||||
// the videos count row already shows.
|
||||
$viewVideoIds = $isOwner
|
||||
? $user->videos()->pluck('id')
|
||||
: Video::public()->where('user_id', $user->id)->pluck('id');
|
||||
$totalViews = \DB::table('video_views')
|
||||
->whereIn('video_id', $user->videos()->pluck('id'))
|
||||
->whereIn('video_id', $viewVideoIds)
|
||||
->count();
|
||||
|
||||
// Filter social links by visibility for the current viewer
|
||||
@@ -310,29 +325,41 @@ class UserController extends Controller
|
||||
}
|
||||
|
||||
// Watch history
|
||||
public function history()
|
||||
public function history(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
// Get videos the user has watched, ordered by most recently watched
|
||||
// Include private videos since they are the user's own
|
||||
$videoIds = \DB::table('video_views')
|
||||
->where('user_id', $user->id)
|
||||
->orderBy('watched_at', 'desc')
|
||||
->pluck('video_id')
|
||||
->unique();
|
||||
|
||||
$videos = Video::whereIn('id', $videoIds)
|
||||
// Latest-first by watched_at. Paginate at the join so a user who
|
||||
// has watched tens of thousands of videos doesn't pull every row
|
||||
// into memory on each page load. Fetch the videos keyed by latest
|
||||
// view so sort order is deterministic even when the same video has
|
||||
// been watched more than once.
|
||||
//
|
||||
// Each history row is one (video_views + videos) pair, with the
|
||||
// uniqueness constraint deferred to presentation: duplicates per
|
||||
// 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) {
|
||||
$q->where('visibility', '!=', 'private')
|
||||
->orWhere('user_id', $user->id);
|
||||
$q->where('videos.visibility', '!=', 'private')
|
||||
->orWhere('videos.user_id', $user->id);
|
||||
})
|
||||
->get()
|
||||
->sortBy(function ($video) use ($videoIds) {
|
||||
return $videoIds->search($video->id);
|
||||
});
|
||||
->orderBy('video_views.watched_at', 'desc')
|
||||
->select('videos.id as id', 'video_views.watched_at')
|
||||
->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
|
||||
@@ -345,18 +372,82 @@ class UserController extends Controller
|
||||
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
|
||||
public function liked()
|
||||
{
|
||||
$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()
|
||||
->where(function ($q) use ($user) {
|
||||
$q->where('visibility', '!=', 'private')
|
||||
->orWhere('videos.user_id', $user->id);
|
||||
})
|
||||
->latest()
|
||||
->paginate(12);
|
||||
->latest('videos.id')
|
||||
->cursorPaginate(24);
|
||||
|
||||
return view('user.liked', compact('videos'));
|
||||
}
|
||||
@@ -394,6 +485,9 @@ class UserController extends Controller
|
||||
} else {
|
||||
$video->likes()->attach($user->id);
|
||||
$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) {
|
||||
try { $video->user->notify(new VideoLikedNotification($video, $user)); } catch (\Throwable) {}
|
||||
}
|
||||
@@ -401,7 +495,32 @@ class UserController extends Controller
|
||||
|
||||
return response()->json([
|
||||
'liked' => $liked,
|
||||
'disliked' => false, // was cleared above if we just liked, otherwise toggle didn't touch it
|
||||
'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()
|
||||
{
|
||||
$this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack', 'lyricsProgress']);
|
||||
$this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'shortsFocused', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'vsPreview', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack', 'lyricsProgress', 'translateDescription', 'feedMore']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cursor-paginated feed slice for infinite scroll.
|
||||
*
|
||||
* Called by the sentinel IntersectionObserver on /videos (and single-type
|
||||
* filters). Returns rendered <x-video-card/> HTML for the next page plus
|
||||
* the next cursor, or null when there's nothing left. The first page is
|
||||
* served by index() as regular HTML, so this endpoint is only ever used
|
||||
* once an initial grid is already on screen.
|
||||
*
|
||||
* Cursor is Laravel's native cursor-pagination token, which is stable
|
||||
* across inserts (unlike `?page=N` which can skip or duplicate items
|
||||
* when a new video is uploaded between page fetches).
|
||||
*/
|
||||
public function feedMore(Request $request)
|
||||
{
|
||||
$filter = (string) $request->query('filter', 'all');
|
||||
$pageSize = 24;
|
||||
|
||||
$query = Video::public()->notShorts();
|
||||
|
||||
if (str_starts_with($filter, 'lang:')) {
|
||||
$lang = strtolower(substr($filter, 5));
|
||||
$primaryIds = Video::public()->where('language', $lang)->pluck('id');
|
||||
$secondaryIds = VideoAudioTrack::where('language', $lang)
|
||||
->whereIn('video_id', Video::public()->pluck('id'))
|
||||
->pluck('video_id');
|
||||
$ids = $primaryIds->concat($secondaryIds)->unique()->values();
|
||||
$query = Video::public()->whereIn('id', $ids);
|
||||
} elseif ($filter === 'shorts') {
|
||||
$query = Video::public()->shorts();
|
||||
} elseif ($filter === 'match') {
|
||||
$query = Video::public()->where('type', 'match')->notShorts();
|
||||
} elseif ($filter === 'music') {
|
||||
$query = Video::public()->where('type', 'music')->notShorts();
|
||||
} elseif ($filter === 'all' || $filter === 'latest' || $filter === '') {
|
||||
// Home + Latest both show the newest-first mixed-type feed.
|
||||
// Playlists intentionally stay on their own tab — this endpoint
|
||||
// only pages the video grid.
|
||||
$query = Video::public()->notShorts();
|
||||
} else {
|
||||
// Unknown filter — behave like the empty state so a crafted
|
||||
// ?filter= value can never leak the whole table.
|
||||
return response()->json(['items' => '', 'next_cursor' => null, 'has_more' => false]);
|
||||
}
|
||||
|
||||
$paginator = $query->latest('id')->cursorPaginate($pageSize);
|
||||
|
||||
$html = '';
|
||||
foreach ($paginator->items() as $video) {
|
||||
$html .= view('components.video-card', ['video' => $video])->render();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'items' => $html,
|
||||
'next_cursor' => optional($paginator->nextCursor())->encode(),
|
||||
'has_more' => $paginator->hasMorePages(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function index()
|
||||
@@ -104,9 +163,12 @@ class VideoController extends Controller
|
||||
|
||||
// ── Filtered single-type views ────────────────────────────
|
||||
if (in_array($filter, ['music', 'latest'])) {
|
||||
$query = Video::public()->notShorts();
|
||||
// Both tabs sort newest-first. Previously the music branch skipped
|
||||
// the latest() call entirely, so Eloquent fell back to id ASC and
|
||||
// the music grid opened on the oldest uploads — never surfacing
|
||||
// anything new.
|
||||
$query = Video::public()->notShorts()->latest();
|
||||
if ($filter === 'music') $query->where('type', 'music');
|
||||
else $query->latest();
|
||||
$videos = $query->limit(50)->get();
|
||||
return view('videos.index', compact('videos', 'filter', 'langChips') + [
|
||||
'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(),
|
||||
@@ -114,7 +176,12 @@ class VideoController extends Controller
|
||||
}
|
||||
|
||||
// ── All (home) — mixed chronological feed ─────────────────
|
||||
$videos = Video::public()->latest()->limit(60)->get();
|
||||
// Videos shown in the grid are the newest non-shorts. Shorts render
|
||||
// in their own horizontally-scrolling shelf injected after the first
|
||||
// row of cards (see videos/index.blade.php), so the home grid isn't
|
||||
// diluted by 60-second portrait clips.
|
||||
$videos = Video::public()->notShorts()->latest()->limit(60)->get();
|
||||
$homeShorts = Video::public()->where('is_shorts', true)->latest()->limit(12)->get();
|
||||
|
||||
$playlistQuery = Playlist::withCount('videos')
|
||||
->with(['user', 'videos' => fn($q) => $q->orderBy('playlist_videos.position')->limit(1)])
|
||||
@@ -131,7 +198,7 @@ class VideoController extends Controller
|
||||
->sortByDesc('date')
|
||||
->values();
|
||||
|
||||
return view('videos.index', compact('feedItems', 'filter', 'langChips') + [
|
||||
return view('videos.index', compact('feedItems', 'filter', 'langChips', 'homeShorts') + [
|
||||
'videos' => collect(), 'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(),
|
||||
]);
|
||||
}
|
||||
@@ -691,11 +758,28 @@ class VideoController extends Controller
|
||||
$viewFp = $request->cookie('_fp');
|
||||
$viewFp = ($viewFp && preg_match('/^[a-f0-9]{64}$/', $viewFp)) ? $viewFp : null;
|
||||
|
||||
if (Auth::check()) {
|
||||
// Owners don't count toward their own view totals. Previews, re-watches
|
||||
// from the owner's own account, and even owner-impersonation sessions
|
||||
// would otherwise inflate the number. The share/analytics screens
|
||||
// already filter owner_id separately, so skipping the insert is safe.
|
||||
$isOwnerViewing = Auth::check() && Auth::id() === (int) $video->user_id;
|
||||
|
||||
if (Auth::check() && ! $isOwnerViewing) {
|
||||
// 30-minute user-level dedupe. Also dedupe against the strongest
|
||||
// device signal available (fingerprint > device_id cookie) so a
|
||||
// user signing out and back in during the same session doesn't
|
||||
// double-count.
|
||||
$exists = \DB::table('video_views')
|
||||
->where('user_id', Auth::id())
|
||||
->where('video_id', $video->id)
|
||||
->where('watched_at', '>', now()->subHour())
|
||||
->where('watched_at', '>', now()->subMinutes(30))
|
||||
->where(function ($q) use ($viewFp, $viewDid) {
|
||||
$q->where('user_id', Auth::id());
|
||||
if ($viewFp) {
|
||||
$q->orWhere('device_hash', $viewFp);
|
||||
} elseif ($viewDid) {
|
||||
$q->orWhere('device_id', $viewDid);
|
||||
}
|
||||
})
|
||||
->exists();
|
||||
|
||||
if (! $exists) {
|
||||
@@ -711,12 +795,13 @@ class VideoController extends Controller
|
||||
'watched_at' => now(),
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
// Guest: prefer the fingerprint hash for dedup (strongest signal); fall back to device_id cookie
|
||||
} elseif (! $isOwnerViewing) {
|
||||
// Guest: 30-minute dedupe keyed on the strongest device signal
|
||||
// (fingerprint hash > device_id cookie).
|
||||
$exists = \DB::table('video_views')
|
||||
->whereNull('user_id')
|
||||
->where('video_id', $video->id)
|
||||
->where('watched_at', '>', now()->subHour())
|
||||
->where('watched_at', '>', now()->subMinutes(30))
|
||||
->where(function ($q) use ($viewFp, $viewDid) {
|
||||
if ($viewFp) $q->where('device_hash', $viewFp);
|
||||
else $q->where('device_id', $viewDid);
|
||||
@@ -778,12 +863,60 @@ class VideoController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$recommendedVideos = Video::public()
|
||||
// Up Next recommendations: a weighted blend instead of the plain
|
||||
// chronological "newest 20" the sidebar used to show.
|
||||
//
|
||||
// +4 same type (music / match / generic)
|
||||
// +3 same primary language
|
||||
// +2 overlapping tag (any match in the comma-separated tags list)
|
||||
// +1 same channel (keeps a creator's recent uploads surfaced, but
|
||||
// does not dominate — cross-channel results compete on type/
|
||||
// language/tags scoring)
|
||||
//
|
||||
// Candidates: the newest ~200 public ready videos excluding the one
|
||||
// being watched. 200 is big enough for the blend to find real
|
||||
// similarities, small enough to score in-process without a query
|
||||
// per candidate.
|
||||
$recommendedVideos = (function () use ($video) {
|
||||
$candidates = Video::public()
|
||||
->where('status', 'ready')
|
||||
->where('id', '!=', $video->id)
|
||||
->latest()
|
||||
->limit(20)
|
||||
->with('user')
|
||||
->latest('id')
|
||||
->limit(200)
|
||||
->get();
|
||||
|
||||
$ownTags = collect(explode(',', (string) ($video->tags ?? '')))
|
||||
->map(fn ($t) => mb_strtolower(trim($t)))
|
||||
->filter()
|
||||
->unique();
|
||||
|
||||
$scored = $candidates->map(function (Video $cand) use ($video, $ownTags) {
|
||||
$score = 0;
|
||||
if ($cand->type === $video->type) $score += 4;
|
||||
if ($video->language && $cand->language === $video->language) $score += 3;
|
||||
if ($ownTags->isNotEmpty()) {
|
||||
$candTags = collect(explode(',', (string) ($cand->tags ?? '')))
|
||||
->map(fn ($t) => mb_strtolower(trim($t)))
|
||||
->filter();
|
||||
if ($ownTags->intersect($candTags)->isNotEmpty()) $score += 2;
|
||||
}
|
||||
if ($cand->user_id === $video->user_id) $score += 1;
|
||||
|
||||
$cand->_rec_score = $score;
|
||||
return $cand;
|
||||
});
|
||||
|
||||
// Sort by score desc, then newest. Keep up to 20.
|
||||
return $scored
|
||||
->sortBy([
|
||||
['_rec_score', 'desc'],
|
||||
['created_at', 'desc'],
|
||||
])
|
||||
->take(20)
|
||||
->values();
|
||||
})();
|
||||
|
||||
$view = match ($video->type) {
|
||||
'match' => 'videos.types.match',
|
||||
'music' => 'videos.types.music',
|
||||
@@ -828,7 +961,15 @@ class VideoController extends Controller
|
||||
: $slideMap['0'];
|
||||
|
||||
$allLangData = \App\Data\Languages::all();
|
||||
$audioTracks = $video->audioTracks->map(fn ($t) => [
|
||||
// Dedupe the language picker by (language + label). A video with two
|
||||
// tracks sharing the same language (e.g. "ar" uploaded twice while
|
||||
// retrying) used to list both and leave the viewer guessing. Keeping
|
||||
// the first occurrence preserves the primary when it exists; using
|
||||
// (lang, label) as the key still lets legit variants coexist (e.g.
|
||||
// "en — Original" and "en — Director's cut").
|
||||
$audioTracks = $video->audioTracks
|
||||
->unique(fn ($t) => mb_strtolower((string) $t->language) . '|' . mb_strtolower((string) ($t->label ?? '')))
|
||||
->map(fn ($t) => [
|
||||
'id' => $t->id,
|
||||
'language' => $t->language,
|
||||
'label' => $t->label,
|
||||
@@ -913,6 +1054,53 @@ class VideoController extends Controller
|
||||
* mirror + the NAS copy and removes any in-flight progress / temp files,
|
||||
* so the next Generate produces a fresh result.
|
||||
*/
|
||||
public function translateDescription(Request $request, Video $video, \App\Services\LlmLyricsService $llm)
|
||||
{
|
||||
if (! $llm->isEnabled()) {
|
||||
return response()->json(['error' => 'Translation is not configured'], 503);
|
||||
}
|
||||
|
||||
$html = (string) $video->description;
|
||||
if (trim($html) === '') {
|
||||
return response()->json(['error' => 'Nothing to translate'], 400);
|
||||
}
|
||||
// Flatten the rich description → plain text, keeping paragraph breaks
|
||||
// so the client can render original and translation line-by-line.
|
||||
$plain = trim(html_entity_decode(strip_tags(
|
||||
preg_replace(['/<\/p>/i', '/<br\s*\/?>/i'], ["\n", "\n"], $html)
|
||||
), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||||
$lines = preg_split('/\R/u', $plain);
|
||||
|
||||
$target = (string) $request->input('target', 'en');
|
||||
|
||||
// Source language inferred from the owner's access-history country,
|
||||
// falling back to users.nationality.
|
||||
$topCountries = \App\Models\User::topCountriesFor([$video->user_id]);
|
||||
$sourceCountry = $topCountries[$video->user_id] ?? null;
|
||||
$sourceLang = \App\Data\CountryLanguages::primary($sourceCountry);
|
||||
|
||||
$translatedLines = $llm->translateLines($lines, $target, $sourceLang);
|
||||
if (! $translatedLines || count($translatedLines) !== count($lines)) {
|
||||
return response()->json(['error' => 'Translation failed'], 502);
|
||||
}
|
||||
|
||||
$segments = [];
|
||||
foreach ($lines as $i => $src) {
|
||||
$segments[] = [
|
||||
'original' => (string) $src,
|
||||
'translated' => (string) ($translatedLines[$i] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'segments' => $segments,
|
||||
'target' => $target,
|
||||
'source_hint' => $sourceLang,
|
||||
'author_country' => $sourceCountry,
|
||||
]);
|
||||
}
|
||||
|
||||
public function deleteLyrics(Request $request, Video $video)
|
||||
{
|
||||
if (Auth::id() !== $video->user_id) {
|
||||
@@ -1951,47 +2139,52 @@ class VideoController extends Controller
|
||||
|
||||
public function trending(Request $request)
|
||||
{
|
||||
$hours = $request->get('hours', 48);
|
||||
$limit = $request->get('limit', 50);
|
||||
|
||||
$hours = min(max($hours, 24), 168);
|
||||
$limit = min(max($limit, 10), 100);
|
||||
// Snap the ?hours param to one of the three windows the UI exposes
|
||||
// (Today / This Week / This Month). The old clamp (24..168) silently
|
||||
// capped the "month" tab at a week, which is why the tabs all showed
|
||||
// the same data; now 720 is a first-class option.
|
||||
$hours = (int) $request->get('hours', 24);
|
||||
$hours = in_array($hours, [24, 168, 720], true) ? $hours : 24;
|
||||
$limit = min(max((int) $request->get('limit', 50), 10), 100);
|
||||
|
||||
// Candidate pool: public ready videos created at any time (not just
|
||||
// the last 10 days) — a video uploaded three weeks ago still belongs
|
||||
// in "This Month" if it caught fire this week. We rank by the number
|
||||
// of views INSIDE the window, with the recency bonus/velocity now
|
||||
// used only as tiebreakers.
|
||||
$windowStart = now()->subHours($hours);
|
||||
$videos = Video::public()
|
||||
->where('status', 'ready')
|
||||
->where('created_at', '>=', now()->subDays(10))
|
||||
->where('created_at', '>=', now()->subMonths(6))
|
||||
->with('user')
|
||||
->get();
|
||||
|
||||
$videos = $videos->map(function ($video) use ($hours) {
|
||||
$videos = $videos->map(function ($video) use ($hours, $windowStart) {
|
||||
$recentViews = \DB::table('video_views')
|
||||
->where('video_id', $video->id)
|
||||
->where('watched_at', '>=', now()->subHours($hours))
|
||||
->where('watched_at', '>=', $windowStart)
|
||||
->count();
|
||||
|
||||
$likeCount = \DB::table('video_likes')
|
||||
->where('video_id', $video->id)
|
||||
->count();
|
||||
|
||||
$ageHours = $video->created_at->diffInHours(now());
|
||||
$velocity = $recentViews / $hours;
|
||||
$recencyBonus = max(0, 1 - ($ageHours / 240));
|
||||
|
||||
$score = ($recentViews * 0.70) +
|
||||
($velocity * 100 * 0.15) +
|
||||
($recencyBonus * 50 * 0.10) +
|
||||
($likeCount * 0.1 * 0.05);
|
||||
|
||||
$video->trending_score = round($score, 2);
|
||||
$video->view_count = $recentViews;
|
||||
$video->like_count = $likeCount;
|
||||
|
||||
$video->_tiebreak_age = $video->created_at->timestamp;
|
||||
return $video;
|
||||
});
|
||||
|
||||
// Rank: in-window views (primary), likes (tiebreak #1), newest
|
||||
// created_at (tiebreak #2). Any video with zero views in the window
|
||||
// is dropped so the list never surfaces truly dormant content.
|
||||
$trendingVideos = $videos
|
||||
->filter(fn ($v) => $v->trending_score > 0)
|
||||
->sortByDesc('trending_score')
|
||||
->filter(fn ($v) => $v->view_count > 0)
|
||||
->sortBy([
|
||||
['view_count', 'desc'],
|
||||
['like_count', 'desc'],
|
||||
['_tiebreak_age', 'desc'],
|
||||
])
|
||||
->take($limit)
|
||||
->values();
|
||||
|
||||
@@ -2002,7 +2195,7 @@ class VideoController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function shorts(Request $request)
|
||||
public function shorts(Request $request, ?int $startId = null)
|
||||
{
|
||||
$videos = Video::public()
|
||||
->where('is_shorts', true)
|
||||
@@ -2011,7 +2204,23 @@ class VideoController extends Controller
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
return view('videos.shorts', compact('videos'));
|
||||
return view('videos.shorts', [
|
||||
'videos' => $videos,
|
||||
'startId' => $startId, // the id to auto-scroll to, if any
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* /shorts/{video}: deep-link to a specific short in the vertical feed.
|
||||
* The feed itself is the same ordered list; the view uses ?start= / the
|
||||
* $startId prop to scroll into position on load.
|
||||
*/
|
||||
public function shortsFocused(Video $video)
|
||||
{
|
||||
if (! $video->is_shorts || ! $video->canView(Auth::user())) {
|
||||
abort(404);
|
||||
}
|
||||
return $this->shorts(request(), $video->id);
|
||||
}
|
||||
|
||||
public function stream(Video $video)
|
||||
@@ -3126,12 +3335,65 @@ class VideoController extends Controller
|
||||
$dest .= '?track=' . (int) $request->input('track');
|
||||
}
|
||||
|
||||
// Social scrapers (WhatsApp, Facebook, Twitter, LinkedIn, Telegram,
|
||||
// Discord, Slack) frequently DO NOT follow 302 redirects when
|
||||
// building link previews — they read og:* tags from the response
|
||||
// body of the URL they were given. Serving them a redirect leaves
|
||||
// them with no title/image, which is what the user hit today. For
|
||||
// those user agents, render the target video page directly so the
|
||||
// OG tags (title, description, VS card image) are in the response.
|
||||
// Real users still get the 302 so cookies + tracking behave the same.
|
||||
$ua = (string) $request->userAgent();
|
||||
$isScraper = (bool) preg_match(
|
||||
'#(whatsapp|facebookexternalhit|facebot|twitterbot|linkedinbot|slackbot|telegrambot|discordbot|skypeuripreview|pinterest|redditbot|embedly|vkshare|w3c_validator|bingbot|googlebot|applebot|yandex|duckduckbot|iframely|nuzzel|quora link preview|outbrain)#i',
|
||||
$ua
|
||||
);
|
||||
if ($isScraper) {
|
||||
return $this->show($request, $video);
|
||||
}
|
||||
|
||||
return redirect($dest)
|
||||
->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5));
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone HTML page that renders the sports-match VS composition
|
||||
* at 1200×630. Called only by Browsershot from ogImage() below to
|
||||
* generate the social-share preview PNG for match videos.
|
||||
*/
|
||||
public function vsPreview(Video $video)
|
||||
{
|
||||
abort_unless($video->type === 'match' && $video->sportsMatch, 404);
|
||||
// Force http scheme for URLs generated on this page (asset(), route(),
|
||||
// media thumbnail routes) — the ogImage renderer proxies Chromium at
|
||||
// this route via loopback (http://127.0.0.1:80), so the app's global
|
||||
// URL::forceScheme('https') would produce URLs Chromium can't reach.
|
||||
\Illuminate\Support\Facades\URL::forceScheme('http');
|
||||
return response()
|
||||
->view('videos.partials.match.vs-og', ['video' => $video])
|
||||
->header('X-Robots-Tag', 'noindex, nofollow');
|
||||
}
|
||||
|
||||
public function ogImage(Video $video)
|
||||
{
|
||||
// Sports matches get a rendered VS card — the actual render is done
|
||||
// by a CLI artisan command (Chromium can't launch under PHP-FPM
|
||||
// reliably), fired detached on cache miss + prefetched on match
|
||||
// save. If the cached PNG exists, serve it. Otherwise return the
|
||||
// thumbnail fallback below and let the background job produce the
|
||||
// PNG for future requests.
|
||||
if ($video->type === 'match' && $video->sportsMatch) {
|
||||
$cached = $this->cachedMatchOgPath($video);
|
||||
if (is_file($cached)) {
|
||||
return response()->file($cached, [
|
||||
'Content-Type' => 'image/jpeg',
|
||||
'Cache-Control' => 'public, max-age=86400',
|
||||
]);
|
||||
}
|
||||
$this->dispatchMatchOgRender($video);
|
||||
// Fall through to thumbnail path while the render runs.
|
||||
}
|
||||
|
||||
// If video has a thumbnail, convert + resize to a small JPEG for WhatsApp/social previews
|
||||
if ($video->thumbnail) {
|
||||
$path = $video->localThumbnailPath();
|
||||
@@ -3383,7 +3645,7 @@ class VideoController extends Controller
|
||||
$totalGeo = $rawCountries->sum('cnt');
|
||||
$countries = $rawCountries->map(fn ($c) => [
|
||||
'code' => $c->country,
|
||||
'name' => $c->country_name,
|
||||
'name' => $c->country_name ?: \App\Data\Countries::name($c->country),
|
||||
'count' => (int) $c->cnt,
|
||||
'pct' => $totalGeo > 0 ? round($c->cnt / $totalGeo * 100) : 0,
|
||||
])->values();
|
||||
@@ -3722,7 +3984,7 @@ class VideoController extends Controller
|
||||
: asset('images/default-avatar.svg'))
|
||||
: null,
|
||||
'country' => $topCountry ? $topCountry->country : null,
|
||||
'country_name' => $topCountry ? $topCountry->country_name : null,
|
||||
'country_name' => $topCountry ? ($topCountry->country_name ?: \App\Data\Countries::name($topCountry->country)) : null,
|
||||
'reach' => $accesses,
|
||||
'created_at' => $s->created_at,
|
||||
];
|
||||
@@ -3868,7 +4130,7 @@ class VideoController extends Controller
|
||||
|
||||
return response()->json([
|
||||
'country' => $country,
|
||||
'country_name' => $countryName ?? $country,
|
||||
'country_name' => $countryName ?: (\App\Data\Countries::name($country) ?? $country),
|
||||
'total_views' => $totalViews,
|
||||
'registered_users' => $registeredUsers,
|
||||
'guest_count' => $guestCount,
|
||||
@@ -3943,7 +4205,7 @@ class VideoController extends Controller
|
||||
->orderByDesc('cnt')
|
||||
->limit(5)
|
||||
->get()
|
||||
->map(fn ($c) => ['code' => $c->country, 'name' => $c->country_name, 'count' => (int) $c->cnt]);
|
||||
->map(fn ($c) => ['code' => $c->country, 'name' => $c->country_name ?: \App\Data\Countries::name($c->country), 'count' => (int) $c->cnt]);
|
||||
|
||||
return response()->json([
|
||||
'date' => $day->format('M d, Y'),
|
||||
@@ -3971,7 +4233,7 @@ class VideoController extends Controller
|
||||
->map(fn ($r) => [
|
||||
'type' => $r->type,
|
||||
'country' => $r->country,
|
||||
'country_name' => $r->country_name,
|
||||
'country_name' => $r->country_name ?: \App\Data\Countries::name($r->country),
|
||||
'at' => $r->downloaded_at,
|
||||
]);
|
||||
|
||||
@@ -4101,7 +4363,7 @@ class VideoController extends Controller
|
||||
foreach ($accesses as $a) {
|
||||
$code = $a->country ?: 'XX';
|
||||
if (! isset($countries[$code])) {
|
||||
$countries[$code] = ['code' => $code, 'name' => $a->country_name ?: $code, 'count' => 0];
|
||||
$countries[$code] = ['code' => $code, 'name' => $a->country_name ?: (\App\Data\Countries::name($code) ?? $code), 'count' => 0];
|
||||
}
|
||||
$countries[$code]['count']++;
|
||||
|
||||
@@ -4199,7 +4461,7 @@ class VideoController extends Controller
|
||||
foreach ($rows as $r) {
|
||||
$code = $r->country ?: 'XX';
|
||||
if (! isset($countries[$code])) {
|
||||
$countries[$code] = ['code' => $code, 'name' => $r->country_name ?: $code, 'count' => 0];
|
||||
$countries[$code] = ['code' => $code, 'name' => $r->country_name ?: (\App\Data\Countries::name($code) ?? $code), 'count' => 0];
|
||||
}
|
||||
$countries[$code]['count']++;
|
||||
}
|
||||
@@ -4241,5 +4503,231 @@ class VideoController extends Controller
|
||||
'recent' => $recent,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute path of the cache file for this match's OG PNG. The stamp
|
||||
* embedded in the filename is sportsMatch.updated_at so a match edit
|
||||
* automatically produces a fresh URL and stale variants get cleaned
|
||||
* during the next render.
|
||||
*/
|
||||
public function cachedMatchOgPath(Video $video): string
|
||||
{
|
||||
$stamp = optional($video->sportsMatch->updated_at ?? $video->updated_at)->timestamp
|
||||
?? $video->id;
|
||||
// JPEG (not PNG) — WhatsApp silently rejects OG images above ~300 KB
|
||||
// and the composed PNG lands at ~700 KB. A quality-85 JPEG of the
|
||||
// same frame is 100-180 KB and looks identical for a share preview.
|
||||
return storage_path("app/og-cache/match-{$video->id}-{$stamp}.jpg");
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the OG PNG synchronously by spawning the artisan CLI and
|
||||
* waiting for it to finish (~3–5s). Called from SportsMatchController
|
||||
* on save so the file is guaranteed to exist before the user copies
|
||||
* the share URL — critical because WhatsApp/Facebook crawl the URL
|
||||
* once and cache whatever they get, so if the crawler beats the
|
||||
* render we end up permanently stuck on the fallback thumbnail.
|
||||
*
|
||||
* Chrome won't launch under PHP-FPM's process context (missing HOME,
|
||||
* stripped env). The artisan CLI subprocess is invoked with a full
|
||||
* env explicitly restored, which sidesteps the FPM restriction.
|
||||
*/
|
||||
public function renderMatchOgSync(Video $video, int $timeoutSeconds = 45): bool
|
||||
{
|
||||
// Skip if the PNG for this exact stamp already exists.
|
||||
$cacheFile = $this->cachedMatchOgPath($video);
|
||||
if (is_file($cacheFile) && filesize($cacheFile) > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$php = '/usr/bin/php';
|
||||
$art = base_path('artisan');
|
||||
$cmd = sprintf(
|
||||
'/usr/bin/env HOME=/tmp TMPDIR=/tmp PATH=/usr/bin:/bin %s %s og:render-match %d 2>&1',
|
||||
escapeshellcmd($php),
|
||||
escapeshellarg($art),
|
||||
(int) $video->id
|
||||
);
|
||||
|
||||
$descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
|
||||
$env = ['HOME' => '/tmp', 'TMPDIR' => '/tmp', 'PATH' => '/usr/bin:/bin'];
|
||||
$proc = @proc_open($cmd, $descriptors, $pipes, '/tmp', $env);
|
||||
if (! is_resource($proc)) {
|
||||
\Log::warning('renderMatchOgSync: proc_open failed', ['video' => $video->id]);
|
||||
return false;
|
||||
}
|
||||
stream_set_blocking($pipes[1], false);
|
||||
|
||||
$deadline = microtime(true) + $timeoutSeconds;
|
||||
while (microtime(true) < $deadline) {
|
||||
$status = proc_get_status($proc);
|
||||
if (! $status['running']) break;
|
||||
usleep(200000);
|
||||
}
|
||||
|
||||
$out = @stream_get_contents($pipes[1]);
|
||||
@fclose($pipes[1]);
|
||||
@fclose($pipes[2]);
|
||||
$exit = proc_close($proc);
|
||||
|
||||
if (! is_file($cacheFile)) {
|
||||
\Log::warning('renderMatchOgSync: render finished without a PNG', [
|
||||
'video' => $video->id,
|
||||
'exit' => $exit,
|
||||
'output' => $out,
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire the artisan renderer in a detached background process so this
|
||||
* request can respond immediately with the fallback thumbnail. Used
|
||||
* as a last-resort backfill when ogImage is hit and no PNG exists
|
||||
* (e.g. matches that pre-date this feature).
|
||||
*/
|
||||
public function dispatchMatchOgRender(Video $video): void
|
||||
{
|
||||
// Skip if a render was fired for this stamp very recently — the
|
||||
// marker file avoids stampeding multiple detached processes for
|
||||
// repeated hits before the first one finishes.
|
||||
$marker = $this->cachedMatchOgPath($video) . '.rendering';
|
||||
if (is_file($marker) && (time() - filemtime($marker)) < 60) {
|
||||
return;
|
||||
}
|
||||
@mkdir(dirname($marker), 0775, true);
|
||||
@touch($marker);
|
||||
|
||||
// PHP_BINARY under FPM is /usr/sbin/php-fpm, which won't run
|
||||
// artisan — must use the CLI binary explicitly.
|
||||
$php = '/usr/bin/php';
|
||||
$art = base_path('artisan');
|
||||
$log = storage_path('logs/og-render.log');
|
||||
// `setsid` detaches the child into a new session so it survives
|
||||
// FPM's request teardown. `< /dev/null` closes stdin so bash
|
||||
// treats it as fully backgrounded. `env HOME=/tmp` is mandatory:
|
||||
// FPM clears the environment (clear_env = yes), the artisan
|
||||
// command inherits that empty env, and Puppeteer/Chromium then
|
||||
// fail with "Failed to launch the browser process: Code: null"
|
||||
// because they can't resolve a user-data directory without HOME.
|
||||
$cmd = sprintf(
|
||||
'setsid /usr/bin/env HOME=/tmp TMPDIR=/tmp PATH=/usr/bin:/bin %s %s og:render-match %d >> %s 2>&1 < /dev/null &',
|
||||
escapeshellcmd($php),
|
||||
escapeshellarg($art),
|
||||
(int) $video->id,
|
||||
escapeshellarg($log)
|
||||
);
|
||||
@exec($cmd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render (or return the cached) 1200×630 PNG of the sports-match VS
|
||||
* composition. Cached under data/app/og-cache/match-{id}-{stamp}.png
|
||||
* so a match edit (which bumps updated_at) automatically produces a
|
||||
* fresh URL for social recrawlers.
|
||||
*/
|
||||
public function renderMatchOgImage(Video $video): ?string
|
||||
{
|
||||
$cacheFile = $this->cachedMatchOgPath($video);
|
||||
$cacheDir = dirname($cacheFile);
|
||||
|
||||
if (is_file($cacheFile)) {
|
||||
return @file_get_contents($cacheFile) ?: null;
|
||||
}
|
||||
|
||||
if (!is_dir($cacheDir)) {
|
||||
@mkdir($cacheDir, 0775, true);
|
||||
}
|
||||
|
||||
// Purge older stamped variants for this match so the cache dir
|
||||
// doesn't grow unbounded across edits. Includes the legacy .png
|
||||
// extension in case any old files are still around.
|
||||
foreach (glob($cacheDir . "/match-{$video->id}-*.{jpg,png}", GLOB_BRACE) ?: [] as $old) {
|
||||
if ($old !== $cacheFile) @unlink($old);
|
||||
}
|
||||
@unlink($cacheFile . '.rendering');
|
||||
|
||||
try {
|
||||
// Rebuild the URL as plain http on the app's own hostname so
|
||||
// Chromium can reach it via loopback. --host-resolver-rules
|
||||
// below points that hostname at 127.0.0.1:80 (nginx). This
|
||||
// sidesteps Cloudflare (origin isn't reachable from itself
|
||||
// over its public hostname) without needing a local TLS cert.
|
||||
// Hit nginx over loopback. server_name is `_` (catch-all) so
|
||||
// Host header doesn't matter. Chromium can't reach the origin
|
||||
// over its public hostname (Cloudflare-fronted) and mapping
|
||||
// the hostname via --host-resolver-rules trips ERR_BLOCKED_BY_CLIENT
|
||||
// on public TLDs. vsPreview() forces http on generated URLs
|
||||
// so all subresources (flag CSS, media thumbnails, headshots)
|
||||
// also resolve to this loopback origin.
|
||||
$url = 'http://127.0.0.1' . route('videos.vsOgFrame', $video, false);
|
||||
|
||||
$chrome = base_path('data/puppeteer-cache/chrome');
|
||||
$binary = null;
|
||||
if (is_dir($chrome)) {
|
||||
$matches = glob($chrome . '/linux-*/chrome-linux64/chrome');
|
||||
if ($matches) { $binary = $matches[0]; }
|
||||
}
|
||||
|
||||
// PHP-FPM runs without a HOME env var; Chromium's user-data-dir
|
||||
// resolution fails without one and the browser exits before
|
||||
// Puppeteer can attach. /tmp is writable by www-data and gets
|
||||
// cleaned periodically, which is fine for a screenshot job.
|
||||
$shot = \Spatie\Browsershot\Browsershot::url($url)
|
||||
->windowSize(1200, 675)
|
||||
->deviceScaleFactor(1)
|
||||
->waitUntilNetworkIdle()
|
||||
// Output JPEG @ q85 (~150 KB) instead of PNG (~700 KB).
|
||||
// WhatsApp silently drops OG images above roughly 300 KB.
|
||||
->setScreenshotType('jpeg', 85)
|
||||
->setNpmBinary('/usr/bin/npm')
|
||||
// PHP-FPM starts with a stripped env (clear_env = yes) so
|
||||
// Chromium sees no HOME/TMPDIR/PATH. Without HOME the
|
||||
// user-data-dir can't be created; without TMPDIR crashpad's
|
||||
// shmem sockets can't bind; without PATH Chromium can't
|
||||
// fork its helper processes. Pass all three explicitly.
|
||||
->setEnvironmentOptions([
|
||||
'HOME' => '/tmp',
|
||||
'TMPDIR' => '/tmp',
|
||||
'PATH' => '/usr/bin:/bin',
|
||||
])
|
||||
// PHP-FPM defaults rlimit_files to 1024; Chromium opens
|
||||
// way more than that on startup and dies silently
|
||||
// ("Failed to launch the browser process: Code: null").
|
||||
// The wrapper script re-execs node with a raised limit
|
||||
// via prlimit, so we don't have to restart php-fpm to
|
||||
// bump the pool-wide rlimit_files setting.
|
||||
->setNodeBinary(base_path('bin/browsershot-node.sh'))
|
||||
->noSandbox()
|
||||
->addChromiumArguments([
|
||||
'disable-gpu',
|
||||
'hide-scrollbars',
|
||||
'disable-dev-shm-usage',
|
||||
// FPM's process context confuses Chromium's zygote —
|
||||
// crashpad fails to fork with its args and chrome
|
||||
// exits before puppeteer can attach. Isolating the
|
||||
// user-data-dir per request + turning off crash
|
||||
// reporting sidesteps the whole handler chain.
|
||||
'user-data-dir=/tmp/chrome-og-' . getmypid() . '-' . mt_rand(),
|
||||
'disable-crash-reporter',
|
||||
'no-crash-upload',
|
||||
]);
|
||||
|
||||
if ($binary) {
|
||||
$shot->setChromePath($binary);
|
||||
}
|
||||
|
||||
$shot->save($cacheFile);
|
||||
} catch (\Throwable $e) {
|
||||
\Log::warning('vs og render failed', [
|
||||
'video' => $video->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_file($cacheFile) ? (@file_get_contents($cacheFile) ?: null) : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,13 @@ class Comment extends Model
|
||||
'user_id',
|
||||
'parent_id',
|
||||
'body',
|
||||
'hearted',
|
||||
'pinned',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'hearted' => 'boolean',
|
||||
'pinned' => 'boolean',
|
||||
];
|
||||
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,14 @@ class SportsMatch extends Model
|
||||
],
|
||||
'weight_category' => $trim($p['weight_class'] ?? null),
|
||||
'division' => $trim($c['division'] ?? null),
|
||||
'championship' => $trim($c['championship_name'] ?? null) ?? $trim($this->event_name),
|
||||
// Sub-header "stage" shown between the two yellow lines above
|
||||
// the weight class (e.g. "Finals", "SEMI FINAL"). Sourced from
|
||||
// the SportsMatch->title column — the form's "Match title" input.
|
||||
'stage' => $trim($this->title),
|
||||
// "Event title" = the championship / tournament name shown above the
|
||||
// fighters. Prefer the SportsMatch->event_name column (the field the
|
||||
// uploader labels "Event title"), fall back to competition.championship_name.
|
||||
'championship' => $trim($this->event_name) ?? $trim($c['championship_name'] ?? null),
|
||||
'match_number' => $trim(($c['match_number'] ?? null) !== null ? (string) $c['match_number'] : null),
|
||||
'court' => $trim($c['court'] ?? null),
|
||||
'format' => $trim($c['format'] ?? null), // "3 min", "Best of 3", …
|
||||
|
||||
@@ -7,6 +7,8 @@ use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
@@ -56,8 +58,57 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
'password' => 'hashed',
|
||||
'two_factor_enabled' => 'boolean',
|
||||
'notification_preferences' => 'array',
|
||||
// TAKEONE account link. The token is a live credential for that
|
||||
// platform, so it is encrypted at rest and never sent back to a browser.
|
||||
'takeone_token' => 'encrypted',
|
||||
'takeone_connected_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Can this user run TAKEONE lookups?
|
||||
*
|
||||
* A super admin always can: the platform holds a TAKEONE super-admin token
|
||||
* on their behalf, so they are connected with full reach without ever
|
||||
* pasting anything. Everyone else links their own account, and sees only
|
||||
* what that account may see.
|
||||
*/
|
||||
public function takeoneLinked(): bool
|
||||
{
|
||||
return $this->takeoneToken() !== null;
|
||||
}
|
||||
|
||||
/** True when the connection is the platform's, not one this user pasted. */
|
||||
public function takeoneManagedLink(): bool
|
||||
{
|
||||
$own = $this->takeone_token;
|
||||
|
||||
return ($own === null || $own === '')
|
||||
&& $this->isSuperAdmin()
|
||||
&& ! empty(config('services.takeone.admin_token'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The token to talk to TAKEONE with. A personally linked account always
|
||||
* wins, so a super admin who deliberately links their own identity is not
|
||||
* silently upgraded back to the platform credential.
|
||||
*/
|
||||
public function takeoneToken(): ?string
|
||||
{
|
||||
$own = $this->takeone_token;
|
||||
|
||||
if ($own !== null && $own !== '') {
|
||||
return $own;
|
||||
}
|
||||
|
||||
if ($this->isSuperAdmin()) {
|
||||
$platform = config('services.takeone.admin_token');
|
||||
|
||||
return ! empty($platform) ? $platform : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Defaults for each preference key (true = on, false = off)
|
||||
private const NOTIF_DEFAULTS = [
|
||||
'notif_new_comment' => true,
|
||||
@@ -105,6 +156,55 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the most-frequent ISO2 country for each user id, aggregating
|
||||
* video_views + profile_visits and falling back to users.nationality.
|
||||
* Returns [user_id => 'BH', …]; keys without data are omitted.
|
||||
*/
|
||||
public static function topCountriesFor(array $userIds): array
|
||||
{
|
||||
$userIds = array_values(array_unique(array_filter($userIds, 'is_numeric')));
|
||||
if (! $userIds) return [];
|
||||
|
||||
$tally = [];
|
||||
$rows = DB::table('video_views')
|
||||
->selectRaw('user_id, country, COUNT(*) as c')
|
||||
->whereIn('user_id', $userIds)
|
||||
->whereNotNull('country')->where('country', '!=', '')
|
||||
->groupBy('user_id', 'country')->get();
|
||||
foreach ($rows as $r) {
|
||||
$cc = strtoupper($r->country);
|
||||
$tally[(int) $r->user_id][$cc] = ($tally[(int) $r->user_id][$cc] ?? 0) + (int) $r->c;
|
||||
}
|
||||
if (Schema::hasTable('profile_visits')) {
|
||||
$rows = DB::table('profile_visits')
|
||||
->selectRaw('visitor_user_id as user_id, country, COUNT(*) as c')
|
||||
->whereIn('visitor_user_id', $userIds)
|
||||
->whereNotNull('country')->where('country', '!=', '')
|
||||
->groupBy('visitor_user_id', 'country')->get();
|
||||
foreach ($rows as $r) {
|
||||
$cc = strtoupper($r->country);
|
||||
$tally[(int) $r->user_id][$cc] = ($tally[(int) $r->user_id][$cc] ?? 0) + (int) $r->c;
|
||||
}
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($tally as $uid => $counts) {
|
||||
arsort($counts);
|
||||
$out[$uid] = array_key_first($counts);
|
||||
}
|
||||
|
||||
// Fallback to users.nationality for anyone still unresolved.
|
||||
$missing = array_values(array_diff($userIds, array_keys($out)));
|
||||
if ($missing) {
|
||||
$nats = static::whereIn('id', $missing)
|
||||
->whereNotNull('nationality')->where('nationality', '!=', '')
|
||||
->pluck('nationality', 'id');
|
||||
foreach ($nats as $uid => $cc) $out[(int) $uid] = strtoupper($cc);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
public static function generateUniqueUsername(string $name): string
|
||||
{
|
||||
$base = substr(Str::slug(trim($name)), 0, 18);
|
||||
@@ -181,7 +281,19 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
|
||||
public function sendEmailVerificationNotification(): void
|
||||
{
|
||||
// Mail failures must never bubble up as a 500 to the user — the account
|
||||
// exists, the HTTP request was fine, only the delivery pipe is broken.
|
||||
// We log the failure (ops picks it up) and move on. Re-sending via
|
||||
// `php artisan app:resend-verification` fixes the user once SMTP recovers.
|
||||
try {
|
||||
$this->notify(new VerifyEmail);
|
||||
} catch (\Throwable $e) {
|
||||
\Illuminate\Support\Facades\Log::warning('verify-email send failed', [
|
||||
'user_id' => $this->id,
|
||||
'email' => $this->email,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Role helper methods
|
||||
|
||||
@@ -53,6 +53,11 @@ class Video extends Model
|
||||
return $this->belongsToMany(User::class, 'video_likes')->withTimestamps();
|
||||
}
|
||||
|
||||
public function dislikes()
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'video_dislikes')->withTimestamps();
|
||||
}
|
||||
|
||||
public function viewers()
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'video_views')
|
||||
@@ -177,6 +182,20 @@ class Video extends Model
|
||||
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
|
||||
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
|
||||
);
|
||||
|
||||
// Force HTTPS
|
||||
// Force HTTPS unless we're running under phpunit — the test client
|
||||
// speaks plain http://localhost; forcing https would make every POST
|
||||
// in a feature test resolve to a 302 instead of hitting the controller.
|
||||
if (! $this->app->environment('testing')) {
|
||||
URL::forceScheme('https');
|
||||
}
|
||||
|
||||
// Universal pagination view — used everywhere by default
|
||||
Paginator::defaultView('partials.pagination');
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
/**
|
||||
* Blocks impersonation of staff/system accounts.
|
||||
*
|
||||
* Applied to both `name` and the email local-part at registration so a user
|
||||
* cannot sign up as "admin", "superadmin", "takeone", etc. — words that would
|
||||
* let them pass themselves off as the platform or its operators to other
|
||||
* users (comments, DMs, channel pages).
|
||||
*/
|
||||
class ReservedIdentifier implements ValidationRule
|
||||
{
|
||||
private const RESERVED = [
|
||||
'admin', 'administrator', 'superadmin', 'super-admin', 'super_admin',
|
||||
'root', 'sysadmin', 'system', 'owner', 'webmaster', 'postmaster',
|
||||
'moderator', 'mod', 'staff', 'official',
|
||||
'support', 'help', 'helpdesk', 'contact', 'info', 'noreply', 'no-reply',
|
||||
'security', 'abuse', 'billing',
|
||||
'takeone', 'take-one', 'take_one', 'takeonebh', 'takeone-bh',
|
||||
'play', 'playbh', 'playtakeone',
|
||||
'anonymous', 'guest', 'null', 'undefined',
|
||||
];
|
||||
|
||||
public function __construct(private readonly string $kind = 'name')
|
||||
{
|
||||
}
|
||||
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$candidate = $this->kind === 'email'
|
||||
? strtolower(trim(strstr($value, '@', true) ?: $value))
|
||||
: strtolower(trim($value));
|
||||
|
||||
// Collapse separators so "super_admin", "super-admin" and "superadmin" all match.
|
||||
$normalized = preg_replace('/[\s._\-]+/', '', $candidate) ?? $candidate;
|
||||
|
||||
foreach (self::RESERVED as $word) {
|
||||
$target = preg_replace('/[\s._\-]+/', '', $word) ?? $word;
|
||||
if ($normalized === $target) {
|
||||
$fail($this->kind === 'email'
|
||||
? 'That email address is reserved. Please use a different one.'
|
||||
: 'That name is reserved. Please choose a different one.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -191,16 +191,176 @@ class LlmLyricsService
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Translate arbitrary text (e.g. a user comment) to a target language.
|
||||
* Returns the translated string, or '' on any failure / when disabled.
|
||||
*/
|
||||
public function translate(string $text, string $targetLang = 'en', ?string $sourceLangHint = null): string
|
||||
{
|
||||
$text = trim($text);
|
||||
if ($text === '' || ! $this->isEnabled()) return '';
|
||||
|
||||
$provider = $this->activeProvider();
|
||||
$targetLang = preg_replace('/[^a-zA-Z-]/', '', $targetLang) ?: 'en';
|
||||
$sourceLangHint = $sourceLangHint ? (preg_replace('/[^a-zA-Z-]/', '', $sourceLangHint) ?: null) : null;
|
||||
$cacheKey = 'llm_translate_v4:' . ($provider['id'] ?? '') . ':' . $targetLang . ':' . ($sourceLangHint ?? '-') . ':' . sha1($text);
|
||||
$cached = Cache::get($cacheKey);
|
||||
if (is_string($cached) && $cached !== '') return $cached;
|
||||
|
||||
$hint = $sourceLangHint
|
||||
? "The author's written language is \"{$sourceLangHint}\" (ISO-639). Assume the text is in that language unless it is OBVIOUSLY in a different language (e.g. uses a different script).\n"
|
||||
: '';
|
||||
|
||||
$prompt = "Translate the following text into the language with ISO-639 code \"{$targetLang}\".\n"
|
||||
. $hint
|
||||
. "Rules:\n"
|
||||
. " - Output ONLY the translated text. No prose, no quotes, no language label, no notes.\n"
|
||||
. " - Translate EVERY word of the source language, including short exclamations,\n"
|
||||
. " repeated words, and words that happen to also look like a name in the target\n"
|
||||
. " language. Example: Portuguese \"Linda, linda, linda\" → \"Beautiful, beautiful,\n"
|
||||
. " beautiful\" (NOT the English name \"Linda\"). Spanish \"hola\" → \"hello\".\n"
|
||||
. " - A word is a proper noun ONLY if clearly a person/place/brand name in context\n"
|
||||
. " (e.g. \"My name is Linda\"). If it stands alone or is repeated as an exclamation,\n"
|
||||
. " it is almost certainly a regular word — translate it.\n"
|
||||
. " - If the text is already in the target language, repeat it unchanged.\n"
|
||||
. " - Preserve emojis, @mentions (e.g. @alice), hashtags, URLs, and timestamp tokens\n"
|
||||
. " like @1:23 or @01:23-02:45 EXACTLY as written.\n"
|
||||
. " - Keep the same tone and line breaks.\n\n"
|
||||
. "TEXT:\n" . $text;
|
||||
|
||||
// Translation is deterministic — temp 0. If the model echoes the input
|
||||
// (common when a word happens to also exist as a name in the target
|
||||
// language, e.g. Portuguese "Linda" → English "Linda"), retry once
|
||||
// with a stronger directive. If the retry also echoes, we return it.
|
||||
// Comments cap at 1000 chars → 400 tokens is plenty of headroom and
|
||||
// keeps the model from drifting into long completions on tiny inputs.
|
||||
$budget = max(128, min(1024, (int) ceil(mb_strlen($text) * 1.5) + 64));
|
||||
$raw = $this->postProcess($this->call($prompt, $budget, 0.0));
|
||||
$needsRetry = $sourceLangHint
|
||||
&& $sourceLangHint !== $targetLang
|
||||
&& self::looksLikeEcho($text, $raw);
|
||||
if ($needsRetry) {
|
||||
$retryPrompt = $prompt
|
||||
. "\n\nIMPORTANT: Your previous attempt was identical to the source. The source is\n"
|
||||
. "DEFINITELY in \"{$sourceLangHint}\", not in \"{$targetLang}\". Translate every word.";
|
||||
$retry = $this->postProcess($this->call($retryPrompt, $budget, 0.0));
|
||||
if ($retry !== '' && ! self::looksLikeEcho($text, $retry)) $raw = $retry;
|
||||
}
|
||||
if ($raw === '') return '';
|
||||
|
||||
Cache::put($cacheKey, $raw, now()->addDays(30));
|
||||
return $raw;
|
||||
}
|
||||
|
||||
private function postProcess(string $raw): string
|
||||
{
|
||||
$raw = trim($raw);
|
||||
$raw = trim(preg_replace('/^```[a-zA-Z]*\s*|\s*```$/m', '', $raw));
|
||||
return $raw;
|
||||
}
|
||||
|
||||
/** Normalised equality: ignore case, whitespace, punctuation, emojis. */
|
||||
private static function looksLikeEcho(string $src, string $out): bool
|
||||
{
|
||||
if ($out === '') return false;
|
||||
$norm = fn (string $s) => mb_strtolower(preg_replace('/[^\p{L}\p{N}]+/u', '', $s) ?? '');
|
||||
$a = $norm($src); $b = $norm($out);
|
||||
return $a !== '' && $a === $b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a sequence of lines, preserving the one-to-one mapping so the
|
||||
* caller can render them side-by-side with the originals. Empty entries
|
||||
* are kept as empty strings. Returns an array of the same length as $lines
|
||||
* (or [] on any failure / when disabled).
|
||||
*/
|
||||
public function translateLines(array $lines, string $targetLang = 'en', ?string $sourceLangHint = null): array
|
||||
{
|
||||
if (! $lines || ! $this->isEnabled()) return [];
|
||||
$targetLang = preg_replace('/[^a-zA-Z-]/', '', $targetLang) ?: 'en';
|
||||
$sourceLangHint = $sourceLangHint ? (preg_replace('/[^a-zA-Z-]/', '', $sourceLangHint) ?: null) : null;
|
||||
|
||||
$provider = $this->activeProvider();
|
||||
$cacheKey = 'llm_translate_lines_v2:' . ($provider['id'] ?? '') . ':' . $targetLang . ':' . ($sourceLangHint ?? '-') . ':' . sha1(json_encode($lines));
|
||||
$cached = Cache::get($cacheKey);
|
||||
if (is_array($cached) && count($cached) === count($lines)) return $cached;
|
||||
|
||||
// Index non-empty lines so empty separators are preserved verbatim.
|
||||
$toTranslate = [];
|
||||
foreach ($lines as $i => $l) {
|
||||
if (trim((string) $l) !== '') $toTranslate[$i] = (string) $l;
|
||||
}
|
||||
if (! $toTranslate) {
|
||||
Cache::put($cacheKey, $lines, now()->addDays(30));
|
||||
return $lines;
|
||||
}
|
||||
|
||||
$numbered = [];
|
||||
foreach ($toTranslate as $i => $l) $numbered[] = $i . "\t" . $l;
|
||||
|
||||
$hint = $sourceLangHint
|
||||
? "The author's written language is \"{$sourceLangHint}\" (ISO-639). Assume the lines are in that language unless they are OBVIOUSLY in a different language (different script).\n"
|
||||
: '';
|
||||
|
||||
$prompt = "Translate each of the following numbered lines into the language with ISO-639 code \"{$targetLang}\".\n"
|
||||
. $hint
|
||||
. "Rules:\n"
|
||||
. " - Output a JSON object mapping each original index (as string) to its translated line.\n"
|
||||
. " - Keep ONE output line per input line — do NOT merge, split, re-order, or drop any.\n"
|
||||
. " - Translate every word including short exclamations and repeated words that happen\n"
|
||||
. " to look like a name in the target language (Portuguese \"Linda\" → \"Beautiful\").\n"
|
||||
. " - If a line is already in the target language, repeat it unchanged.\n"
|
||||
. " - Preserve emojis, @mentions, hashtags, URLs, and timestamp tokens like @1:23 EXACTLY.\n"
|
||||
. " - Respond with ONLY the JSON object. No prose, no markdown, no code fence.\n\n"
|
||||
. "LINES (index TAB text):\n" . implode("\n", $numbered);
|
||||
|
||||
$raw = $this->postProcess($this->call($prompt, 8192, 0.0));
|
||||
$obj = json_decode($raw, true);
|
||||
if (! is_array($obj)) return [];
|
||||
|
||||
$out = $lines;
|
||||
$echoCount = 0;
|
||||
$translatedCount = 0;
|
||||
foreach ($toTranslate as $i => $src) {
|
||||
$v = $obj[(string) $i] ?? ($obj[$i] ?? null);
|
||||
if (is_string($v) && trim($v) !== '') {
|
||||
$out[$i] = $v;
|
||||
$translatedCount++;
|
||||
if ($sourceLangHint && $sourceLangHint !== $targetLang && self::looksLikeEcho($src, $v)) {
|
||||
$echoCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
// If > 30% of the translated lines just echo the source, nudge the model.
|
||||
if ($sourceLangHint && $sourceLangHint !== $targetLang
|
||||
&& $translatedCount > 0 && $echoCount / $translatedCount > 0.3) {
|
||||
$retryPrompt = $prompt . "\n\nIMPORTANT: Your previous attempt echoed the source for many lines. "
|
||||
. "The source is DEFINITELY in \"{$sourceLangHint}\", not in \"{$targetLang}\". Translate every word.";
|
||||
$raw2 = $this->postProcess($this->call($retryPrompt, 8192, 0.0));
|
||||
$obj2 = json_decode($raw2, true);
|
||||
if (is_array($obj2)) {
|
||||
foreach ($toTranslate as $i => $src) {
|
||||
$v = $obj2[(string) $i] ?? ($obj2[$i] ?? null);
|
||||
if (is_string($v) && trim($v) !== '' && ! self::looksLikeEcho($src, $v)) {
|
||||
$out[$i] = $v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Cache::put($cacheKey, $out, now()->addDays(30));
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** Dispatch to the active provider's adapter. */
|
||||
private function call(string $prompt, int $maxTokens): string
|
||||
private function call(string $prompt, int $maxTokens, float $temperature = 0.2): string
|
||||
{
|
||||
$p = $this->activeProvider();
|
||||
if (! $p) return '';
|
||||
try {
|
||||
return match ($p['kind']) {
|
||||
'anthropic' => $this->callAnthropic($p, $prompt, $maxTokens),
|
||||
'openai' => $this->callOpenAI($p, $prompt, $maxTokens),
|
||||
default => $this->callOllama($p, $prompt, $maxTokens),
|
||||
'anthropic' => $this->callAnthropic($p, $prompt, $maxTokens, $temperature),
|
||||
'openai' => $this->callOpenAI($p, $prompt, $maxTokens, $temperature),
|
||||
default => $this->callOllama($p, $prompt, $maxTokens, $temperature),
|
||||
};
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('LLM call failed: ' . $e->getMessage(), ['provider' => $p['name'] ?? '?']);
|
||||
@@ -208,24 +368,37 @@ class LlmLyricsService
|
||||
}
|
||||
}
|
||||
|
||||
private function callOllama(array $p, string $prompt, int $maxTokens): string
|
||||
private function callOllama(array $p, string $prompt, int $maxTokens, float $temperature = 0.2): string
|
||||
{
|
||||
$endpoint = rtrim((string) ($p['endpoint'] ?? 'http://localhost:11434'), '/');
|
||||
// Qwen3-style "thinking" models respect /no_think to skip the hidden
|
||||
// reasoning phase. Without it they can exhaust num_predict inside
|
||||
// the thinking block and emit empty content.
|
||||
$taggedPrompt = $prompt . "\n\n/no_think";
|
||||
$resp = Http::timeout(180)->acceptJson()->post($endpoint . '/api/chat', [
|
||||
'model' => $p['model'],
|
||||
'messages' => [['role' => 'user', 'content' => $prompt]],
|
||||
'messages' => [['role' => 'user', 'content' => $taggedPrompt]],
|
||||
'stream' => false,
|
||||
'options' => ['num_predict' => $maxTokens, 'temperature' => 0.2],
|
||||
'think' => false,
|
||||
// Keep the model resident for an hour so follow-up requests skip
|
||||
// the cold-start reload (default is 5 minutes). Dramatically
|
||||
// reduces per-click latency for comment/description translation.
|
||||
'keep_alive' => '1h',
|
||||
'options' => ['num_predict' => $maxTokens, 'temperature' => $temperature],
|
||||
]);
|
||||
if (! $resp->successful()) {
|
||||
Log::warning('Ollama API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]);
|
||||
return '';
|
||||
}
|
||||
$j = $resp->json();
|
||||
return (string) ($j['message']['content'] ?? '');
|
||||
$content = (string) ($j['message']['content'] ?? '');
|
||||
// Some thinking models still ignore the flag and leak <think>…</think>
|
||||
// tags into content. Strip them.
|
||||
$content = preg_replace('/<think>.*?<\/think>\s*/is', '', $content) ?? $content;
|
||||
return trim($content);
|
||||
}
|
||||
|
||||
private function callAnthropic(array $p, string $prompt, int $maxTokens): string
|
||||
private function callAnthropic(array $p, string $prompt, int $maxTokens, float $temperature = 0.2): string
|
||||
{
|
||||
$endpoint = rtrim((string) ($p['endpoint'] ?? 'https://api.anthropic.com'), '/');
|
||||
$resp = Http::timeout(120)->withHeaders([
|
||||
@@ -235,6 +408,7 @@ class LlmLyricsService
|
||||
])->post($endpoint . '/v1/messages', [
|
||||
'model' => $p['model'],
|
||||
'max_tokens' => $maxTokens,
|
||||
'temperature' => $temperature,
|
||||
'messages' => [['role' => 'user', 'content' => $prompt]],
|
||||
]);
|
||||
if (! $resp->successful()) {
|
||||
@@ -245,7 +419,7 @@ class LlmLyricsService
|
||||
return (string) ($j['content'][0]['text'] ?? '');
|
||||
}
|
||||
|
||||
private function callOpenAI(array $p, string $prompt, int $maxTokens): string
|
||||
private function callOpenAI(array $p, string $prompt, int $maxTokens, float $temperature = 0.2): string
|
||||
{
|
||||
$endpoint = rtrim((string) ($p['endpoint'] ?? 'https://api.openai.com'), '/');
|
||||
$resp = Http::timeout(120)->withToken((string) $p['api_key'])
|
||||
@@ -254,7 +428,7 @@ class LlmLyricsService
|
||||
'model' => $p['model'],
|
||||
'messages' => [['role' => 'user', 'content' => $prompt]],
|
||||
'max_tokens' => $maxTokens,
|
||||
'temperature' => 0.2,
|
||||
'temperature' => $temperature,
|
||||
]);
|
||||
if (! $resp->successful()) {
|
||||
Log::warning('OpenAI API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]);
|
||||
|
||||
@@ -90,9 +90,15 @@ class VideoMergeService
|
||||
DB::transaction(function () use ($source, $target, $sourceDir, $targetDir) {
|
||||
|
||||
// ── 1. Promote source's primary audio to a new track on target ─
|
||||
$srcPrimaryExt = pathinfo($source->filename ?: 'audio.mp3', PATHINFO_EXTENSION) ?: 'mp3';
|
||||
// Layout-aware: the source may be stored either as the modern
|
||||
// per-track folder ({sourceDir}/tracks/{srcLang}-{srcVideoId}/…)
|
||||
// or as the legacy flat layout ({sourceDir}/audio.ext,
|
||||
// {sourceDir}/slides/, …). We detect which from source->path
|
||||
// and move only what actually exists in each case.
|
||||
$srcPrimaryPath = (string) $source->path;
|
||||
$srcPrimaryFolder = $srcPrimaryPath !== '' ? dirname($srcPrimaryPath) : $sourceDir;
|
||||
$srcPrimaryExt = pathinfo($source->filename ?: ($srcPrimaryPath ?: 'audio.mp3'), PATHINFO_EXTENSION) ?: 'mp3';
|
||||
$srcPrimaryFilename = "audio.{$srcPrimaryExt}";
|
||||
$srcPrimaryOldFolder = "{$sourceDir}/tracks/" . $this->nas->trackFolderName($source, null);
|
||||
|
||||
$newPrimaryTrack = VideoAudioTrack::create([
|
||||
'video_id' => $target->id,
|
||||
@@ -108,43 +114,83 @@ class VideoMergeService
|
||||
}
|
||||
|
||||
$newPrimaryFolder = "{$targetDir}/tracks/" . $this->nas->trackFolderName($target, $newPrimaryTrack);
|
||||
$this->moveFolder($srcPrimaryOldFolder, $newPrimaryFolder);
|
||||
// Case A: primary audio sits in a dedicated per-track folder → one
|
||||
// folder move brings the audio, its slides and its thumb along.
|
||||
// Case B: primary audio sits directly at the video root (legacy) →
|
||||
// move the individual files, since the video root also holds the
|
||||
// extra tracks' subfolders which stay put until step 2.
|
||||
if ($srcPrimaryFolder !== $sourceDir && str_starts_with($srcPrimaryFolder, "{$sourceDir}/tracks/")) {
|
||||
$this->moveFolder($srcPrimaryFolder, $newPrimaryFolder);
|
||||
} else {
|
||||
$this->moveFile($srcPrimaryPath, "{$newPrimaryFolder}/{$srcPrimaryFilename}");
|
||||
// Legacy slides live at {sourceDir}/slides/ as a sibling of audio.
|
||||
if (is_dir(storage_path('app/' . "{$sourceDir}/slides"))) {
|
||||
$this->moveFolder("{$sourceDir}/slides", "{$newPrimaryFolder}/slides");
|
||||
}
|
||||
// Opportunistically move thumb.{ext} if present.
|
||||
foreach (['jpg', 'jpeg', 'png', 'webp'] as $ext) {
|
||||
$thumb = "{$sourceDir}/thumb.{$ext}";
|
||||
if (file_exists(storage_path('app/' . $thumb))) {
|
||||
$this->moveFile($thumb, "{$newPrimaryFolder}/thumb.{$ext}");
|
||||
}
|
||||
}
|
||||
// Legacy primary lyrics live at {sourceDir}/lyrics/primary.json.
|
||||
// The new per-track layout stores lyrics at
|
||||
// {newPrimaryFolder}/lyrics.json — move the file so lyricsNasPath
|
||||
// on the target resolves it immediately.
|
||||
$legacyPrimaryLyrics = "{$sourceDir}/lyrics/primary.json";
|
||||
if (file_exists(storage_path('app/' . $legacyPrimaryLyrics))) {
|
||||
$this->moveFile($legacyPrimaryLyrics, "{$newPrimaryFolder}/lyrics.json");
|
||||
}
|
||||
}
|
||||
$newPrimaryTrack->update(['path' => "{$newPrimaryFolder}/{$srcPrimaryFilename}"]);
|
||||
|
||||
// ── 2. Move each source extra track's folder into target/tracks/
|
||||
// The folder basename ({lang}-{track_id}) is globally unique because
|
||||
// track ids are globally unique — no collisions with target's own tracks.
|
||||
// The folder basename ({lang}-{track_id}) is globally unique
|
||||
// because track ids are globally unique — no collisions with
|
||||
// target's own tracks. We derive the actual old folder from
|
||||
// the track's stored path so unusual layouts still work.
|
||||
foreach ($source->audioTracks as $track) {
|
||||
$basename = $this->nas->trackFolderName($source, $track);
|
||||
$oldFolder = "{$sourceDir}/tracks/{$basename}";
|
||||
$newFolder = "{$targetDir}/tracks/{$basename}";
|
||||
$oldFolder = $track->path ? dirname($track->path) : "{$sourceDir}/tracks/" . $this->nas->trackFolderName($source, $track);
|
||||
$newFolder = "{$targetDir}/tracks/" . $this->nas->trackFolderName($source, $track);
|
||||
if ($oldFolder !== $newFolder) {
|
||||
$this->moveFolder($oldFolder, $newFolder);
|
||||
}
|
||||
|
||||
$newTrackPath = $track->path && str_starts_with($track->path, $oldFolder . '/')
|
||||
? $newFolder . '/' . substr($track->path, strlen($oldFolder) + 1)
|
||||
: $track->path;
|
||||
: "{$newFolder}/" . ($track->filename ?: 'audio.mp3');
|
||||
|
||||
$track->video_id = $target->id;
|
||||
$track->path = $newTrackPath;
|
||||
$track->save();
|
||||
|
||||
// Legacy per-track lyrics live at {sourceDir}/lyrics/track-{id}.json.
|
||||
// In the new layout they belong inside the moved track folder.
|
||||
$legacyTrackLyrics = "{$sourceDir}/lyrics/track-{$track->id}.json";
|
||||
if (file_exists(storage_path('app/' . $legacyTrackLyrics))) {
|
||||
$this->moveFile($legacyTrackLyrics, "{$newFolder}/lyrics.json");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Move ALL slides for the source video ────────────────────
|
||||
// One pass: re-parent every source slide, rewrite any filename
|
||||
// that started with sourceDir/... to targetDir/..., and if the
|
||||
// slide had NULL audio_track_id (belonged to source's primary),
|
||||
// point it at the newly-created primary track.
|
||||
$sourcePrefix = $sourceDir . '/';
|
||||
$targetPrefix = $targetDir . '/';
|
||||
// ── 3. Rewrite every source slide's filename to its new on-disk
|
||||
// location, derived from the track it belongs to AFTER the
|
||||
// per-track folder moves above. This is layout-agnostic —
|
||||
// it works for both new (/tracks/{lang-id}/slides/N) and
|
||||
// legacy (/{sourceDir}/slides/N) source layouts.
|
||||
$updatedTracks = VideoAudioTrack::where('video_id', $target->id)->get()->keyBy('id');
|
||||
$slides = DB::table('video_slides')->where('video_id', $source->id)->get();
|
||||
foreach ($slides as $s) {
|
||||
$newFilename = $s->filename;
|
||||
if (is_string($s->filename) && str_starts_with($s->filename, $sourcePrefix)) {
|
||||
$newFilename = $targetPrefix . substr($s->filename, strlen($sourcePrefix));
|
||||
}
|
||||
$base = $s->filename ? basename($s->filename) : null;
|
||||
if (!$base) continue; // nothing we can safely rewrite
|
||||
|
||||
$owningTrackId = $s->audio_track_id === null ? $newPrimaryTrack->id : $s->audio_track_id;
|
||||
$owningTrack = $updatedTracks->get($owningTrackId);
|
||||
$owningFolder = $owningTrack && $owningTrack->path ? dirname($owningTrack->path) : $newPrimaryFolder;
|
||||
|
||||
$updates = [
|
||||
'video_id' => $target->id,
|
||||
'filename' => $newFilename,
|
||||
'filename' => "{$owningFolder}/slides/{$base}",
|
||||
];
|
||||
if ($s->audio_track_id === null) {
|
||||
$updates['audio_track_id'] = $newPrimaryTrack->id;
|
||||
@@ -197,12 +243,54 @@ class VideoMergeService
|
||||
});
|
||||
|
||||
// ── 10. Best-effort cleanup of the emptied source folder ───────────
|
||||
// The legacy lyrics/ sibling is tidied first so pruneEmptyFolder's
|
||||
// rmdir on sourceDir can succeed.
|
||||
$this->pruneEmptyFolder("{$sourceDir}/lyrics");
|
||||
$this->pruneEmptyFolder("{$sourceDir}/tracks");
|
||||
$this->pruneEmptyFolder($sourceDir);
|
||||
|
||||
Log::info('Merge: complete', ['source_id' => $source->id, 'target_id' => $target->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an individual file (not a directory) on NAS + local. Needed for
|
||||
* legacy-layout sources where the primary audio and thumb live directly
|
||||
* in the video root rather than inside a per-track subfolder.
|
||||
*/
|
||||
private function moveFile(string $oldRel, string $newRel): void
|
||||
{
|
||||
if ($oldRel === '' || $newRel === '' || $oldRel === $newRel) return;
|
||||
|
||||
$nasMoved = false;
|
||||
if ($this->nas->isEnabled()) {
|
||||
$this->nas->mkdirp(dirname($newRel));
|
||||
$this->nas->renameNasPath($oldRel, $newRel);
|
||||
$nasMoved = true;
|
||||
} else {
|
||||
DB::table('pending_nas_moves')->insert([
|
||||
'from_path' => $oldRel,
|
||||
'to_path' => $newRel,
|
||||
'kind' => 'file',
|
||||
'origin' => 'merge',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$oldLocal = storage_path('app/' . $oldRel);
|
||||
$newLocal = storage_path('app/' . $newRel);
|
||||
$localMoved = false;
|
||||
if (is_file($oldLocal)) {
|
||||
@mkdir(dirname($newLocal), 0755, true);
|
||||
$localMoved = @rename($oldLocal, $newLocal);
|
||||
}
|
||||
Log::info('Merge: moveFile', [
|
||||
'old' => $oldRel, 'new' => $newRel,
|
||||
'nas' => $nasMoved, 'local' => $localMoved,
|
||||
'queued' => !$nasMoved,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a folder on both NAS and local disk. NAS ops are queued in
|
||||
* pending_nas_moves when NAS is unreachable; nas:auto-sync drains them.
|
||||
|
||||
@@ -77,6 +77,11 @@ class HtmlSanitizer
|
||||
/**
|
||||
* Produce safe display HTML. Legacy plain-text values (no tags) are
|
||||
* escaped and converted to <br>; rich values are run through clean().
|
||||
*
|
||||
* Also detects a handful of common markdown-ish prefixes that leaked
|
||||
* in through AI-generated descriptions ("## Letra", "### Chorus") and
|
||||
* turns them into real <h2>/<h3> tags before sanitising. Without this
|
||||
* step a reader saw literal "## Letra" instead of a heading.
|
||||
*/
|
||||
public static function render(?string $value): string
|
||||
{
|
||||
@@ -85,6 +90,8 @@ class HtmlSanitizer
|
||||
return '';
|
||||
}
|
||||
|
||||
$value = self::promoteLeakedMarkdown($value);
|
||||
|
||||
if (strip_tags($value) === $value) {
|
||||
return nl2br(e($value), false);
|
||||
}
|
||||
@@ -92,6 +99,24 @@ class HtmlSanitizer
|
||||
return self::clean($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert markdown-style heading prefixes at the start of a line into
|
||||
* real HTML tags, so the sanitiser can keep them. Only touches lines
|
||||
* that start with ##, ###, or **…**; everything else is untouched.
|
||||
*/
|
||||
private static function promoteLeakedMarkdown(string $value): string
|
||||
{
|
||||
$lines = preg_split("/\r?\n/", $value);
|
||||
foreach ($lines as $i => $line) {
|
||||
if (preg_match('/^\s*###\s+(.+?)\s*$/u', $line, $m)) {
|
||||
$lines[$i] = '<h3>' . e($m[1]) . '</h3>';
|
||||
} elseif (preg_match('/^\s*##\s+(.+?)\s*$/u', $line, $m)) {
|
||||
$lines[$i] = '<h2>' . e($m[1]) . '</h2>';
|
||||
}
|
||||
}
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
private static function sanitizeChildren(DOMNode $node): void
|
||||
{
|
||||
foreach (iterator_to_array($node->childNodes) as $child) {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Notification failures (SMTP down, Gmail 535, Mailgun 4xx, etc.) must never
|
||||
* crash a user-facing request. Everywhere we previously did
|
||||
*
|
||||
* $user->notify(new SomeNotification(...));
|
||||
*
|
||||
* we now do
|
||||
*
|
||||
* SafeNotifier::send($user, new SomeNotification(...));
|
||||
*
|
||||
* which runs the same code, catches any Throwable, and writes a warning line
|
||||
* so ops still has the signal.
|
||||
*/
|
||||
class SafeNotifier
|
||||
{
|
||||
/**
|
||||
* @param mixed $notifiable Anything with ->notify(), usually a User.
|
||||
*/
|
||||
public static function send(mixed $notifiable, Notification $notification): void
|
||||
{
|
||||
if (! $notifiable || ! is_object($notifiable) || ! method_exists($notifiable, 'notify')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$notifiable->notify($notification);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('notification send failed', [
|
||||
'notification' => $notification::class,
|
||||
'notifiable' => $notifiable::class,
|
||||
'notifiable_id'=> $notifiable->getKey() ?? null,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
# Wraps `node` with a raised file-descriptor limit. PHP-FPM starts with
|
||||
# rlimit_files=1024; Chromium opens ~2000 fds on startup and dies before
|
||||
# Puppeteer can attach, surfacing as "Failed to launch the browser
|
||||
# process: Code: null". Bumping just this subprocess tree avoids
|
||||
# restarting FPM to raise the pool-wide limit.
|
||||
exec /usr/bin/prlimit --nofile=65536 /usr/bin/node "$@"
|
||||
+2
-1
@@ -14,7 +14,8 @@
|
||||
"laravel/tinker": "^2.8",
|
||||
"p7h/nas-file-manager": "dev-main",
|
||||
"php-ffmpeg/php-ffmpeg": "^1.4",
|
||||
"pragmarx/google2fa-laravel": "^3.0"
|
||||
"pragmarx/google2fa-laravel": "^3.0",
|
||||
"spatie/browsershot": "^3.61"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.9.1",
|
||||
|
||||
Generated
+340
-1
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "40fa327b55e9b6fafab4b2da3f763724",
|
||||
"content-hash": "bdb64417cd2491642a0712fe40bade22",
|
||||
"packages": [
|
||||
{
|
||||
"name": "bacon/bacon-qr-code",
|
||||
@@ -1457,6 +1457,90 @@
|
||||
],
|
||||
"time": "2025-08-22T14:27:06+00:00"
|
||||
},
|
||||
{
|
||||
"name": "intervention/image",
|
||||
"version": "2.7.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Intervention/image.git",
|
||||
"reference": "04be355f8d6734c826045d02a1079ad658322dad"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Intervention/image/zipball/04be355f8d6734c826045d02a1079ad658322dad",
|
||||
"reference": "04be355f8d6734c826045d02a1079ad658322dad",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-fileinfo": "*",
|
||||
"guzzlehttp/psr7": "~1.1 || ^2.0",
|
||||
"php": ">=5.4.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "~0.9.2",
|
||||
"phpunit/phpunit": "^4.8 || ^5.7 || ^7.5.15"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-gd": "to use GD library based image processing.",
|
||||
"ext-imagick": "to use Imagick based image processing.",
|
||||
"intervention/imagecache": "Caching extension for the Intervention Image library"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"aliases": {
|
||||
"Image": "Intervention\\Image\\Facades\\Image"
|
||||
},
|
||||
"providers": [
|
||||
"Intervention\\Image\\ImageServiceProvider"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-master": "2.4-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Intervention\\Image\\": "src/Intervention/Image"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Oliver Vogel",
|
||||
"email": "[email protected]",
|
||||
"homepage": "https://intervention.io/"
|
||||
}
|
||||
],
|
||||
"description": "Image handling and manipulation library with support for Laravel integration",
|
||||
"homepage": "http://image.intervention.io/",
|
||||
"keywords": [
|
||||
"gd",
|
||||
"image",
|
||||
"imagick",
|
||||
"laravel",
|
||||
"thumbnail",
|
||||
"watermark"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Intervention/image/issues",
|
||||
"source": "https://github.com/Intervention/image/tree/2.7.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://paypal.me/interventionio",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/Intervention",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2022-05-21T17:30:32+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/framework",
|
||||
"version": "10.50.2",
|
||||
@@ -2237,6 +2321,71 @@
|
||||
},
|
||||
"time": "2026-01-23T15:30:45+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/glide",
|
||||
"version": "2.3.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/glide.git",
|
||||
"reference": "b8e946dd87c79a9dce3290707ab90b5b52602813"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/glide/zipball/b8e946dd87c79a9dce3290707ab90b5b52602813",
|
||||
"reference": "b8e946dd87c79a9dce3290707ab90b5b52602813",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"intervention/image": "^2.7",
|
||||
"league/flysystem": "^2.0|^3.0",
|
||||
"php": "^7.2|^8.0",
|
||||
"psr/http-message": "^1.0|^2.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "^1.3.3",
|
||||
"phpunit/php-token-stream": "^3.1|^4.0",
|
||||
"phpunit/phpunit": "^8.5|^9.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"League\\Glide\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Jonathan Reinink",
|
||||
"email": "[email protected]",
|
||||
"homepage": "http://reinink.ca"
|
||||
},
|
||||
{
|
||||
"name": "Titouan Galopin",
|
||||
"email": "[email protected]",
|
||||
"homepage": "https://titouangalopin.com"
|
||||
}
|
||||
],
|
||||
"description": "Wonderfully easy on-demand image manipulation library with an HTTP based API.",
|
||||
"homepage": "http://glide.thephpleague.com",
|
||||
"keywords": [
|
||||
"ImageMagick",
|
||||
"editing",
|
||||
"gd",
|
||||
"image",
|
||||
"imagick",
|
||||
"league",
|
||||
"manipulation",
|
||||
"processing"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/thephpleague/glide/issues",
|
||||
"source": "https://github.com/thephpleague/glide/tree/2.3.2"
|
||||
},
|
||||
"time": "2025-03-21T13:48:39+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/mime-type-detection",
|
||||
"version": "1.16.0",
|
||||
@@ -4021,6 +4170,196 @@
|
||||
},
|
||||
"time": "2025-12-14T04:43:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/browsershot",
|
||||
"version": "3.61.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spatie/browsershot.git",
|
||||
"reference": "14d75679390b8b84a71b3a17dc5905928deeb887"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/spatie/browsershot/zipball/14d75679390b8b84a71b3a17dc5905928deeb887",
|
||||
"reference": "14d75679390b8b84a71b3a17dc5905928deeb887",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"php": "^8.0",
|
||||
"spatie/image": "^1.5.3|^2.0",
|
||||
"spatie/temporary-directory": "^1.1|^2.0",
|
||||
"symfony/process": "^4.2|^5.0|^6.0|^7.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"pestphp/pest": "^1.20",
|
||||
"spatie/phpunit-snapshot-assertions": "^4.2.3"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Spatie\\Browsershot\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Freek Van der Herten",
|
||||
"email": "[email protected]",
|
||||
"homepage": "https://github.com/freekmurze",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Convert a webpage to an image or pdf using headless Chrome",
|
||||
"homepage": "https://github.com/spatie/browsershot",
|
||||
"keywords": [
|
||||
"chrome",
|
||||
"convert",
|
||||
"headless",
|
||||
"image",
|
||||
"pdf",
|
||||
"puppeteer",
|
||||
"screenshot",
|
||||
"webpage"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/spatie/browsershot/tree/3.61.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/spatie",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2023-12-21T10:00:28+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/image",
|
||||
"version": "2.2.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spatie/image.git",
|
||||
"reference": "2f802853aab017aa615224daae1588054b5ab20e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/spatie/image/zipball/2f802853aab017aa615224daae1588054b5ab20e",
|
||||
"reference": "2f802853aab017aa615224daae1588054b5ab20e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-exif": "*",
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"league/glide": "^2.2.2",
|
||||
"php": "^8.0",
|
||||
"spatie/image-optimizer": "^1.7",
|
||||
"spatie/temporary-directory": "^1.0|^2.0",
|
||||
"symfony/process": "^3.0|^4.0|^5.0|^6.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"pestphp/pest": "^1.22",
|
||||
"phpunit/phpunit": "^9.5",
|
||||
"symfony/var-dumper": "^4.0|^5.0|^6.0",
|
||||
"vimeo/psalm": "^4.6"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Spatie\\Image\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Freek Van der Herten",
|
||||
"email": "[email protected]",
|
||||
"homepage": "https://spatie.be",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Manipulate images with an expressive API",
|
||||
"homepage": "https://github.com/spatie/image",
|
||||
"keywords": [
|
||||
"image",
|
||||
"spatie"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/spatie/image/tree/2.2.7"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://spatie.be/open-source/support-us",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/spatie",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2023-07-24T13:54:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/image-optimizer",
|
||||
"version": "1.10.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spatie/image-optimizer.git",
|
||||
"reference": "333c03952289dc2df0a91874636a0dffeb5b6aec"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/spatie/image-optimizer/zipball/333c03952289dc2df0a91874636a0dffeb5b6aec",
|
||||
"reference": "333c03952289dc2df0a91874636a0dffeb5b6aec",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-fileinfo": "*",
|
||||
"php": "^7.4|^8.0",
|
||||
"psr/log": "^1.0 | ^2.0 | ^3.0",
|
||||
"symfony/process": "^4.2|^5.0|^6.0|^7.0|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"pestphp/pest": "^1.21|^2.0|^3.0|^4.0",
|
||||
"phpunit/phpunit": "^8.5.21|^9.4.4|^10.0|^11.0|^12.0",
|
||||
"symfony/var-dumper": "^4.2|^5.0|^6.0|^7.0|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Spatie\\ImageOptimizer\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Freek Van der Herten",
|
||||
"email": "[email protected]",
|
||||
"homepage": "https://spatie.be",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Easily optimize images using PHP",
|
||||
"homepage": "https://github.com/spatie/image-optimizer",
|
||||
"keywords": [
|
||||
"image-optimizer",
|
||||
"spatie"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/spatie/image-optimizer/issues",
|
||||
"source": "https://github.com/spatie/image-optimizer/tree/1.10.0"
|
||||
},
|
||||
"time": "2026-06-29T08:28:30+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/temporary-directory",
|
||||
"version": "2.3.1",
|
||||
|
||||
@@ -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
|
||||
```
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
@@ -0,0 +1,391 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Bundled Page</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: #0a0a0e; display: flex; align-items: center; justify-content: center; min-height: 100vh; font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
|
||||
#__bundler_loading { position: fixed; bottom: 20px; right: 20px; font: 13px/1.4 -apple-system, BlinkMacSystemFont, sans-serif; color: #666; background: #fff; padding: 8px 14px; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.12); z-index: 10000; }
|
||||
#__bundler_thumbnail { position: fixed; inset: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: #0a0a0e; z-index: 9999; }
|
||||
#__bundler_thumbnail svg { width: 100%; height: 100%; object-fit: contain; }
|
||||
#__bundler_placeholder { color: #999; font-size: 14px; }
|
||||
</style>
|
||||
<noscript>
|
||||
<style>#__bundler_loading { display: none; }</style>
|
||||
<div style="position:fixed;bottom:12px;left:12px;font:13px/1.4 -apple-system,BlinkMacSystemFont,sans-serif;color:#999;background:rgba(255,255,255,0.9);padding:6px 12px;border-radius:6px;box-shadow:0 1px 4px rgba(0,0,0,0.08);z-index:10000;">
|
||||
This page requires JavaScript to display.
|
||||
</div>
|
||||
</noscript>
|
||||
</head>
|
||||
<body>
|
||||
<div id="__bundler_thumbnail">
|
||||
<svg viewBox="0 0 1200 800" xmlns="http://www.w3.org/2000/svg">
|
||||
<polygon points="0,0 620,0 480,800 0,800" fill="#7a2226"></polygon>
|
||||
<polygon points="580,0 1200,0 1200,800 720,800" fill="#22357a"></polygon>
|
||||
<text x="600" y="470" font-family="Arial Black, Arial, sans-serif" font-size="220" font-weight="900" font-style="italic" text-anchor="middle" fill="#fffdf5" stroke="#e8c265" stroke-width="4">VS</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div id="__bundler_loading">Unpacking...</div>
|
||||
|
||||
<script>
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
const loading = document.getElementById('__bundler_loading');
|
||||
function setStatus(msg) { if (loading) loading.textContent = msg; }
|
||||
|
||||
const FONT_MIME = /^(font[/]|application[/](x-)?font-|application[/]vnd\.ms-fontobject)/i;
|
||||
const MIME_TOKEN = /^[\w.+-]+[/][\w.+-]+$/;
|
||||
function toBase64(bytes) {
|
||||
let bin = '';
|
||||
for (let i = 0; i < bytes.length; i += 0x8000) {
|
||||
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
|
||||
}
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
// Error sink persists across replaceWith since it's on window, not the DOM.
|
||||
window.addEventListener('error', function(e) {
|
||||
// Failed resource loads (CSP-blocked links, scripts, images) fire plain
|
||||
// events at the element — warn only; real JS errors carry message/error.
|
||||
if (!e.message && !e.error && e.target && e.target !== window) {
|
||||
console.warn('[bundle] resource failed to load:',
|
||||
e.target.tagName, String(e.target.src || e.target.href || ''));
|
||||
return;
|
||||
}
|
||||
var p = document.body || document.documentElement;
|
||||
var d = document.getElementById('__bundler_err') || p.appendChild(document.createElement('div'));
|
||||
d.id = '__bundler_err';
|
||||
d.style.cssText = 'position:fixed;bottom:12px;left:12px;right:12px;font:12px/1.4 ui-monospace,monospace;background:#2a1215;color:#ff8a80;padding:10px 14px;border-radius:8px;border:1px solid #5c2b2e;z-index:99999;white-space:pre-wrap;max-height:40vh;overflow:auto';
|
||||
d.textContent = (d.textContent ? d.textContent + String.fromCharCode(10) : '') +
|
||||
'[bundle] ' + (e.message || e.type) +
|
||||
(e.filename ? ' (' + e.filename.slice(0, 60) + ':' + e.lineno + ')' : '');
|
||||
}, true);
|
||||
|
||||
try {
|
||||
const manifestEl = document.querySelector('script[type="__bundler/manifest"]');
|
||||
const templateEl = document.querySelector('script[type="__bundler/template"]');
|
||||
if (!manifestEl || !templateEl) {
|
||||
setStatus('Error: missing bundle data');
|
||||
console.error('[bundler] Missing script tags — manifestEl:', !!manifestEl, 'templateEl:', !!templateEl);
|
||||
return;
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(manifestEl.textContent);
|
||||
let template = JSON.parse(templateEl.textContent);
|
||||
|
||||
// Nested page bundles (iframe targets). Each ships ONCE, in the ROOT
|
||||
// document's manifest; frame srcs carry an about:blank#<uuid> marker
|
||||
// instead of a substitutable uuid. Every document mints the blobs for
|
||||
// its OWN frames — from local text at the root, or text obtained from
|
||||
// its DIRECT parent over the relay protocol below — so each frame
|
||||
// navigation stays same-origin with its initiator and a deduped page
|
||||
// loads at any depth on any host, including a file:// download's
|
||||
// opaque origins (where a blob minted by one document cannot be
|
||||
// navigated to by another).
|
||||
const pageOrderEl = document.querySelector('script[type="__bundler/page_order"]');
|
||||
const pageOrder = pageOrderEl ? JSON.parse(pageOrderEl.textContent) : [];
|
||||
const pageSet = new Set(pageOrder);
|
||||
const pageTexts = {};
|
||||
|
||||
// ── Nested-page text protocol (parent-chain relay) ──
|
||||
// Page bundles ship once, in the ROOT document's manifest. Every
|
||||
// document mints the blobs for its OWN frames, obtaining page text
|
||||
// through a strict parent-chain relay: a document only ever SENDS to
|
||||
// its direct parent (which, for every document the publisher's own
|
||||
// runtime minted, is another publisher document) and only ACCEPTS
|
||||
// text from that direct parent; requests from below are honored only
|
||||
// for the document's own child frames, and the ROOT never sends
|
||||
// upward at all. That last rule is load-bearing: the artifact host's
|
||||
// frame-ancestors allows claude.ai (and friends) to frame an
|
||||
// artifact, so window.top can be a FOREIGN document — a top-addressed
|
||||
// protocol would hand page uuids to that ancestor and accept forged
|
||||
// "page text" from it, minting attacker HTML as an artifact-origin
|
||||
// blob document. The chain never touches the ancestor: requests stop
|
||||
// at the root, replies are origin-addressed per hop (never '*' when
|
||||
// an origin exists), and both directions are origin-gated (on https
|
||||
// a foreign sender's origin can never equal this document's; in
|
||||
// opaque contexts — file:// — everything serializes as 'null', where
|
||||
// an embedded EXTERNAL frame still fails the gate by carrying its
|
||||
// own real origin, and a sandboxed child of such a frame is excluded
|
||||
// by the own-child-frames check plus uuid unguessability). Shape is
|
||||
// deny-by-default: one request form, one reply form, no error
|
||||
// replies.
|
||||
// window.origin is the SERIALIZED SECURITY origin ('null' in opaque
|
||||
// contexts such as a sandboxed preview iframe) — location.origin is
|
||||
// computed from the URL and stays 'https://…' even when the document
|
||||
// is sandboxed, which would misgate every opaque chain.
|
||||
const OWN_TARGET = /^https?:[/][/]/.test(window.origin || '')
|
||||
? window.origin
|
||||
: '*';
|
||||
function trustedOrigin(e) {
|
||||
if (e.origin === window.origin) return true; // https chains; opaque==opaque
|
||||
// file:// trees: Chrome serializes the file document's origin as
|
||||
// 'file://' while the blob: documents it mints serialize as 'null',
|
||||
// so the two legitimate shapes can never string-match — accept the
|
||||
// pair in both directions. https origins never serialize as either,
|
||||
// and sender identity (own child frame / direct parent) is checked
|
||||
// separately.
|
||||
return (
|
||||
(e.origin === 'null' &&
|
||||
(window.origin === 'file://' ||
|
||||
window.location.protocol === 'file:')) ||
|
||||
(/^file:/.test(e.origin) && window.origin === 'null')
|
||||
);
|
||||
}
|
||||
// Non-empty only in the root document (nested bundles ship an empty
|
||||
// island), so it doubles as the root marker: the root answers from
|
||||
// local text and treats a miss as authoritative — never relaying to
|
||||
// its (possibly foreign) parent.
|
||||
const isPageRoot = pageOrder.length > 0;
|
||||
const blobUrls = {};
|
||||
const resourceBlobs = {};
|
||||
const UUID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const pendingFrames = {}; // uuid -> [{el, frag}] awaiting text for own frames
|
||||
const pendingRelays = {}; // uuid -> [childWindow] awaiting text to pass down
|
||||
function mountPage(el, frag, text) {
|
||||
const pageBlob = new Blob([text], { type: 'text/html' });
|
||||
const u = URL.createObjectURL(pageBlob);
|
||||
resourceBlobs[u] = pageBlob;
|
||||
el.src = u + frag;
|
||||
}
|
||||
function deliverPage(uuid, text) {
|
||||
const frames = pendingFrames[uuid];
|
||||
delete pendingFrames[uuid];
|
||||
if (frames) for (const f of frames) mountPage(f.el, f.frag, text);
|
||||
const relays = pendingRelays[uuid];
|
||||
delete pendingRelays[uuid];
|
||||
if (relays) {
|
||||
for (const cw of relays) {
|
||||
try { cw.postMessage({ __bundler_page: uuid, __bundler_text: text }, OWN_TARGET); } catch (err) { /* gone */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
function ownFrameWindows() {
|
||||
const out = [];
|
||||
for (const f of Array.from(document.querySelectorAll('iframe'))) {
|
||||
try { if (f.contentWindow) out.push(f.contentWindow); } catch (err) { /* detached */ }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
window.addEventListener('message', function (e) {
|
||||
const d = e.data;
|
||||
if (!d || typeof d !== 'object' || !e.source) return;
|
||||
// Origin gate first; the structural checks below (own-child-frames
|
||||
// for requests, direct-parent + solicited-uuid for replies, the
|
||||
// root's never-relay rule) are what hold in fully opaque chains
|
||||
// where origin strings can't discriminate — a sandboxed root's
|
||||
// FOREIGN wrapper still fails all three even though both serialize
|
||||
// as real-vs-'null' rather than matching.
|
||||
if (!trustedOrigin(e)) return;
|
||||
if (typeof d.__bundler_need === 'string' && UUID_SHAPE.test(d.__bundler_need)) {
|
||||
// Requests are honored only for this document's own child frames.
|
||||
if (ownFrameWindows().indexOf(e.source) < 0) return;
|
||||
const uuid = d.__bundler_need;
|
||||
const text = pageTexts[uuid];
|
||||
if (typeof text === 'string') {
|
||||
try { e.source.postMessage({ __bundler_page: uuid, __bundler_text: text }, OWN_TARGET); } catch (err) { /* gone */ }
|
||||
return;
|
||||
}
|
||||
if (isPageRoot || window.parent === window) return; // authoritative miss
|
||||
const waiting = pendingRelays[uuid];
|
||||
if (waiting) {
|
||||
if (waiting.indexOf(e.source) < 0) waiting.push(e.source);
|
||||
return;
|
||||
}
|
||||
// Never-answered uuids would otherwise accumulate forever under a
|
||||
// request spray; past the cap (far above any real page count) new
|
||||
// relays are dropped rather than remembered.
|
||||
if (Object.keys(pendingRelays).length >= 128) return;
|
||||
pendingRelays[uuid] = [e.source];
|
||||
window.parent.postMessage({ __bundler_need: uuid }, OWN_TARGET);
|
||||
} else if (
|
||||
typeof d.__bundler_page === 'string' &&
|
||||
UUID_SHAPE.test(d.__bundler_page) &&
|
||||
typeof d.__bundler_text === 'string'
|
||||
) {
|
||||
// The shape test is load-bearing beyond tidiness: the solicited
|
||||
// check below uses the in operator, which matches prototype keys
|
||||
// ('__proto__', 'constructor') — an unshaped key would reach
|
||||
// deliverPage and throw, painting the error banner.
|
||||
// Text is accepted only from the direct parent — the document
|
||||
// that minted this one.
|
||||
if (e.source !== window.parent || window.parent === window) return;
|
||||
if (!(d.__bundler_page in pendingFrames) && !(d.__bundler_page in pendingRelays)) return;
|
||||
deliverPage(d.__bundler_page, d.__bundler_text);
|
||||
}
|
||||
});
|
||||
|
||||
const uuids = Object.keys(manifest);
|
||||
setStatus('Unpacking ' + uuids.length + ' assets...');
|
||||
|
||||
await Promise.all(uuids.map(async (uuid) => {
|
||||
const entry = manifest[uuid];
|
||||
try {
|
||||
const binaryStr = atob(entry.data);
|
||||
const bytes = new Uint8Array(binaryStr.length);
|
||||
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i);
|
||||
|
||||
let finalBytes = bytes;
|
||||
if (entry.compressed) {
|
||||
if (typeof DecompressionStream !== 'undefined') {
|
||||
const ds = new DecompressionStream('gzip');
|
||||
const writer = ds.writable.getWriter();
|
||||
const reader = ds.readable.getReader();
|
||||
writer.write(bytes);
|
||||
writer.close();
|
||||
const chunks = [];
|
||||
let totalLen = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
totalLen += value.length;
|
||||
}
|
||||
finalBytes = new Uint8Array(totalLen);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) { finalBytes.set(chunk, offset); offset += chunk.length; }
|
||||
} else {
|
||||
console.warn('DecompressionStream not available, asset ' + uuid + ' may not render');
|
||||
}
|
||||
}
|
||||
|
||||
if (pageSet.has(uuid)) {
|
||||
pageTexts[uuid] = new TextDecoder().decode(finalBytes);
|
||||
return;
|
||||
}
|
||||
if (FONT_MIME.test(entry.mime) && MIME_TOKEN.test(entry.mime)) {
|
||||
// Strict artifact hosts allow font-src data: but not blob:.
|
||||
blobUrls[uuid] = 'data:' + entry.mime + ';base64,' +
|
||||
(entry.compressed ? toBase64(finalBytes) : entry.data);
|
||||
} else {
|
||||
const blob = new Blob([finalBytes], { type: entry.mime });
|
||||
blobUrls[uuid] = URL.createObjectURL(blob);
|
||||
resourceBlobs[blobUrls[uuid]] = blob;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to decode asset ' + uuid + ':', err);
|
||||
const blob = new Blob([], { type: entry.mime });
|
||||
blobUrls[uuid] = URL.createObjectURL(blob);
|
||||
resourceBlobs[blobUrls[uuid]] = blob;
|
||||
}
|
||||
}));
|
||||
|
||||
const extResEl = document.querySelector('script[type="__bundler/ext_resources"]');
|
||||
const extResources = extResEl ? JSON.parse(extResEl.textContent) : [];
|
||||
const resourceMap = {};
|
||||
for (const entry of extResources) {
|
||||
if (blobUrls[entry.uuid]) resourceMap[entry.id] = blobUrls[entry.uuid];
|
||||
}
|
||||
|
||||
// Artifact-host CSP (connect-src 'self') refuses fetch() of blob: URLs —
|
||||
// consumers read these Blobs directly. Survives the swap like the error sink.
|
||||
window.__resourceBlobs = resourceBlobs;
|
||||
|
||||
setStatus('Rendering...');
|
||||
// Page uuids have no blob here — they ride inside about:blank#<uuid>
|
||||
// frame markers the pass below resolves after the swap.
|
||||
for (const uuid of uuids) {
|
||||
if (pageSet.has(uuid)) continue;
|
||||
template = template.split(uuid).join(blobUrls[uuid]);
|
||||
}
|
||||
|
||||
// Strip integrity + crossorigin — blob URLs from a file:// document inherit
|
||||
// a null origin, so crossorigin forces a CORS fetch that SRI then rejects.
|
||||
// The manifest bytes are ours; SRI protects against CDN compromise, not this.
|
||||
template = template.replace(/\s+integrity="[^"]*"/gi, '').replace(/\s+crossorigin="[^"]*"/gi, '');
|
||||
|
||||
const resourceScript = '<script>window.__resources = ' +
|
||||
JSON.stringify(resourceMap).replace(/<\//g, '<\\/') +
|
||||
';</' + 'script>';
|
||||
// Inject after <head> so the DOCTYPE stays first; prepending the script
|
||||
// would push the parser into quirks mode. DOMParser always emits a <head>
|
||||
// (synthesizing one if the source HTML omitted it) but may carry
|
||||
// attributes through, so match the full opening tag. slice() rather than
|
||||
// replace() keeps us clear of $-pattern substitution in resourceScript.
|
||||
const headOpen = template.match(/<head[^>]*>/i);
|
||||
if (headOpen) {
|
||||
const i = headOpen.index + headOpen[0].length;
|
||||
template = template.slice(0, i) + resourceScript + template.slice(i);
|
||||
}
|
||||
|
||||
// Parse the template and swap the root element. Scripts inserted via
|
||||
// DOMParser/replaceWith are inert per spec — re-create each with
|
||||
// createElement so they execute, awaiting onload for src scripts to
|
||||
// preserve ordering (React before ReactDOM before Babel before text/babel).
|
||||
const doc = new DOMParser().parseFromString(template, 'text/html');
|
||||
document.documentElement.replaceWith(doc.documentElement);
|
||||
|
||||
// Resolve page-frame markers: mint this document's own blob for each
|
||||
// framed page — from local text when this is the root, otherwise by
|
||||
// asking the direct parent over the relay protocol above. An
|
||||
// unanswered request leaves the frame on about:blank, the same
|
||||
// degraded state as a refused embed.
|
||||
{
|
||||
const MARKER = /^about:blank#([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(#.*)?$/;
|
||||
const needed = [];
|
||||
for (const el of Array.from(document.querySelectorAll('iframe'))) {
|
||||
const m = MARKER.exec(el.getAttribute('src') || '');
|
||||
if (!m) continue;
|
||||
const text = pageTexts[m[1]];
|
||||
if (typeof text === 'string') {
|
||||
mountPage(el, m[2] || '', text);
|
||||
} else if (window.parent !== window && !isPageRoot) {
|
||||
const had = pendingFrames[m[1]];
|
||||
(pendingFrames[m[1]] = had || []).push({ el: el, frag: m[2] || '' });
|
||||
if (!had) needed.push(m[1]);
|
||||
}
|
||||
}
|
||||
for (const uuid of needed) {
|
||||
window.parent.postMessage({ __bundler_need: uuid }, OWN_TARGET);
|
||||
}
|
||||
}
|
||||
|
||||
const dead = Array.from(document.scripts);
|
||||
for (const old of dead) {
|
||||
const s = document.createElement('script');
|
||||
for (const a of old.attributes) s.setAttribute(a.name, a.value);
|
||||
s.textContent = old.textContent;
|
||||
// text/babel scripts with a src: read and inline. transformScriptTags
|
||||
// does XHR against the src, but blob:null/ from a file:// origin is
|
||||
// silently dropped. Inlining makes it a plain inline babel script,
|
||||
// which transformScriptTags handles unconditionally.
|
||||
if ((s.type === 'text/babel' || s.type === 'text/jsx') && s.src) {
|
||||
const pre = resourceBlobs[s.src.split('#')[0]];
|
||||
s.textContent = pre ? await pre.text() : await (await fetch(s.src)).text();
|
||||
s.removeAttribute('src');
|
||||
}
|
||||
const p = s.src ? new Promise(function(r) { s.onload = s.onerror = r; }) : null;
|
||||
old.replaceWith(s);
|
||||
if (p) await p;
|
||||
}
|
||||
// Babel standalone auto-transforms type=text/babel on DOMContentLoaded,
|
||||
// which fired before we swapped the document. Trigger manually if present.
|
||||
if (window.Babel && typeof window.Babel.transformScriptTags === 'function') {
|
||||
window.Babel.transformScriptTags();
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('Error unpacking: ' + err.message);
|
||||
console.error('Bundle unpack error:', err);
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<script type="__bundler/manifest">
|
||||
{"634cd639-9aa5-4e99-93cf-a35ae77b2724":{"mime":"image/webp","compressed":false,"data":"UklGRvTaAQBXRUJQVlA4WAoAAAAgAAAAkQMArwQASUNDUMgBAAAAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADZWUDggBtkBAHBCB50BKpIDsAQ+USaPRaOiISclkmqI4AoJaUCysa0j+oobVxBkm/YlAdvU4vUrVn6p/y94G65+i5wliAHgw8xP2g6YP/E9Bf6W6fv+j48ftDHnGLqhM79X/6s2CCH3N0IXpT46b8k8mP0D/A72/z37l/sf5H0JPq7xJex/4P/1/3/qP/R/0d/h/y/tn/zPAn9o/wPQO/WP8l/5vVqgWeGqBO4q+9/+3+29Zf2L/V//L/S/AJ/Rv8B/6P8t7c/9zxXfyf/S/cz4Cf7D/r/WD/5f/96Cv2n/pew5+037adu0oBDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcDjwXbEMuTM5CeDg0/WbfYG+wN9gb7A32BvsDfYG+wN9gb7A32BTOiZ/s/u9oCmXy05wZPHPMfTnL633x7vzZzRd7E74ap4Mp2WUd0VyWrhkClM+spcZ1VTpB0aQjld7eiVLVtmAfWEdANCJ0eFvsDfYG+wN9gb7A32BvsDfYG+wN9gbJLYFTAG0xAzI6yHjv74Wcp42UesdhAWEll0Mz77JaUHlulxgqy+mQczgDbGkrVJcHFlufap6I14MODzHIj4F5x2QValCk7J2+xcfxzZ5PjhYM+KVx57CekqiCUSkolJRKSiUlEpKJSUSkolJRI5nMN8+PfaP/ppGXaK90lr75ZWuF9bNrauU95rEzdUb1C1qZvsyk6ee95lySUR1MMLe+EaEbA+E+8nlgXyG6OR9nH0ZnNIalWTZMQkfeTPYorOy1Sbnr6WUqF6yG3G36Um43KVRDvK9XenCUSkolJRKSiUlEpKJSUSkolJQw8SkbOxva7a/dbRhay/9p6k1ps2tLL3PsGeB3wmUc2O1r6Ko/YIvzJQc3U823Qht+gk3DOwMXSPzPd1Rvk4qGS4lADC6jMNLREiRDWbffaISXTWM1SYEApKk342E26eK2J/CS/KKc8EVwEA5hmsX9nP8COuVi3YchhVhMrBh3BDuCHcEO4IdwQ7gh3BDuBiybDoJyHc6CgsI2nhNxcK4ivBgZvAqaCNDzcelDlw/42MaI44iLd6aOUkPa8wOZjckLrCfg74lJifr6Fl7z9i11am5PIHpxW5Lh4k3BIe8znZeLqHsmSU30RQmaCNtofvX3laWBKIwywl3UO+EhtmqXCHcXy9ynkiDU0YfurvhvE7zx522PN2UlEpKJSUSkolJRKSiUlEpJMXhiiR1qJ8wdnNkUdpHrFPa6B9Trd/6xgHcCtMzbEUX/yr6C3e1ucYiafg9kimrNPFPNxmJOBhvdJ6EmCz8EO4r/DHV+z4OTGDW6wudaAGkifd4DZNTLCVHs3KSzJSwdsATv7gqklIqM1QZFTGQHReShA0/1asKVrvbdM+rofcn/JfddtaGmGOujFdGK6MV0YroxXRiujFNpLVXaxIvpx3hifcd7liCmLjfmrHyV65wDvnMSivzOk/Z1pIBa4mVBTb0y7SwY+7z8xyyMmQh7kNSCrFl216OJd35YarFe6rX2AfRpOHXPXxGUOSSr2krq58AObG7z0K9/pMo/OCzuaroHIiScJkSv32huF6MHQ1EzJJR5UpRGEUD3GcQugdd7ZLSdd7C/gNgidIbgEXlrqT32z1GaJEh6JD0SHokPRIeiQ9Eh6AoJPaRhTn8rsZCbovqMXKFY/fqFYv4kryByMluIi9b85ACjpfIpD/n+uHIEY1/vy9NW8J3mHKIVMWvDrHwvArTbWahABxEn/4MfQwOL8e7qWBz036DLHmnEb6wb/G5cLoWVPRU0HNWVPMI0D+hUvL4wSEXmqJSoqWpegUU2X3xLFRTvB+Ad8dVl8Mu0Q9+vxUoVpeb8YgLCE9kxSkIOV+AfV5eCHcEO4IdwQ7gh3BDuCFudkHNn5AZZJm1k14VrlyYOrkTelg986azm/sU7cIQaLRH3awJCRbooUEC1Ek75bzWrEehXhOOoXXASBjvqypB4A+nf45d6rSL0PzbPObtdlLIInJHdRsx3AXtWpgkXu56zIjFFO/o+8RrGqY5H+7+eh0q2fm/qMZo82AyCujHkwpC6uQnxt+ql1w7GbuDk37GsOcp5hMnB2QCSIgqpHEs1irSpVkFAqnf9QB4e32BvsDfYG+wN9gb7A3xLiZxiATkzmUJil8/0ZNBhV4qpM2PxOXeZXpwESkXrLO9Y72EVtFaCZOhDEXNyNUDS3bqkmbuaB//9p5MGu0n7KE+Sua45KtgLXknZ58fqHl3wp/qhSl1RmR4H7OCRsH1LSr9cTAqRdB1XbiZQXi8l5dgFyzr8NYMcytr0Rma04S4DasMs7H7eT6pgUE4VvYL1kuHUgW6rEZHE3cyrodkBF3xzc8cEUIbKndn0KwnRBKJSUSkolJRKSiUlEo6kOI5c0wxEFWb8fKoVSLzQhwoEV2MY9584Z4wHZdqj5hEWu76ISmrh5Uwg1qecy/b56qr4pOkVwH191QtcafaXzo+AIveV774pKr6odSyZVIg5ZIljpmLXwcOuu7Z6h73OvualT+AcqPMvtZtPHvgrjbmUdKcPrrHt+qPrbYijbyHrtylxcO+2AYr3qSd0GzGfmHYF7w1WGCGtFUQygCMWGgHldn3uCTnMm4LrWiOLj+zKHZXpLqgva1UJn0QN4gbxA3iBvEDeIG8BeGRNnhORiTRsYCTwm/Wyboie+RH2TDnrkA49wBhTEJOPZdDoPkh9bID5uGOVRyd6KJ/LZKWedSQT6xv/kd1jsNnX4Gcq+KpK+ks1iLdxAvL9dVOZgQCfbhRJ9Ud6IbNRhpNxA8CHurPmsBAQnl3oGyYIchZBfFZhEx4htp24RLwPhFdG3ePXKgWQKgdLAnipfcb4hsmdCbQcb8YTYK9jf0I+Lf8SWjeRLN123mPthgspiCA9QroxXRiujFdGK6MV0Il+A16DJE5OEN3WAAO8kM0Gy485YQCYkQdlxoxxq4P6dDkoToeua2tOq3FGHc7nVRxF9OZOwhai69+EtTg3HXkEzcTBRRL6ZG9FrDA386i697WywkkzNmh1WSxHSWZ7lLLNn1WEr7lcy0VqClSbvIvMah3YoGEMG0WJDTxY79+LgT4lHBErwe/2kgQ/nCotjPRHK+BonkFVqtkCujFdGK6MV0YroxXRfPkMZzx6+PtaN2HAqNrO/UfyYO3iuO87jvHAsmWu9zk45ub4UsguiESaAjsu1dn2Evvmgtl0hy4xNxwTlgC2xlW7XU+C2J4TxB0u55GgYDmwnDNbdsArbMWq5ZGOMAjfUFR8gH+RmxmtRJ72OF3+RbVt+d8nIILRohEJLI7oIEqjvf/t+dzC3h3MqL4+Hgpa1WQgB2/1A7fYG+wN9gb7A32BukcZ3BdYzmixZOKsedwI+gJZIEDEVvq2SYOBlfC5HWKGRKbyrrUdklIWAX0QMNmf7j/DfV98yzm0asQT1pPnq5/xg+0+QOWgJIvkpUWXp/o5qqGwgmPBfImydZJwnTmzk1+K4jAWI6RWlDOaXMTWLuMH4WlOUc1FaDooj0wDXJ7+mJnAkLpNQEYHjah7GD3EV2Yk3a6MV0YroxXRiujFbwBf8c8AZMz+AWk6vaLearIoD6L134Ryaahu7ieexHDqchVojiWPc1bOmbZjGao22xkxeqLOSMQA3uOLPoN0tJ0OsldnW3U0bW9KtZD7LP57F5tAmYRkz7j8cHvUnSzVbfIwFVz7ESOXwQ2qG1BjGg1r3M3oAzfNa2lk1krwG0o8BqOng2vr1+YPQvF+GJWIRH2QyotACNbIfhvoN570YroxXRiujFdGK6EeiOZU5zmBSHaf6HJggxi2sKqoXKnYq+OYy5NwTDmHOB0MjNm79HKeAxMnaXfn6KNXaUIuyrUAg3l0+HUpmJF7pUSrIROA4y+eqbDB4QUTuRnZszseLy5uQSnW+2kbHuwEj9WeRW8TXS5r4mhL7MkqNxC3L4lijAjLundafKs4kV3W+sqBcE8VcdxLbE36k9MaVY93F882ef8bVdIG7nQL9sfp8ThWqUlEpLine truncated
|
||||
</script>
|
||||
|
||||
<script type="__bundler/ext_resources">
|
||||
[{"id":"flagJp","uuid":"8f819462-17b1-46f6-8aa3-2de374e257f7"},{"id":"flagBr","uuid":"cc3aaea8-173f-4338-b7fa-2bae75a84d2b"},{"id":"https://unpkg.com/[email protected]/umd/react.production.min.js","uuid":"dc229245-056d-4703-a7d0-641172e38b84"},{"id":"https://unpkg.com/[email protected]/umd/react-dom.production.min.js","uuid":"96676beb-9588-43e1-aef2-459578e9a205"}]
|
||||
</script>
|
||||
|
||||
<script type="__bundler/page_order">
|
||||
[]
|
||||
</script>
|
||||
|
||||
<script type="__bundler/template">
|
||||
"<!DOCTYPE html>\n<html><head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<script src=\"efd1862c-8a2a-4b74-9741-9eb4a7b79af6\"><\u002Fscript>\n\n\n\n<\u002Fhead>\n<body>\n<x-dc>\n<helmet>\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<style>/* vietnamese */\n@font-face {\n font-family: 'Anton';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"786f769a-eb68-482a-90e4-5f4c6341055c\") format('woff2');\n unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n font-family: 'Anton';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"e50aced0-6710-4914-b239-a64494fce97d\") format('woff2');\n unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: 'Anton';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"66dcdbee-b198-4b92-90c2-93c3b6f9111b\") format('woff2');\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n/* vietnamese */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"0be5c901-33ac-4507-bb55-5dafed4159e7\") format('woff2');\n unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"2f8f7fe2-ce99-4a10-b583-ecf8c782a45d\") format('woff2');\n unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"754fb47d-ebf2-4909-b5a0-f9abb2ed5c4d\") format('woff2');\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n/* vietnamese */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 600;\n font-display: swap;\n src: url(\"7b059d00-08e1-424c-9715-c5b65f9a9411\") format('woff2');\n unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 600;\n font-display: swap;\n src: url(\"faa370e3-1d36-48e0-b7c4-1a760ef73c77\") format('woff2');\n unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 600;\n font-display: swap;\n src: url(\"dcfb04b8-57f1-4948-b81e-dcad7d26a879\") format('woff2');\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n/* vietnamese */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 700;\n font-display: swap;\n src: url(\"eb64d5f4-973e-4865-8cd1-89be941b16be\") format('woff2');\n unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 700;\n font-display: swap;\n src: url(\"11b56c5f-a515-42d7-bf11-6aa8bed5ecf6\") format('woff2');\n unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 700;\n font-display: swap;\n src: url(\"3cf32598-e7d4-4db2-8d36-ceee8f725195\") format('woff2');\n unicode-range: U+0000-00FF, U+0131, U+0152Line truncated
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,391 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Bundled Page</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: #0a0a0e; display: flex; align-items: center; justify-content: center; min-height: 100vh; font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
|
||||
#__bundler_loading { position: fixed; bottom: 20px; right: 20px; font: 13px/1.4 -apple-system, BlinkMacSystemFont, sans-serif; color: #666; background: #fff; padding: 8px 14px; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.12); z-index: 10000; }
|
||||
#__bundler_thumbnail { position: fixed; inset: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: #0a0a0e; z-index: 9999; }
|
||||
#__bundler_thumbnail svg { width: 100%; height: 100%; object-fit: contain; }
|
||||
#__bundler_placeholder { color: #999; font-size: 14px; }
|
||||
</style>
|
||||
<noscript>
|
||||
<style>#__bundler_loading { display: none; }</style>
|
||||
<div style="position:fixed;bottom:12px;left:12px;font:13px/1.4 -apple-system,BlinkMacSystemFont,sans-serif;color:#999;background:rgba(255,255,255,0.9);padding:6px 12px;border-radius:6px;box-shadow:0 1px 4px rgba(0,0,0,0.08);z-index:10000;">
|
||||
This page requires JavaScript to display.
|
||||
</div>
|
||||
</noscript>
|
||||
</head>
|
||||
<body>
|
||||
<div id="__bundler_thumbnail">
|
||||
<svg viewBox="0 0 1200 800" xmlns="http://www.w3.org/2000/svg">
|
||||
<polygon points="0,0 620,0 480,800 0,800" fill="#7a2226"></polygon>
|
||||
<polygon points="580,0 1200,0 1200,800 720,800" fill="#22357a"></polygon>
|
||||
<text x="600" y="470" font-family="Arial Black, Arial, sans-serif" font-size="220" font-weight="900" font-style="italic" text-anchor="middle" fill="#fffdf5" stroke="#e8c265" stroke-width="4">VS</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div id="__bundler_loading">Unpacking...</div>
|
||||
|
||||
<script>
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
const loading = document.getElementById('__bundler_loading');
|
||||
function setStatus(msg) { if (loading) loading.textContent = msg; }
|
||||
|
||||
const FONT_MIME = /^(font[/]|application[/](x-)?font-|application[/]vnd\.ms-fontobject)/i;
|
||||
const MIME_TOKEN = /^[\w.+-]+[/][\w.+-]+$/;
|
||||
function toBase64(bytes) {
|
||||
let bin = '';
|
||||
for (let i = 0; i < bytes.length; i += 0x8000) {
|
||||
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
|
||||
}
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
// Error sink persists across replaceWith since it's on window, not the DOM.
|
||||
window.addEventListener('error', function(e) {
|
||||
// Failed resource loads (CSP-blocked links, scripts, images) fire plain
|
||||
// events at the element — warn only; real JS errors carry message/error.
|
||||
if (!e.message && !e.error && e.target && e.target !== window) {
|
||||
console.warn('[bundle] resource failed to load:',
|
||||
e.target.tagName, String(e.target.src || e.target.href || ''));
|
||||
return;
|
||||
}
|
||||
var p = document.body || document.documentElement;
|
||||
var d = document.getElementById('__bundler_err') || p.appendChild(document.createElement('div'));
|
||||
d.id = '__bundler_err';
|
||||
d.style.cssText = 'position:fixed;bottom:12px;left:12px;right:12px;font:12px/1.4 ui-monospace,monospace;background:#2a1215;color:#ff8a80;padding:10px 14px;border-radius:8px;border:1px solid #5c2b2e;z-index:99999;white-space:pre-wrap;max-height:40vh;overflow:auto';
|
||||
d.textContent = (d.textContent ? d.textContent + String.fromCharCode(10) : '') +
|
||||
'[bundle] ' + (e.message || e.type) +
|
||||
(e.filename ? ' (' + e.filename.slice(0, 60) + ':' + e.lineno + ')' : '');
|
||||
}, true);
|
||||
|
||||
try {
|
||||
const manifestEl = document.querySelector('script[type="__bundler/manifest"]');
|
||||
const templateEl = document.querySelector('script[type="__bundler/template"]');
|
||||
if (!manifestEl || !templateEl) {
|
||||
setStatus('Error: missing bundle data');
|
||||
console.error('[bundler] Missing script tags — manifestEl:', !!manifestEl, 'templateEl:', !!templateEl);
|
||||
return;
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(manifestEl.textContent);
|
||||
let template = JSON.parse(templateEl.textContent);
|
||||
|
||||
// Nested page bundles (iframe targets). Each ships ONCE, in the ROOT
|
||||
// document's manifest; frame srcs carry an about:blank#<uuid> marker
|
||||
// instead of a substitutable uuid. Every document mints the blobs for
|
||||
// its OWN frames — from local text at the root, or text obtained from
|
||||
// its DIRECT parent over the relay protocol below — so each frame
|
||||
// navigation stays same-origin with its initiator and a deduped page
|
||||
// loads at any depth on any host, including a file:// download's
|
||||
// opaque origins (where a blob minted by one document cannot be
|
||||
// navigated to by another).
|
||||
const pageOrderEl = document.querySelector('script[type="__bundler/page_order"]');
|
||||
const pageOrder = pageOrderEl ? JSON.parse(pageOrderEl.textContent) : [];
|
||||
const pageSet = new Set(pageOrder);
|
||||
const pageTexts = {};
|
||||
|
||||
// ── Nested-page text protocol (parent-chain relay) ──
|
||||
// Page bundles ship once, in the ROOT document's manifest. Every
|
||||
// document mints the blobs for its OWN frames, obtaining page text
|
||||
// through a strict parent-chain relay: a document only ever SENDS to
|
||||
// its direct parent (which, for every document the publisher's own
|
||||
// runtime minted, is another publisher document) and only ACCEPTS
|
||||
// text from that direct parent; requests from below are honored only
|
||||
// for the document's own child frames, and the ROOT never sends
|
||||
// upward at all. That last rule is load-bearing: the artifact host's
|
||||
// frame-ancestors allows claude.ai (and friends) to frame an
|
||||
// artifact, so window.top can be a FOREIGN document — a top-addressed
|
||||
// protocol would hand page uuids to that ancestor and accept forged
|
||||
// "page text" from it, minting attacker HTML as an artifact-origin
|
||||
// blob document. The chain never touches the ancestor: requests stop
|
||||
// at the root, replies are origin-addressed per hop (never '*' when
|
||||
// an origin exists), and both directions are origin-gated (on https
|
||||
// a foreign sender's origin can never equal this document's; in
|
||||
// opaque contexts — file:// — everything serializes as 'null', where
|
||||
// an embedded EXTERNAL frame still fails the gate by carrying its
|
||||
// own real origin, and a sandboxed child of such a frame is excluded
|
||||
// by the own-child-frames check plus uuid unguessability). Shape is
|
||||
// deny-by-default: one request form, one reply form, no error
|
||||
// replies.
|
||||
// window.origin is the SERIALIZED SECURITY origin ('null' in opaque
|
||||
// contexts such as a sandboxed preview iframe) — location.origin is
|
||||
// computed from the URL and stays 'https://…' even when the document
|
||||
// is sandboxed, which would misgate every opaque chain.
|
||||
const OWN_TARGET = /^https?:[/][/]/.test(window.origin || '')
|
||||
? window.origin
|
||||
: '*';
|
||||
function trustedOrigin(e) {
|
||||
if (e.origin === window.origin) return true; // https chains; opaque==opaque
|
||||
// file:// trees: Chrome serializes the file document's origin as
|
||||
// 'file://' while the blob: documents it mints serialize as 'null',
|
||||
// so the two legitimate shapes can never string-match — accept the
|
||||
// pair in both directions. https origins never serialize as either,
|
||||
// and sender identity (own child frame / direct parent) is checked
|
||||
// separately.
|
||||
return (
|
||||
(e.origin === 'null' &&
|
||||
(window.origin === 'file://' ||
|
||||
window.location.protocol === 'file:')) ||
|
||||
(/^file:/.test(e.origin) && window.origin === 'null')
|
||||
);
|
||||
}
|
||||
// Non-empty only in the root document (nested bundles ship an empty
|
||||
// island), so it doubles as the root marker: the root answers from
|
||||
// local text and treats a miss as authoritative — never relaying to
|
||||
// its (possibly foreign) parent.
|
||||
const isPageRoot = pageOrder.length > 0;
|
||||
const blobUrls = {};
|
||||
const resourceBlobs = {};
|
||||
const UUID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const pendingFrames = {}; // uuid -> [{el, frag}] awaiting text for own frames
|
||||
const pendingRelays = {}; // uuid -> [childWindow] awaiting text to pass down
|
||||
function mountPage(el, frag, text) {
|
||||
const pageBlob = new Blob([text], { type: 'text/html' });
|
||||
const u = URL.createObjectURL(pageBlob);
|
||||
resourceBlobs[u] = pageBlob;
|
||||
el.src = u + frag;
|
||||
}
|
||||
function deliverPage(uuid, text) {
|
||||
const frames = pendingFrames[uuid];
|
||||
delete pendingFrames[uuid];
|
||||
if (frames) for (const f of frames) mountPage(f.el, f.frag, text);
|
||||
const relays = pendingRelays[uuid];
|
||||
delete pendingRelays[uuid];
|
||||
if (relays) {
|
||||
for (const cw of relays) {
|
||||
try { cw.postMessage({ __bundler_page: uuid, __bundler_text: text }, OWN_TARGET); } catch (err) { /* gone */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
function ownFrameWindows() {
|
||||
const out = [];
|
||||
for (const f of Array.from(document.querySelectorAll('iframe'))) {
|
||||
try { if (f.contentWindow) out.push(f.contentWindow); } catch (err) { /* detached */ }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
window.addEventListener('message', function (e) {
|
||||
const d = e.data;
|
||||
if (!d || typeof d !== 'object' || !e.source) return;
|
||||
// Origin gate first; the structural checks below (own-child-frames
|
||||
// for requests, direct-parent + solicited-uuid for replies, the
|
||||
// root's never-relay rule) are what hold in fully opaque chains
|
||||
// where origin strings can't discriminate — a sandboxed root's
|
||||
// FOREIGN wrapper still fails all three even though both serialize
|
||||
// as real-vs-'null' rather than matching.
|
||||
if (!trustedOrigin(e)) return;
|
||||
if (typeof d.__bundler_need === 'string' && UUID_SHAPE.test(d.__bundler_need)) {
|
||||
// Requests are honored only for this document's own child frames.
|
||||
if (ownFrameWindows().indexOf(e.source) < 0) return;
|
||||
const uuid = d.__bundler_need;
|
||||
const text = pageTexts[uuid];
|
||||
if (typeof text === 'string') {
|
||||
try { e.source.postMessage({ __bundler_page: uuid, __bundler_text: text }, OWN_TARGET); } catch (err) { /* gone */ }
|
||||
return;
|
||||
}
|
||||
if (isPageRoot || window.parent === window) return; // authoritative miss
|
||||
const waiting = pendingRelays[uuid];
|
||||
if (waiting) {
|
||||
if (waiting.indexOf(e.source) < 0) waiting.push(e.source);
|
||||
return;
|
||||
}
|
||||
// Never-answered uuids would otherwise accumulate forever under a
|
||||
// request spray; past the cap (far above any real page count) new
|
||||
// relays are dropped rather than remembered.
|
||||
if (Object.keys(pendingRelays).length >= 128) return;
|
||||
pendingRelays[uuid] = [e.source];
|
||||
window.parent.postMessage({ __bundler_need: uuid }, OWN_TARGET);
|
||||
} else if (
|
||||
typeof d.__bundler_page === 'string' &&
|
||||
UUID_SHAPE.test(d.__bundler_page) &&
|
||||
typeof d.__bundler_text === 'string'
|
||||
) {
|
||||
// The shape test is load-bearing beyond tidiness: the solicited
|
||||
// check below uses the in operator, which matches prototype keys
|
||||
// ('__proto__', 'constructor') — an unshaped key would reach
|
||||
// deliverPage and throw, painting the error banner.
|
||||
// Text is accepted only from the direct parent — the document
|
||||
// that minted this one.
|
||||
if (e.source !== window.parent || window.parent === window) return;
|
||||
if (!(d.__bundler_page in pendingFrames) && !(d.__bundler_page in pendingRelays)) return;
|
||||
deliverPage(d.__bundler_page, d.__bundler_text);
|
||||
}
|
||||
});
|
||||
|
||||
const uuids = Object.keys(manifest);
|
||||
setStatus('Unpacking ' + uuids.length + ' assets...');
|
||||
|
||||
await Promise.all(uuids.map(async (uuid) => {
|
||||
const entry = manifest[uuid];
|
||||
try {
|
||||
const binaryStr = atob(entry.data);
|
||||
const bytes = new Uint8Array(binaryStr.length);
|
||||
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i);
|
||||
|
||||
let finalBytes = bytes;
|
||||
if (entry.compressed) {
|
||||
if (typeof DecompressionStream !== 'undefined') {
|
||||
const ds = new DecompressionStream('gzip');
|
||||
const writer = ds.writable.getWriter();
|
||||
const reader = ds.readable.getReader();
|
||||
writer.write(bytes);
|
||||
writer.close();
|
||||
const chunks = [];
|
||||
let totalLen = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
totalLen += value.length;
|
||||
}
|
||||
finalBytes = new Uint8Array(totalLen);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) { finalBytes.set(chunk, offset); offset += chunk.length; }
|
||||
} else {
|
||||
console.warn('DecompressionStream not available, asset ' + uuid + ' may not render');
|
||||
}
|
||||
}
|
||||
|
||||
if (pageSet.has(uuid)) {
|
||||
pageTexts[uuid] = new TextDecoder().decode(finalBytes);
|
||||
return;
|
||||
}
|
||||
if (FONT_MIME.test(entry.mime) && MIME_TOKEN.test(entry.mime)) {
|
||||
// Strict artifact hosts allow font-src data: but not blob:.
|
||||
blobUrls[uuid] = 'data:' + entry.mime + ';base64,' +
|
||||
(entry.compressed ? toBase64(finalBytes) : entry.data);
|
||||
} else {
|
||||
const blob = new Blob([finalBytes], { type: entry.mime });
|
||||
blobUrls[uuid] = URL.createObjectURL(blob);
|
||||
resourceBlobs[blobUrls[uuid]] = blob;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to decode asset ' + uuid + ':', err);
|
||||
const blob = new Blob([], { type: entry.mime });
|
||||
blobUrls[uuid] = URL.createObjectURL(blob);
|
||||
resourceBlobs[blobUrls[uuid]] = blob;
|
||||
}
|
||||
}));
|
||||
|
||||
const extResEl = document.querySelector('script[type="__bundler/ext_resources"]');
|
||||
const extResources = extResEl ? JSON.parse(extResEl.textContent) : [];
|
||||
const resourceMap = {};
|
||||
for (const entry of extResources) {
|
||||
if (blobUrls[entry.uuid]) resourceMap[entry.id] = blobUrls[entry.uuid];
|
||||
}
|
||||
|
||||
// Artifact-host CSP (connect-src 'self') refuses fetch() of blob: URLs —
|
||||
// consumers read these Blobs directly. Survives the swap like the error sink.
|
||||
window.__resourceBlobs = resourceBlobs;
|
||||
|
||||
setStatus('Rendering...');
|
||||
// Page uuids have no blob here — they ride inside about:blank#<uuid>
|
||||
// frame markers the pass below resolves after the swap.
|
||||
for (const uuid of uuids) {
|
||||
if (pageSet.has(uuid)) continue;
|
||||
template = template.split(uuid).join(blobUrls[uuid]);
|
||||
}
|
||||
|
||||
// Strip integrity + crossorigin — blob URLs from a file:// document inherit
|
||||
// a null origin, so crossorigin forces a CORS fetch that SRI then rejects.
|
||||
// The manifest bytes are ours; SRI protects against CDN compromise, not this.
|
||||
template = template.replace(/\s+integrity="[^"]*"/gi, '').replace(/\s+crossorigin="[^"]*"/gi, '');
|
||||
|
||||
const resourceScript = '<script>window.__resources = ' +
|
||||
JSON.stringify(resourceMap).replace(/<\//g, '<\\/') +
|
||||
';</' + 'script>';
|
||||
// Inject after <head> so the DOCTYPE stays first; prepending the script
|
||||
// would push the parser into quirks mode. DOMParser always emits a <head>
|
||||
// (synthesizing one if the source HTML omitted it) but may carry
|
||||
// attributes through, so match the full opening tag. slice() rather than
|
||||
// replace() keeps us clear of $-pattern substitution in resourceScript.
|
||||
const headOpen = template.match(/<head[^>]*>/i);
|
||||
if (headOpen) {
|
||||
const i = headOpen.index + headOpen[0].length;
|
||||
template = template.slice(0, i) + resourceScript + template.slice(i);
|
||||
}
|
||||
|
||||
// Parse the template and swap the root element. Scripts inserted via
|
||||
// DOMParser/replaceWith are inert per spec — re-create each with
|
||||
// createElement so they execute, awaiting onload for src scripts to
|
||||
// preserve ordering (React before ReactDOM before Babel before text/babel).
|
||||
const doc = new DOMParser().parseFromString(template, 'text/html');
|
||||
document.documentElement.replaceWith(doc.documentElement);
|
||||
|
||||
// Resolve page-frame markers: mint this document's own blob for each
|
||||
// framed page — from local text when this is the root, otherwise by
|
||||
// asking the direct parent over the relay protocol above. An
|
||||
// unanswered request leaves the frame on about:blank, the same
|
||||
// degraded state as a refused embed.
|
||||
{
|
||||
const MARKER = /^about:blank#([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(#.*)?$/;
|
||||
const needed = [];
|
||||
for (const el of Array.from(document.querySelectorAll('iframe'))) {
|
||||
const m = MARKER.exec(el.getAttribute('src') || '');
|
||||
if (!m) continue;
|
||||
const text = pageTexts[m[1]];
|
||||
if (typeof text === 'string') {
|
||||
mountPage(el, m[2] || '', text);
|
||||
} else if (window.parent !== window && !isPageRoot) {
|
||||
const had = pendingFrames[m[1]];
|
||||
(pendingFrames[m[1]] = had || []).push({ el: el, frag: m[2] || '' });
|
||||
if (!had) needed.push(m[1]);
|
||||
}
|
||||
}
|
||||
for (const uuid of needed) {
|
||||
window.parent.postMessage({ __bundler_need: uuid }, OWN_TARGET);
|
||||
}
|
||||
}
|
||||
|
||||
const dead = Array.from(document.scripts);
|
||||
for (const old of dead) {
|
||||
const s = document.createElement('script');
|
||||
for (const a of old.attributes) s.setAttribute(a.name, a.value);
|
||||
s.textContent = old.textContent;
|
||||
// text/babel scripts with a src: read and inline. transformScriptTags
|
||||
// does XHR against the src, but blob:null/ from a file:// origin is
|
||||
// silently dropped. Inlining makes it a plain inline babel script,
|
||||
// which transformScriptTags handles unconditionally.
|
||||
if ((s.type === 'text/babel' || s.type === 'text/jsx') && s.src) {
|
||||
const pre = resourceBlobs[s.src.split('#')[0]];
|
||||
s.textContent = pre ? await pre.text() : await (await fetch(s.src)).text();
|
||||
s.removeAttribute('src');
|
||||
}
|
||||
const p = s.src ? new Promise(function(r) { s.onload = s.onerror = r; }) : null;
|
||||
old.replaceWith(s);
|
||||
if (p) await p;
|
||||
}
|
||||
// Babel standalone auto-transforms type=text/babel on DOMContentLoaded,
|
||||
// which fired before we swapped the document. Trigger manually if present.
|
||||
if (window.Babel && typeof window.Babel.transformScriptTags === 'function') {
|
||||
window.Babel.transformScriptTags();
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('Error unpacking: ' + err.message);
|
||||
console.error('Bundle unpack error:', err);
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<script type="__bundler/manifest">
|
||||
{"ace74a53-87ed-4d99-ad8b-261f3f162016":{"mime":"image/webp","compressed":false,"data":"UklGRvTaAQBXRUJQVlA4WAoAAAAgAAAAkQMArwQASUNDUMgBAAAAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADZWUDggBtkBAHBCB50BKpIDsAQ+USaPRaOiISclkmqI4AoJaUCysa0j+oobVxBkm/YlAdvU4vUrVn6p/y94G65+i5wliAHgw8xP2g6YP/E9Bf6W6fv+j48ftDHnGLqhM79X/6s2CCH3N0IXpT46b8k8mP0D/A72/z37l/sf5H0JPq7xJex/4P/1/3/qP/R/0d/h/y/tn/zPAn9o/wPQO/WP8l/5vVqgWeGqBO4q+9/+3+29Zf2L/V//L/S/AJ/Rv8B/6P8t7c/9zxXfyf/S/cz4Cf7D/r/WD/5f/96Cv2n/pew5+037adu0oBDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcDjwXbEMuTM5CeDg0/WbfYG+wN9gb7A32BvsDfYG+wN9gb7A32BTOiZ/s/u9oCmXy05wZPHPMfTnL633x7vzZzRd7E74ap4Mp2WUd0VyWrhkClM+spcZ1VTpB0aQjld7eiVLVtmAfWEdANCJ0eFvsDfYG+wN9gb7A32BvsDfYG+wN9gbJLYFTAG0xAzI6yHjv74Wcp42UesdhAWEll0Mz77JaUHlulxgqy+mQczgDbGkrVJcHFlufap6I14MODzHIj4F5x2QValCk7J2+xcfxzZ5PjhYM+KVx57CekqiCUSkolJRKSiUlEpKJSUSkolJRI5nMN8+PfaP/ppGXaK90lr75ZWuF9bNrauU95rEzdUb1C1qZvsyk6ee95lySUR1MMLe+EaEbA+E+8nlgXyG6OR9nH0ZnNIalWTZMQkfeTPYorOy1Sbnr6WUqF6yG3G36Um43KVRDvK9XenCUSkolJRKSiUlEpKJSUSkolJQw8SkbOxva7a/dbRhay/9p6k1ps2tLL3PsGeB3wmUc2O1r6Ko/YIvzJQc3U823Qht+gk3DOwMXSPzPd1Rvk4qGS4lADC6jMNLREiRDWbffaISXTWM1SYEApKk342E26eK2J/CS/KKc8EVwEA5hmsX9nP8COuVi3YchhVhMrBh3BDuCHcEO4IdwQ7gh3BDuBiybDoJyHc6CgsI2nhNxcK4ivBgZvAqaCNDzcelDlw/42MaI44iLd6aOUkPa8wOZjckLrCfg74lJifr6Fl7z9i11am5PIHpxW5Lh4k3BIe8znZeLqHsmSU30RQmaCNtofvX3laWBKIwywl3UO+EhtmqXCHcXy9ynkiDU0YfurvhvE7zx522PN2UlEpKJSUSkolJRKSiUlEpJMXhiiR1qJ8wdnNkUdpHrFPa6B9Trd/6xgHcCtMzbEUX/yr6C3e1ucYiafg9kimrNPFPNxmJOBhvdJ6EmCz8EO4r/DHV+z4OTGDW6wudaAGkifd4DZNTLCVHs3KSzJSwdsATv7gqklIqM1QZFTGQHReShA0/1asKVrvbdM+rofcn/JfddtaGmGOujFdGK6MV0YroxXRiujFNpLVXaxIvpx3hifcd7liCmLjfmrHyV65wDvnMSivzOk/Z1pIBa4mVBTb0y7SwY+7z8xyyMmQh7kNSCrFl216OJd35YarFe6rX2AfRpOHXPXxGUOSSr2krq58AObG7z0K9/pMo/OCzuaroHIiScJkSv32huF6MHQ1EzJJR5UpRGEUD3GcQugdd7ZLSdd7C/gNgidIbgEXlrqT32z1GaJEh6JD0SHokPRIeiQ9Eh6AoJPaRhTn8rsZCbovqMXKFY/fqFYv4kryByMluIi9b85ACjpfIpD/n+uHIEY1/vy9NW8J3mHKIVMWvDrHwvArTbWahABxEn/4MfQwOL8e7qWBz036DLHmnEb6wb/G5cLoWVPRU0HNWVPMI0D+hUvL4wSEXmqJSoqWpegUU2X3xLFRTvB+Ad8dVl8Mu0Q9+vxUoVpeb8YgLCE9kxSkIOV+AfV5eCHcEO4IdwQ7gh3BDuCFudkHNn5AZZJm1k14VrlyYOrkTelg986azm/sU7cIQaLRH3awJCRbooUEC1Ek75bzWrEehXhOOoXXASBjvqypB4A+nf45d6rSL0PzbPObtdlLIInJHdRsx3AXtWpgkXu56zIjFFO/o+8RrGqY5H+7+eh0q2fm/qMZo82AyCujHkwpC6uQnxt+ql1w7GbuDk37GsOcp5hMnB2QCSIgqpHEs1irSpVkFAqnf9QB4e32BvsDfYG+wN9gb7A3xLiZxiATkzmUJil8/0ZNBhV4qpM2PxOXeZXpwESkXrLO9Y72EVtFaCZOhDEXNyNUDS3bqkmbuaB//9p5MGu0n7KE+Sua45KtgLXknZ58fqHl3wp/qhSl1RmR4H7OCRsH1LSr9cTAqRdB1XbiZQXi8l5dgFyzr8NYMcytr0Rma04S4DasMs7H7eT6pgUE4VvYL1kuHUgW6rEZHE3cyrodkBF3xzc8cEUIbKndn0KwnRBKJSUSkolJRKSiUlEo6kOI5c0wxEFWb8fKoVSLzQhwoEV2MY9584Z4wHZdqj5hEWu76ISmrh5Uwg1qecy/b56qr4pOkVwH191QtcafaXzo+AIveV774pKr6odSyZVIg5ZIljpmLXwcOuu7Z6h73OvualT+AcqPMvtZtPHvgrjbmUdKcPrrHt+qPrbYijbyHrtylxcO+2AYr3qSd0GzGfmHYF7w1WGCGtFUQygCMWGgHldn3uCTnMm4LrWiOLj+zKHZXpLqgva1UJn0QN4gbxA3iBvEDeIG8BeGRNnhORiTRsYCTwm/Wyboie+RH2TDnrkA49wBhTEJOPZdDoPkh9bID5uGOVRyd6KJ/LZKWedSQT6xv/kd1jsNnX4Gcq+KpK+ks1iLdxAvL9dVOZgQCfbhRJ9Ud6IbNRhpNxA8CHurPmsBAQnl3oGyYIchZBfFZhEx4htp24RLwPhFdG3ePXKgWQKgdLAnipfcb4hsmdCbQcb8YTYK9jf0I+Lf8SWjeRLN123mPthgspiCA9QroxXRiujFdGK6MV0Il+A16DJE5OEN3WAAO8kM0Gy485YQCYkQdlxoxxq4P6dDkoToeua2tOq3FGHc7nVRxF9OZOwhai69+EtTg3HXkEzcTBRRL6ZG9FrDA386i697WywkkzNmh1WSxHSWZ7lLLNn1WEr7lcy0VqClSbvIvMah3YoGEMG0WJDTxY79+LgT4lHBErwe/2kgQ/nCotjPRHK+BonkFVqtkCujFdGK6MV0YroxXRfPkMZzx6+PtaN2HAqNrO/UfyYO3iuO87jvHAsmWu9zk45ub4UsguiESaAjsu1dn2Evvmgtl0hy4xNxwTlgC2xlW7XU+C2J4TxB0u55GgYDmwnDNbdsArbMWq5ZGOMAjfUFR8gH+RmxmtRJ72OF3+RbVt+d8nIILRohEJLI7oIEqjvf/t+dzC3h3MqL4+Hgpa1WQgB2/1A7fYG+wN9gb7A32BukcZ3BdYzmixZOKsedwI+gJZIEDEVvq2SYOBlfC5HWKGRKbyrrUdklIWAX0QMNmf7j/DfV98yzm0asQT1pPnq5/xg+0+QOWgJIvkpUWXp/o5qqGwgmPBfImydZJwnTmzk1+K4jAWI6RWlDOaXMTWLuMH4WlOUc1FaDooj0wDXJ7+mJnAkLpNQEYHjah7GD3EV2Yk3a6MV0YroxXRiujFbwBf8c8AZMz+AWk6vaLearIoD6L134Ryaahu7ieexHDqchVojiWPc1bOmbZjGao22xkxeqLOSMQA3uOLPoN0tJ0OsldnW3U0bW9KtZD7LP57F5tAmYRkz7j8cHvUnSzVbfIwFVz7ESOXwQ2qG1BjGg1r3M3oAzfNa2lk1krwG0o8BqOng2vr1+YPQvF+GJWIRH2QyotACNbIfhvoN570YroxXRiujFdGK6EeiOZU5zmBSHaf6HJggxi2sKqoXKnYq+OYy5NwTDmHOB0MjNm79HKeAxMnaXfn6KNXaUIuyrUAg3l0+HUpmJF7pUSrIROA4y+eqbDB4QUTuRnZszseLy5uQSnW+2kbHuwEj9WeRW8TXS5r4mhL7MkqNxC3L4lijAjLundafKs4kV3W+sqBcE8VcdxLbE36k9MaVY93F882ef8bVdIG7nQL9sfp8ThWqUlEpLine truncated
|
||||
</script>
|
||||
|
||||
<script type="__bundler/ext_resources">
|
||||
[{"id":"flagJp","uuid":"00166f10-cf88-4a70-bbd1-280330a31a6b"},{"id":"flagBr","uuid":"10064871-2703-4007-829b-9fe2ea8c9830"},{"id":"https://unpkg.com/[email protected]/umd/react.production.min.js","uuid":"02eff75b-1dc5-4564-a819-548848bc3ea6"},{"id":"https://unpkg.com/[email protected]/umd/react-dom.production.min.js","uuid":"13d22153-cc4e-43c7-b6eb-12374e56dc44"}]
|
||||
</script>
|
||||
|
||||
<script type="__bundler/page_order">
|
||||
[]
|
||||
</script>
|
||||
|
||||
<script type="__bundler/template">
|
||||
"<!DOCTYPE html>\n<html><head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<script src=\"290e25e2-2bac-49af-b3d1-10cdd1ae8fbb\"><\u002Fscript>\n\n\n\n<\u002Fhead>\n<body>\n<x-dc>\n<helmet>\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<style>/* vietnamese */\n@font-face {\n font-family: 'Anton';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"e49d5281-88ab-4404-9f97-f101a278fd68\") format('woff2');\n unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n font-family: 'Anton';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"41d613c6-fe3e-4f74-a5ee-c82165b70e04\") format('woff2');\n unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: 'Anton';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"73180845-6d9d-47f1-b736-31e7c919b715\") format('woff2');\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n/* vietnamese */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"024f77f2-3778-4e20-945a-dfc5de912f19\") format('woff2');\n unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"20b18051-9da7-42dd-a265-bdcef03ef454\") format('woff2');\n unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"a141991d-2d31-4e13-9ba3-a7e1903cb3a4\") format('woff2');\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n/* vietnamese */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 600;\n font-display: swap;\n src: url(\"d996ac61-9dd4-4628-92ec-575a6a8ebab7\") format('woff2');\n unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 600;\n font-display: swap;\n src: url(\"4c129e83-dcd6-40f3-9b2f-75eb52c2570f\") format('woff2');\n unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 600;\n font-display: swap;\n src: url(\"b0420d9b-b5d9-4e9b-a506-be5ec972d317\") format('woff2');\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n/* vietnamese */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 700;\n font-display: swap;\n src: url(\"643b5739-aeb8-4056-81ee-78f1669802f5\") format('woff2');\n unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 700;\n font-display: swap;\n src: url(\"1c70acd1-9588-4697-b346-56c7675f7674\") format('woff2');\n unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 700;\n font-display: swap;\n src: url(\"fc1721cb-bce6-4bcc-8311-e4be3be85090\") format('woff2');\n unicode-range: U+0000-00FF, U+0131, U+0152Line truncated
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,349 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700;800&family=Zen+Old+Mincho:wght@400;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { margin:0; background:#0a0a0c; }
|
||||
a { color:#e8534a; } a:hover { color:#ff7d70; }
|
||||
@keyframes riseIn { from { opacity:0; transform:translateY(10px); } to { opacity:1; transform:none; } }
|
||||
@keyframes driftA { 0%,100% { transform: translate3d(0,0,0) scale(1); } 50% { transform: translate3d(60px,-28px,0) scale(1.14); } }
|
||||
@keyframes driftB { 0%,100% { transform: translate3d(0,0,0) scale(1.08); } 50% { transform: translate3d(-54px,26px,0) scale(1); } }
|
||||
@keyframes sweep { 0% { transform: translateX(-60%) skewX(-14deg); opacity:0; } 25% { opacity:.5; } 60% { opacity:0; } 100% { transform: translateX(160%) skewX(-14deg); opacity:0; } }
|
||||
@keyframes pulseDot { 0%,100% { opacity:1; } 50% { opacity:.25; } }
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="min-height:100vh;background:#0a0a0c;padding:40px;display:flex;flex-direction:column;align-items:flex-start;gap:22px;overflow-x:auto;font-family:'Barlow Condensed',sans-serif">
|
||||
|
||||
<div style="position:relative;width:1150px;flex:none;aspect-ratio:16/9;overflow:hidden;background:#08080a;box-shadow:0 30px 80px rgba(0,0,0,.6)">
|
||||
<div style="position:absolute;inset:-12%;background:radial-gradient(closest-side, rgba(168,42,34,.62), rgba(8,8,10,0) 72%);width:70%;left:-6%;animation:driftA 19s ease-in-out infinite"></div>
|
||||
<div style="position:absolute;inset:-12%;background:radial-gradient(closest-side, rgba(28,64,136,.62), rgba(8,8,10,0) 72%);width:70%;left:36%;animation:driftB 23s ease-in-out infinite"></div>
|
||||
<div style="position:absolute;top:0;bottom:0;left:0;width:26%;background:linear-gradient(90deg, rgba(255,255,255,0), rgba(255,255,255,.09), rgba(255,255,255,0));animation:sweep 11s linear infinite"></div>
|
||||
<div style="position:absolute;inset:0;opacity:.12;mix-blend-mode:overlay;background:repeating-linear-gradient(115deg, rgba(255,255,255,.6) 0 1px, transparent 1px 5px)"></div>
|
||||
<div style="position:absolute;top:0;bottom:0;left:0;width:54%;clip-path:polygon(0 0, 100% 0, 78% 100%, 0 100%);background:linear-gradient(120deg, rgba(122,26,22,.5), rgba(8,8,10,0) 76%)"></div>
|
||||
<div style="position:absolute;top:0;bottom:0;right:0;width:54%;clip-path:polygon(22% 0, 100% 0, 100% 100%, 0 100%);background:linear-gradient(300deg, rgba(24,52,110,.5), rgba(8,8,10,0) 76%)"></div>
|
||||
|
||||
<div style="position:absolute;top:34px;left:0;right:0;display:flex;flex-direction:column;align-items:center;gap:8px">
|
||||
<div style="font-family:'Zen Old Mincho',serif;font-size:24px;letter-spacing:.44em;color:#efe9e0;text-transform:uppercase;text-indent:.44em">Takeone Karate Series</div>
|
||||
<div style="display:flex;align-items:center;gap:14px;font-size:12px;letter-spacing:.34em;color:#8f8a83;text-transform:uppercase">
|
||||
<span>Ladies</span><span style="width:4px;height:4px;background:#e8534a"></span><span>Classification</span><span style="width:4px;height:4px;background:#6aa6ff"></span><span>Round 1</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="position:absolute;inset:96px 0 54px;display:grid;grid-template-columns:1fr 200px 1fr;align-items:start">
|
||||
|
||||
<div style="display:flex;flex-direction:column;align-items:center;padding:34px 24px 0;min-width:0">
|
||||
<div style="position:relative;width:228px;height:304px">
|
||||
<div style="position:absolute;inset:0;background:repeating-linear-gradient(135deg,rgba(255,255,255,.1) 0 6px,rgba(255,255,255,.03) 6px 12px);border:1px solid rgba(255,255,255,.16);display:flex;align-items:flex-end;justify-content:center;padding-bottom:12px;font-family:ui-monospace,monospace;font-size:10px;letter-spacing:.1em;color:rgba(255,255,255,.6)">FIGHTER PHOTO</div>
|
||||
<div style="position:absolute;bottom:-26px;right:-26px;width:104px;height:104px;border-radius:50%;background:#0d0d10;border:1px solid rgba(255,255,255,.28);box-shadow:0 10px 30px rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:9px;letter-spacing:.06em;color:rgba(255,255,255,.7);text-align:center;line-height:1.35">CLUB<br>LOGO</div>
|
||||
</div>
|
||||
<div style="margin-top:34px;padding-top:12px;border-top:3px solid #e8534a;display:flex;flex-direction:column;align-items:center;gap:8px">
|
||||
<div style="font-size:42px;line-height:.95;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap">Fawzia Ahmed</div>
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<span style="width:40px;height:27px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 5px,rgba(255,255,255,.1) 5px 10px);box-shadow:0 0 0 1px rgba(255,255,255,.32);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:8px;color:rgba(255,255,255,.85)">FLAG</span>
|
||||
<span style="font-size:14px;letter-spacing:.3em;color:#d3b3ad;text-transform:uppercase">Bahrain</span>
|
||||
</div>
|
||||
<div style="font-size:13px;letter-spacing:.18em;color:#9d9691;text-transform:uppercase">Manama Karate Club</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;align-items:center;gap:12px;padding-top:96px">
|
||||
<span style="font-family:'Zen Old Mincho',serif;font-size:88px;line-height:.8;font-weight:700;color:#efe9e0;text-shadow:0 8px 34px rgba(0,0,0,.75)">VS</span>
|
||||
<span style="padding:7px 16px;border:1px solid rgba(255,255,255,.3);font-size:14px;letter-spacing:.32em;color:#efe9e0;text-transform:uppercase">−61 kg</span>
|
||||
<span style="font-size:11px;letter-spacing:.34em;color:#8f8a83;text-transform:uppercase">Ladies · 3 min</span>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;align-items:center;padding:34px 24px 0;min-width:0">
|
||||
<div style="position:relative;width:228px;height:304px">
|
||||
<div style="position:absolute;inset:0;background:repeating-linear-gradient(135deg,rgba(255,255,255,.1) 0 6px,rgba(255,255,255,.03) 6px 12px);border:1px solid rgba(255,255,255,.16);display:flex;align-items:flex-end;justify-content:center;padding-bottom:12px;font-family:ui-monospace,monospace;font-size:10px;letter-spacing:.1em;color:rgba(255,255,255,.6)">FIGHTER PHOTO</div>
|
||||
<div style="position:absolute;bottom:-26px;left:-26px;width:104px;height:104px;border-radius:50%;background:#0d0d10;border:1px solid rgba(255,255,255,.28);box-shadow:0 10px 30px rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:9px;letter-spacing:.06em;color:rgba(255,255,255,.7);text-align:center;line-height:1.35">CLUB<br>LOGO</div>
|
||||
</div>
|
||||
<div style="margin-top:34px;padding-top:12px;border-top:3px solid #6aa6ff;display:flex;flex-direction:column;align-items:center;gap:8px">
|
||||
<div style="font-size:42px;line-height:.95;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap">Noor Salman</div>
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<span style="font-size:14px;letter-spacing:.3em;color:#a8b6cf;text-transform:uppercase">Bahrain</span>
|
||||
<span style="width:40px;height:27px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 5px,rgba(255,255,255,.1) 5px 10px);box-shadow:0 0 0 1px rgba(255,255,255,.32);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:8px;color:rgba(255,255,255,.85)">FLAG</span>
|
||||
</div>
|
||||
<div style="font-size:13px;letter-spacing:.18em;color:#9d9691;text-transform:uppercase">Riffa Martial Arts</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="position:absolute;bottom:26px;left:0;right:0;display:flex;align-items:center;justify-content:center;gap:16px;font-size:11px;letter-spacing:.32em;color:#8f8a83;text-transform:uppercase">
|
||||
<span>Bout 12</span><span style="width:4px;height:4px;background:#e8534a"></span><span>Tatami 1</span><span style="width:4px;height:4px;background:#6aa6ff"></span><span>Aug 08, 2026 · Manama</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="position:relative;width:1150px;flex:none;aspect-ratio:16/9;overflow:hidden;border-radius:10px;background:#111;box-shadow:0 30px 80px rgba(0,0,0,.6)">
|
||||
<img src="assets/mat.png" alt="" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover">
|
||||
<div style="position:absolute;inset:0;background:linear-gradient(to top, rgba(6,6,8,.9) 0%, rgba(6,6,8,.45) 15%, rgba(6,6,8,0) 30%), linear-gradient(to bottom, rgba(6,6,8,.85) 0%, rgba(6,6,8,.4) 14%, rgba(6,6,8,0) 30%)"></div>
|
||||
|
||||
|
||||
|
||||
<div style="display:{{ overlayDisplay }}">
|
||||
<!-- top left: match identity -->
|
||||
<div style="position:absolute;top:22px;left:26px;pointer-events:none;display:{{ dIdentity }};align-items:stretch;gap:12px">
|
||||
<div style="width:4px;background:linear-gradient(#e8534a,#7c1d18)"></div>
|
||||
<div style="display:flex;flex-direction:column;gap:2px">
|
||||
<div style="font-family:'Zen Old Mincho',serif;font-size:15px;letter-spacing:.34em;color:#efe9e0;text-transform:uppercase">{{ disciplineLabel }}</div>
|
||||
<div style="font-size:13px;letter-spacing:.28em;color:#cfc9c1;text-transform:uppercase;text-shadow:0 1px 6px rgba(0,0,0,.9)">Ladies Classification · −61 kg · Round 1</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- top right: live point ticker -->
|
||||
<div style="position:absolute;top:20px;right:24px;width:262px;pointer-events:none;display:{{ dTicker }};flex-direction:column;gap:7px">
|
||||
<div style="display:flex;align-items:center;justify-content:flex-end;gap:8px;font-size:12px;letter-spacing:.3em;color:#d5cfc7;text-transform:uppercase;text-shadow:0 1px 6px rgba(0,0,0,.9)">
|
||||
<span style="width:7px;height:7px;border-radius:50%;background:#e8534a;animation:pulseDot 1.6s infinite"></span>Live scoring
|
||||
</div>
|
||||
<sc-for list="{{ feed }}" as="ev" hint-placeholder-count="4">
|
||||
<div style="display:flex;align-items:center;justify-content:flex-end;gap:10px;padding:7px 10px;background:rgba(10,10,12,.62);backdrop-filter:blur(6px);border-right:3px solid transparent;animation:riseIn .45s ease both;border-color:{{ ev.color }}">
|
||||
<span style="font-size:12px;letter-spacing:.16em;color:#79736c">{{ ev.time }}</span>
|
||||
<span style="font-size:17px;letter-spacing:.12em;font-weight:700;color:#efe9e0;text-transform:uppercase">{{ ev.name }}</span>
|
||||
<span style="font-family:'Zen Old Mincho',serif;font-size:19px;font-weight:700;color:{{ ev.color }}">{{ ev.pts }}</span>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
|
||||
<!-- bottom scoreboard -->
|
||||
<div style="position:absolute;left:0;right:0;bottom:56px;padding:0 26px;display:{{ dBar }};align-items:stretch;gap:0;height:84px;z-index:2;pointer-events:none">
|
||||
|
||||
<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="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);display:{{ dClubs }};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 style="flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:3px">
|
||||
<div style="display:flex;align-items:center;gap:10px;min-width:0">
|
||||
<span 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:{{ dFlags }};align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:7px;color:rgba(255,255,255,.85)">FLAG</span>
|
||||
<span 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">Fawzia A.</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;min-width:0">
|
||||
<span style="flex:1 1 auto;min-width:0;font-size:12px;letter-spacing:.03em;color:rgba(255,232,228,.85);text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">Manama Karate Club</span>
|
||||
<span style="display:{{ dPenalty }};font-size:10px;letter-spacing:.18em;padding:1px 6px;background:#ffdf6b;color:#3a2a00;font-weight:700;flex:none">SENSHU</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="width:60px;flex:none;display:flex;flex-direction:column;align-items:flex-end;gap:5px"><span style="font-size:11px;letter-spacing:.24em;color:rgba(255,236,232,.7)">{{ redCorner }}</span><span style="font-family:'Zen Old Mincho',serif;font-size:46px;line-height:.8;font-weight:700;color:#fff;text-shadow:0 6px 20px rgba(0,0,0,.5)">{{ akaScore }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="width:118px;flex:none;transform:skewX(-9deg);background:rgba(9,9,11,.9);backdrop-filter:blur(8px);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;border-bottom:3px solid #2c2a28">
|
||||
<div style="transform:skewX(9deg);font-size:11px;letter-spacing:.3em;color:#8f8a83;text-transform:uppercase">{{ roundLabel }}</div>
|
||||
<div style="transform:skewX(9deg);font-size:34px;line-height:1;font-weight:800;color:#efe9e0;font-variant-numeric:tabular-nums">{{ clock }}</div>
|
||||
<div style="transform:skewX(9deg);display:flex;gap:4px">
|
||||
<span style="width:20px;height:3px;background:#e8534a"></span>
|
||||
<span style="width:20px;height:3px;background:#e8534a"></span>
|
||||
<span style="width:20px;height:3px;background:#3a3734"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="flex:1 1 0;min-width:0;transform:skewX(-9deg);overflow:hidden;background:linear-gradient(90deg, rgba(30,72,140,.9), rgba(18,44,92,.94));border-bottom:3px solid #6aa6ff">
|
||||
<div style="transform:skewX(9deg);height:100%;padding:0 16px;display:flex;align-items:center;gap:12px">
|
||||
<div style="width:60px;flex:none;display:flex;flex-direction:column;align-items:flex-start;gap:5px"><span style="font-size:11px;letter-spacing:.24em;color:rgba(226,238,255,.7)">{{ blueCorner }}</span><span style="font-family:'Zen Old Mincho',serif;font-size:46px;line-height:.8;font-weight:700;color:#fff;text-shadow:0 6px 20px rgba(0,0,0,.5)">{{ aoScore }}</span></div>
|
||||
<div style="flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:3px;align-items:flex-end;text-align:right">
|
||||
<div style="display:flex;align-items:center;gap:10px;min-width:0">
|
||||
<span 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">Noor S.</span>
|
||||
<span 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:{{ dFlags }};align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:7px;color:rgba(255,255,255,.85)">FLAG</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;min-width:0">
|
||||
<span style="display:{{ dPenalty }};font-size:10px;letter-spacing:.18em;padding:1px 6px;background:#c8492f;color:#fff;font-weight:700;flex:none">{{ penaltyLabel }}</span>
|
||||
<span style="flex:1 1 auto;min-width:0;font-size:12px;letter-spacing:.1em;color:rgba(226,238,255,.85);text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">Riffa Martial Arts</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);display:{{ dClubs }};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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- point timeline strip -->
|
||||
<div style="position:absolute;left:26px;right:26px;bottom:48px;height:5px;display:{{ dTimeline }};gap:2px;z-index:2;pointer-events:none">
|
||||
<sc-for list="{{ timeline }}" as="seg" hint-placeholder-count="14">
|
||||
<div style="flex:1;background:{{ seg.color }}"></div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="position:absolute;inset:0;z-index:8;display:{{ introDisplay }};opacity:{{ introOpacity }};transition:opacity .55s ease;background:#08080a;overflow:hidden">
|
||||
<div style="position:absolute;inset:-12%;width:70%;left:-6%;background:radial-gradient(closest-side, rgba(168,42,34,.62), rgba(8,8,10,0) 72%);animation:driftA 19s ease-in-out infinite"></div>
|
||||
<div style="position:absolute;inset:-12%;width:70%;left:36%;background:radial-gradient(closest-side, rgba(28,64,136,.62), rgba(8,8,10,0) 72%);animation:driftB 23s ease-in-out infinite"></div>
|
||||
<div style="position:absolute;top:0;bottom:0;left:0;width:26%;background:linear-gradient(90deg, rgba(255,255,255,0), rgba(255,255,255,.09), rgba(255,255,255,0));animation:sweep 11s linear infinite"></div>
|
||||
<div style="position:absolute;top:26px;left:0;right:0;display:flex;flex-direction:column;align-items:center;gap:5px">
|
||||
<div style="font-family:'Zen Old Mincho',serif;font-size:19px;letter-spacing:.42em;color:#efe9e0;text-transform:uppercase;text-indent:.42em">Takeone Karate Series</div>
|
||||
<div style="font-size:11px;letter-spacing:.32em;color:#8f8a83;text-transform:uppercase">Ladies · Classification · Round 1</div>
|
||||
</div>
|
||||
<div style="position:absolute;inset:82px 0 46px;display:grid;grid-template-columns:1fr 150px 1fr;align-items:center">
|
||||
<div style="display:flex;flex-direction:column;align-items:center;padding:0 20px">
|
||||
<div style="position:relative;width:168px;height:224px">
|
||||
<div style="position:absolute;inset:0;background:repeating-linear-gradient(135deg,rgba(255,255,255,.1) 0 6px,rgba(255,255,255,.03) 6px 12px);border:1px solid rgba(255,255,255,.16);display:flex;align-items:flex-end;justify-content:center;padding-bottom:10px;font-family:ui-monospace,monospace;font-size:9px;letter-spacing:.1em;color:rgba(255,255,255,.6)">FIGHTER PHOTO</div>
|
||||
<div style="position:absolute;bottom:-18px;right:-18px;width:70px;height:70px;border-radius:50%;background:#0d0d10;border:1px solid rgba(255,255,255,.28);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:7px;color:rgba(255,255,255,.7);text-align:center;line-height:1.3">CLUB<br>LOGO</div>
|
||||
</div>
|
||||
<div style="margin-top:30px;padding-top:12px;border-top:3px solid #e8534a;display:flex;flex-direction:column;align-items:center;gap:6px">
|
||||
<div style="font-size:30px;line-height:.95;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap">Fawzia Ahmed</div>
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<span style="width:30px;height:20px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 5px,rgba(255,255,255,.1) 5px 10px);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 style="font-size:12px;letter-spacing:.26em;color:#d3b3ad;text-transform:uppercase">Bahrain · Manama Karate Club</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;align-items:center;gap:10px">
|
||||
<span style="font-family:'Zen Old Mincho',serif;font-size:64px;line-height:.8;font-weight:700;color:#efe9e0;text-shadow:0 8px 30px rgba(0,0,0,.75)">VS</span>
|
||||
<span style="padding:5px 13px;border:1px solid rgba(255,255,255,.3);font-size:12px;letter-spacing:.3em;color:#efe9e0;text-transform:uppercase">−61 kg</span>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;align-items:center;padding:0 20px">
|
||||
<div style="position:relative;width:168px;height:224px">
|
||||
<div style="position:absolute;inset:0;background:repeating-linear-gradient(135deg,rgba(255,255,255,.1) 0 6px,rgba(255,255,255,.03) 6px 12px);border:1px solid rgba(255,255,255,.16);display:flex;align-items:flex-end;justify-content:center;padding-bottom:10px;font-family:ui-monospace,monospace;font-size:9px;letter-spacing:.1em;color:rgba(255,255,255,.6)">FIGHTER PHOTO</div>
|
||||
<div style="position:absolute;bottom:-18px;left:-18px;width:70px;height:70px;border-radius:50%;background:#0d0d10;border:1px solid rgba(255,255,255,.28);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:7px;color:rgba(255,255,255,.7);text-align:center;line-height:1.3">CLUB<br>LOGO</div>
|
||||
</div>
|
||||
<div style="margin-top:30px;padding-top:12px;border-top:3px solid #6aa6ff;display:flex;flex-direction:column;align-items:center;gap:6px">
|
||||
<div style="font-size:30px;line-height:.95;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap">Noor Salman</div>
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<span style="font-size:12px;letter-spacing:.26em;color:#a8b6cf;text-transform:uppercase">Bahrain · Riffa Martial Arts</span>
|
||||
<span style="width:30px;height:20px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 5px,rgba(255,255,255,.1) 5px 10px);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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="position:absolute;left:0;right:0;bottom:0;display:flex;flex-direction:column;background:linear-gradient(to top, rgba(6,6,8,.94) 40%, rgba(6,6,8,0));z-index:9">
|
||||
<div style="height:4px;background:rgba(255,255,255,.28);position:relative">
|
||||
<div style="position:absolute;left:0;top:0;bottom:0;width:23%;background:#e8534a"></div>
|
||||
<div style="position:absolute;left:23%;top:-3px;width:10px;height:10px;border-radius:50%;background:#e8534a"></div>
|
||||
</div>
|
||||
<div style="height:44px;padding:0 16px;display:flex;align-items:center;gap:18px;color:#efe9e0">
|
||||
<button onClick="{{ replayIntro }}" style="display:flex;gap:4px;padding:0;background:transparent;border:0;cursor:pointer;color:inherit" style-hover="opacity:.75"><span style="width:4px;height:15px;background:currentColor"></span><span style="width:4px;height:15px;background:currentColor"></span></button>
|
||||
<span style="display:flex;align-items:center;gap:2px"><span style="width:6px;height:9px;background:currentColor"></span><span style="width:0;height:0;border-right:8px solid currentColor;border-top:8px solid transparent;border-bottom:8px solid transparent"></span><span style="width:5px;height:5px;border-right:2px solid currentColor;border-top:2px solid currentColor;transform:rotate(45deg);margin-left:2px"></span></span>
|
||||
<span style="font-size:13px;letter-spacing:.1em;color:#cfc9c1;font-variant-numeric:tabular-nums">0:57 / 4:11</span>
|
||||
<span style="flex:1"></span>
|
||||
<button onClick="{{ toggleMenu }}" style="width:24px;height:24px;flex:none;display:flex;align-items:center;justify-content:center;background:transparent;border:0;cursor:pointer;color:{{ gearColor }}" style-hover="color:#fff">
|
||||
<span style="width:15px;height:15px;border:2px solid currentColor;border-radius:50%;display:block;position:relative"><span style="position:absolute;inset:3px;border:2px solid currentColor;border-radius:50%"></span></span>
|
||||
</button>
|
||||
<span style="width:22px;height:16px;border:2px solid #e8534a;border-radius:3px;display:block"></span>
|
||||
<span style="width:22px;height:16px;border:2px solid currentColor;border-radius:2px;display:flex;align-items:flex-end;justify-content:flex-end;padding:2px"><span style="width:9px;height:6px;background:currentColor"></span></span>
|
||||
<span style="width:22px;height:14px;border:2px solid currentColor;border-radius:2px;display:block"></span>
|
||||
<span style="width:18px;height:16px;display:grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;gap:4px">
|
||||
<span style="border-left:2px solid currentColor;border-top:2px solid currentColor"></span><span style="border-right:2px solid currentColor;border-top:2px solid currentColor"></span>
|
||||
<span style="border-left:2px solid currentColor;border-bottom:2px solid currentColor"></span><span style="border-right:2px solid currentColor;border-bottom:2px solid currentColor"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="position:absolute;right:118px;bottom:58px;width:236px;z-index:10;display:{{ menuDisplay }};flex-direction:column;background:rgba(10,10,12,.94);backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,.16);box-shadow:0 20px 50px rgba(0,0,0,.6)">
|
||||
<div style="padding:10px 14px;border-bottom:1px solid rgba(255,255,255,.1);font-size:11px;letter-spacing:.3em;color:#8f8a83;text-transform:uppercase">Scoreboard</div>
|
||||
<sc-for list="{{ menuItems }}" as="it" hint-placeholder-count="7">
|
||||
<button onClick="{{ it.toggle }}" style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding:9px 14px;background:transparent;border:0;cursor:pointer;font-family:'Barlow Condensed',sans-serif;font-size:14px;letter-spacing:.14em;color:#efe9e0;text-transform:uppercase;text-align:left" style-hover="background:rgba(255,255,255,.07)">
|
||||
<span>{{ it.label }}</span>
|
||||
<span style="width:26px;height:14px;flex:none;background:{{ it.track }};position:relative"><span style="position:absolute;top:2px;left:{{ it.knob }};width:10px;height:10px;background:#efe9e0"></span></span>
|
||||
</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="width:1150px;flex:none;display:flex;align-items:center;justify-content:space-between;color:#6f6a64;font-size:14px;letter-spacing:.22em;text-transform:uppercase">
|
||||
<span>Overlay is a function of playback time · points come from the Points panel</span>
|
||||
<span style="color:#efe9e0">{{ statusLine }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":1230,"height":1400},"sport":{"editor":"enum","options":["Karate","Taekwondo"],"default":"Karate","tsType":"string"},"showTicker":{"editor":"boolean","default":true,"tsType":"boolean"}}">
|
||||
class Component extends DCLogic {
|
||||
state = {
|
||||
t: 107, aka: 6, ao: 4, menu: false,
|
||||
parts: { scorebar: true, ticker: true, identity: true, timeline: true, clubs: true, flags: true, penalties: false }
|
||||
};
|
||||
|
||||
playIntro = () => {
|
||||
clearTimeout(this.t1); clearTimeout(this.t2);
|
||||
this.setState({ intro: true, introFade: false });
|
||||
this.t1 = setTimeout(() => this.setState({ introFade: true }), 2000);
|
||||
this.t2 = setTimeout(() => this.setState({ intro: false }), 2600);
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.playIntro();
|
||||
this.iv = setInterval(() => this.setState(s => ({ t: s.t > 0 ? s.t - 1 : 0 })), 1000);
|
||||
}
|
||||
componentWillUnmount() { clearInterval(this.iv); }
|
||||
|
||||
renderVals() {
|
||||
const tkd = (this.props.sport ?? 'Karate') === 'Taekwondo';
|
||||
const RED = '#ff6a5e', BLUE = '#6aa6ff';
|
||||
const ev = [
|
||||
{ at: 11, corner: 'blue', pts: 1 }, { at: 11, corner: 'red', pts: 1 },
|
||||
{ at: 30, corner: 'red', pts: 2 }, { at: 51, corner: 'red', pts: 1 },
|
||||
{ at: 112, corner: 'blue', pts: 1 }, { at: 159, corner: 'blue', pts: 1 }
|
||||
];
|
||||
const aka = ev.filter(e => e.corner === 'red').reduce((a, e) => a + e.pts, 0);
|
||||
const ao = ev.filter(e => e.corner === 'blue').reduce((a, e) => a + e.pts, 0);
|
||||
const p = this.state.parts;
|
||||
const m = Math.floor(this.state.t / 60), s = String(this.state.t % 60).padStart(2, '0');
|
||||
|
||||
// mirrors the platform's Points data: round, @timestamp, corner, point value
|
||||
const events = [
|
||||
{ at: 11, corner: 'blue', pts: 1 }, { at: 11, corner: 'red', pts: 1 },
|
||||
{ at: 30, corner: 'red', pts: 2 }, { at: 51, corner: 'red', pts: 1 },
|
||||
{ at: 112, corner: 'blue', pts: 1 }, { at: 159, corner: 'blue', pts: 1 }
|
||||
];
|
||||
const fmt = n => Math.floor(n / 60) + ":" + String(n % 60).padStart(2, '0');
|
||||
const karateFeed = events.slice().reverse().slice(0, 4).map(e => ({
|
||||
time: "@" + fmt(e.at),
|
||||
name: e.corner === 'red' ? "Point · Red" : "Point · Blue",
|
||||
pts: "+" + e.pts,
|
||||
color: e.corner === 'red' ? RED : BLUE
|
||||
}));
|
||||
const totals = events.reduce((a, e) => (a[e.corner] += e.pts, a), { red: 0, blue: 0 });
|
||||
const tkdFeed = [
|
||||
{ time: "1:12", name: "Head kick", pts: "+3", color: RED },
|
||||
{ time: "1:44", name: "Turning body", pts: "+4", color: BLUE },
|
||||
{ time: "2:03", name: "Body kick", pts: "+2", color: RED },
|
||||
{ time: "2:21", name: "Gam-jeom", pts: "+1", color: BLUE }
|
||||
];
|
||||
|
||||
return {
|
||||
disciplineLabel: tkd ? "Taekwondo Kyorugi" : "Karate Kumite",
|
||||
overlayDisplay: 'contents',
|
||||
introDisplay: this.state.intro ? 'block' : 'none',
|
||||
introOpacity: this.state.introFade ? 0 : 1,
|
||||
replayIntro: this.playIntro,
|
||||
menuDisplay: this.state.menu ? 'flex' : 'none',
|
||||
gearColor: this.state.menu ? '#e8534a' : '#efe9e0',
|
||||
toggleMenu: () => this.setState(s => ({ menu: !s.menu })),
|
||||
menuItems: [
|
||||
['scorebar', 'Score bar'], ['ticker', 'Live scoring feed'], ['identity', 'Match info'],
|
||||
['timeline', 'Point timeline'], ['clubs', 'Club logos'], ['flags', 'Country flags'], ['penalties', 'Penalties']
|
||||
].map(([k, label]) => ({
|
||||
label,
|
||||
toggle: () => this.setState(s => ({ parts: Object.assign({}, s.parts, { [k]: !s.parts[k] }) })),
|
||||
track: p[k] ? '#e8534a' : '#3a3734',
|
||||
knob: p[k] ? '14px' : '2px'
|
||||
})),
|
||||
dIdentity: p.identity ? 'flex' : 'none',
|
||||
dTicker: p.ticker ? 'flex' : 'none',
|
||||
dBar: p.scorebar ? 'flex' : 'none',
|
||||
dTimeline: p.timeline ? 'flex' : 'none',
|
||||
dClubs: p.clubs ? 'flex' : 'none',
|
||||
dFlags: p.flags ? 'flex' : 'none',
|
||||
dPenalty: p.penalties ? 'inline-block' : 'none',
|
||||
redCorner: "Red",
|
||||
blueCorner: "Blue",
|
||||
penaltyLabel: tkd ? "GAM-JEOM 1" : "C1",
|
||||
roundLabel: "Round 1",
|
||||
penaltyLabel2: null,
|
||||
akaScore: aka,
|
||||
aoScore: ao,
|
||||
clock: "2:39",
|
||||
feed: (this.props.showTicker ?? true) ? (tkd ? tkdFeed : karateFeed) : [],
|
||||
timeline: [RED, '#2a2724', BLUE, RED, '#2a2724', '#2a2724', BLUE, RED, '#2a2724', RED, BLUE, '#2a2724', RED, '#2a2724']
|
||||
.map(c => ({ color: c })),
|
||||
statusLine: aka > ao ? "Red leads +" + (aka - ao) : ao > aka ? "Blue leads +" + (ao - aka) : "Level"
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,102 +0,0 @@
|
||||
# TASK: Add a match scoreboard overlay + VS intro to the video player on video.takeone.bh
|
||||
|
||||
Implement this end to end in this codebase, in the existing player and video page. Follow the codebase's own framework, component and styling patterns. Do not ship the reference HTML as-is — recreate it.
|
||||
|
||||
Reference prototype (design intent, exact colors/geometry): `Match Points Overlay.dc.html` in this folder. Open it in a browser: it shows the standalone VS card, then the player frame with the VS intro, score bar, live scoring feed, point timeline, control bar and gear menu.
|
||||
|
||||
---
|
||||
|
||||
## 1. What exists today (do not rebuild)
|
||||
|
||||
- **Points panel** on the video page: rounds (number, optional name, optional start time) and point events shaped `@mm:ss · Point (N pt) · Blue | Red | Both`, with a running score printed `Score <blue> – <red>`.
|
||||
- **Coach review** panel: time-ranged notes with author, plus reaction icons.
|
||||
- **Player**: progress bar, play/pause, volume, time `0:57 / 4:11`, gear (settings) menu, loop, mini player (PiP), theater, fullscreen; playback-speed menu and ¼× ½× ¾× slow motion; a "Highlights" badge top-right.
|
||||
|
||||
## 2. What to add to the data model
|
||||
|
||||
A **Match metadata** form for sports videos, next to "Add Round":
|
||||
|
||||
- Event/series name, division/category, weight class, bout number, tatami/ring, date, venue.
|
||||
- Per corner (red, blue): athlete name, country name + ISO code, flag image, club name, club logo, optional headshot (3:4).
|
||||
|
||||
Rules:
|
||||
- Every field is optional. **An empty field hides its element.** Never render placeholder strings ("NOT SET", "—", empty boxes).
|
||||
- No athlete name → show the corner label ("RED" / "BLUE") in the name slot.
|
||||
- No flag image → hide the flag chip. No club logo → hide the logo slot. No headshot → hide the photo frame and center the text block.
|
||||
- Corners in data stay **red** and **blue**. Display labels are a per-video option: Red/Blue (default), Aka/Ao (karate), Hong/Chung (taekwondo).
|
||||
- A `Both` point event increments both corners and emits two feed rows.
|
||||
|
||||
## 3. Overlay: driven by playback time
|
||||
|
||||
The overlay is a pure function of `video.currentTime`. No timers, no countdown. Slow motion and playback speed must not affect it; scrubbing backwards must remove points again.
|
||||
|
||||
- **Scores** = sum of point values for events with `at <= currentTime`, per corner.
|
||||
- **Round label** = the round whose start time is the latest `<= currentTime`.
|
||||
- **Clock** = `currentTime` as `m:ss` (offset from round start when round start times exist).
|
||||
- **Live scoring feed** = last 4 events with `at <= currentTime`, newest on top, each animating in (translateY 10px → 0, opacity 0 → 1, .45s ease) as playback crosses it.
|
||||
- **Point timeline** = one segment per event across the full duration, red or blue, positioned by timestamp; empty segments `#2a2724`.
|
||||
|
||||
## 4. Layout spec (design width 1150px, frame 16:9)
|
||||
|
||||
Overlay layer: `position:absolute; inset:0; pointer-events:none;` inside the player container. Only the gear menu is interactive. Scrims: bottom `rgba(6,6,8,.9) → .45 @15% → 0 @30%`, top `rgba(6,6,8,.85) → .4 @14% → 0 @30%`. No texture/grain layer over the footage.
|
||||
|
||||
**Match info** — top-left, 22px/26px inset: 4px vertical rule `linear-gradient(#e8534a,#7c1d18)`; discipline line in `Zen Old Mincho` 15px, .34em tracking, `#efe9e0`; subtitle 13px, .28em, `#cfc9c1`, with `text-shadow: 0 1px 6px rgba(0,0,0,.9)`.
|
||||
|
||||
**Live scoring feed** — top-right, 20px/24px inset, 262px wide. Header: 7px red dot pulsing (opacity 1→.25→1, 1.6s) + "LIVE SCORING" 12px, .3em, `#d5cfc7`. Rows: `rgba(10,10,12,.62)` + `blur(6px)`, padding 7px/10px, `border-right:3px solid <corner color>`; timestamp 12px `#79736c` · label 17px/700 .12em uppercase `#efe9e0` · points in `Zen Old Mincho` 19px in the corner color.
|
||||
|
||||
**Score bar** — 84px tall, inset 26px left/right, `bottom: 56px` (clears the 48px control band), z-index below controls. Three panels, no gaps:
|
||||
|
||||
- Red panel: `flex:1 1 0; min-width:0; overflow:hidden; transform:skewX(-9deg)`, background `linear-gradient(90deg, rgba(122,26,22,.94), rgba(180,52,44,.9))`, `border-bottom:3px solid #ff6a5e`.
|
||||
Inside, ONE inner row un-skews: `transform:skewX(9deg); height:100%; padding:0 16px; display:flex; align-items:center; gap:12px`. Never counter-skew individual children — that is what made scores collide with names in the first attempt.
|
||||
Children: club logo 46×46 (`rgba(0,0,0,.32)`, 1px white 24% border) → text column (`flex:1 1 auto; min-width:0`) → score box (`width:60px; flex:none`, column, right-aligned).
|
||||
Text column row 1: flag chip 26×17 + athlete name (25px/800 uppercase, `flex:1 1 auto; min-width:0`, nowrap + ellipsis). Row 2: club name (12px, .03em, uppercase, ellipsis) + penalty chip.
|
||||
Score box: corner label 11px .24em above the numeral; numeral `Zen Old Mincho` 46px/700 white, `text-shadow:0 6px 20px rgba(0,0,0,.5)`.
|
||||
- Center: `width:118px; flex:none`, `rgba(9,9,11,.9)` + `blur(8px)`, `border-bottom:3px solid #2c2a28`; round label 11px .3em `#8f8a83`, clock 34px/800 `#efe9e0` tabular-nums, three 20×3px pips (`#e8534a` done, `#3a3734` pending).
|
||||
- Blue panel: exact mirror — score box, text column right-aligned, club logo. Gradient `rgba(30,72,140,.9) → rgba(18,44,92,.94)`, `border-bottom:3px solid #6aa6ff`.
|
||||
|
||||
Both panels must be true mirrors: logo → flag+name → club+penalty → score, with the corner label above its own score on both sides.
|
||||
|
||||
**Point timeline** — `left/right: 26px; bottom: 48px; height: 5px`, segments with 2px gaps.
|
||||
|
||||
## 5. Gear-menu controls
|
||||
|
||||
No floating on-screen toggle. Add a **Scoreboard** section to the existing gear menu (same pattern as quality/speed), anchored above the gear button:
|
||||
|
||||
Panel `rgba(10,10,12,.94)` + `blur(10px)`, 1px white 16% border, 236px wide, header "SCOREBOARD" 11px .3em `#8f8a83`. One switch row per part:
|
||||
|
||||
`Score bar · Live scoring feed · Match info · Point timeline · Club logos · Country flags · Penalties`
|
||||
|
||||
Rows: 14px uppercase label, .14em tracking, hover `rgba(255,255,255,.07)`; switch = 26×14 track (`#e8534a` on, `#3a3734` off) with a 10px knob. Persist per user; the uploader's per-video settings seed the defaults. **Penalties defaults off** — the Points panel has no penalty events yet; enable when it does. Gear icon turns `#e8534a` while the menu is open. Menu closes on outside click and Esc, and never covers the score bar.
|
||||
|
||||
## 6. VS intro before playback
|
||||
|
||||
Part of the play experience, inside the video box at 16:9:
|
||||
|
||||
- On first play of a session, render the VS card over the frame while the video is paused at frame 0.
|
||||
- Hold **2s**, then fade out over 0.5s; call `video.play()` as the fade begins so motion is already running when the card clears. Scoreboard fades in after.
|
||||
- Any click, tap or key press skips it immediately and starts playback.
|
||||
- Replays and scrubs do not re-trigger it. If match metadata is missing, skip the intro entirely.
|
||||
|
||||
**VS card content**: event name in `Zen Old Mincho` 24px .44em uppercase; division line 12px .34em with red/blue 4px square separators; per fighter a **3:4 photo frame** with a **104px circular club crest** overlapping its inner-bottom corner (`bottom:-26px`, `#0d0d10`, 1px white 28% border, `0 10px 30px rgba(0,0,0,.6)`), then a 3px corner-colored rule, name 42px/800 uppercase, flag chip 40×27 + country 14px .3em, club name 13px .18em; center column: "VS" `Zen Old Mincho` 88px, weight class in a 1px-bordered chip, format line; bottom line: bout · tatami · date · venue.
|
||||
|
||||
**Background animation** (CSS only, no video): two radial glows — red `rgba(168,42,34,.62)` and blue `rgba(28,64,136,.62)`, 70% width, drifting `translate3d(±60px, ∓28px) scale(1→1.14)` over 19s and 23s ease-in-out infinite — plus a 26%-wide white 9% light sweep crossing every 11s. No center divider line.
|
||||
|
||||
Also expose the VS card as a 1200×630 share image for `og-image`.
|
||||
|
||||
## 7. Design tokens
|
||||
|
||||
Ink `#0a0a0c` · panel `rgba(9,9,11,.9)` · bone `#efe9e0` · muted `#8f8a83` · brand red `#e8534a` · red edge `#ff6a5e` · red panel `#7a1a16 → #b4342c` · blue panel `#1e488c → #122c5c` · blue edge `#6aa6ff` · senshu `#ffdf6b` on `#3a2a00` · penalty `#c8492f`.
|
||||
Type: **Barlow Condensed** 400/600/700/800 for all UI; **Zen Old Mincho** 700 for score numerals, "VS" and the discipline/event lines.
|
||||
Geometry: skew `-9deg` with a single `+9deg` counter-skew row; bar 84px; frame inset 26px; gaps 12px; no border radius on overlay parts.
|
||||
|
||||
## 8. Acceptance criteria
|
||||
|
||||
1. Overlay never blocks the scrubber or any control (`pointer-events:none`, controls above it).
|
||||
2. Score bar sits fully above the control band; nothing crosses the progress bar or the panels' bottom borders.
|
||||
3. No text is clipped or overlapping at 1150px design width: every text span has `min-width:0` with ellipsis; fixed boxes are `flex:none`.
|
||||
4. Red and blue panels are exact mirrors.
|
||||
5. Scores, feed, round and timeline all match the Points panel at any `currentTime`, including after scrubbing backwards and at ¼×/½×/¾× speed.
|
||||
6. Missing metadata hides elements — no placeholder text anywhere.
|
||||
7. Overlay scales with the player (fixed 1150px layer scaled, or clamp-based sizing); minimum rendered text 12px; below ~700px player width only the score bar remains.
|
||||
8. VS intro holds 2s, fades, playback starts; skippable; first play only.
|
||||
9. Every one of the seven parts toggles independently from the gear menu and persists per user.
|
||||
@@ -1,200 +0,0 @@
|
||||
# Handoff: Live Match Scoreboard Overlay (Karate / Taekwondo)
|
||||
|
||||
Paste this file to Claude Code as the brief. The bundled `Match Points Overlay.dc.html` is a **design reference prototype written in HTML** — not production code. Recreate it inside the existing video platform (React/Vue/whatever the player is built in), using the codebase's own component and styling patterns. Fidelity: **high** — colors, type, sizes below are final.
|
||||
|
||||
## Goal
|
||||
|
||||
An overlay that sits on top of the video player and shows live match state for a karate kumite or taekwondo kyorugi bout. **Every part must be independently switchable on and off** (per-viewer preference, and per-broadcast defaults set by the uploader).
|
||||
|
||||
## Structure
|
||||
|
||||
The overlay is a layer inside the player container:
|
||||
|
||||
```
|
||||
<div class="player"> position: relative
|
||||
<video> base layer
|
||||
<div class="overlay"> position:absolute; inset:0; pointer-events:none
|
||||
...parts... each part pointer-events:none except the toggle chip
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Nothing about the video element changes. The overlay never intercepts clicks except on its own toggle control.
|
||||
|
||||
## Switchable parts
|
||||
|
||||
Each is a boolean, defaulting to on, stored per user (localStorage or profile) and overridable per video:
|
||||
|
||||
| key | part | position in frame |
|
||||
|---|---|---|
|
||||
| `identity` | discipline label + match subtitle, with a 4px red-gradient rule at its left | top-left, 22px / 26px inset |
|
||||
| `ticker` | "LIVE SCORING" header + last 4 judged calls | top-right, 20px / 24px inset, 262px wide |
|
||||
| `scorebar` | the main bottom bar (both corner panels + center clock) | bottom, full width minus 26px, height 84px, 18px from bottom |
|
||||
| `timeline` | thin segmented point-history strip | bottom, 5px tall, 8px from bottom |
|
||||
| `clubs` | club logo slot + club name inside each corner panel | inside scorebar |
|
||||
| `flags` | country flag chip next to each athlete name | inside scorebar |
|
||||
| `penalties` | SENSHU chip (red) and C1 / GAM-JEOM chip (blue) | inside scorebar |
|
||||
|
||||
Also a master `overlay` toggle: a chip in the video box (bottom-right, `SCOREBOARD ON/OFF`, dark translucent, 1px white 18% border, red dot when on, grey when off) that hides everything at once. This chip is the only pointer-events:auto element. When the overlay is off, the chip drops from 112px above the bottom to 18px.
|
||||
|
||||
Where the platform has a player settings menu (like captions), expose the same booleans there.
|
||||
|
||||
## Layout spec (design width 1150px, 16:9-ish frame)
|
||||
|
||||
Scrim over the video so text reads: bottom gradient `rgba(6,6,8,.9) 0% → .45 at 15% → 0 at 30%`, top gradient `rgba(6,6,8,.85) 0% → .4 at 14% → 0 at 30%`. A diagonal texture layer over the frame: `repeating-linear-gradient(115deg, rgba(255,255,255,.5) 0 1px, transparent 1px 4px)`, opacity .16, `mix-blend-mode: overlay`.
|
||||
|
||||
### Bottom bar (84px tall, flex row, no gaps between panels)
|
||||
|
||||
- **Red panel** — `flex:1 1 0; min-width:0; overflow:hidden; transform:skewX(-9deg)`, background `linear-gradient(90deg, rgba(122,26,22,.94), rgba(180,52,44,.9))`, `border-bottom:3px solid #ff6a5e`.
|
||||
Inner row un-skews once: `transform:skewX(9deg); height:100%; padding:0 16px; display:flex; align-items:center; gap:12px`. Applying the counter-skew to the row (not to each child) is what keeps the score numeral from overlapping the name.
|
||||
Children in order: club logo box (46×46, `rgba(0,0,0,.32)` fill, 1px white 24% border, monospace 8px "CLUB LOGO" placeholder) → text column (`flex:1 1 auto; min-width:0`) → score box (`width:56px; flex:none; text-align:right`).
|
||||
Text column row 1: flag chip 26×17 → athlete name (25px/800, uppercase, `flex:1 1 auto; min-width:0`, nowrap + ellipsis) → corner label "AKA" (11px, .24em tracking, 66% white).
|
||||
Text column row 2: club name (12px, .03em tracking, 85% white, uppercase, ellipsis) → SENSHU chip (10px/700, .18em, 1px 6px padding, `#ffdf6b` on `#3a2a00`).
|
||||
- **Center clock** — `width:118px; flex:none`, `rgba(9,9,11,.9)` + `backdrop-filter: blur(8px)`, `border-bottom:3px solid #2c2a28`. Stacked: round label (11px, .3em, `#8f8a83`), clock `mm:ss` (34px/800, `#efe9e0`, tabular-nums), three 20×3px round pips (`#e8534a` for completed, `#3a3734` for pending).
|
||||
- **Blue panel** — mirror of red: gradient `rgba(30,72,140,.9) → rgba(18,44,92,.94)`, `border-bottom:3px solid #6aa6ff`, order reversed (score, text column right-aligned, club logo), penalty chip `#c8492f` on white text.
|
||||
- Score numerals: `'Zen Old Mincho', serif`, 46px, weight 700, white, `text-shadow: 0 6px 20px rgba(0,0,0,.5)`.
|
||||
|
||||
### Ticker (top-right)
|
||||
|
||||
Header row: 7px red dot pulsing (`opacity 1 → .25 → 1`, 1.6s infinite) + "LIVE SCORING" (12px, .3em, `#d5cfc7`, text-shadow for legibility). Each entry: `rgba(10,10,12,.62)` + `blur(6px)`, 7px/10px padding, `border-right: 3px solid <corner color>`, animating in with `translateY(10px) → 0`, opacity 0 → 1, .45s ease. Entry content: timestamp (12px, `#79736c`) · call name (17px/700, .12em, uppercase, `#efe9e0`) · points (`Zen Old Mincho` 19px, corner color). Newest at top, keep 4.
|
||||
|
||||
Karate calls: Ippon +3, Waza-ari +2, Yuko +1, Chukoku/Keikoku/Hansoku penalties.
|
||||
Taekwondo calls: head kick +3, turning body +4, body kick +2, punch +1, gam-jeom (awards +1 to the opponent).
|
||||
|
||||
### Timeline strip
|
||||
|
||||
14 equal segments, 5px tall, 2px gaps; red / blue for scored points in chronological order, `#2a2724` for empty. Grows as the bout progresses.
|
||||
|
||||
## Sport switch
|
||||
|
||||
One `sport` setting flips labels without changing layout:
|
||||
|
||||
| | Karate | Taekwondo |
|
||||
|---|---|---|
|
||||
| discipline label | KARATE KUMITE | TAEKWONDO KYORUGI |
|
||||
| corners | AKA / AO | HONG / CHUNG |
|
||||
| round label | KUMITE | ROUND n / 3 |
|
||||
| penalty chip | C1 | GAM-JEOM n |
|
||||
| point names | Ippon / Waza-ari / Yuko | head, turning, body, punch |
|
||||
|
||||
## Data contract
|
||||
|
||||
Drive the overlay from the existing Points panel. Suggested shape:
|
||||
|
||||
```ts
|
||||
type Corner = 'red' | 'blue';
|
||||
interface Athlete { name: string; countryCode: string; flagUrl?: string; clubName: string; clubLogoUrl?: string; }
|
||||
interface ScoreEvent { id: string; at: string; corner: Corner; label: string; points: number; }
|
||||
interface MatchState {
|
||||
sport: 'karate' | 'taekwondo';
|
||||
title: string; subtitle: string; // "Finals", "Senior −75 kg · Tatami 1"
|
||||
red: Athlete; blue: Athlete;
|
||||
redScore: number; blueScore: number;
|
||||
senshu: Corner | null;
|
||||
penalties: { red: number; blue: number };
|
||||
round: number; totalRounds: number;
|
||||
clockSeconds: number; running: boolean;
|
||||
events: ScoreEvent[];
|
||||
}
|
||||
```
|
||||
|
||||
Scores, clock and events come from the scorekeeper; the overlay is presentational. The clock counts down once per second while `running`. New events prepend to the ticker and push a segment onto the timeline.
|
||||
|
||||
## Design tokens
|
||||
|
||||
Colors: ink `#0a0a0c` / panel `rgba(9,9,11,.9)` / bone `#efe9e0` / muted `#8f8a83` / brand red `#e8534a` / red edge `#ff6a5e` / red panel `#7a1a16 → #b4342c` / blue panel `#1e488c → #122c5c` / blue edge `#6aa6ff` / senshu `#ffdf6b` on `#3a2a00` / penalty `#c8492f`.
|
||||
Type: `Barlow Condensed` 400/600/700/800 for all UI; `Zen Old Mincho` 700 for score numerals and the discipline label.
|
||||
Geometry: panel skew `-9deg` (content counter-skewed `+9deg`), bar height 84px, frame inset 26px, bottom inset 18px, gaps 12px, no border radius on overlay parts.
|
||||
|
||||
## Accessibility / safety
|
||||
|
||||
- Keep every text span `min-width:0` inside its flex column so names truncate instead of overflowing.
|
||||
- Minimum on-screen text size 12px at 1150px design width; scale the whole overlay proportionally with the player (`transform: scale()` on a fixed 1150px layer, or clamp-based sizing) so it stays legible at small player sizes and in fullscreen.
|
||||
- Below ~700px player width, hide `identity` and `ticker` automatically and keep only the score bar.
|
||||
- Overlay is decorative for screen readers; expose the same data as text in the existing Points panel.
|
||||
|
||||
## Files
|
||||
|
||||
- `Match Points Overlay.dc.html` — the reference prototype (open in a browser; the SCOREBOARD chip demonstrates the master toggle).
|
||||
- `assets/mat.png` — still frame used as the video stand-in. Not part of the deliverable.
|
||||
- Flags and club logos are mockup placeholders in the prototype; wire real images in.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Corrections after first implementation pass
|
||||
|
||||
The first build broke the score bar. Specific fixes:
|
||||
|
||||
1. **Keep the skew, but counter-skew ONCE.** The panel gets `transform: skewX(-9deg)`; a single inner row gets `transform: skewX(9deg); height:100%; display:flex; align-items:center`. Do not apply the counter-skew to individual children, and do not drop the skew altogether — the parallelogram edges are the design.
|
||||
2. **The bar is a fixed-height 84px strip, vertically centered content.** In the broken build the names sat on the very bottom edge and the panels were full-bleed to the frame. The bar is inset 26px left/right, sits above the player control bar, and its content is vertically centered — nothing touches the bar's top or bottom edge.
|
||||
3. **Both scores must render.** Red score right-aligned in a 56px `flex:none` box at the panel's inner end; blue score left-aligned in the mirrored position. In the broken build the red score was missing and the blue "1" was floating at the top edge.
|
||||
4. **Never let panel content overflow.** Text spans need `min-width:0` and `overflow:hidden; text-overflow:ellipsis`; fixed boxes (logo 46px, score 56px) are `flex:none`.
|
||||
5. **Empty data:** when an athlete/club is unknown, render the corner as "—" or hide the row — do not print "NOT SET".
|
||||
6. **Club logo and flag slots must be filled or hidden.** If no image, hide the slot (toggle default off) rather than showing an empty box.
|
||||
|
||||
## Controls belong in the player's gear menu
|
||||
|
||||
Remove any floating on-screen toggle chip. The scoreboard switches live in the **existing player settings (gear) menu**, as a "Scoreboard" section — same pattern as quality/captions:
|
||||
|
||||
- Gear button in the control bar opens a popup anchored bottom-right, above the control bar.
|
||||
- Popup: dark translucent panel (`rgba(10,10,12,.94)` + `blur(10px)`, 1px white 16% border), 236px wide, a "SCOREBOARD" header (11px, .3em tracking, `#8f8a83`), then one switch row per part: Score bar, Live scoring feed, Match info, Point timeline, Club logos, Country flags, Penalties.
|
||||
- Rows: 14px label, uppercase, .14em tracking; switch is a 26×14 track (`#e8534a` on, `#3a3734` off) with a 10px knob; hover `rgba(255,255,255,.07)`.
|
||||
- Persist each switch per user; the uploader's per-video defaults seed them.
|
||||
- The popup closes on outside click / Esc, and never covers the score bar.
|
||||
|
||||
The updated prototype in this folder demonstrates the gear menu and all seven switches.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Target: video.takeone.bh video page (e.g. /videos/11vu7R)
|
||||
|
||||
Studied the live page. The overlay must be driven by what the platform already stores, and by **playback time** — not by a live clock.
|
||||
|
||||
### What the platform already has
|
||||
|
||||
- **Points panel**: rounds (number, name, optional start time) and point events shaped like `@00:11 · Point (1 pt) · Blue|Red|Both` with a running score printed as `Score <blue> – <red>`.
|
||||
- **Coach review**: time-ranged notes (`01:40 — 01:45`, text, author) and reaction icons.
|
||||
- **Player**: Highlights badge, mini player, playback speed menu, ¼× ½× ¾× slow-motion buttons, scrub speeds 1× 5× 20× in the points editor.
|
||||
- **No fighter metadata at all** — no athlete names, clubs, flags, weight class, or event name. This is exactly why the first implementation printed "NOT SET".
|
||||
|
||||
### Required changes on the platform side
|
||||
|
||||
1. **Add match metadata to the video's sports settings** (one form, next to Add Round): event name, division/category, weight class, bout number, tatami/ring, date, venue — and per corner: athlete name, country (ISO code + flag image), club name, club logo, plus optional headshot for the VS card.
|
||||
2. **Any field left empty hides its element.** Never render a placeholder string like "NOT SET". If no flag → hide the flag chip; no club logo → hide the logo slot; no athlete name → fall back to "RED" / "BLUE".
|
||||
3. **Corners are Red and Blue** in the existing data. Karate labels (AKA/AO) or taekwondo (HONG/CHUNG) are a display option, not new data.
|
||||
4. **A "Both" point event** awards to both corners — increment both scores and emit two ticker rows.
|
||||
|
||||
### Binding to playback
|
||||
|
||||
The overlay is a pure function of `video.currentTime`:
|
||||
|
||||
- Scores = sum of point values for events with `at <= currentTime`, per corner.
|
||||
- Round label = the round whose start time is the latest `<= currentTime`.
|
||||
- Clock = `currentTime` formatted mm:ss (offset from the round start if round start times exist). It does **not** count down.
|
||||
- Ticker = the last 4 events with `at <= currentTime`, newest on top; a new event animates in as playback crosses it. Scrubbing backwards removes them again.
|
||||
- Timeline strip = one segment per point event across the video duration, red/blue, positioned by timestamp — clicking a segment can seek to that point (nice-to-have; keep `pointer-events` off otherwise).
|
||||
- Slow-motion (¼× ½× ¾×) and playback speed must not affect the overlay: it reads `currentTime`, never a timer.
|
||||
|
||||
### Pre-match VS card
|
||||
|
||||
Rendered as a poster/intro layer at 16:9 over the player before playback starts (and available as an exportable share image at 1200×630 for the og-image). Content: event name, division line, both fighters' 3:4 photos with 104px circular club crests overlapping the inner corner, name, flag + country, club, the weight class under the VS mark, and a bout/tatami/date line. Background: two slow drifting red/blue radial glows plus a periodic light sweep — CSS animations only, no video.
|
||||
|
||||
### Controls
|
||||
|
||||
Everything toggles from the **gear menu** in the existing control bar, under a "Scoreboard" section: Score bar, Live scoring feed, Match info, Point timeline, Club logos, Country flags, Penalties. Penalties defaults **off** because the current data model has no penalty events; enable it when penalties are added to the Points panel. The overlay layer is `pointer-events:none` so the scrubber and buttons stay clickable, and the score bar sits above the 48px control band (bottom ≈ 56px) so nothing crosses the progress bar.
|
||||
|
||||
The prototype in this folder uses the real point data from /videos/11vu7R (blue 1 @0:11, red 1 @0:11, red 2 @0:30, red 1 @0:51, blue 1 @1:52, blue 1 @2:39 → 4–3 red).
|
||||
|
||||
|
||||
## VS intro before playback
|
||||
|
||||
The VS card is part of the play experience, not a separate screen:
|
||||
|
||||
- On load / on pressing play, the player renders the VS card **inside the video box** at 16:9, covering the frame, while the video is buffering/paused at frame 0.
|
||||
- It holds for **2 seconds**, then fades out over ~0.5s and playback starts (`video.play()` fires as the fade begins, so the first frames are already moving as the card clears).
|
||||
- The scoreboard overlay fades in after the card clears.
|
||||
- The card is skippable: any click, key press, or tap on the frame ends it immediately and starts playback.
|
||||
- Only on the first play of a session — a replay/scrub does not re-trigger it (the prototype's play button replays it for demo purposes).
|
||||
- If match metadata is missing, skip the intro entirely rather than showing an empty card.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1002 KiB |
@@ -1,94 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>VS screen — exact markup</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700;800&family=Zen+Old+Mincho:wght@700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { margin:0; background:#0a0a0c; font-family:'Barlow Condensed',sans-serif; }
|
||||
@keyframes driftA { 0%,100% { transform: translate3d(0,0,0) scale(1); } 50% { transform: translate3d(60px,-28px,0) scale(1.14); } }
|
||||
@keyframes driftB { 0%,100% { transform: translate3d(0,0,0) scale(1.08); } 50% { transform: translate3d(-54px,26px,0) scale(1); } }
|
||||
@keyframes sweep { 0% { transform: translateX(-60%) skewX(-14deg); opacity:0; } 25% { opacity:.5; } 60% { opacity:0; } 100% { transform: translateX(160%) skewX(-14deg); opacity:0; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!--
|
||||
VS SCREEN — copy verbatim. Replace ONLY the {{ }} holes.
|
||||
Sits inside the player container as an absolutely positioned 16:9 layer:
|
||||
position:absolute; inset:0; z-index:8; opacity 1 -> 0 over .55s after a 2s hold.
|
||||
Photo frames are 3:4. The club crest overlaps the photo's INNER-bottom corner (right on the left fighter, left on the right one).
|
||||
Hide a missing element with display:none — never substitute placeholder text.
|
||||
The three animated background layers are decorative; keep them behind everything (they are first in source order).
|
||||
-->
|
||||
|
||||
<div style="position:relative;width:1150px;aspect-ratio:16/9;overflow:hidden;background:#08080a">
|
||||
|
||||
<div style="position:absolute;inset:-12%;width:70%;left:-6%;background:radial-gradient(closest-side, rgba(168,42,34,.62), rgba(8,8,10,0) 72%);animation:driftA 19s ease-in-out infinite"></div>
|
||||
<div style="position:absolute;inset:-12%;width:70%;left:36%;background:radial-gradient(closest-side, rgba(28,64,136,.62), rgba(8,8,10,0) 72%);animation:driftB 23s ease-in-out infinite"></div>
|
||||
<div style="position:absolute;top:0;bottom:0;left:0;width:26%;background:linear-gradient(90deg, rgba(255,255,255,0), rgba(255,255,255,.09), rgba(255,255,255,0));animation:sweep 11s linear infinite"></div>
|
||||
<div style="position:absolute;top:0;bottom:0;left:0;width:54%;clip-path:polygon(0 0, 100% 0, 78% 100%, 0 100%);background:linear-gradient(120deg, rgba(122,26,22,.5), rgba(8,8,10,0) 76%)"></div>
|
||||
<div style="position:absolute;top:0;bottom:0;right:0;width:54%;clip-path:polygon(22% 0, 100% 0, 100% 100%, 0 100%);background:linear-gradient(300deg, rgba(24,52,110,.5), rgba(8,8,10,0) 76%)"></div>
|
||||
|
||||
<!-- event header -->
|
||||
<div style="position:absolute;top:34px;left:0;right:0;display:flex;flex-direction:column;align-items:center;gap:8px">
|
||||
<div style="font-family:'Zen Old Mincho',serif;font-size:24px;letter-spacing:.44em;color:#efe9e0;text-transform:uppercase;text-indent:.44em">{{ eventName }}</div>
|
||||
<div style="display:flex;align-items:center;gap:14px;font-size:12px;letter-spacing:.34em;color:#8f8a83;text-transform:uppercase">
|
||||
<span>{{ category }}</span><span style="width:4px;height:4px;background:#e8534a"></span><span>{{ division }}</span><span style="width:4px;height:4px;background:#6aa6ff"></span><span>{{ roundName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="position:absolute;inset:96px 0 54px;display:grid;grid-template-columns:1fr 200px 1fr;align-items:start">
|
||||
|
||||
<!-- RED fighter -->
|
||||
<div style="display:flex;flex-direction:column;align-items:center;padding:34px 24px 0;min-width:0">
|
||||
<div style="position:relative;width:228px;height:304px">
|
||||
<div style="position:absolute;inset:0;background:repeating-linear-gradient(135deg,rgba(255,255,255,.1) 0 6px,rgba(255,255,255,.03) 6px 12px);border:1px solid rgba(255,255,255,.16);display:flex;align-items:flex-end;justify-content:center;padding-bottom:12px;font-family:ui-monospace,monospace;font-size:10px;letter-spacing:.1em;color:rgba(255,255,255,.6)">FIGHTER PHOTO</div>
|
||||
<div style="position:absolute;bottom:-26px;right:-26px;width:104px;height:104px;border-radius:50%;background:#0d0d10;border:1px solid rgba(255,255,255,.28);box-shadow:0 10px 30px rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:9px;letter-spacing:.06em;color:rgba(255,255,255,.7);text-align:center;line-height:1.35">CLUB<br>LOGO</div>
|
||||
</div>
|
||||
<div style="margin-top:34px;padding-top:12px;border-top:3px solid #e8534a;display:flex;flex-direction:column;align-items:center;gap:8px">
|
||||
<div style="font-size:42px;line-height:.95;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap">{{ redName }}</div>
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<span style="width:40px;height:27px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 5px,rgba(255,255,255,.1) 5px 10px);box-shadow:0 0 0 1px rgba(255,255,255,.32);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:8px;color:rgba(255,255,255,.85)">FLAG</span>
|
||||
<span style="font-size:14px;letter-spacing:.3em;color:#d3b3ad;text-transform:uppercase">{{ redCountry }}</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-top:2px">
|
||||
<span style="font-size:13px;letter-spacing:.18em;color:#9d9691;text-transform:uppercase">{{ redClub }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- centre -->
|
||||
<div style="display:flex;flex-direction:column;align-items:center;gap:12px;padding-top:96px">
|
||||
<span style="font-family:'Zen Old Mincho',serif;font-size:88px;line-height:.8;font-weight:700;color:#efe9e0;text-shadow:0 8px 34px rgba(0,0,0,.75)">VS</span>
|
||||
<span style="padding:7px 16px;border:1px solid rgba(255,255,255,.3);font-size:14px;letter-spacing:.32em;color:#efe9e0;text-transform:uppercase">{{ weightClass }}</span>
|
||||
<span style="font-size:11px;letter-spacing:.34em;color:#8f8a83;text-transform:uppercase">{{ format }}</span>
|
||||
</div>
|
||||
|
||||
<!-- BLUE fighter — mirror -->
|
||||
<div style="display:flex;flex-direction:column;align-items:center;padding:34px 24px 0;min-width:0">
|
||||
<div style="position:relative;width:228px;height:304px">
|
||||
<div style="position:absolute;inset:0;background:repeating-linear-gradient(135deg,rgba(255,255,255,.1) 0 6px,rgba(255,255,255,.03) 6px 12px);border:1px solid rgba(255,255,255,.16);display:flex;align-items:flex-end;justify-content:center;padding-bottom:12px;font-family:ui-monospace,monospace;font-size:10px;letter-spacing:.1em;color:rgba(255,255,255,.6)">FIGHTER PHOTO</div>
|
||||
<div style="position:absolute;bottom:-26px;left:-26px;width:104px;height:104px;border-radius:50%;background:#0d0d10;border:1px solid rgba(255,255,255,.28);box-shadow:0 10px 30px rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:9px;letter-spacing:.06em;color:rgba(255,255,255,.7);text-align:center;line-height:1.35">CLUB<br>LOGO</div>
|
||||
</div>
|
||||
<div style="margin-top:34px;padding-top:12px;border-top:3px solid #6aa6ff;display:flex;flex-direction:column;align-items:center;gap:8px">
|
||||
<div style="font-size:42px;line-height:.95;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap">{{ blueName }}</div>
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<span style="font-size:14px;letter-spacing:.3em;color:#a8b6cf;text-transform:uppercase">{{ blueCountry }}</span>
|
||||
<span style="width:40px;height:27px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 5px,rgba(255,255,255,.1) 5px 10px);box-shadow:0 0 0 1px rgba(255,255,255,.32);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:8px;color:rgba(255,255,255,.85)">FLAG</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-top:2px">
|
||||
<span style="font-size:13px;letter-spacing:.18em;color:#9d9691;text-transform:uppercase">{{ blueClub }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- bout line -->
|
||||
<div style="position:absolute;bottom:26px;left:0;right:0;display:flex;align-items:center;justify-content:center;gap:16px;font-size:11px;letter-spacing:.32em;color:#8f8a83;text-transform:uppercase">
|
||||
<span>{{ bout }}</span><span style="width:4px;height:4px;background:#e8534a"></span><span>{{ tatami }}</span><span style="width:4px;height:4px;background:#6aa6ff"></span><span>{{ dateVenue }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2537
File diff suppressed because it is too large.
Load diff
@@ -9,5 +9,8 @@
|
||||
"axios": "^1.6.4",
|
||||
"laravel-vite-plugin": "^1.0.0",
|
||||
"vite": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"puppeteer": "^24.43.1"
|
||||
}
|
||||
}
|
||||
@@ -351,24 +351,40 @@
|
||||
Showing {{ $logs->firstItem() }}–{{ $logs->lastItem() }} of {{ $logs->total() }} events
|
||||
</div>
|
||||
<div class="al-page-links">
|
||||
{{-- First --}}
|
||||
@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
|
||||
<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
|
||||
|
||||
@foreach($logs->getUrlRange(max(1, $logs->currentPage()-2), min($logs->lastPage(), $logs->currentPage()+2)) as $page => $url)
|
||||
@if($page == $logs->currentPage())
|
||||
<span class="active">{{ $page }}</span>
|
||||
<span class="active" aria-current="page">{{ $page }}</span>
|
||||
@else
|
||||
<a href="{{ $url }}">{{ $page }}</a>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
{{-- Next --}}
|
||||
@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
|
||||
<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
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -292,13 +292,16 @@
|
||||
|
||||
{{-- Ready Videos --}}
|
||||
<div class="col-6 col-lg-4 col-xl-2">
|
||||
<div class="stat-card clickable-card" onclick="openDashModal('Live Videos','videos',{status:'ready'},'bi-check-circle-fill')" title="Click to view live videos">
|
||||
<div class="stat-card clickable-card" onclick="openDashModal('Ready','videos',{status:'ready'},'bi-check-circle-fill')" title="Click to view ready videos">
|
||||
<div class="stat-card-accent" style="background: linear-gradient(90deg,#22c55e,#4ade80);"></div>
|
||||
<div class="stat-card-top">
|
||||
<div class="stat-icon" style="background:rgba(34,197,94,.15);color:#4ade80;"><i class="bi bi-check-circle-fill"></i></div>
|
||||
</div>
|
||||
<div class="stat-value">{{ number_format($videosByStatus->get('ready', 0)) }}</div>
|
||||
<div class="stat-label">Live Videos</div>
|
||||
{{-- "Ready" matches the DB status column; "Live Videos" was misleading
|
||||
— nothing streams live from this card, it just counts videos that
|
||||
finished processing successfully. --}}
|
||||
<div class="stat-label">Ready</div>
|
||||
@php $failPct = $stats['totalVideos'] > 0 ? round(($failedCount/$stats['totalVideos'])*100,1) : 0; @endphp
|
||||
<div class="stat-growth {{ $failPct > 5 ? 'down' : 'flat' }}">
|
||||
<i class="bi bi-x-circle"></i> {{ $failedCount }} failed
|
||||
@@ -479,10 +482,10 @@
|
||||
@else
|
||||
@php $maxViews = $viewsByCountry->first()->total; @endphp
|
||||
@foreach($viewsByCountry as $i => $row)
|
||||
<div class="country-row clickable-seg" style="cursor:pointer" onclick="openDashModal('Viewers from {{ addslashes($row->country_name ?? $row->country ?? 'Unknown') }}','country_viewers',{country:'{{ $row->country }}'},'bi-globe2')">
|
||||
<div class="country-row clickable-seg" style="cursor:pointer" onclick="openDashModal('Viewers from {{ addslashes($row->country_name ?? \App\Data\Countries::name($row->country) ?? 'Unknown') }}','country_viewers',{country:'{{ $row->country }}'},'bi-globe2')">
|
||||
<div class="country-rank">{{ $i + 1 }}</div>
|
||||
<div class="country-flag" title="{{ $row->country }}">{!! $row->country ? countryCodeToFlag($row->country) : countryCodeToFlag('xx') !!}</div>
|
||||
<div class="country-name">{{ $row->country_name ?? 'Unknown' }}</div>
|
||||
<div class="country-flag" title="{{ $row->country_name ?? \App\Data\Countries::name($row->country) ?? 'Unknown' }}">{!! $row->country ? countryCodeToFlag($row->country) : countryCodeToFlag('xx') !!}</div>
|
||||
<div class="country-name">{{ $row->country_name ?? \App\Data\Countries::name($row->country) ?? 'Unknown' }}</div>
|
||||
<div class="country-bar-wrap">
|
||||
<div class="country-bar" style="width:{{ round(($row->total / $maxViews) * 100) }}%;"></div>
|
||||
</div>
|
||||
@@ -741,8 +744,21 @@ new Chart(document.getElementById('typeChart'), {
|
||||
|
||||
// ── Country Chart ───────────────────────────────────────────────
|
||||
@if($viewsByCountry->isNotEmpty())
|
||||
@php
|
||||
// Blade's @json() parser can't handle a multi-line arrow function with
|
||||
// a `[` array literal inside its body — the directive reads until the
|
||||
// closing paren and sees the inner `[` as an unmatched bracket. Build
|
||||
// the array in PHP first, then hand the plain variable to @json().
|
||||
$countryChartData = $viewsByCountry->map(function ($r) {
|
||||
return [
|
||||
'country' => $r->country,
|
||||
'country_name' => $r->country_name ?? \App\Data\Countries::name($r->country),
|
||||
'total' => $r->total,
|
||||
];
|
||||
})->values();
|
||||
@endphp
|
||||
(function() {
|
||||
const countryData = @json($viewsByCountry);
|
||||
const countryData = @json($countryChartData);
|
||||
const labels = countryData.map(r => r.country_name || r.country || '');
|
||||
const values = countryData.map(r => r.total);
|
||||
const maxVal = Math.max(...values);
|
||||
@@ -1015,7 +1031,10 @@ if (typeChartInst) {
|
||||
// Country bar chart click
|
||||
const countryChartInst = Chart.getChart('countryChart');
|
||||
if (countryChartInst) {
|
||||
const countryData = @json($viewsByCountry ?? collect());
|
||||
const countryData = @json(($viewsByCountry ?? collect())->map(fn($r) => [
|
||||
'country' => $r->country,
|
||||
'country_name' => $r->country_name ?? \App\Data\Countries::name($r->country),
|
||||
])->values());
|
||||
countryChartInst.options.onClick = function(evt, elements) {
|
||||
if (!elements.length) return;
|
||||
const row = countryData[elements[0].index];
|
||||
|
||||
@@ -64,26 +64,74 @@
|
||||
</button>
|
||||
</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))
|
||||
<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' : '' }}.
|
||||
</div>
|
||||
@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;">
|
||||
@foreach($lines as $line)
|
||||
@foreach($groups as $gi => $g)
|
||||
@php
|
||||
$color = '#aaa';
|
||||
if (str_contains($line, '.ERROR:')) $color = '#f87171';
|
||||
elseif (str_contains($line, '.WARNING:')) $color = '#fbbf24';
|
||||
elseif (str_contains($line, '.INFO:')) $color = '#6ee7b7';
|
||||
elseif (str_contains($line, '.DEBUG:')) $color = '#93c5fd';
|
||||
if (str_contains($g['header'], '.ERROR:') || str_contains($g['header'], '.CRITICAL:')) $color = '#f87171';
|
||||
elseif (str_contains($g['header'], '.WARNING:')) $color = '#fbbf24';
|
||||
elseif (str_contains($g['header'], '.INFO:')) $color = '#6ee7b7';
|
||||
elseif (str_contains($g['header'], '.DEBUG:')) $color = '#93c5fd';
|
||||
$hasTrace = !empty($g['trace']);
|
||||
@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
|
||||
</div>
|
||||
@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>
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
@extends('admin.layout')
|
||||
|
||||
@section('title', 'OG Previews')
|
||||
|
||||
@section('content')
|
||||
<div style="padding: 24px;">
|
||||
<h1 style="margin: 0 0 8px; color: #fff;">Generated VS Cards</h1>
|
||||
<p style="color: #aaa; margin: 0 0 20px;">
|
||||
These are the 1200×630 PNGs that WhatsApp, Facebook, and Twitter show
|
||||
when a sports match video is shared. Generated on save; regenerated on
|
||||
edit. Filename is <code>match-{video_id}-{updated_at}.png</code>.
|
||||
</p>
|
||||
|
||||
@if($files->isEmpty())
|
||||
<p style="color: #aaa;">No previews cached yet. Save a match video to generate one.</p>
|
||||
@else
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 20px;">
|
||||
@foreach($files as $f)
|
||||
<div style="background: #1a1a1a; border: 1px solid #333; border-radius: 12px; overflow: hidden;">
|
||||
<a href="{{ route('admin.og-previews.file', $f['name']) }}" target="_blank" style="display: block; background: #000;">
|
||||
<img src="{{ route('admin.og-previews.file', $f['name']) }}"
|
||||
alt="{{ $f['name'] }}"
|
||||
style="display: block; width: 100%; height: auto;">
|
||||
</a>
|
||||
<div style="padding: 12px 14px; color: #ccc; font-size: 13px;">
|
||||
<div style="font-weight: 600; color: #fff;">Video #{{ $f['id'] }}</div>
|
||||
<div style="color: #888; margin-top: 4px; font-family: monospace; font-size: 11px; word-break: break-all;">
|
||||
{{ $f['name'] }}
|
||||
</div>
|
||||
<div style="color: #888; margin-top: 4px;">
|
||||
{{ number_format($f['size'] / 1024, 1) }} KB ·
|
||||
{{ \Carbon\Carbon::createFromTimestamp($f['mtime'])->diffForHumans() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
@@ -294,7 +294,7 @@ function flagEmoji(string $code): string {
|
||||
<div class="country-row">
|
||||
<span class="country-rank">{{ $i + 1 }}</span>
|
||||
<span class="country-flag">{!! flagEmoji($row->country) !!}</span>
|
||||
<span class="country-name">{{ $row->country_name ?? $row->country }}</span>
|
||||
<span class="country-name">{{ $row->country_name ?? \App\Data\Countries::name($row->country) }}</span>
|
||||
<div class="country-bar-wrap">
|
||||
<div class="country-bar" style="width:{{ round(($row->total / $maxCountry) * 100) }}%"></div>
|
||||
</div>
|
||||
@@ -423,7 +423,7 @@ function flagEmoji(string $code): string {
|
||||
<td>
|
||||
@if($view->country)
|
||||
{!! flagEmoji($view->country) !!}
|
||||
<span style="font-size:13px; margin-left:4px;">{{ $view->country_name ?? $view->country }}</span>
|
||||
<span style="font-size:13px; margin-left:4px;">{{ $view->country_name ?? \App\Data\Countries::name($view->country) }}</span>
|
||||
@else
|
||||
<span style="color:var(--text-secondary); font-size:12px;">Unknown</span>
|
||||
@endif
|
||||
@@ -498,7 +498,7 @@ new Chart(document.getElementById('dailyChart'), {
|
||||
|
||||
// ── Country chart ───────────────────────────────────────────────────────────
|
||||
@if($viewsByCountry->isNotEmpty())
|
||||
const countryLabels = {!! json_encode($viewsByCountry->map(fn($r) => ($r->country_name ?? $r->country))->values()) !!};
|
||||
const countryLabels = {!! json_encode($viewsByCountry->map(fn($r) => ($r->country_name ?? \App\Data\Countries::name($r->country)))->values()) !!};
|
||||
const countryData = {!! json_encode($viewsByCountry->pluck('total')->values()) !!};
|
||||
const countryMax = Math.max(...countryData);
|
||||
|
||||
|
||||
@@ -125,12 +125,25 @@ $adminNeedsOtp = auth()->user()->two_factor_enabled &&
|
||||
</div>
|
||||
@endif
|
||||
<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 }}
|
||||
</div>
|
||||
@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;">
|
||||
{{ Str::limit($video->description, 55) }}
|
||||
@php
|
||||
// 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>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,10 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<x-date-picker name="birthday" label="Date of Birth" :value="old('birthday')" required />
|
||||
{{-- COPPA / platform minimum age: 13 at sign-up. maxYear caps
|
||||
the year dropdown at (this year − 13). --}}
|
||||
<x-date-picker name="birthday" label="Date of Birth" :value="old('birthday')"
|
||||
:max-year="(int) date('Y') - 13" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
|
||||
@@ -3,9 +3,38 @@
|
||||
/videos?filter=playlists. Wrapped in @once so it emits only once. --}}
|
||||
@once
|
||||
<style>
|
||||
/* Base styles for video card */
|
||||
/* Base styles for video card — every card in the grid renders at the same
|
||||
overall size regardless of type (video / shorts / match / playlist). The
|
||||
grid handles equal widths; height parity is enforced here by making the
|
||||
card a column-flex container, the thumb a strict 16/9 box, and every
|
||||
text row a fixed line-height so missing channel/meta rows never shrink
|
||||
the card. */
|
||||
.yt-video-card {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* When the 3-dot menu is open, promote this card above its siblings so
|
||||
the dropdown-menu (Bootstrap default z-index 1000) isn't visually
|
||||
covered by later cards in the grid. Cards are position:static by
|
||||
default, so siblings later in the DOM naturally paint on top even
|
||||
though the dropdown itself has a high z-index — the stacking is
|
||||
compared at the card level, not the dropdown level. `:has()` triggers
|
||||
only while a menu is `.show`ing, so this doesn't affect the normal
|
||||
grid layout. */
|
||||
.yt-video-card:has(.dropdown-menu.show) {
|
||||
position: relative;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
/* Same fix for the ⋮ menu container — .yt-video-info has overflow:hidden
|
||||
(to hard-cap card height for grid parity), which was clipping the
|
||||
dropdown's bottom edge. Allow the dropdown to escape by giving its
|
||||
wrapper a stacking context and letting overflow leak upward from that
|
||||
ancestor only when a menu is open. */
|
||||
.yt-video-card .yt-video-info:has(.dropdown-menu.show) {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.yt-video-card .yt-video-thumb {
|
||||
@@ -61,7 +90,11 @@
|
||||
.yt-video-card .yt-video-thumb video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
/* Match the still-image thumbnail: always cover the 16/9 box so
|
||||
portrait and landscape sources render at the same visible size,
|
||||
instead of letterboxing (which made portrait previews look
|
||||
smaller than landscape ones). */
|
||||
object-fit: cover;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -212,6 +245,22 @@
|
||||
display: flex;
|
||||
margin-top: 12px;
|
||||
gap: 12px;
|
||||
/* Reserve a floor for the info block so every card lands at the same
|
||||
height when titles are short. Two-line titles are allowed to grow
|
||||
the block vertically — overflow:visible so no row is hidden. */
|
||||
min-height: 76px;
|
||||
}
|
||||
/* On channel pages the avatar + channel-name row is suppressed by the
|
||||
component's hideChannelInfo prop; drop the floor so the card doesn't
|
||||
leave empty space where the row used to be. */
|
||||
.yt-video-card .yt-video-info-no-channel { min-height: 60px; }
|
||||
.yt-video-card .yt-video-details {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.yt-video-card .yt-channel-icon {
|
||||
@@ -237,20 +286,32 @@
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
margin: 0 0 4px;
|
||||
line-height: 1.3;
|
||||
/* Two-line clamp. overflow-wrap:anywhere lets CJK titles and long
|
||||
words (e.g. "SPARTAWORRIOR" or "EVIS ― 帰還を待つ覇者の鼓動")
|
||||
break mid-word so they can't widen the card beyond its grid track.
|
||||
Line-clamp replaces the single-line ellipsis the old rule used. */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
line-height: 1.3;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
max-height: calc(1.3em * 2);
|
||||
}
|
||||
|
||||
.yt-video-card .yt-video-title a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
/* Inline children (flags, corner-colored fighter spans) must not break
|
||||
the single-line layout. */
|
||||
.yt-video-card .yt-video-title a > * { vertical-align: middle; }
|
||||
.yt-video-card .yt-video-title a .fi { display: inline-block; }
|
||||
.vc-lang-flag {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
@@ -274,6 +335,8 @@
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.yt-video-card a.yt-channel-name {
|
||||
@@ -390,305 +453,5 @@
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
/* Cute Edit Modal Styles */
|
||||
.cute-edit-modal {
|
||||
max-width: 380px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.cute-edit-content {
|
||||
background: linear-gradient(145deg, #1f1f1f 0%, #2a2a2a 100%);
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.cute-edit-header {
|
||||
background: linear-gradient(135deg, #ff6b8a 0%, #ff8fa3 100%);
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cute-edit-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.cute-edit-header h5 {
|
||||
margin: 0;
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-close-cute {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
border: none;
|
||||
color: white;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-close-cute:hover {
|
||||
background: rgba(255,255,255,0.3);
|
||||
}
|
||||
|
||||
.cute-edit-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.cute-form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.cute-form-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #ccc;
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cute-form-group label i {
|
||||
color: #ff6b8a;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cute-input, .cute-textarea {
|
||||
width: 100%;
|
||||
background: #151515;
|
||||
border: 1px solid #333;
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.cute-input:focus, .cute-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #ff6b8a;
|
||||
box-shadow: 0 0 0 3px rgba(255, 107, 138, 0.15);
|
||||
}
|
||||
|
||||
.cute-input::placeholder, .cute-textarea::placeholder {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* Type Options */
|
||||
.cute-type-options, .cute-privacy-options {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cute-type-option, .cute-privacy-option {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cute-type-option input, .cute-privacy-option input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cute-type-option span, .cute-privacy-option span {
|
||||
display: block;
|
||||
padding: 8px 10px;
|
||||
background: #151515;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
text-align: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.cute-type-option:hover span, .cute-privacy-option:hover span {
|
||||
border-color: #555;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.cute-type-option.active span, .cute-privacy-option.active span {
|
||||
background: rgba(255, 107, 138, 0.15);
|
||||
border-color: #ff6b8a;
|
||||
color: #ff6b8a;
|
||||
}
|
||||
|
||||
/* Shorts Toggle in Edit Modal */
|
||||
.cute-shorts-toggle {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.cute-shorts-toggle input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.cute-shorts-slider {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
background-color: #333;
|
||||
border-radius: 12px;
|
||||
transition: 0.3s;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.cute-shorts-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
.cute-shorts-toggle input:checked + .cute-shorts-slider {
|
||||
background-color: #e63030;
|
||||
}
|
||||
|
||||
.cute-shorts-toggle input:checked + .cute-shorts-slider:before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.cute-shorts-label {
|
||||
color: #aaa;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Thumbnail Upload */
|
||||
.cute-thumbnail-upload {
|
||||
border: 2px dashed #444;
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: #151515;
|
||||
}
|
||||
|
||||
.cute-thumbnail-upload:hover {
|
||||
border-color: #ff6b8a;
|
||||
background: rgba(255, 107, 138, 0.05);
|
||||
}
|
||||
|
||||
.cute-thumbnail-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.cute-thumbnail-preview i {
|
||||
font-size: 24px;
|
||||
color: #ff6b8a;
|
||||
}
|
||||
|
||||
.cute-thumbnail-preview span {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Status */
|
||||
.cute-status {
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cute-status.success {
|
||||
display: block;
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #4ade80;
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.cute-status.error {
|
||||
display: block;
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #f87171;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.cute-edit-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.cute-btn-cancel, .cute-btn-save {
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.cute-btn-cancel {
|
||||
background: #333;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.cute-btn-cancel:hover {
|
||||
background: #444;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.cute-btn-save {
|
||||
background: linear-gradient(135deg, #ff6b8a 0%, #ff8fa3 100%);
|
||||
color: white;
|
||||
box-shadow: 0 4px 15px rgba(255, 107, 138, 0.3);
|
||||
}
|
||||
|
||||
.cute-btn-save:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 20px rgba(255, 107, 138, 0.4);
|
||||
}
|
||||
|
||||
.cute-btn-save:disabled {
|
||||
background: #444;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.cute-edit-modal {
|
||||
max-width: 320px;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.cute-type-options, .cute-privacy-options {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@@ -0,0 +1,377 @@
|
||||
{{-- ══════════════════════════════════════════════════════════════════════
|
||||
SCOREBAR MINI — the same match scorebar shown on the full player,
|
||||
rendered inside a video-card thumbnail during hover playback.
|
||||
|
||||
Uses the design's fixed 1150 px canvas (identical to the main player's
|
||||
msb-scale block). Every card scales its own canvas via ResizeObserver
|
||||
so the scorebar keeps its pixel-perfect proportions at any card size.
|
||||
|
||||
Static-only: fighter names, flags, clubs, corner labels, "Round 1"
|
||||
and 0-0 / 0:00. No live-scoring script — hover previews cover the
|
||||
first few seconds of the match where those values are always the
|
||||
baseline anyway.
|
||||
════════════════════════════════════════════════════════════════════════ --}}
|
||||
@php
|
||||
$sbm_hd = $video->sportsMatch?->headerData();
|
||||
$sbm_red = $sbm_hd['red'] ?? null;
|
||||
$sbm_blue = $sbm_hd['blue'] ?? null;
|
||||
$sbm_show = $sbm_hd && (($sbm_red['name'] ?? null) || ($sbm_blue['name'] ?? null));
|
||||
@endphp
|
||||
|
||||
@if ($sbm_show)
|
||||
@php
|
||||
$sbm_redName = $sbm_red['name'] ?? 'RED';
|
||||
$sbm_blueName = $sbm_blue['name'] ?? 'BLUE';
|
||||
$sbm_redClub = $sbm_red['club'] ?? '';
|
||||
$sbm_blueClub = $sbm_blue['club'] ?? '';
|
||||
$sbm_redFlag = strtolower(trim((string) ($sbm_red['flag'] ?? '')));
|
||||
$sbm_blueFlag = strtolower(trim((string) ($sbm_blue['flag'] ?? '')));
|
||||
$sbm_redLogo = !empty($sbm_red['club_logo']) ? route('media.thumbnail', $sbm_red['club_logo']) : null;
|
||||
$sbm_blueLogo = !empty($sbm_blue['club_logo']) ? route('media.thumbnail', $sbm_blue['club_logo']) : null;
|
||||
|
||||
// Live-scoring payload — same shape as the full-player scoreboard uses.
|
||||
// Rounds drive the round label + per-round clock. Points give us the
|
||||
// score at any t (last point ≤ t defines the score).
|
||||
$sbm_rounds = $video->matchRounds()
|
||||
->orderBy('round_number')
|
||||
->get(['round_number', 'name', 'start_time_seconds'])
|
||||
->map(fn ($r) => [
|
||||
'n' => (int) $r->round_number,
|
||||
'name' => (string) ($r->name ?? ''),
|
||||
'start' => (float) ($r->start_time_seconds ?? 0),
|
||||
])->values()->all();
|
||||
if (empty($sbm_rounds)) $sbm_rounds = [['n' => 1, 'name' => '', 'start' => 0]];
|
||||
|
||||
$sbm_points = $video->matchPoints()
|
||||
->orderBy('timestamp_seconds')
|
||||
->get(['timestamp_seconds', 'action', 'points', 'competitor', 'score_red', 'score_blue'])
|
||||
->flatMap(function ($p) {
|
||||
// Same rule the full-player uses: a "both" event emits two feed rows
|
||||
// — one per corner — so the ticker shows both fighters scoring.
|
||||
$side = ($p->competitor === 'both') ? 'red' : $p->competitor;
|
||||
$rows = [[
|
||||
't' => (float) $p->timestamp_seconds,
|
||||
'action' => (string) ($p->action ?? 'Point'),
|
||||
'pts' => (int) $p->points,
|
||||
'side' => $side,
|
||||
'sr' => (int) ($p->score_red ?? 0),
|
||||
'sb' => (int) ($p->score_blue ?? 0),
|
||||
]];
|
||||
if ($p->competitor === 'both') {
|
||||
$rows[] = array_merge($rows[0], ['side' => 'blue', 't' => (float) $p->timestamp_seconds + 0.001]);
|
||||
}
|
||||
return $rows;
|
||||
})->values()->all();
|
||||
|
||||
$sbm_sport = $sbm_hd['sport'] ?? null;
|
||||
|
||||
$sbm_data = json_encode(['rounds' => $sbm_rounds, 'points' => $sbm_points, 'sport' => $sbm_sport], JSON_HEX_APOS | JSON_HEX_QUOT);
|
||||
@endphp
|
||||
|
||||
<div class="sbm" data-sbm data-sbm-state='{{ $sbm_data }}'>
|
||||
{{-- Live scoring ticker — sits in its OWN scaled canvas at the
|
||||
top-right of the thumb. Populated by the sbm live-sync JS. --}}
|
||||
<div class="sbm-feed-scale">
|
||||
<div class="sbm-feed" data-sbm-feed>
|
||||
<div class="sbm-feed-hdr"><span class="sbm-feed-dot"></span><span>Live scoring</span></div>
|
||||
<div class="sbm-feed-list" data-sbm-feed-list></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sbm-scale">
|
||||
{{-- ══ Scorebar markup — same design + inline styles as the
|
||||
overlay.blade.php `data-msb-part="scorebar"` block, but stripped
|
||||
of the live-sync data-msb hooks. ══ --}}
|
||||
<div style="position:absolute;left:0;right:0;bottom:14px;padding:0 26px;display:flex;align-items:stretch;gap:0;height:84px;pointer-events:none">
|
||||
|
||||
{{-- RED / AKA --}}
|
||||
<div style="flex:1 1 0;min-width:0;transform:skewX(-9deg);overflow:hidden;background:linear-gradient(90deg, rgba(122,26,22,.94), rgba(180,52,44,.9));border-bottom:3px solid #ff6a5e">
|
||||
<div style="transform:skewX(9deg);height:100%;padding:0 16px;display:flex;align-items:center;gap:12px">
|
||||
<div style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);background-size:cover;background-position:center;
|
||||
@if($sbm_redLogo) background-image:url('{{ $sbm_redLogo }}'); @endif"></div>
|
||||
<div style="flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:3px">
|
||||
<div style="display:flex;align-items:center;gap:10px;min-width:0">
|
||||
@if ($sbm_redFlag !== '')
|
||||
<span class="fi fi-{{ $sbm_redFlag ?: 'xx' }}" style="width:26px;height:17px;flex:none;box-shadow:0 0 0 1px rgba(255,255,255,.3);background-size:cover !important;background-position:center !important;display:inline-block"></span>
|
||||
@endif
|
||||
<span 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">{{ $sbm_redName }}</span>
|
||||
</div>
|
||||
@if ($sbm_redClub !== '')
|
||||
<div style="display:flex;align-items:center;gap:8px;min-width:0">
|
||||
<span style="flex:1 1 auto;min-width:0;font-size:12px;letter-spacing:.03em;color:rgba(255,232,228,.85);text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $sbm_redClub }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div style="width:60px;flex:none;display:flex;flex-direction:column;align-items:flex-end;gap:5px">
|
||||
<span style="font-size:11px;letter-spacing:.24em;color:rgba(255,236,232,.7)">AKA</span>
|
||||
<span data-sbm-red-score style="font-family:'Zen Old Mincho',serif;font-size:46px;line-height:.8;font-weight:700;color:#fff;text-shadow:0 6px 20px rgba(0,0,0,.5)">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- CENTER: round + clock --}}
|
||||
<div style="width:118px;flex:none;transform:skewX(-9deg);background:rgba(9,9,11,.9);backdrop-filter:blur(8px);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;border-bottom:3px solid #2c2a28">
|
||||
<div data-sbm-round style="transform:skewX(9deg);font-size:11px;letter-spacing:.3em;color:#8f8a83;text-transform:uppercase">Round 1</div>
|
||||
<div data-sbm-clock style="transform:skewX(9deg);font-size:34px;line-height:1;font-weight:800;color:#efe9e0;font-variant-numeric:tabular-nums">0:00</div>
|
||||
<div style="transform:skewX(9deg);display:flex;gap:4px">
|
||||
<span style="width:20px;height:3px;background:#e8534a"></span>
|
||||
<span style="width:20px;height:3px;background:#e8534a"></span>
|
||||
<span style="width:20px;height:3px;background:#3a3734"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- BLUE / AO --}}
|
||||
<div style="flex:1 1 0;min-width:0;transform:skewX(-9deg);overflow:hidden;background:linear-gradient(90deg, rgba(30,72,140,.9), rgba(18,44,92,.94));border-bottom:3px solid #6aa6ff">
|
||||
<div style="transform:skewX(9deg);height:100%;padding:0 16px;display:flex;align-items:center;gap:12px">
|
||||
<div style="width:60px;flex:none;display:flex;flex-direction:column;align-items:flex-start;gap:5px">
|
||||
<span style="font-size:11px;letter-spacing:.24em;color:rgba(226,238,255,.7)">AO</span>
|
||||
<span data-sbm-blue-score style="font-family:'Zen Old Mincho',serif;font-size:46px;line-height:.8;font-weight:700;color:#fff;text-shadow:0 6px 20px rgba(0,0,0,.5)">0</span>
|
||||
</div>
|
||||
<div style="flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:3px;align-items:flex-end;text-align:right">
|
||||
<div style="display:flex;align-items:center;gap:10px;min-width:0">
|
||||
<span 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">{{ $sbm_blueName }}</span>
|
||||
@if ($sbm_blueFlag !== '')
|
||||
<span class="fi fi-{{ $sbm_blueFlag ?: 'xx' }}" style="width:26px;height:17px;flex:none;box-shadow:0 0 0 1px rgba(255,255,255,.3);background-size:cover !important;background-position:center !important;display:inline-block"></span>
|
||||
@endif
|
||||
</div>
|
||||
@if ($sbm_blueClub !== '')
|
||||
<div style="display:flex;align-items:center;gap:8px;min-width:0;justify-content:flex-end">
|
||||
<span 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;text-align:right">{{ $sbm_blueClub }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);background-size:cover;background-position:center;
|
||||
@if($sbm_blueLogo) background-image:url('{{ $sbm_blueLogo }}'); @endif"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@once
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700;800&family=Zen+Old+Mincho:wght@400;700&display=swap" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
/* ══ SCOREBAR MINI — same 1150 px canvas as the full player scorebar.
|
||||
Sits above the hover-preview <video> (z-index 6, one above the
|
||||
video at 5) and only appears while the video is active. ══ */
|
||||
.yt-video-thumb .sbm {
|
||||
position: absolute; inset: 0;
|
||||
z-index: 6;
|
||||
font-family: 'Barlow Condensed', sans-serif;
|
||||
color: #efe9e0;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity .25s ease .05s;
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
--sbm-scale: 0.4;
|
||||
}
|
||||
.sbm * { box-sizing: border-box; }
|
||||
|
||||
/* Show only while the hover video is playing (playVideo() adds .active) */
|
||||
.yt-video-thumb:has(video.active) .sbm { opacity: 1; }
|
||||
|
||||
.sbm .sbm-scale {
|
||||
position: absolute; bottom: 0; left: 0;
|
||||
width: 1150px; height: 100px;
|
||||
transform-origin: bottom left;
|
||||
transform: scale(var(--sbm-scale));
|
||||
}
|
||||
|
||||
/* ── Live scoring feed (top-right) — SAME 1150 px design canvas as the
|
||||
full-player msb-feed, so the sizes and positions are pixel-identical.
|
||||
Scaled by --sbm-scale (same as the bottom scorebar) so the feed
|
||||
shrinks/grows in lockstep with the rest of the overlay. ── */
|
||||
.sbm .sbm-feed-scale {
|
||||
position: absolute; top: 0; left: 0;
|
||||
width: 1150px; height: 100%;
|
||||
transform-origin: top left;
|
||||
transform: scale(var(--sbm-scale));
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Verbatim copy of .msb-feed / .msb-feed .hdr / .msb-feed .entry from
|
||||
videos/partials/match/scoreboard/styles.blade.php — only the class
|
||||
names are prefixed .sbm- to avoid colliding with the full player
|
||||
when a card is opened as a sub-page. */
|
||||
.sbm .sbm-feed {
|
||||
position: absolute; top: 22px; right: 24px;
|
||||
width: 262px;
|
||||
display: flex; flex-direction: column; gap: 7px;
|
||||
}
|
||||
.sbm .sbm-feed-hdr {
|
||||
display: flex; align-items: center; justify-content: flex-end; gap: 8px;
|
||||
font-size: 12px; letter-spacing: .3em; color: #d5cfc7; text-transform: uppercase;
|
||||
font-family: 'Barlow Condensed', sans-serif;
|
||||
text-shadow: 0 1px 6px rgba(0,0,0,.9);
|
||||
}
|
||||
.sbm .sbm-feed-dot {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: #e8534a; animation: sbmPulse 1.6s infinite;
|
||||
}
|
||||
.sbm .sbm-feed-list { display: flex; flex-direction: column; gap: 7px; }
|
||||
.sbm .sbm-feed-entry {
|
||||
display: flex; align-items: center; justify-content: flex-end; gap: 10px;
|
||||
padding: 7px 10px;
|
||||
background: rgba(10,10,12,.62); backdrop-filter: blur(6px);
|
||||
border-right: 3px solid transparent;
|
||||
animation: sbmRiseIn .45s ease both;
|
||||
}
|
||||
.sbm .sbm-feed-entry .ts { font-size: 12px; line-height: 1; letter-spacing: .16em; color: #79736c; font-family: 'Barlow Condensed', sans-serif; }
|
||||
.sbm .sbm-feed-entry .name { font-size: 17px; line-height: 1; letter-spacing: .12em; font-weight: 700; color: #efe9e0; text-transform: uppercase; font-family: 'Barlow Condensed', sans-serif; }
|
||||
.sbm .sbm-feed-entry .pts { font-family: 'Zen Old Mincho', serif; font-size: 19px; line-height: 1; font-weight: 700; }
|
||||
|
||||
/* Hide feed on very small thumbs (matches the msb-small breakpoint scaled
|
||||
down for cards — below ~340 px it becomes illegible). */
|
||||
.sbm.sbm-small .sbm-feed { display: none; }
|
||||
|
||||
@keyframes sbmPulse { 0%, 100% { opacity: 1; } 50% { opacity: .25; } }
|
||||
@keyframes sbmRiseIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
if (window._sbmBound) return;
|
||||
window._sbmBound = true;
|
||||
|
||||
// ── Fit the fixed 1150 px scorebar canvas to the parent thumb ──
|
||||
function fit(el) {
|
||||
const host = el.parentElement;
|
||||
if (!host) return;
|
||||
const w = host.clientWidth;
|
||||
if (!w) return;
|
||||
el.style.setProperty('--sbm-scale', (w / 1150));
|
||||
// Below ~340 px thumb width the ticker would shrink under legibility
|
||||
// (matches the full player's `msb-small` breakpoint at 700 px, halved
|
||||
// for card contexts). Hide the feed on those tiny cards.
|
||||
el.classList.toggle('sbm-small', w < 340);
|
||||
}
|
||||
|
||||
// ── Live scoring sync ─────────────────────────────────────────
|
||||
// Each .sbm carries its match state as a data attribute. Bind the
|
||||
// sibling <video>'s timeupdate to updates on THIS card's scorebar
|
||||
// only — no globals, no ID collisions with other cards on the page.
|
||||
function fmtClock(sec) {
|
||||
sec = Math.max(0, Math.floor(sec || 0));
|
||||
return Math.floor(sec / 60) + ':' + String(sec % 60).padStart(2, '0');
|
||||
}
|
||||
function currentRound(rounds, t) {
|
||||
if (!rounds || !rounds.length) return { n: 1, name: '', start: 0 };
|
||||
let cur = rounds[0];
|
||||
for (const r of rounds) { if (t >= (r.start || 0)) cur = r; }
|
||||
return cur;
|
||||
}
|
||||
function lastPoint(points, t) {
|
||||
if (!points || !points.length) return null;
|
||||
let last = null;
|
||||
for (const p of points) { if (p.t <= t + 0.001) last = p; else break; }
|
||||
return last;
|
||||
}
|
||||
// Same label rules as the full player: karate → Yuko/Waza-ari/Ippon,
|
||||
// taekwondo → Punch/Body kick/Head kick/Turning body, else action text.
|
||||
function pointLabel(p, sport) {
|
||||
sport = (sport || '').toLowerCase();
|
||||
if (sport.startsWith('taekwondo')) {
|
||||
if (p.pts >= 4) return 'Turning body';
|
||||
if (p.pts === 3) return 'Head kick';
|
||||
if (p.pts === 2) return 'Body kick';
|
||||
if (p.pts === 1) return 'Punch';
|
||||
}
|
||||
if (p.pts === 3) return 'Ippon';
|
||||
if (p.pts === 2) return 'Waza-ari';
|
||||
if (p.pts === 1) return 'Yuko';
|
||||
return p.action || 'Point';
|
||||
}
|
||||
function esc(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||
const feedColor = (side) => side === 'red' ? '#ff6a5e' : '#6aa6ff';
|
||||
|
||||
function bindLive(sbm) {
|
||||
let state = null;
|
||||
try { state = JSON.parse(sbm.getAttribute('data-sbm-state') || 'null'); }
|
||||
catch (e) { return; }
|
||||
if (!state) return;
|
||||
|
||||
const thumb = sbm.parentElement;
|
||||
const video = thumb && thumb.querySelector(':scope > video');
|
||||
if (!video) return;
|
||||
|
||||
const redEl = sbm.querySelector('[data-sbm-red-score]');
|
||||
const blueEl = sbm.querySelector('[data-sbm-blue-score]');
|
||||
const roundEl = sbm.querySelector('[data-sbm-round]');
|
||||
const clockEl = sbm.querySelector('[data-sbm-clock]');
|
||||
const feedEl = sbm.querySelector('[data-sbm-feed-list]');
|
||||
let lastR = null, lastB = null, lastRoundTxt = null, lastClk = null, lastFeedSig = null;
|
||||
|
||||
const tick = () => {
|
||||
const t = video.currentTime || 0;
|
||||
const p = lastPoint(state.points, t);
|
||||
const r = currentRound(state.rounds, t);
|
||||
|
||||
const sr = p ? p.sr : 0, sb = p ? p.sb : 0;
|
||||
if (sr !== lastR) { if (redEl) redEl.textContent = sr; lastR = sr; }
|
||||
if (sb !== lastB) { if (blueEl) blueEl.textContent = sb; lastB = sb; }
|
||||
|
||||
const roundTxt = r.name || ('Round ' + r.n);
|
||||
if (roundTxt !== lastRoundTxt) { if (roundEl) roundEl.textContent = roundTxt; lastRoundTxt = roundTxt; }
|
||||
|
||||
const clk = fmtClock(Math.max(0, t - (r.start || 0)));
|
||||
if (clk !== lastClk) { if (clockEl) clockEl.textContent = clk; lastClk = clk; }
|
||||
|
||||
// Live scoring ticker — newest 3 point events, newest on top.
|
||||
if (feedEl) {
|
||||
const seen = (state.points || []).filter(pt => pt.t <= t + 0.001);
|
||||
const last4 = seen.slice(-4).reverse();
|
||||
const sig = last4.map(pt => pt.t + ':' + pt.side + ':' + pt.pts).join(',');
|
||||
if (sig !== lastFeedSig) {
|
||||
feedEl.innerHTML = last4.map(pt => {
|
||||
const col = feedColor(pt.side);
|
||||
return '<div class="sbm-feed-entry" style="border-color:' + col + '">'
|
||||
+ '<span class="ts">' + fmtClock(pt.t) + '</span>'
|
||||
+ '<span class="name">' + esc(pointLabel(pt, state.sport)) + '</span>'
|
||||
+ '<span class="pts" style="color:' + col + '">+' + pt.pts + '</span>'
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
lastFeedSig = sig;
|
||||
}
|
||||
}
|
||||
};
|
||||
video.addEventListener('timeupdate', tick);
|
||||
video.addEventListener('seeked', tick);
|
||||
video.addEventListener('loadedmetadata', tick);
|
||||
// Reset to 0-0 / 0:00 when the video stops (matches VS mini restart).
|
||||
video.addEventListener('pause', tick);
|
||||
video.addEventListener('emptied', tick);
|
||||
tick();
|
||||
}
|
||||
|
||||
const ro = ('ResizeObserver' in window) ? new ResizeObserver(entries => {
|
||||
for (const e of entries) {
|
||||
const el = e.target.querySelector(':scope > .sbm');
|
||||
if (el) fit(el);
|
||||
}
|
||||
}) : null;
|
||||
|
||||
function register(el) {
|
||||
fit(el);
|
||||
if (ro) ro.observe(el.parentElement);
|
||||
if (!el._sbmBound) { el._sbmBound = true; bindLive(el); }
|
||||
}
|
||||
|
||||
document.querySelectorAll('.sbm').forEach(register);
|
||||
|
||||
new MutationObserver(muts => {
|
||||
for (const m of muts) {
|
||||
for (const n of m.addedNodes) {
|
||||
if (!(n instanceof Element)) continue;
|
||||
if (n.matches?.('.sbm')) register(n);
|
||||
n.querySelectorAll?.('.sbm').forEach(register);
|
||||
}
|
||||
}
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
})();
|
||||
</script>
|
||||
@endonce
|
||||
@@ -0,0 +1,604 @@
|
||||
{{-- ══════════════════════════════════════════════════════════════════════
|
||||
VS MINI — the arena intro card rendered INSIDE a video-card thumbnail.
|
||||
No countdown / no skip button; identical layout to the full-page VS
|
||||
intro (fixed 1920×1080 stage, transform-scaled to fit the thumb).
|
||||
|
||||
Behavior:
|
||||
- Sits at z-index 1 (above the still image, BELOW the hover video
|
||||
which uses z-index 2 + .active { opacity: 1 }).
|
||||
- On mouseleave from the thumb, animations restart so the intro
|
||||
plays again the next time the user's mouse is off the card.
|
||||
════════════════════════════════════════════════════════════════════════ --}}
|
||||
@php
|
||||
$vsm_hd = $video->sportsMatch?->headerData();
|
||||
$vsm_red = $vsm_hd['red'] ?? null;
|
||||
$vsm_blue = $vsm_hd['blue'] ?? null;
|
||||
$vsm_show = $vsm_hd && (($vsm_red['name'] ?? null) || ($vsm_blue['name'] ?? null));
|
||||
@endphp
|
||||
|
||||
@if ($vsm_show)
|
||||
@php
|
||||
$vsm_flag = fn(?string $c) => (($c = strtolower(trim((string) $c))) !== '' ? $c : 'xx');
|
||||
$vsm_cname = fn (?string $c) => \App\Data\Countries::name($c);
|
||||
$vsm_img = fn(?string $p) => $p ? route('media.thumbnail', $p) : null;
|
||||
$vsm_str = fn ($x) => (is_string($x) && trim($x) !== '') ? trim($x) : '';
|
||||
|
||||
// Every placeholder from the design is always in the DOM; empty ones
|
||||
// are hidden by `.vsm-nullable:empty` / `.vsm-nullable-hide` in the
|
||||
// CSS below. Same treatment as the full-page VS.
|
||||
$vsm_event = $vsm_str($vsm_hd['championship'] ?? '');
|
||||
$vsm_stage = $vsm_str($vsm_hd['stage'] ?? '');
|
||||
$vsm_weight = $vsm_str($vsm_hd['weight_category'] ?? '');
|
||||
$vsm_matchNo = $vsm_str($vsm_hd['match_number'] ?? '');
|
||||
$vsm_court = $vsm_str($vsm_hd['court'] ?? '');
|
||||
$vsm_ref = $vsm_str($vsm_hd['referee']['name'] ?? '');
|
||||
|
||||
// Per-fighter chips (Record / Rank / Stats) — fields aren't in
|
||||
// headerData() today, so fall back to raw participants JSON.
|
||||
$vsm_parts = $video->sportsMatch?->participants ?? [];
|
||||
$vsm_redRecord = $vsm_str($vsm_parts['p2_record'] ?? '');
|
||||
$vsm_redRank = $vsm_str($vsm_parts['p2_rank'] ?? '');
|
||||
$vsm_redStats = $vsm_str($vsm_parts['p2_stats'] ?? '');
|
||||
$vsm_blueRecord = $vsm_str($vsm_parts['p1_record'] ?? '');
|
||||
$vsm_blueRank = $vsm_str($vsm_parts['p1_rank'] ?? '');
|
||||
$vsm_blueStats = $vsm_str($vsm_parts['p1_stats'] ?? '');
|
||||
|
||||
$vsm_redName = $vsm_str($vsm_red['name'] ?? '');
|
||||
$vsm_blueName = $vsm_str($vsm_blue['name'] ?? '');
|
||||
$vsm_redClub = $vsm_str($vsm_red['club'] ?? '');
|
||||
$vsm_blueClub = $vsm_str($vsm_blue['club'] ?? '');
|
||||
$vsm_redFlag = $vsm_str($vsm_red['flag'] ?? '');
|
||||
$vsm_blueFlag = $vsm_str($vsm_blue['flag'] ?? '');
|
||||
$vsm_redLogo = $vsm_img($vsm_red['club_logo'] ?? null);
|
||||
$vsm_blueLogo = $vsm_img($vsm_blue['club_logo'] ?? null);
|
||||
@endphp
|
||||
|
||||
<div class="vs-mini vs-mini-run" data-vs-mini>
|
||||
<div class="vs-mini-stage">
|
||||
{{-- RED panel --}}
|
||||
<div class="vs-mini-panel vs-mini-panel-red">
|
||||
<div class="vs-mini-photo"
|
||||
@if($vsm_img($vsm_red['headshot'] ?? null)) style="background-image:url('{{ $vsm_img($vsm_red['headshot']) }}')"@endif></div>
|
||||
<div class="vs-mini-scrim vs-mini-scrim-red"></div>
|
||||
<div class="vs-mini-fade"></div>
|
||||
</div>
|
||||
{{-- BLUE panel --}}
|
||||
<div class="vs-mini-panel vs-mini-panel-blue">
|
||||
<div class="vs-mini-photo vs-mini-photo-rev"
|
||||
@if($vsm_img($vsm_blue['headshot'] ?? null)) style="background-image:url('{{ $vsm_img($vsm_blue['headshot']) }}')"@endif></div>
|
||||
<div class="vs-mini-scrim vs-mini-scrim-blue"></div>
|
||||
<div class="vs-mini-fade"></div>
|
||||
</div>
|
||||
|
||||
<div class="vs-mini-divider"></div>
|
||||
|
||||
{{-- RED identity (left) — every placeholder always in DOM ── --}}
|
||||
<div class="vs-mini-info vs-mini-info-red">
|
||||
<div class="vs-mini-tag vs-mini-tag-red">AKA · RED</div>
|
||||
<div class="vs-mini-flag-row">
|
||||
<span class="vs-mini-flag fi fi-{{ $vsm_flag($vsm_redFlag) }} {{ $vsm_redFlag === '' ? 'vsm-nullable-hide' : '' }}"></span>
|
||||
<div class="vs-mini-country vs-mini-country-red vsm-nullable">{{ $vsm_redFlag !== '' ? strtoupper($vsm_cname($vsm_redFlag) ?? $vsm_redFlag) : '' }}</div>
|
||||
</div>
|
||||
<div class="vs-mini-name vsm-nullable">{{ $vsm_redName }}</div>
|
||||
<div class="vs-mini-club-row">
|
||||
<div class="vs-mini-logo {{ $vsm_redLogo ? '' : 'vsm-nullable-hide' }}"
|
||||
@if($vsm_redLogo) style="background-image:url('{{ $vsm_redLogo }}')"@endif></div>
|
||||
<div class="vs-mini-club vsm-nullable">{{ $vsm_redClub }}</div>
|
||||
</div>
|
||||
<div class="vs-mini-chips-row">
|
||||
<div class="vs-mini-chip-fighter vsm-nullable">{{ $vsm_redRecord }}</div>
|
||||
<div class="vs-mini-chip-fighter vs-mini-chip-fighter-gold vsm-nullable">{{ $vsm_redRank }}</div>
|
||||
<div class="vs-mini-chip-fighter vsm-nullable">{{ $vsm_redStats }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- BLUE identity (right) ── --}}
|
||||
<div class="vs-mini-info vs-mini-info-blue">
|
||||
<div class="vs-mini-tag vs-mini-tag-blue">AO · BLUE</div>
|
||||
<div class="vs-mini-flag-row vs-mini-flag-row-r">
|
||||
<span class="vs-mini-flag fi fi-{{ $vsm_flag($vsm_blueFlag) }} {{ $vsm_blueFlag === '' ? 'vsm-nullable-hide' : '' }}"></span>
|
||||
<div class="vs-mini-country vs-mini-country-blue vsm-nullable">{{ $vsm_blueFlag !== '' ? strtoupper($vsm_cname($vsm_blueFlag) ?? $vsm_blueFlag) : '' }}</div>
|
||||
</div>
|
||||
<div class="vs-mini-name vsm-nullable">{{ $vsm_blueName }}</div>
|
||||
<div class="vs-mini-club-row vs-mini-club-row-r">
|
||||
<div class="vs-mini-logo {{ $vsm_blueLogo ? '' : 'vsm-nullable-hide' }}"
|
||||
@if($vsm_blueLogo) style="background-image:url('{{ $vsm_blueLogo }}')"@endif></div>
|
||||
<div class="vs-mini-club vsm-nullable">{{ $vsm_blueClub }}</div>
|
||||
</div>
|
||||
<div class="vs-mini-chips-row">
|
||||
<div class="vs-mini-chip-fighter vsm-nullable">{{ $vsm_blueRecord }}</div>
|
||||
<div class="vs-mini-chip-fighter vs-mini-chip-fighter-gold vsm-nullable">{{ $vsm_blueRank }}</div>
|
||||
<div class="vs-mini-chip-fighter vsm-nullable">{{ $vsm_blueStats }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Top: event + stage + weight (all in DOM) --}}
|
||||
<div class="vs-mini-top">
|
||||
<div class="vs-mini-top-event vsm-nullable">{{ $vsm_event }}</div>
|
||||
<div class="vs-mini-top-stage-row {{ $vsm_stage === '' ? 'vsm-nullable-hide' : '' }}">
|
||||
<div class="vs-mini-top-line vs-mini-top-line-l"></div>
|
||||
<div class="vs-mini-top-stage">{{ $vsm_stage }}</div>
|
||||
<div class="vs-mini-top-line vs-mini-top-line-r"></div>
|
||||
</div>
|
||||
<div class="vs-mini-top-weight vsm-nullable">{{ $vsm_weight }}</div>
|
||||
</div>
|
||||
|
||||
{{-- Center VS --}}
|
||||
<div class="vs-mini-center">
|
||||
<div class="vs-mini-word">VS
|
||||
<div class="vs-mini-shine-wrap"><div class="vs-mini-shine"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
{{-- .vs-mini-flash removed — the intro's full-thumb white flash at
|
||||
t=1.5s was firing on every mouseleave-triggered replay, which
|
||||
read as strobing on the left edge of the card. --}}
|
||||
|
||||
{{-- Bottom chips — Match / Court / Referee always in DOM; each hides
|
||||
via data-vsm-empty and the flanking diamonds collapse via :has(). --}}
|
||||
<div class="vs-mini-bottom">
|
||||
<div class="vs-mini-chip vsm-nullable-chip" data-vsm-empty="{{ $vsm_matchNo === '' ? '1' : '0' }}">
|
||||
<span class="vs-mini-chip-lbl">Match</span><span class="vs-mini-chip-val vsm-nullable">{{ $vsm_matchNo }}</span>
|
||||
</div>
|
||||
<div class="vs-mini-diamond vs-mini-diamond-mc"></div>
|
||||
<div class="vs-mini-chip vsm-nullable-chip" data-vsm-empty="{{ $vsm_court === '' ? '1' : '0' }}">
|
||||
<span class="vs-mini-chip-lbl">Court</span><span class="vs-mini-chip-val vsm-nullable">{{ $vsm_court }}</span>
|
||||
</div>
|
||||
<div class="vs-mini-diamond vs-mini-diamond-cr"></div>
|
||||
<div class="vs-mini-chip vsm-nullable-chip" data-vsm-empty="{{ $vsm_ref === '' ? '1' : '0' }}">
|
||||
<span class="vs-mini-chip-lbl">Referee</span><span class="vs-mini-chip-val vsm-nullable">{{ $vsm_ref }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@once
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Anton&family=Barlow+Condensed:wght@400;600;700;800&display=swap">
|
||||
|
||||
<style>
|
||||
/* ══ VS MINI — arena intro inside a video-card thumbnail.
|
||||
Same 1920×1080 canvas as the full-page overlay, transform-scaled
|
||||
to fit the thumb. Every child in ABSOLUTE PIXELS on that canvas.
|
||||
z-index 1: below the hover <video> (which we bump to 5 for match
|
||||
cards), above the still <img>. ══ */
|
||||
.yt-video-thumb .vs-mini {
|
||||
position: absolute; inset: 0; z-index: 1;
|
||||
background: #050507;
|
||||
font-family: 'Barlow Condensed', sans-serif;
|
||||
color: #e8e6e0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
--vs-mini-scale: 0.5;
|
||||
--vs-mini-w: 1920px;
|
||||
--vs-mini-h: 1080px;
|
||||
}
|
||||
|
||||
/* When a match card is hovered, the VS mini EXPLODES outward — the
|
||||
inverse of its implode entrance. Each part flies away in the direction
|
||||
opposite to where it came from, clearing the stage for the video.
|
||||
The whole overlay fades to fully hidden by the time the panels are
|
||||
off-screen (~0.6s), so the video takes over cleanly. */
|
||||
.yt-video-card:hover .yt-video-thumb .vs-mini {
|
||||
animation: vsmFadeOut .55s .25s ease forwards;
|
||||
}
|
||||
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-panel-red { animation: vsmExplodeL .55s cubic-bezier(.55,0,.7,.2) forwards !important; }
|
||||
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-panel-blue { animation: vsmExplodeR .55s cubic-bezier(.55,0,.7,.2) forwards !important; }
|
||||
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-info-red { animation: vsmExplodeInfoL .5s cubic-bezier(.55,0,.7,.2) forwards !important; }
|
||||
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-info-blue { animation: vsmExplodeInfoR .5s cubic-bezier(.55,0,.7,.2) forwards !important; }
|
||||
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-top { animation: vsmExplodeTop .5s cubic-bezier(.55,0,.7,.2) forwards !important; }
|
||||
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-bottom { animation: vsmExplodeBot .5s cubic-bezier(.55,0,.7,.2) forwards !important; }
|
||||
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-center { animation: vsmExplodeVS .6s cubic-bezier(.34,1.56,.64,1) forwards !important; }
|
||||
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-divider { animation: vsmDividerOut .35s ease forwards !important; }
|
||||
|
||||
/* Force the hover-preview video above the VS mini's stacking context.
|
||||
The default rule sets video { z-index: 2 } but the VS mini itself
|
||||
is z-index 1 + a stacking context, so we bump the video to be safe. */
|
||||
.yt-video-thumb:has(.vs-mini) video { z-index: 5 !important; background: transparent !important; }
|
||||
.vs-mini .vs-mini-stage {
|
||||
position: absolute;
|
||||
left: 50%; top: 50%;
|
||||
width: var(--vs-mini-w); height: var(--vs-mini-h);
|
||||
transform: translate(-50%, -50%) scale(var(--vs-mini-scale));
|
||||
transform-origin: center center;
|
||||
background: radial-gradient(120% 90% at 50% 40%, #16161f 0%, #0a0a0e 65%, #050507 100%);
|
||||
overflow: hidden;
|
||||
will-change: transform;
|
||||
/* Hidden until JS's fit() has measured the parent and set the real
|
||||
--vs-mini-scale. Without this, a card whose parent had 0 height at
|
||||
registration time (grid still resolving on mobile) would paint the
|
||||
stage at the fallback 0.5 scale — larger than the ~640×360 mobile
|
||||
thumb — clipping to just the center (looks like a broken card). */
|
||||
visibility: hidden;
|
||||
}
|
||||
.vs-mini.vs-mini-fit .vs-mini-stage { visibility: visible; }
|
||||
|
||||
/* ══ Touch / no-hover devices: skip the orchestrated intro entirely and
|
||||
render every element in its final state. The intro relies on ~1.3s
|
||||
of CSS animation-delays; on mobile it was fragile — anything that
|
||||
interrupted timing (late layout resolve, ResizeObserver refires
|
||||
when the browser toolbar hides/shows, portrait class toggles that
|
||||
swap `vsmPanelL` ↔ `vsmPanelT`) left cards frozen mid-animation.
|
||||
On mobile users are scrolling, not hovering to watch a preview,
|
||||
so the intro adds no value — show the final composition instead. ══ */
|
||||
/* Same "no-animation, final-state" treatment when the parent explicitly
|
||||
requests it — used by the OG-image render route so Browsershot
|
||||
screenshots the composed final frame regardless of what Chromium
|
||||
thinks its hover capability is. */
|
||||
.vs-mini-static,
|
||||
.vs-mini-static .vs-mini-panel,
|
||||
.vs-mini-static .vs-mini-info,
|
||||
.vs-mini-static .vs-mini-top,
|
||||
.vs-mini-static .vs-mini-center,
|
||||
.vs-mini-static .vs-mini-bottom {
|
||||
animation: none !important;
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
.vs-mini-static .vs-mini-panel-red { transform: translateX(0) !important; }
|
||||
.vs-mini-static .vs-mini-panel-blue { transform: translateX(0) !important; }
|
||||
.vs-mini-static .vs-mini-top { transform: translateX(-50%) !important; }
|
||||
.vs-mini-static .vs-mini-bottom { transform: translateX(-50%) !important; }
|
||||
.vs-mini-static .vs-mini-center { transform: translate(-50%, -52%) !important; }
|
||||
.vs-mini-static .vs-mini-photo,
|
||||
.vs-mini-static .vs-mini-word,
|
||||
.vs-mini-static .vs-mini-shine { animation: none !important; }
|
||||
|
||||
@media (hover: none) {
|
||||
.vs-mini,
|
||||
.vs-mini .vs-mini-panel,
|
||||
.vs-mini .vs-mini-info,
|
||||
.vs-mini .vs-mini-top,
|
||||
.vs-mini .vs-mini-center,
|
||||
.vs-mini .vs-mini-bottom {
|
||||
animation: none !important;
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
.vs-mini .vs-mini-panel-red { transform: translateX(0) !important; }
|
||||
.vs-mini .vs-mini-panel-blue { transform: translateX(0) !important; }
|
||||
.vs-mini .vs-mini-top { transform: translateX(-50%) !important; }
|
||||
.vs-mini .vs-mini-bottom { transform: translateX(-50%) !important; }
|
||||
.vs-mini .vs-mini-center { transform: translate(-50%, -52%) !important; }
|
||||
/* Also skip the ambient photo drift + VS pulse/shine — they were
|
||||
running fine, but there's no need to burn cycles on animation
|
||||
loops for a scrolling grid on mobile. */
|
||||
.vs-mini .vs-mini-photo,
|
||||
.vs-mini .vs-mini-word,
|
||||
.vs-mini .vs-mini-shine { animation: none !important; }
|
||||
}
|
||||
|
||||
/* Animations only run while the .vs-mini-run class is set (removed +
|
||||
re-added on mouseleave to force a replay from the intro). */
|
||||
|
||||
/* ── Panels ── */
|
||||
.vs-mini .vs-mini-panel { position: absolute; overflow: hidden; opacity: 0; }
|
||||
.vs-mini .vs-mini-panel-red {
|
||||
inset: 0 auto 0 0; width: 56%;
|
||||
background: oklch(0.28 0.09 25);
|
||||
clip-path: polygon(0 0, 100% 0, 82% 100%, 0 100%);
|
||||
}
|
||||
.vs-mini .vs-mini-panel-blue {
|
||||
inset: 0 0 0 auto; width: 56%;
|
||||
background: oklch(0.28 0.09 255);
|
||||
clip-path: polygon(18% 0, 100% 0, 100% 100%, 0 100%);
|
||||
}
|
||||
.vs-mini.vs-mini-run .vs-mini-panel-red { animation: vsmPanelL .9s cubic-bezier(.22,1,.36,1) both; }
|
||||
.vs-mini.vs-mini-run .vs-mini-panel-blue { animation: vsmPanelR .9s cubic-bezier(.22,1,.36,1) both; }
|
||||
|
||||
.vs-mini .vs-mini-photo {
|
||||
position: absolute; inset: 0;
|
||||
background-size: cover; background-position: center;
|
||||
animation: vsmDrift 18s ease-in-out infinite;
|
||||
}
|
||||
.vs-mini .vs-mini-photo-rev { animation-direction: reverse; }
|
||||
.vs-mini .vs-mini-scrim-red { position: absolute; inset: 0; pointer-events: none;
|
||||
background: linear-gradient(115deg, oklch(0.45 0.18 25 / 0.55) 0%, transparent 55%); }
|
||||
.vs-mini .vs-mini-scrim-blue { position: absolute; inset: 0; pointer-events: none;
|
||||
background: linear-gradient(245deg, oklch(0.45 0.15 255 / 0.55) 0%, transparent 55%); }
|
||||
.vs-mini .vs-mini-fade {
|
||||
position: absolute; inset: 0; pointer-events: none;
|
||||
background:
|
||||
linear-gradient(to top, rgba(5,5,7,.95) 0%, rgba(5,5,7,.72) 22%, rgba(5,5,7,.30) 42%, transparent 62%),
|
||||
linear-gradient(to bottom, rgba(5,5,7,.82) 0%, rgba(5,5,7,.35) 18%, transparent 32%);
|
||||
}
|
||||
|
||||
/* ── Divider ── */
|
||||
.vs-mini .vs-mini-divider {
|
||||
position: absolute; top: -6%; bottom: -6%; left: 50%;
|
||||
width: 3px; margin-left: -1.5px;
|
||||
transform: rotate(10.15deg);
|
||||
background: linear-gradient(to bottom, transparent, oklch(0.85 0.16 85 / .9) 20%, oklch(0.85 0.16 85 / .9) 80%, transparent);
|
||||
pointer-events: none; filter: blur(1px);
|
||||
}
|
||||
|
||||
/* ── Fighter identity (fixed px on 1920×1080 canvas) ── */
|
||||
.vs-mini .vs-mini-info {
|
||||
position: absolute; z-index: 6; max-width: 44%;
|
||||
display: flex; flex-direction: column; gap: 13px;
|
||||
opacity: 0;
|
||||
}
|
||||
.vs-mini .vs-mini-info-red { left: 43.2px; bottom: 118.8px; align-items: flex-start; }
|
||||
.vs-mini .vs-mini-info-blue { right: 43.2px; bottom: 118.8px; align-items: flex-end; text-align: right; }
|
||||
.vs-mini.vs-mini-run .vs-mini-info-red { animation: vsmRiseUp .8s .7s cubic-bezier(.22,1,.36,1) both; }
|
||||
.vs-mini.vs-mini-run .vs-mini-info-blue { animation: vsmRiseUp .8s .85s cubic-bezier(.22,1,.36,1) both; }
|
||||
|
||||
.vs-mini .vs-mini-tag {
|
||||
font: 800 21.6px/1 'Barlow Condensed', sans-serif;
|
||||
letter-spacing: .35em; color: #fff;
|
||||
padding: 5.4px 15.1px 5.4px 18.9px;
|
||||
}
|
||||
.vs-mini .vs-mini-tag-red { background: oklch(0.55 0.20 25); }
|
||||
.vs-mini .vs-mini-tag-blue { background: oklch(0.50 0.16 255); }
|
||||
|
||||
.vs-mini .vs-mini-flag-row { display: flex; align-items: center; gap: 15.1px; }
|
||||
.vs-mini .vs-mini-flag-row-r { flex-direction: row-reverse; }
|
||||
.vs-mini .vs-mini-flag {
|
||||
width: 56.2px; height: auto; aspect-ratio: 4/3;
|
||||
background-size: cover !important; background-position: center !important;
|
||||
border: 1px solid rgba(255,255,255,.35);
|
||||
box-shadow: 0 4px 18px rgba(0,0,0,.6);
|
||||
display: inline-block; line-height: 0;
|
||||
}
|
||||
.vs-mini .vs-mini-country { font: 700 32.4px/1 'Barlow Condensed', sans-serif; letter-spacing: .28em; }
|
||||
.vs-mini .vs-mini-country-red { color: oklch(0.85 0.05 25); }
|
||||
.vs-mini .vs-mini-country-blue { color: oklch(0.85 0.05 255); }
|
||||
|
||||
.vs-mini .vs-mini-name {
|
||||
font-family: 'Anton', 'Barlow Condensed', sans-serif;
|
||||
font-size: 71.3px; line-height: .95;
|
||||
text-transform: uppercase; color: #fff;
|
||||
text-shadow: 0 6px 30px rgba(0,0,0,.8);
|
||||
}
|
||||
|
||||
.vs-mini .vs-mini-club-row { display: flex; align-items: center; gap: 13px; margin-top: 4.3px; }
|
||||
.vs-mini .vs-mini-club-row-r { flex-direction: row-reverse; }
|
||||
.vs-mini .vs-mini-logo {
|
||||
width: 69.1px; height: 69.1px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,.06);
|
||||
background-size: cover; background-position: center;
|
||||
border: 1px solid rgba(255,255,255,.2);
|
||||
}
|
||||
.vs-mini .vs-mini-club {
|
||||
font: 600 30.2px/1.1 'Barlow Condensed', sans-serif;
|
||||
letter-spacing: .12em; text-transform: uppercase;
|
||||
color: rgba(232,230,224,.9);
|
||||
}
|
||||
|
||||
/* Fighter chips row (Record / Rank / Stats) — matches full-page VS. */
|
||||
.vs-mini .vs-mini-chips-row { display: flex; flex-wrap: wrap; gap: 9.7px; margin-top: 5.4px; }
|
||||
.vs-mini .vs-mini-info-blue .vs-mini-chips-row { justify-content: flex-end; }
|
||||
.vs-mini .vs-mini-chip-fighter {
|
||||
font: 700 23.8px/1 'Barlow Condensed', sans-serif;
|
||||
letter-spacing: .12em; text-transform: uppercase;
|
||||
padding: 5.4px 14px;
|
||||
background: rgba(10,10,14,.62);
|
||||
border: 1px solid rgba(255,255,255,.25);
|
||||
color: #fff;
|
||||
}
|
||||
.vs-mini .vs-mini-chip-fighter-gold {
|
||||
border-color: oklch(0.85 0.16 85 / .6);
|
||||
color: oklch(0.87 0.14 85);
|
||||
}
|
||||
|
||||
/* ══ Placeholder hiding — parity with the full-page VS. ══ */
|
||||
.vs-mini .vsm-nullable:empty { display: none; }
|
||||
.vs-mini .vsm-nullable-hide { display: none; }
|
||||
.vs-mini .vs-mini-chip.vsm-nullable-chip[data-vsm-empty="1"] { display: none; }
|
||||
.vs-mini .vs-mini-bottom .vs-mini-diamond-mc:has(+ .vs-mini-chip[data-vsm-empty="1"]),
|
||||
.vs-mini .vs-mini-bottom .vs-mini-chip[data-vsm-empty="1"] + .vs-mini-diamond-mc,
|
||||
.vs-mini .vs-mini-bottom .vs-mini-chip[data-vsm-empty="1"] + .vs-mini-diamond-cr,
|
||||
.vs-mini .vs-mini-bottom .vs-mini-diamond-cr:has(+ .vs-mini-chip[data-vsm-empty="1"]) { display: none; }
|
||||
|
||||
/* ── Top block ── */
|
||||
.vs-mini .vs-mini-top {
|
||||
position: absolute; top: 34.6px; left: 50%; transform: translateX(-50%);
|
||||
display: flex; flex-direction: column; align-items: center; gap: 10.8px;
|
||||
z-index: 8; width: 92%; pointer-events: none; opacity: 0;
|
||||
}
|
||||
.vs-mini.vs-mini-run .vs-mini-top { animation: vsmDropIn .8s .5s cubic-bezier(.22,1,.36,1) both; }
|
||||
.vs-mini .vs-mini-top-event {
|
||||
font: 700 30.2px/1.05 'Barlow Condensed', sans-serif;
|
||||
letter-spacing: .42em; text-transform: uppercase;
|
||||
color: rgba(232,230,224,.92); text-align: center;
|
||||
text-shadow: 0 2px 14px rgba(0,0,0,.9);
|
||||
}
|
||||
.vs-mini .vs-mini-top-stage-row { display: flex; align-items: center; gap: 17.3px; }
|
||||
.vs-mini .vs-mini-top-line { height: 2px; width: 64.8px; }
|
||||
.vs-mini .vs-mini-top-line-l { background: linear-gradient(to left, oklch(0.85 0.16 85), transparent); }
|
||||
.vs-mini .vs-mini-top-line-r { background: linear-gradient(to right, oklch(0.85 0.16 85), transparent); }
|
||||
.vs-mini .vs-mini-top-stage {
|
||||
font-family: 'Anton', 'Barlow Condensed', sans-serif;
|
||||
font-size: 38.9px;
|
||||
letter-spacing: .30em; padding-left: .3em;
|
||||
color: oklch(0.85 0.16 85); text-transform: uppercase;
|
||||
}
|
||||
.vs-mini .vs-mini-top-weight {
|
||||
font: 700 42px/1.1 'Anton', 'Barlow Condensed', sans-serif;
|
||||
letter-spacing: .28em; padding-left: .28em; text-transform: uppercase;
|
||||
color: #fff; text-shadow: 0 2px 14px rgba(0,0,0,.9);
|
||||
}
|
||||
|
||||
/* ── Center VS ── */
|
||||
.vs-mini .vs-mini-center {
|
||||
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -52%);
|
||||
z-index: 7; pointer-events: none;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
opacity: 0;
|
||||
}
|
||||
.vs-mini.vs-mini-run .vs-mini-center { animation: vsmSlam .7s 1.1s cubic-bezier(.22,1,.36,1) both; }
|
||||
.vs-mini .vs-mini-word {
|
||||
position: relative;
|
||||
font-family: 'Anton', 'Barlow Condensed', sans-serif;
|
||||
font-size: 183.6px; font-style: italic;
|
||||
color: #fffdf5; line-height: 1;
|
||||
-webkit-text-stroke: 2px oklch(0.85 0.16 85 / 0.6);
|
||||
animation: vsmPulse 2.4s ease-in-out infinite;
|
||||
overflow: visible;
|
||||
}
|
||||
.vs-mini .vs-mini-shine-wrap { position: absolute; inset: -10% -20%; overflow: hidden; pointer-events: none; }
|
||||
.vs-mini .vs-mini-shine {
|
||||
position: absolute; top: 0; bottom: 0; width: 34%;
|
||||
background: linear-gradient(to right, transparent, rgba(255,255,255,.16), transparent);
|
||||
animation: vsmShine 5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* .vs-mini-flash rules removed with the element itself. */
|
||||
|
||||
/* ── Bottom chips ── */
|
||||
.vs-mini .vs-mini-bottom {
|
||||
position: absolute; bottom: 32.4px; left: 50%; transform: translateX(-50%);
|
||||
display: flex; gap: 17.3px; z-index: 8; align-items: center;
|
||||
flex-wrap: wrap; justify-content: center; max-width: 94%; opacity: 0;
|
||||
}
|
||||
.vs-mini.vs-mini-run .vs-mini-bottom { animation: vsmRiseC .8s 1.3s cubic-bezier(.22,1,.36,1) both; }
|
||||
.vs-mini .vs-mini-chip {
|
||||
display: flex; align-items: baseline; gap: 8.6px;
|
||||
background: rgba(10,10,14,.72);
|
||||
border: 1px solid oklch(0.85 0.16 85 / .45);
|
||||
padding: 10.8px 23.8px; backdrop-filter: blur(6px);
|
||||
}
|
||||
.vs-mini .vs-mini-chip-lbl {
|
||||
font: 600 23.8px/1 'Barlow Condensed', sans-serif;
|
||||
letter-spacing: .30em; text-transform: uppercase;
|
||||
color: rgba(232,230,224,.65);
|
||||
}
|
||||
.vs-mini .vs-mini-chip-val {
|
||||
font-family: 'Anton', 'Barlow Condensed', sans-serif;
|
||||
font-size: 34.6px; color: #fff;
|
||||
}
|
||||
.vs-mini .vs-mini-diamond {
|
||||
width: 6px; height: 6px; transform: rotate(45deg);
|
||||
background: oklch(0.85 0.16 85);
|
||||
}
|
||||
|
||||
/* ── Portrait canvas (rare — vertical match cards) ── */
|
||||
.vs-mini.vs-mini-portrait { --vs-mini-w: 1080px; --vs-mini-h: 1920px; }
|
||||
.vs-mini.vs-mini-portrait .vs-mini-panel-red { inset: 0 0 auto 0; width: 100%; height: 56%;
|
||||
clip-path: polygon(0 0, 100% 0, 100% 82%, 0 96%); }
|
||||
.vs-mini.vs-mini-portrait .vs-mini-panel-blue { inset: auto 0 0 0; width: 100%; height: 56%;
|
||||
clip-path: polygon(0 18%, 100% 4%, 100% 100%, 0 100%); }
|
||||
.vs-mini.vs-mini-portrait .vs-mini-divider {
|
||||
left: -4%; right: -4%; top: 50%; bottom: auto;
|
||||
width: auto; height: 3px; margin-top: -1.5px; margin-left: 0;
|
||||
transform: rotate(-4.48deg);
|
||||
background: linear-gradient(to right, transparent, oklch(0.85 0.16 85 / .9) 20%, oklch(0.85 0.16 85 / .9) 80%, transparent);
|
||||
}
|
||||
.vs-mini.vs-mini-portrait .vs-mini-info-red { left: 43.2px; top: 129.6px; bottom: auto; }
|
||||
.vs-mini.vs-mini-portrait .vs-mini-info-blue { right: 43.2px; bottom: 129.6px; top: auto; }
|
||||
.vs-mini.vs-mini-portrait.vs-mini-run .vs-mini-panel-red { animation-name: vsmPanelT; }
|
||||
.vs-mini.vs-mini-portrait.vs-mini-run .vs-mini-panel-blue { animation-name: vsmPanelB; }
|
||||
|
||||
/* Hide the shimmer skeleton on match cards — the VS mini fills the thumb. */
|
||||
.yt-video-thumb:has(.vs-mini)::before { display: none; }
|
||||
|
||||
/* Animations */
|
||||
@keyframes vsmPulse {
|
||||
0%,100% { text-shadow: 0 0 30px rgba(255,215,120,.55), 0 0 90px rgba(255,170,60,.3); transform: scale(1); }
|
||||
50% { text-shadow: 0 0 65px rgba(255,220,130,1), 0 0 160px rgba(255,170,60,.7); transform: scale(1.045); }
|
||||
}
|
||||
@keyframes vsmShine { 0% { transform: translateX(-130%) skewX(-18deg); } 60%,100% { transform: translateX(230%) skewX(-18deg); } }
|
||||
@keyframes vsmDrift { 0% { transform: translate3d(0,0,0) scale(1.02); } 50% { transform: translate3d(0,-1.2%,0) scale(1.05); } 100% { transform: translate3d(0,0,0) scale(1.02); } }
|
||||
@keyframes vsmPanelL { from { opacity: 1; transform: translateX(-105%); } to { opacity: 1; transform: translateX(0); } }
|
||||
@keyframes vsmPanelR { from { opacity: 1; transform: translateX( 105%); } to { opacity: 1; transform: translateX(0); } }
|
||||
@keyframes vsmPanelT { from { opacity: 1; transform: translateY(-105%); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes vsmPanelB { from { opacity: 1; transform: translateY( 105%); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes vsmRiseUp { from { opacity: 0; transform: translateY(43.2px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes vsmRiseC { from { opacity: 0; transform: translate(-50%, 43.2px); } to { opacity: 1; transform: translate(-50%, 0); } }
|
||||
@keyframes vsmDropIn { from { opacity: 0; transform: translate(-50%, -32.4px); } to { opacity: 1; transform: translate(-50%, 0); } }
|
||||
@keyframes vsmSlam {
|
||||
0% { opacity: 0; transform: translate(-50%,-52%) scale(3.4) rotate(-6deg); }
|
||||
60% { opacity: 1; transform: translate(-50%,-52%) scale(.92) rotate(1deg); }
|
||||
80% { transform: translate(-50%,-52%) scale(1.06); }
|
||||
100% { opacity: 1; transform: translate(-50%,-52%) scale(1) rotate(0deg); }
|
||||
}
|
||||
|
||||
/* ══ EXPLODE — the inverse of the implode entrance. Each piece flies
|
||||
OUT in the direction opposite to where it came from, then the
|
||||
whole mini fades to zero opacity while the video takes the stage. ══ */
|
||||
@keyframes vsmFadeOut { to { opacity: 0; } }
|
||||
@keyframes vsmExplodeL { from { opacity: 1; transform: translateX(0) scale(1); } to { opacity: 0; transform: translateX(-140%) scale(1.05); } }
|
||||
@keyframes vsmExplodeR { from { opacity: 1; transform: translateX(0) scale(1); } to { opacity: 0; transform: translateX( 140%) scale(1.05); } }
|
||||
@keyframes vsmExplodeInfoL { from { opacity: 1; transform: translate(0,0) scale(1); } to { opacity: 0; transform: translate(-70%, 40%) scale(.85); } }
|
||||
@keyframes vsmExplodeInfoR { from { opacity: 1; transform: translate(0,0) scale(1); } to { opacity: 0; transform: translate( 70%, 40%) scale(.85); } }
|
||||
@keyframes vsmExplodeTop { from { opacity: 1; transform: translate(-50%,0) scale(1); } to { opacity: 0; transform: translate(-50%,-120%) scale(.9); } }
|
||||
@keyframes vsmExplodeBot { from { opacity: 1; transform: translate(-50%,0) scale(1); } to { opacity: 0; transform: translate(-50%, 120%) scale(.9); } }
|
||||
@keyframes vsmExplodeVS {
|
||||
0% { opacity: 1; transform: translate(-50%,-52%) scale(1) rotate(0deg); filter: blur(0); }
|
||||
40% { opacity: 1; transform: translate(-50%,-52%) scale(1.3) rotate(-2deg); filter: blur(1px); }
|
||||
100% { opacity: 0; transform: translate(-50%,-52%) scale(4) rotate(6deg); filter: blur(8px); }
|
||||
}
|
||||
@keyframes vsmDividerOut { to { opacity: 0; transform: rotate(10.15deg) scaleY(0); } }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
if (window._vsMiniBound) return;
|
||||
window._vsMiniBound = true;
|
||||
|
||||
// Fit the 1920×1080 (or 1080×1920) stage to whatever thumb size the
|
||||
// card resolves to. One ResizeObserver watches every current + future
|
||||
// thumb — cheap because the callback is a couple of arithmetic ops.
|
||||
function fit(el) {
|
||||
const host = el.parentElement;
|
||||
if (!host) return;
|
||||
const w = host.clientWidth, h = host.clientHeight;
|
||||
if (!w || !h) return;
|
||||
const portrait = h > w * 1.05;
|
||||
el.classList.toggle('vs-mini-portrait', portrait);
|
||||
const sw = portrait ? 1080 : 1920;
|
||||
const sh = portrait ? 1920 : 1080;
|
||||
el.style.setProperty('--vs-mini-scale', Math.min(w / sw, h / sh));
|
||||
// Reveal the stage — hidden by default so the CSS's fallback
|
||||
// scale never paints a pre-fit oversized/clipped stage.
|
||||
el.classList.add('vs-mini-fit');
|
||||
}
|
||||
|
||||
const ro = ('ResizeObserver' in window) ? new ResizeObserver(entries => {
|
||||
for (const e of entries) {
|
||||
const el = e.target.querySelector(':scope > .vs-mini');
|
||||
if (el) fit(el);
|
||||
}
|
||||
}) : null;
|
||||
|
||||
function register(el) {
|
||||
fit(el);
|
||||
if (ro) ro.observe(el.parentElement);
|
||||
}
|
||||
|
||||
// Register everything already in the DOM.
|
||||
document.querySelectorAll('.vs-mini').forEach(register);
|
||||
|
||||
// Register anything added later (SPA nav, infinite-scroll loads).
|
||||
new MutationObserver(muts => {
|
||||
for (const m of muts) {
|
||||
for (const n of m.addedNodes) {
|
||||
if (!(n instanceof Element)) continue;
|
||||
if (n.matches?.('.vs-mini')) register(n);
|
||||
n.querySelectorAll?.('.vs-mini').forEach(register);
|
||||
}
|
||||
}
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Restart entrance animations whenever the mouse leaves a card thumb
|
||||
// that has a VS mini in it. Only bind on real hover devices — on touch,
|
||||
// Chrome synthesizes mouseleave on tap-lift and during scroll, which
|
||||
// kept restarting animations and leaving cards frozen mid-intro
|
||||
// (elements at opacity:0 during the 0.5–1.3s animation-delay window).
|
||||
const hasHover = window.matchMedia && window.matchMedia('(hover: hover)').matches;
|
||||
if (hasHover) {
|
||||
document.addEventListener('mouseleave', (e) => {
|
||||
const thumb = e.target?.classList?.contains('yt-video-thumb') ? e.target : null;
|
||||
if (!thumb) return;
|
||||
const vsm = thumb.querySelector(':scope > .vs-mini');
|
||||
if (!vsm) return;
|
||||
vsm.classList.remove('vs-mini-run');
|
||||
void vsm.offsetWidth;
|
||||
vsm.classList.add('vs-mini-run');
|
||||
}, true);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@endonce
|
||||
@@ -306,7 +306,7 @@
|
||||
@if($embedAudioInput)
|
||||
<input type="file" id="edit-{{ $p }}-audio-input" name="{{ $audioInputName }}"
|
||||
accept="audio/*,.mp3,.m4a,.aac,.flac,.wav" style="display:none;"
|
||||
onchange="document.getElementById('edit-{{ $p }}-fname').textContent=this.files[0]?.name||'Keep existing / choose new…'">
|
||||
onchange="editHandleTrackAudio(this, '{{ $p }}', '{{ $titleId }}')">
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -158,25 +158,44 @@
|
||||
</button>
|
||||
@endif
|
||||
@else
|
||||
<button onclick="window.location.href='{{ route('login') }}'" class="action-btn desktop-action">
|
||||
<i class="bi bi-bell"></i><span>Subscribe</span>
|
||||
{{-- Guest Subscribe: showToast first so the user understands what's
|
||||
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>
|
||||
@endauth
|
||||
|
||||
@auth
|
||||
{{-- Like + Dislike pair. Mutually exclusive on the server; clicking
|
||||
one clears the other via the controllers' detach() call. --}}
|
||||
<button type="button"
|
||||
class="action-btn desktop-action like-toggle-btn {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}"
|
||||
data-video-id="{{ $video->id }}"
|
||||
data-toggle-url="{{ route('videos.toggleLike', $video) }}"
|
||||
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>
|
||||
data-liked="{{ $video->isLikedBy(Auth::user()) ? 'true' : 'false' }}"
|
||||
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>
|
||||
</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
|
||||
<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' }}
|
||||
</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
|
||||
|
||||
@if ($video->isShareable())
|
||||
@@ -451,6 +470,46 @@
|
||||
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 ──────────────────────────────
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@props(['video' => null, 'size' => 'medium'])
|
||||
@props(['video' => null, 'size' => 'medium', 'hideChannelInfo' => false])
|
||||
|
||||
@php
|
||||
use App\Data\Languages;
|
||||
@@ -41,6 +41,38 @@ $langFlag = $forceTrackFlag ?: ($video ? Languages::flag($video->language) : nul
|
||||
|
||||
$showUrl = $video ? route('videos.show', $video) . ($forceTrackId ? ('?track=' . $forceTrackId) : '') : '#';
|
||||
|
||||
// Composed display title for match videos — matches the header on the
|
||||
// match page: "{match title} – {blue flag+name} vs {red flag+name}
|
||||
// ({weight})", with blue/red corner colours and inline flags. Falls
|
||||
// back to plain $video->title for non-match cards.
|
||||
$displayTitle = $video?->title;
|
||||
$displayTitleHtml = null;
|
||||
if ($video && $video->type === 'match' && $video->sportsMatch) {
|
||||
$hdCard = $video->sportsMatch->headerData();
|
||||
$blueN = $hdCard['blue']['name'] ?? null;
|
||||
$redN = $hdCard['red']['name'] ?? null;
|
||||
$blueF = $hdCard['blue']['flag'] ?? null;
|
||||
$redF = $hdCard['red']['flag'] ?? null;
|
||||
if ($blueN || $redN) {
|
||||
$flagStyle = 'width:16px;height:12px;border-radius:2px;display:inline-block;vertical-align:middle;margin-right:4px;';
|
||||
$mkSide = function ($name, $flag, $color) use ($flagStyle) {
|
||||
if (!$name) return '';
|
||||
$flagHtml = $flag ? '<span class="fi fi-'.e($flag).'" style="'.$flagStyle.'"></span>' : '';
|
||||
return '<span style="color:'.$color.';font-weight:600;">'.$flagHtml.e($name).'</span>';
|
||||
};
|
||||
$blueHtml = $mkSide($blueN, $blueF, '#2563eb');
|
||||
$redHtml = $mkSide($redN, $redF, '#ef4444');
|
||||
|
||||
$parts = [];
|
||||
if (!empty($video->title)) $parts[] = e(strtoupper($video->title)) . ' –';
|
||||
if ($blueHtml && $redHtml) $parts[] = $blueHtml . ' <span>vs</span> ' . $redHtml;
|
||||
elseif ($blueHtml) $parts[] = $blueHtml;
|
||||
elseif ($redHtml) $parts[] = $redHtml;
|
||||
if (!empty($hdCard['weight_category'])) $parts[] = '(' . e($hdCard['weight_category']) . ')';
|
||||
$displayTitleHtml = implode(' ', $parts);
|
||||
}
|
||||
}
|
||||
|
||||
// Size classes
|
||||
$sizeClasses = match($size) {
|
||||
'small' => 'yt-video-card-sm',
|
||||
@@ -53,12 +85,30 @@ $sizeClasses = match($size) {
|
||||
<a href="{{ $showUrl }}">
|
||||
<div class="yt-video-thumb" onmouseenter="playVideo(this)" onmouseleave="stopVideo(this)"
|
||||
data-audio="{{ $video && $video->isAudioOnly() ? 'true' : 'false' }}">
|
||||
<img src="{{ $thumbnailUrl }}" alt="{{ $video->title ?? 'Video' }}" loading="lazy" decoding="async" onload="this.classList.add('loaded');this.closest('.yt-video-thumb').classList.add('loaded')">
|
||||
{{-- width/height let the browser reserve the card's thumb box
|
||||
BEFORE the image decodes, so a grid of 60 cards doesn't shift
|
||||
as each thumbnail finishes loading. The ratio is honored by
|
||||
.yt-video-thumb's aspect-ratio: 16/9 regardless of actual px. --}}
|
||||
<img src="{{ $thumbnailUrl }}" alt="{{ $video->title ?? 'Video' }}"
|
||||
width="320" height="180"
|
||||
loading="lazy" decoding="async"
|
||||
onload="this.classList.add('loaded');this.closest('.yt-video-thumb').classList.add('loaded')">
|
||||
@if($videoUrl)
|
||||
<video preload="none">
|
||||
{{-- Match cards preload metadata so the first frame is ready the
|
||||
instant the user hovers (otherwise the browser fetches, buffers
|
||||
then paints — showing a black frame behind the fade). Other
|
||||
card types keep preload="none" to save bandwidth. --}}
|
||||
<video preload="{{ $video && $video->type === 'match' ? 'metadata' : 'none' }}" playsinline>
|
||||
<source src="{{ $videoUrl }}" type="{{ $video->mime_type ?? 'video/mp4' }}">
|
||||
</video>
|
||||
@endif
|
||||
{{-- Match cards used to inline the full VS portrait + the live-scoring
|
||||
scorebar as thumbnail overlays. Those partials leaked fighter
|
||||
names, "AKA / AO", "ROUND 1", "0:00" and club text into the
|
||||
card DOM — showing stale 0-0 for finished matches, breaking
|
||||
the title layout at narrow widths, and inflating card HTML.
|
||||
The regular <img> above is the thumbnail now; the VS intro
|
||||
and live scorebar live on the full match player page only. --}}
|
||||
{{-- Equalizer overlay shown when audio-only track is previewing --}}
|
||||
<div class="audio-preview-overlay">
|
||||
<div class="audio-eq">
|
||||
@@ -90,23 +140,29 @@ $sizeClasses = match($size) {
|
||||
@endif
|
||||
</div>
|
||||
</a>
|
||||
<div class="yt-video-info">
|
||||
<div class="yt-video-info {{ $hideChannelInfo ? 'yt-video-info-no-channel' : '' }}">
|
||||
@unless($hideChannelInfo)
|
||||
<a href="{{ $video && $video->user ? route('channel', $video->user->channel) : '#' }}"
|
||||
class="yt-channel-icon" onclick="event.stopPropagation();">
|
||||
@if($video && $video->user && $video->user->avatar_url)
|
||||
<img src="{{ $video->user->avatar_url }}" alt="{{ $video->user->name }}" loading="lazy" decoding="async" onload="this.classList.add('loaded')" style="width: 100%; height: 100%; object-fit: cover; border-radius: 50%; opacity: 0; transition: opacity 0.25s ease;">
|
||||
@endif
|
||||
</a>
|
||||
@endunless
|
||||
<div class="yt-video-details">
|
||||
<h3 class="yt-video-title">
|
||||
<a href="{{ $showUrl }}">
|
||||
@if($langFlag)
|
||||
<span class="fi fi-{{ $langFlag }} vc-lang-flag"></span>
|
||||
@endif
|
||||
{{ $video->title ?? 'Untitled Video' }}
|
||||
@if($displayTitleHtml)
|
||||
{!! $displayTitleHtml !!}
|
||||
@else
|
||||
{{ $displayTitle ?? 'Untitled Video' }}
|
||||
@endif
|
||||
</a>
|
||||
</h3>
|
||||
@if($video && $video->user)
|
||||
@if($video && $video->user && ! $hideChannelInfo)
|
||||
<a href="{{ route('channel', $video->user->channel) }}"
|
||||
class="yt-channel-name" onclick="event.stopPropagation();">{{ $video->user->name }}</a>
|
||||
@endif
|
||||
@@ -125,15 +181,15 @@ $sizeClasses = match($size) {
|
||||
</div>
|
||||
@if($video)
|
||||
<div class="position-relative">
|
||||
<button class="yt-more-btn" type="button" data-bs-toggle="dropdown" data-bs-auto-close="true" aria-expanded="false">
|
||||
<i class="bi bi-three-dots-vertical"></i>
|
||||
<button class="yt-more-btn" type="button" data-bs-toggle="dropdown" data-bs-auto-close="true" aria-expanded="false" aria-label="More actions">
|
||||
<i class="bi bi-three-dots-vertical" aria-hidden="true"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark">
|
||||
@if($isOwner)
|
||||
<li>
|
||||
<a class="dropdown-item" href="javascript:void(0)" onclick="openEditVideoModal('{{ $video->getRouteKey() }}')">
|
||||
<button type="button" class="dropdown-item" onclick="openEditVideoModal('{{ $video->getRouteKey() }}')">
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
</a>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" class="dropdown-item text-danger" onclick="showDeleteModal('{{ $video->getRouteKey() }}', {{ json_encode($video->title) }})">
|
||||
@@ -143,17 +199,17 @@ $sizeClasses = match($size) {
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
@endif
|
||||
<li>
|
||||
<a class="dropdown-item" href="javascript:void(0)" onclick="addToQueue('{{ $video->getRouteKey() }}')">
|
||||
<button type="button" class="dropdown-item" onclick="addToQueue('{{ $video->getRouteKey() }}')">
|
||||
<i class="bi bi-list-nested"></i> Add to queue
|
||||
</a>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="javascript:void(0)" onclick="saveToWatchLater('{{ $video->getRouteKey() }}')">
|
||||
<button type="button" class="dropdown-item" onclick="saveToWatchLater('{{ $video->getRouteKey() }}')">
|
||||
<i class="bi bi-clock"></i> Save to Watch later
|
||||
</a>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<x-save-to-playlist-button :video="$video" tag="a" class="dropdown-item">
|
||||
<x-save-to-playlist-button :video="$video" tag="button" class="dropdown-item">
|
||||
<i class="bi bi-bookmark"></i> Save to playlist
|
||||
</x-save-to-playlist-button>
|
||||
</li>
|
||||
@@ -166,133 +222,45 @@ $sizeClasses = match($size) {
|
||||
@endif
|
||||
@if($video->isShareable())
|
||||
<li>
|
||||
<x-share-button :video="$video" tag="a" class="dropdown-item">
|
||||
<x-share-button :video="$video" tag="button" class="dropdown-item">
|
||||
<i class="bi bi-share"></i> Share
|
||||
</x-share-button>
|
||||
</li>
|
||||
@endif
|
||||
{{-- "Not interested", "Don't recommend channel" and "Report"
|
||||
only make sense for someone else's video. On your own, the
|
||||
Edit/Delete items above are what you want instead. --}}
|
||||
@unless($isOwner)
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="javascript:void(0)" onclick="notInterested('{{ $video->getRouteKey() }}')">
|
||||
<button type="button" class="dropdown-item" onclick="notInterested('{{ $video->getRouteKey() }}')">
|
||||
<i class="bi bi-dash-circle"></i> Not interested
|
||||
</a>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="javascript:void(0)" onclick="dontRecommendChannel('{{ $video->user_id }}')">
|
||||
<button type="button" class="dropdown-item" onclick="dontRecommendChannel('{{ $video->user_id }}')">
|
||||
<i class="bi bi-x-circle"></i> Don't recommend channel
|
||||
</a>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="javascript:void(0)" onclick="reportVideo('{{ $video->getRouteKey() }}')">
|
||||
<button type="button" class="dropdown-item" onclick="reportVideo('{{ $video->getRouteKey() }}')">
|
||||
<i class="bi bi-flag"></i> Report
|
||||
</a>
|
||||
</button>
|
||||
</li>
|
||||
@endunless
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@php $vk = $video->getRouteKey() ?? ''; @endphp
|
||||
<!-- Cute Edit Video Modal -->
|
||||
<div class="modal fade" id="editVideoModal{{ $vk }}" tabindex="-1" aria-labelledby="editVideoModalLabel{{ $vk }}" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered cute-edit-modal">
|
||||
<div class="modal-content cute-edit-content">
|
||||
<div class="cute-edit-header">
|
||||
<span class="cute-edit-icon">✏️</span>
|
||||
<h5>Edit Video</h5>
|
||||
<button type="button" class="btn-close-cute" onclick="closeEditVideoModal('{{ $vk }}')">×</button>
|
||||
</div>
|
||||
<div class="cute-edit-body">
|
||||
<form id="edit-video-form-{{ $vk }}" enctype="multipart/form-data">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<!-- Title -->
|
||||
<div class="cute-form-group">
|
||||
<label><i class="bi bi-card-heading"></i> Title</label>
|
||||
<input type="text" name="title" id="edit-title-{{ $vk }}" class="cute-input" placeholder="Video title">
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="cute-form-group">
|
||||
<label><i class="bi bi-text-paragraph"></i> Description</label>
|
||||
<textarea name="description" id="edit-description-{{ $vk }}" class="cute-textarea" rows="2" placeholder="Tell viewers about your video"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Video Type -->
|
||||
<div class="cute-form-group">
|
||||
<label><i class="bi bi-collection-play"></i> Type</label>
|
||||
<div class="cute-type-options">
|
||||
<label class="cute-type-option active" data-type="generic">
|
||||
<input type="radio" name="type" value="generic" checked>
|
||||
<span>🎬 Generic</span>
|
||||
</label>
|
||||
<label class="cute-type-option" data-type="music">
|
||||
<input type="radio" name="type" value="music">
|
||||
<span>🎵 Music</span>
|
||||
</label>
|
||||
<label class="cute-type-option" data-type="match">
|
||||
<input type="radio" name="type" value="match">
|
||||
<span>🏆 Match</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shorts Toggle -->
|
||||
<div class="cute-form-group">
|
||||
<label><i class="bi bi-lightning-charge-fill"></i> Shorts</label>
|
||||
<label class="cute-shorts-toggle">
|
||||
<input type="checkbox" name="is_shorts" id="edit-is-shorts-{{ $vk }}" value="1">
|
||||
<span class="cute-shorts-slider"></span>
|
||||
<span class="cute-shorts-label">Mark as Short</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Thumbnail -->
|
||||
<div class="cute-form-group">
|
||||
<label><i class="bi bi-image"></i> Thumbnail</label>
|
||||
<div class="cute-thumbnail-upload" onclick="document.getElementById('edit-thumbnail-{{ $vk }}').click()">
|
||||
<input type="file" name="thumbnail" id="edit-thumbnail-{{ $vk }}" accept="image/*" hidden>
|
||||
<div class="cute-thumbnail-preview" id="thumbnail-preview-{{ $vk }}">
|
||||
<i class="bi bi-camera"></i>
|
||||
<span>Click to change</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Privacy -->
|
||||
<div class="cute-form-group">
|
||||
<label><i class="bi bi-shield-lock"></i> Privacy</label>
|
||||
<div class="cute-privacy-options">
|
||||
<label class="cute-privacy-option active" data-privacy="public">
|
||||
<input type="radio" name="visibility" value="public" checked>
|
||||
<span>🌐 Public</span>
|
||||
</label>
|
||||
<label class="cute-privacy-option" data-privacy="unlisted">
|
||||
<input type="radio" name="visibility" value="unlisted">
|
||||
<span>🔗 Unlisted</span>
|
||||
</label>
|
||||
<label class="cute-privacy-option" data-privacy="private">
|
||||
<input type="radio" name="visibility" value="private">
|
||||
<span>🔒 Private</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="cute-status" id="edit-status-{{ $vk }}"></div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="cute-edit-actions">
|
||||
<button type="button" class="cute-btn-cancel" onclick="closeEditVideoModal('{{ $vk }}')">Cancel</button>
|
||||
<button type="submit" class="cute-btn-save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{--
|
||||
The per-card edit modal that used to live here (97 lines of markup, 100+ lines of JS)
|
||||
was dead code — the singleton at resources/views/layouts/partials/edit-video-modal.blade.php
|
||||
defines a global window.openEditVideoModal that always wins the last-definition race,
|
||||
so clicking "Edit" on a card has always been going through the singleton. Removing the
|
||||
per-card copy drops ~6000 lines of HTML from a 60-card feed page.
|
||||
--}}
|
||||
|
||||
@include('components.partials.card-styles')
|
||||
|
||||
@@ -347,155 +315,8 @@ function stopVideo(element) {
|
||||
element.classList.remove('audio-playing');
|
||||
}
|
||||
|
||||
// Edit Modal Functions
|
||||
let currentEditVideoId = null;
|
||||
|
||||
function openEditVideoModal(videoId) {
|
||||
currentEditVideoId = videoId;
|
||||
const modalId = 'editVideoModal' + (videoId || '');
|
||||
const modal = new bootstrap.Modal(document.getElementById(modalId));
|
||||
modal.show();
|
||||
|
||||
// Fetch video data
|
||||
fetch(`/videos/${videoId}/edit`, {
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
const video = data.video;
|
||||
document.getElementById('edit-title-' + videoId).value = video.title || '';
|
||||
document.getElementById('edit-description-' + videoId).value = video.description || '';
|
||||
|
||||
// Set type
|
||||
const typeOptions = document.querySelectorAll('#' + modalId + ' .cute-type-option');
|
||||
typeOptions.forEach(opt => {
|
||||
opt.classList.remove('active');
|
||||
if (opt.dataset.type === (video.type || 'generic')) {
|
||||
opt.classList.add('active');
|
||||
opt.querySelector('input').checked = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Set privacy
|
||||
const privacyOptions = document.querySelectorAll('#' + modalId + ' .cute-privacy-option');
|
||||
privacyOptions.forEach(opt => {
|
||||
opt.classList.remove('active');
|
||||
if (opt.dataset.privacy === (video.visibility || 'public')) {
|
||||
opt.classList.add('active');
|
||||
opt.querySelector('input').checked = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Set shorts toggle
|
||||
const shortsCheckbox = document.getElementById('edit-is-shorts-' + videoId);
|
||||
if (shortsCheckbox) {
|
||||
shortsCheckbox.checked = video.is_shorts === true || video.is_shorts === 1 || video.is_shorts === '1';
|
||||
}
|
||||
|
||||
// Clear status
|
||||
const statusEl = document.getElementById('edit-status-' + videoId);
|
||||
statusEl.className = 'cute-status';
|
||||
statusEl.textContent = '';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function closeEditVideoModal(videoId) {
|
||||
const modalId = 'editVideoModal' + (videoId || '');
|
||||
const modalEl = document.getElementById(modalId);
|
||||
const modal = bootstrap.Modal.getInstance(modalEl);
|
||||
if (modal) {
|
||||
modal.hide();
|
||||
}
|
||||
}
|
||||
|
||||
// SPA navigation re-executes this once-protected script on every page swap.
|
||||
// Guard the document-level listeners so they don't stack up across navs.
|
||||
if (!window._videoCardListenersBound) {
|
||||
window._videoCardListenersBound = true;
|
||||
|
||||
// Type option click handlers
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.closest('.cute-type-option')) {
|
||||
const option = e.target.closest('.cute-type-option');
|
||||
const parent = option.parentElement;
|
||||
parent.querySelectorAll('.cute-type-option').forEach(opt => opt.classList.remove('active'));
|
||||
option.classList.add('active');
|
||||
option.querySelector('input').checked = true;
|
||||
}
|
||||
if (e.target.closest('.cute-privacy-option')) {
|
||||
const option = e.target.closest('.cute-privacy-option');
|
||||
const parent = option.parentElement;
|
||||
parent.querySelectorAll('.cute-privacy-option').forEach(opt => opt.classList.remove('active'));
|
||||
option.classList.add('active');
|
||||
option.querySelector('input').checked = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Thumbnail preview
|
||||
document.addEventListener('change', function(e) {
|
||||
if (e.target.id && e.target.id.startsWith('edit-thumbnail-')) {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
const videoId = e.target.id.replace('edit-thumbnail-', '');
|
||||
const preview = document.getElementById('thumbnail-preview-' + videoId);
|
||||
preview.innerHTML = `<span>${file.name}</span>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Form submission
|
||||
document.addEventListener('submit', function(e) {
|
||||
const form = e.target;
|
||||
if (form.id && form.id.startsWith('edit-video-form-')) {
|
||||
e.preventDefault();
|
||||
const videoId = form.id.replace('edit-video-form-', '');
|
||||
const formData = new FormData(form);
|
||||
const statusEl = document.getElementById('edit-status-' + videoId);
|
||||
const submitBtn = form.querySelector('.cute-btn-save');
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Saving...';
|
||||
|
||||
fetch(`/videos/${videoId}`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
statusEl.className = 'cute-status success';
|
||||
statusEl.textContent = '✓ Saved successfully!';
|
||||
setTimeout(() => {
|
||||
closeEditVideoModal(videoId);
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} else {
|
||||
throw new Error(data.message || 'Update failed');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
statusEl.className = 'cute-status error';
|
||||
statusEl.textContent = '✗ ' + error.message;
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Save';
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
} // end _videoCardListenersBound guard
|
||||
// Edit-modal open/close + its form handlers live in the singleton at
|
||||
// layouts/partials/edit-video-modal.blade.php (not here) — see the
|
||||
// removed-markup note above the dropdown for why.
|
||||
</script>
|
||||
@endonce
|
||||
@@ -4,8 +4,31 @@
|
||||
use App\Models\CommentLike;
|
||||
|
||||
$currentUserId = Auth::id() ?? 0;
|
||||
$topComments = $video->comments()->whereNull('parent_id')->with('user', 'replies.user')->latest()->get();
|
||||
$commentCount = $topComments->sum(fn($c) => 1 + $c->replies->count());
|
||||
$llmEnabled = app(\App\Services\LlmLyricsService::class)->isEnabled();
|
||||
|
||||
// Flag per commenter: most-frequent access country, falling back to the
|
||||
// user's nationality. Batched so the comments list stays a single query.
|
||||
$commenterIds = $video->comments()->pluck('user_id')->filter()->unique()->values()->all();
|
||||
$commenterCountry = \App\Models\User::topCountriesFor($commenterIds);
|
||||
// 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')
|
||||
->merge($topComments->flatMap(fn($c) => $c->replies->pluck('id')))
|
||||
@@ -40,7 +63,17 @@
|
||||
|
||||
{{-- ── Header ── --}}
|
||||
<div class="ytc-header">
|
||||
<span class="ytc-count" id="ytcCount">{{ number_format($commentCount) }} Comments</span>
|
||||
{{-- Top-level count on the header; reply count surfaces inside each
|
||||
thread's "N replies" expander. "1 Comment" singular when there's
|
||||
exactly one — never "1 Comments". --}}
|
||||
<span class="ytc-count" id="ytcCount">
|
||||
{{ number_format($topLevelCount) }} {{ Str::plural('Comment', $topLevelCount) }}
|
||||
@if($replyCount > 0)
|
||||
<span class="ytc-count-replies" aria-label="reply count">
|
||||
· {{ number_format($replyCount) }} {{ Str::plural('reply', $replyCount) }}
|
||||
</span>
|
||||
@endif
|
||||
</span>
|
||||
<div class="ytc-sort-wrap" id="ytcSortWrap">
|
||||
<button class="ytc-sort-btn" id="ytcSortBtn">
|
||||
<svg viewBox="0 0 24 24"><path d="M3 18h6v-2H3v2zM3 6v2h18V6H3zm0 7h12v-2H3v2z"/></svg>
|
||||
@@ -82,18 +115,41 @@
|
||||
{{-- ── Comments list ── --}}
|
||||
<div id="ytcList">
|
||||
@forelse($topComments as $comment)
|
||||
@php $isOwn = $comment->user_id === $currentUserId; @endphp
|
||||
<div class="ytc-comment" id="comment-{{ $comment->id }}">
|
||||
@php
|
||||
$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">
|
||||
<img src="{{ ytcAvatar($comment->user) }}" class="ytc-avatar" alt="{{ $comment->user->name ?? 'User' }}">
|
||||
</a>
|
||||
<div class="ytc-body-wrap">
|
||||
<div class="ytc-meta">
|
||||
@php $cFlag = $commenterCountry[$comment->user_id] ?? null; @endphp
|
||||
@if($cFlag)<span class="ytc-flag fi fi-{{ strtolower($cFlag) }}" title="{{ $cFlag }}"></span>@endif
|
||||
<a href="{{ $comment->user ? route('channel', $comment->user->channel) : '#' }}" class="ytc-author">{{ $comment->user->name ?? 'User' }}</a>
|
||||
<span class="ytc-time">{{ ytcTime($comment->created_at) }}</span>
|
||||
<a class="ytc-time ytc-permalink"
|
||||
href="?lc={{ $comment->id }}#comment-{{ $comment->id }}"
|
||||
title="Copy link to this comment"
|
||||
aria-label="Permalink">{{ ytcTime($comment->created_at) }}</a>
|
||||
@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 class="ytc-text _comment-body" data-_comment-enhanced="0">{{ $comment->body }}</div>
|
||||
@if($llmEnabled)
|
||||
<div class="ytc-translation" id="translation-{{ $comment->id }}" style="display:none"></div>
|
||||
@endif
|
||||
|
||||
{{-- Edit form --}}
|
||||
@if($isOwn)
|
||||
@@ -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>
|
||||
</button>
|
||||
@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>
|
||||
@endauth
|
||||
@if($llmEnabled)
|
||||
<button class="ytc-translate-btn _comment-translate-trigger" data-comment-id="{{ $comment->id }}" title="Translate this comment">
|
||||
<svg viewBox="0 0 24 24"><path d="M12.87 15.07l-2.54-2.51.03-.03c1.74-1.94 2.98-4.17 3.71-6.53H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z"/></svg>
|
||||
<span class="ytc-translate-label">Translate</span>
|
||||
</button>
|
||||
@endif
|
||||
@if($isOwn)
|
||||
<div class="ytc-more-wrap">
|
||||
<button class="ytc-more-btn" title="More actions">
|
||||
@@ -139,20 +218,12 @@
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Reply form --}}
|
||||
{{-- Reply form is lazy-cloned from #ytc-reply-form-tpl on
|
||||
first click; the ~13-line markup used to render for
|
||||
every top-level comment (so a 50-comment video shipped
|
||||
~650 lines of hidden form markup to every viewer). --}}
|
||||
@auth
|
||||
<div class="ytc-reply-form" id="replyForm{{ $comment->id }}">
|
||||
<img src="{{ Auth::user()->avatar_url }}" class="ytc-avatar ytc-avatar-sm" alt="{{ Auth::user()->name }}">
|
||||
<div class="ytc-input-wrap">
|
||||
<textarea class="ytc-textarea ytc-reply-textarea" id="replyBody{{ $comment->id }}"
|
||||
placeholder="Add a reply..." rows="1"></textarea>
|
||||
<div class="ytc-form-actions">
|
||||
<button class="ytc-btn ytc-btn-cancel _comment-cancel-reply-trigger" data-comment-id="{{ $comment->id }}">Cancel</button>
|
||||
<button class="ytc-btn ytc-btn-submit _comment-submit-reply-trigger"
|
||||
data-video-id="{{ $video->getRouteKey() }}" data-parent-id="{{ $comment->id }}">Reply</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ytc-reply-slot" data-comment-id="{{ $comment->id }}" data-video-id="{{ $video->getRouteKey() }}"></div>
|
||||
@endauth
|
||||
|
||||
{{-- Replies --}}
|
||||
@@ -172,10 +243,18 @@
|
||||
</a>
|
||||
<div class="ytc-body-wrap">
|
||||
<div class="ytc-meta">
|
||||
@php $rFlag = $commenterCountry[$reply->user_id] ?? null; @endphp
|
||||
@if($rFlag)<span class="ytc-flag fi fi-{{ strtolower($rFlag) }}" title="{{ $rFlag }}"></span>@endif
|
||||
<a href="{{ $reply->user ? route('channel', $reply->user->channel) : '#' }}" class="ytc-author">{{ $reply->user->name ?? 'User' }}</a>
|
||||
<span class="ytc-time">{{ ytcTime($reply->created_at) }}</span>
|
||||
<a class="ytc-time ytc-permalink"
|
||||
href="?lc={{ $reply->id }}#comment-{{ $reply->id }}"
|
||||
title="Copy link to this reply"
|
||||
aria-label="Permalink">{{ ytcTime($reply->created_at) }}</a>
|
||||
</div>
|
||||
<div class="ytc-text _comment-body" data-_comment-enhanced="0">{{ $reply->body }}</div>
|
||||
@if($llmEnabled)
|
||||
<div class="ytc-translation" id="translation-{{ $reply->id }}" style="display:none"></div>
|
||||
@endif
|
||||
|
||||
@if($replyIsOwn)
|
||||
<div class="ytc-edit-form" id="commentEditWrap{{ $reply->id }}" style="display:none">
|
||||
@@ -197,6 +276,21 @@
|
||||
<button class="ytc-dislike-btn" data-id="{{ $reply->id }}" title="Dislike">
|
||||
<svg viewBox="0 0 24 24"><path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L10.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z"/></svg>
|
||||
</button>
|
||||
@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)
|
||||
<div class="ytc-more-wrap">
|
||||
<button class="ytc-more-btn">
|
||||
@@ -270,6 +364,66 @@
|
||||
font-weight: 400;
|
||||
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-btn {
|
||||
display: flex;
|
||||
@@ -440,7 +594,11 @@
|
||||
.ytc-time {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
/* The time stamp is now an <a> permalink. Underline on hover so it's
|
||||
discoverable but doesn't scream "link" at rest. */
|
||||
.ytc-time.ytc-permalink:hover { text-decoration: underline; cursor: pointer; }
|
||||
|
||||
/* ── Comment text ── */
|
||||
.ytc-text {
|
||||
@@ -451,6 +609,31 @@
|
||||
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 ── */
|
||||
.ytc-actions {
|
||||
display: flex;
|
||||
@@ -498,6 +681,57 @@
|
||||
}
|
||||
.ytc-reply-btn:hover { background: rgba(255,255,255,.1); }
|
||||
|
||||
/* ── Translate ── */
|
||||
.ytc-translate-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 6px 10px;
|
||||
border-radius: 18px;
|
||||
transition: background .15s, color .15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.ytc-translate-btn:hover { background: rgba(255,255,255,.1); color: var(--text-primary); }
|
||||
.ytc-translate-btn svg { width: 16px; height: 16px; fill: currentColor; }
|
||||
.ytc-translate-btn.loading { opacity: .6; cursor: wait; }
|
||||
.ytc-translate-btn.active { color: #3ea6ff; }
|
||||
.ytc-flag {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 13px;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 0 0 1px rgba(255,255,255,.08);
|
||||
vertical-align: middle;
|
||||
transform: translateY(-1px);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ytc-translation {
|
||||
margin-top: 6px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(62,166,255,.08);
|
||||
border-left: 2px solid #3ea6ff;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.ytc-translation-meta {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .4px;
|
||||
}
|
||||
|
||||
/* ── Three-dot menu ── */
|
||||
.ytc-more-wrap {
|
||||
position: relative;
|
||||
@@ -695,9 +929,16 @@ const YTC = {
|
||||
videoId: '{{ $video->getRouteKey() }}',
|
||||
csrf: '{{ csrf_token() }}',
|
||||
userId: {{ $currentUserId }},
|
||||
llmEnabled: {{ $llmEnabled ? 'true' : 'false' }},
|
||||
meCountry: @json($currentUserId && isset($commenterCountry[$currentUserId]) ? strtolower($commenterCountry[$currentUserId]) : null),
|
||||
deleteId: null,
|
||||
toastTimer: null,
|
||||
};
|
||||
const YTC_TRANSLATE_SVG = '<svg viewBox="0 0 24 24"><path d="M12.87 15.07l-2.54-2.51.03-.03c1.74-1.94 2.98-4.17 3.71-6.53H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z"/></svg>';
|
||||
function targetLang() {
|
||||
const l = (navigator.language || 'en').toLowerCase().split('-')[0];
|
||||
return l || 'en';
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────
|
||||
function esc(s) {
|
||||
@@ -877,6 +1118,26 @@ document.getElementById('ytcList')?.addEventListener('click', e => {
|
||||
const rt = e.target.closest('._comment-reply-trigger');
|
||||
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
|
||||
const et = e.target.closest('._comment-edit-trigger');
|
||||
if (et) { e.stopPropagation(); startEdit(et.dataset.commentId); return; }
|
||||
@@ -904,12 +1165,148 @@ document.getElementById('ytcList')?.addEventListener('click', e => {
|
||||
// Dislike btn
|
||||
const db = e.target.closest('.ytc-dislike-btn');
|
||||
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) {
|
||||
const form = document.getElementById('replyForm' + commentId);
|
||||
if (!form) return;
|
||||
let form = document.getElementById('replyForm' + commentId);
|
||||
if (!form) {
|
||||
const slot = document.querySelector('.ytc-reply-slot[data-comment-id="' + commentId + '"]');
|
||||
const tpl = document.getElementById('ytc-reply-form-tpl');
|
||||
if (!slot || !tpl) return;
|
||||
const frag = tpl.content.cloneNode(true);
|
||||
const root = frag.querySelector('.ytc-reply-form');
|
||||
const ta = frag.querySelector('.ytc-reply-textarea');
|
||||
const cancel = frag.querySelector('._comment-cancel-reply-trigger');
|
||||
const submit = frag.querySelector('._comment-submit-reply-trigger');
|
||||
root.id = 'replyForm' + commentId;
|
||||
ta.id = 'replyBody' + commentId;
|
||||
cancel.dataset.commentId = commentId;
|
||||
submit.dataset.parentId = commentId;
|
||||
submit.dataset.videoId = slot.dataset.videoId || YTC.videoId;
|
||||
slot.appendChild(frag);
|
||||
form = root;
|
||||
}
|
||||
const isOpen = form.classList.contains('open');
|
||||
document.querySelectorAll('.ytc-reply-form.open').forEach(f => f.classList.remove('open'));
|
||||
if (!isOpen) {
|
||||
@@ -970,16 +1367,26 @@ async function saveEdit(commentId) {
|
||||
|
||||
// ── Post comment ──────────────────────────────────────
|
||||
async function postComment() {
|
||||
if (postComment._busy) return; // guard against double-submit (fast dbl-click, Enter+click, duplicate listeners after SPA swap)
|
||||
const ta = document.getElementById('ytcTextarea');
|
||||
if (!ta) return;
|
||||
const text = ta.value.trim();
|
||||
if (!text) { toast('Write something first', 'error'); return; }
|
||||
postComment._busy = true;
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Posting…';
|
||||
try {
|
||||
// Idempotency key: random per attempt. On retry the client reuses the
|
||||
// same key so the server returns the original comment instead of
|
||||
// writing a second one.
|
||||
const idemKey = (window.crypto?.randomUUID?.() || (Date.now() + '-' + Math.random().toString(36).slice(2)));
|
||||
const r = await fetch('/videos/' + YTC.videoId + '/comments', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': YTC.csrf },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': YTC.csrf,
|
||||
'X-Idempotency-Key': idemKey,
|
||||
},
|
||||
body: JSON.stringify({ body: text })
|
||||
});
|
||||
if (!(r.headers.get('content-type') || '').includes('application/json')) {
|
||||
@@ -1000,6 +1407,7 @@ async function postComment() {
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
} finally {
|
||||
postComment._busy = false;
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Comment';
|
||||
}
|
||||
@@ -1012,11 +1420,21 @@ async function postReply(vid, parentId) {
|
||||
const text = input.value.trim();
|
||||
if (!text) { toast('Write something first', 'error'); return; }
|
||||
const btn = input.closest('.ytc-reply-form')?.querySelector('._comment-submit-reply-trigger');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Posting…'; }
|
||||
if (btn) {
|
||||
if (btn.dataset.busy === '1') return; // guard against double-submit
|
||||
btn.dataset.busy = '1';
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Posting…';
|
||||
}
|
||||
try {
|
||||
const idemKey = (window.crypto?.randomUUID?.() || (Date.now() + '-' + Math.random().toString(36).slice(2)));
|
||||
const r = await fetch('/videos/' + vid + '/comments', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': YTC.csrf },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': YTC.csrf,
|
||||
'X-Idempotency-Key': idemKey,
|
||||
},
|
||||
body: JSON.stringify({ body: text, parent_id: parentId })
|
||||
});
|
||||
if (!(r.headers.get('content-type') || '').includes('application/json')) {
|
||||
@@ -1035,7 +1453,7 @@ async function postReply(vid, parentId) {
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Reply'; }
|
||||
if (btn) { btn.dataset.busy = ''; btn.disabled = false; btn.textContent = 'Reply'; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1133,6 +1551,12 @@ function buildCommentEl(c, isReply) {
|
||||
</div>` : '';
|
||||
|
||||
const replyBtn = YTC.userId ? `<button class="ytc-reply-btn _comment-reply-trigger" data-comment-id="${id}">Reply</button>` : '';
|
||||
const translateBtn = YTC.llmEnabled ? `
|
||||
<button class="ytc-translate-btn _comment-translate-trigger" data-comment-id="${id}" title="Translate this comment">
|
||||
${YTC_TRANSLATE_SVG}<span class="ytc-translate-label">Translate</span>
|
||||
</button>` : '';
|
||||
const translationPanel = YTC.llmEnabled ? `<div class="ytc-translation" id="translation-${id}" style="display:none"></div>` : '';
|
||||
const flagEl = YTC.meCountry ? `<span class="ytc-flag fi fi-${YTC.meCountry}"></span>` : '';
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = 'ytc-comment' + (isReply ? ' ytc-reply' : '');
|
||||
@@ -1143,10 +1567,12 @@ function buildCommentEl(c, isReply) {
|
||||
</a>
|
||||
<div class="ytc-body-wrap">
|
||||
<div class="ytc-meta">
|
||||
${flagEl}
|
||||
<span class="ytc-author">${name}</span>
|
||||
<span class="ytc-time">${time}</span>
|
||||
</div>
|
||||
<div class="ytc-text _comment-body" data-_comment-enhanced="0">${body}</div>
|
||||
${translationPanel}
|
||||
${editForm}
|
||||
<div class="ytc-actions">
|
||||
<button class="ytc-like-btn" data-id="${id}" title="Like">
|
||||
@@ -1157,6 +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>
|
||||
</button>
|
||||
${replyBtn}
|
||||
${translateBtn}
|
||||
${moreMenu}
|
||||
</div>
|
||||
${replyForm}
|
||||
@@ -1226,6 +1653,32 @@ function toggleDislike(btn) {
|
||||
function initLikeStates() {} // server-rendered state is already in the HTML
|
||||
|
||||
// ── 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) {
|
||||
if (!el || el.dataset._commentEnhanced === '1') return;
|
||||
let text = el.textContent || '';
|
||||
@@ -1255,6 +1708,9 @@ function enhanceBody(el) {
|
||||
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() {
|
||||
document.querySelectorAll('._comment-body:not([data-_comment-enhanced="1"])').forEach(enhanceBody);
|
||||
@@ -1309,16 +1765,39 @@ const listObserver = new MutationObserver(() => {
|
||||
const list = document.getElementById('ytcList');
|
||||
if (list) listObserver.observe(list, { childList: true, subtree: true });
|
||||
|
||||
// ── Init ──────────────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
enhanceAll();
|
||||
initLikeStates();
|
||||
});
|
||||
if (document.readyState !== 'loading') {
|
||||
enhanceAll();
|
||||
initLikeStates();
|
||||
// ── Comment permalink (?lc=<id>) ─────────────────────
|
||||
// Scroll to #comment-<id> and briefly highlight the row so a shared link
|
||||
// lands on the exact message. The reply row is nested inside a .ytc-replies
|
||||
// expander by default — we auto-open the parent thread first so the target
|
||||
// is visible before we scroll.
|
||||
function openCommentPermalink(id) {
|
||||
if (!id) return;
|
||||
const target = document.getElementById('comment-' + id);
|
||||
if (!target) return;
|
||||
// Find and expand the parent replies section, if any.
|
||||
const repliesSection = target.closest('.ytc-replies-section');
|
||||
if (repliesSection) {
|
||||
const toggle = repliesSection.querySelector('.ytc-replies-toggle');
|
||||
if (toggle && toggle.dataset.open !== '1') toggle.click();
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
target.classList.add('ytc-highlight');
|
||||
setTimeout(() => target.classList.remove('ytc-highlight'), 2800);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────
|
||||
function _ytcInit() {
|
||||
enhanceAll();
|
||||
initLikeStates();
|
||||
@if($permalinkId)
|
||||
openCommentPermalink({{ (int) $permalinkId }});
|
||||
@endif
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', _ytcInit);
|
||||
if (document.readyState !== 'loading') _ytcInit();
|
||||
|
||||
// ── Public API (for backward compat) ─────────────────
|
||||
window._comment = {
|
||||
deleteComment: openDeleteModal,
|
||||
@@ -1327,3 +1806,22 @@ window._comment = {
|
||||
|
||||
})();
|
||||
</script>
|
||||
|
||||
@auth
|
||||
{{-- Reply-form template. Cloned into .ytc-reply-slot the first time a user
|
||||
clicks Reply on a given comment. Keeping it here (not in the layout)
|
||||
means the markup travels with the comments component — the only place
|
||||
that uses it. --}}
|
||||
<template id="ytc-reply-form-tpl">
|
||||
<div class="ytc-reply-form">
|
||||
<img src="{{ Auth::user()->avatar_url }}" class="ytc-avatar ytc-avatar-sm" alt="{{ Auth::user()->name }}">
|
||||
<div class="ytc-input-wrap">
|
||||
<textarea class="ytc-textarea ytc-reply-textarea" placeholder="Add a reply..." rows="1"></textarea>
|
||||
<div class="ytc-form-actions">
|
||||
<button class="ytc-btn ytc-btn-cancel _comment-cancel-reply-trigger">Cancel</button>
|
||||
<button class="ytc-btn ytc-btn-submit _comment-submit-reply-trigger">Reply</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@endauth
|
||||
@@ -205,12 +205,99 @@
|
||||
</div>{{-- /chrome-controls --}}
|
||||
</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-wrap --}}
|
||||
|
||||
|
||||
@once
|
||||
<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
|
||||
══════════════════════════════════════════════════════════ */
|
||||
@@ -220,23 +307,31 @@
|
||||
background: #000;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
/* default aspect ratio; overridden per orientation */
|
||||
/* Always 16:9 — no max-height so the shape is never distorted. */
|
||||
aspect-ratio: 16/9;
|
||||
max-height: 70vh;
|
||||
/* Establish a size container so descendants can size themselves relative
|
||||
to the player width (used by the coach-note overlay to scale). */
|
||||
container-type: inline-size;
|
||||
container-name: ytpwrap;
|
||||
}
|
||||
.ytp-wrap.portrait { aspect-ratio: 9/16; max-height: 80vh; width: auto; max-width: 100%; margin: 0 auto; }
|
||||
.ytp-wrap.square { aspect-ratio: 1/1; max-height: 75vh; max-width: 75vh; margin: 0 auto; }
|
||||
.ytp-wrap.ultrawide { aspect-ratio: 21/9; max-height: 65vh; }
|
||||
/* Orientation classes intentionally forced to 16:9 too, so the frame
|
||||
shape never changes with the underlying media orientation. The video
|
||||
element itself uses object-fit: contain and will letterbox as needed. */
|
||||
.ytp-wrap.portrait,
|
||||
.ytp-wrap.square,
|
||||
.ytp-wrap.ultrawide {
|
||||
aspect-ratio: 16/9;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Theater mode */
|
||||
/* Theater mode — keep 16:9 shape, just remove rounded corners. */
|
||||
.ytp-wrap.theater {
|
||||
max-height: 80vh;
|
||||
aspect-ratio: unset;
|
||||
height: 80vh;
|
||||
aspect-ratio: 16/9;
|
||||
height: auto;
|
||||
max-height: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
@@ -534,10 +629,23 @@
|
||||
background: rgba(28,28,28,.95);
|
||||
border-radius: 12px;
|
||||
min-width: 200px;
|
||||
overflow: hidden;
|
||||
/* Cap height so the panel stays inside the player and scrolls when the
|
||||
item list (mini/speed/autoplay/shuffle + injected Scoreboard toggles)
|
||||
grows taller than the available space above the control bar. */
|
||||
max-height: calc(100vh - 120px);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior: contain;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,.6);
|
||||
z-index: 100;
|
||||
}
|
||||
/* Slim, on-brand scrollbar so it doesn't clash with the dark panel. */
|
||||
.ytp-settings-panel::-webkit-scrollbar { width: 6px; }
|
||||
.ytp-settings-panel::-webkit-scrollbar-thumb {
|
||||
background: rgba(255,255,255,.22); border-radius: 3px;
|
||||
}
|
||||
.ytp-settings-panel::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,.35); }
|
||||
.ytp-settings-panel::-webkit-scrollbar-track { background: transparent; }
|
||||
.ytp-settings-panel.open { display: block; }
|
||||
.ytp-settings-item {
|
||||
display: flex;
|
||||
@@ -591,10 +699,10 @@
|
||||
|
||||
/* ── Mobile ── */
|
||||
@media (max-width: 768px) {
|
||||
.ytp-wrap { border-radius: 0; max-height: 56vw; margin: 0; width: 100%; }
|
||||
.ytp-wrap.portrait { max-height: 75vh; width: auto; max-width: 100%; }
|
||||
.ytp-wrap.square { max-height: 85vw; max-width: 85vw; }
|
||||
.ytp-wrap.ultrawide { max-height: 50vw; }
|
||||
.ytp-wrap { border-radius: 0; margin: 0; width: 100%; aspect-ratio: 16/9; max-height: none; }
|
||||
.ytp-wrap.portrait,
|
||||
.ytp-wrap.square,
|
||||
.ytp-wrap.ultrawide { aspect-ratio: 16/9; width: 100%; max-width: none; max-height: none; }
|
||||
.video-view-page .ytp-wrap ~ * { padding-left: 16px; padding-right: 16px; }
|
||||
}
|
||||
|
||||
@@ -1283,17 +1391,96 @@ function 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', () => {
|
||||
updatePlayIcon();
|
||||
largePlay.classList.add('visible');
|
||||
clearTimeout(hideTimer);
|
||||
releaseWakeLock();
|
||||
if (window._plOnVideoEnd) {
|
||||
window._plOnVideoEnd();
|
||||
} else if (autoplayOn || isLooping) {
|
||||
navigateNext();
|
||||
if (isLooping) { navigateNext(); return; }
|
||||
if (autoplayOn || window._plOnVideoEnd) {
|
||||
openEndScreen();
|
||||
}
|
||||
});
|
||||
// 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);
|
||||
|
||||
// ── Keyboard shortcuts ────────────────────────────────
|
||||
@@ -1318,10 +1505,39 @@ document.addEventListener('keydown', e => {
|
||||
e.preventDefault(); theaterBtn && theaterBtn.click(); break;
|
||||
case 'i': case 'I':
|
||||
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':
|
||||
e.preventDefault(); seekRelative(-10); flashDbl(dblLeft); showControls(); break;
|
||||
case 'l': case 'L':
|
||||
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':
|
||||
e.preventDefault(); seekRelative(-5); showControls(); break;
|
||||
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>
|
||||
</head>
|
||||
<body class="{{ $bodyClass ?? '' }} {{ session('impersonator_id') ? 'has-impersonate-bar' : '' }}">
|
||||
{{-- Skip link: keyboard users land here first and can jump straight to
|
||||
the main content region, past the header + sidebar nav. Hidden
|
||||
off-screen until focused. --}}
|
||||
<a href="#main" class="skip-to-content">Skip to content</a>
|
||||
<style>
|
||||
.skip-to-content {
|
||||
position: absolute; top: -40px; left: 8px;
|
||||
padding: 8px 14px; z-index: 10000;
|
||||
background: #ef4444; color: #fff; border-radius: 0 0 8px 8px;
|
||||
text-decoration: none; font-weight: 600;
|
||||
transition: top .15s ease;
|
||||
}
|
||||
.skip-to-content:focus { top: 0; outline: 2px solid #fff; outline-offset: 2px; }
|
||||
</style>
|
||||
<!-- Header -->
|
||||
@include('layouts.partials.header')
|
||||
|
||||
@@ -1237,10 +1251,11 @@
|
||||
|
||||
<!-- Mobile Search Overlay -->
|
||||
<div class="mobile-search-overlay" id="mobileSearchOverlay">
|
||||
<form action="{{ route('videos.search') }}" method="GET" class="mobile-search-form">
|
||||
<input type="text" name="q" class="mobile-search-input" placeholder="Search" value="{{ request('q') }}">
|
||||
<button type="submit" class="mobile-search-submit">
|
||||
<i class="bi bi-search"></i>
|
||||
<form action="{{ route('search') }}" method="GET" class="mobile-search-form" role="search">
|
||||
<label for="mobile-search-input" class="visually-hidden">Search</label>
|
||||
<input type="search" id="mobile-search-input" name="q" class="mobile-search-input" placeholder="Search" value="{{ request('q') }}" aria-label="Search videos, channels and playlists">
|
||||
<button type="submit" class="mobile-search-submit" aria-label="Submit search">
|
||||
<i class="bi bi-search" aria-hidden="true"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -1697,11 +1712,24 @@
|
||||
};
|
||||
|
||||
function loadNotifications() {
|
||||
// Switch the pre-rendered "No notifications" placeholder to a
|
||||
// spinner while the fetch is in flight, then let the response
|
||||
// handler replace it with the real list, an empty state, or
|
||||
// an error message. Previously the panel just said "Loading…"
|
||||
// forever if the fetch threw synchronously or returned non-JSON.
|
||||
list.innerHTML = '<div class="yt-notif-empty"><i class="bi bi-arrow-repeat spin"></i> Loading…</div>';
|
||||
fetch(fetchUrl, { credentials: 'same-origin', headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error('http ' + r.status);
|
||||
return r.json();
|
||||
})
|
||||
.then(function (data) { loaded = true; renderNotifications(data); })
|
||||
.catch(function () { list.innerHTML = '<div class="yt-notif-empty">Could not load notifications.</div>'; });
|
||||
.catch(function () {
|
||||
list.innerHTML = '<div class="yt-notif-empty">Could not load notifications. <button type="button" class="yt-notif-retry" onclick="loadNotifications()">Retry</button></div>';
|
||||
});
|
||||
}
|
||||
// Expose for the inline Retry button above.
|
||||
window.loadNotifications = loadNotifications;
|
||||
|
||||
window.toggleNotifPanel = function () {
|
||||
var btn = document.getElementById('notifBtn');
|
||||
|
||||
@@ -178,22 +178,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Secondary track form template (cloned by JS for each extra track) ──── --}}
|
||||
<div id="edit-extra-track-tpl" style="display:none;" aria-hidden="true">
|
||||
<x-track-editor-form
|
||||
prefix="__TPL__"
|
||||
:is-primary="false"
|
||||
language-name="__LANGNAME__"
|
||||
language-id="csd_v___TPL__"
|
||||
title-name="__TITLENAME__"
|
||||
title-id="edit-__TPL__-title"
|
||||
desc-name="__DESCNAME__"
|
||||
desc-id="edit-__TPL__-desc"
|
||||
:embed-audio-input="true"
|
||||
audio-input-name="__AUDIONAME__"
|
||||
video-file-input-id=""
|
||||
/>
|
||||
</div>
|
||||
{{-- Secondary tracks in this modal are cloned from the SHARED
|
||||
#um-track-form-template defined in layouts.partials.upload-modal. One
|
||||
template → identical UX in both the create (upload) modal and this modal. --}}
|
||||
|
||||
<style>
|
||||
#editVideoModal .modal-dialog { opacity: 0; transition: opacity .25s ease; }
|
||||
@@ -720,6 +707,8 @@ function editSlidesZoneDrop(e, tid) { tid=tid||'t1'; e.preventDefault(); doc
|
||||
function editHandleSlides(fileList, tid) {
|
||||
tid = tid || 't1';
|
||||
_editSlidesCropStart(fileList, tid, _editRenderSlides);
|
||||
const inp = document.getElementById('edit-' + tid + '-slides-input');
|
||||
if (inp) inp.value = '';
|
||||
}
|
||||
|
||||
// ── Slides crop queue: every added image is cropped before it enters the strip ──
|
||||
@@ -740,8 +729,12 @@ function _editSlidesCropLoadNext() {
|
||||
if (!_editSlidesCropQueue.length) { window.closeCropperModal('slides_edit'); return; }
|
||||
const f = _editSlidesCropQueue.shift();
|
||||
if (typeof window.tcPreload_slides_edit === 'function') {
|
||||
window.tcPreload_slides_edit(f);
|
||||
// Open modal first so the cropper canvas has a real layout box when
|
||||
// initCropper measures. Preloading first (prior order) caused the
|
||||
// second batch to open with stale zero-sized measurements and a Save
|
||||
// button that silently did nothing.
|
||||
window.openCropperModal_slides_edit();
|
||||
requestAnimationFrame(() => window.tcPreload_slides_edit(f));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -827,6 +820,8 @@ function editSlidesZoneDropE(e, tid) { e.preventDefault(); document.getEleme
|
||||
|
||||
function editHandleSlidesForTrack(tid, fileList) {
|
||||
_editSlidesCropStart(fileList, tid, _editRenderSlidesForTrack);
|
||||
const inp = document.getElementById('edit-slides-input-' + tid);
|
||||
if (inp) inp.value = '';
|
||||
}
|
||||
|
||||
function editClearSlidesForTrack(e, tid) {
|
||||
@@ -885,7 +880,9 @@ function _editRenderSlidesForTrack(tid) {
|
||||
// ── Track popup ───────────────────────────────────────────────────────────────
|
||||
function editOpenTrackPopup(trackId) {
|
||||
document.querySelectorAll('#edit-track-popup .um-track-form').forEach(f => f.style.display = 'none');
|
||||
const form = document.getElementById('edit-tf-' + trackId);
|
||||
// Primary track form uses the legacy id (edit-tf-t1); extra tracks are
|
||||
// cloned from the SHARED #um-track-form-template and use id um-tf-{id}.
|
||||
const form = document.getElementById('edit-tf-' + trackId) || document.getElementById('um-tf-' + trackId);
|
||||
if (form) form.style.display = 'block';
|
||||
const popup = document.getElementById('edit-track-popup');
|
||||
popup.style.opacity = '0';
|
||||
@@ -902,6 +899,10 @@ function editCloseTrackPopup() {
|
||||
}
|
||||
}
|
||||
|
||||
// For t1 the inputs are the primary-track Blade ids. For extra tracks (both
|
||||
// newly-added and existing-track rows) the inputs come from the SHARED
|
||||
// #um-track-form-template, so they use the shared ids track-lang-{id} /
|
||||
// track-title-{id}. One function reads whichever is present.
|
||||
function editUpdateTrackCard(trackId) {
|
||||
let langCode = '', title = '';
|
||||
if (trackId === 't1') {
|
||||
@@ -910,9 +911,8 @@ function editUpdateTrackCard(trackId) {
|
||||
langCode = li ? li.value : '';
|
||||
title = ti ? ti.value.trim() : '';
|
||||
} else {
|
||||
const n = trackId.replace('e','');
|
||||
const li = document.getElementById('csd_v_e' + n);
|
||||
const ti = document.getElementById('edit-e' + n + '-title');
|
||||
const li = document.getElementById('track-lang-' + trackId);
|
||||
const ti = document.getElementById('track-title-' + trackId);
|
||||
langCode = li ? li.value : '';
|
||||
title = ti ? ti.value.trim() : '';
|
||||
}
|
||||
@@ -932,24 +932,26 @@ function editUpdateTrackCard(trackId) {
|
||||
// ── Extra track management ────────────────────────────────────────────────────
|
||||
const LANG_OPTIONS_EDIT_MODAL = @json(\App\Data\Languages::forLanguage());
|
||||
|
||||
// Clone the SHARED upload-modal track-form partial into the edit popup. Both
|
||||
// "add new track" and "load existing track" go through here, so there is one
|
||||
// source of truth for the form markup. The caller picks the submission names:
|
||||
// - new track → extra_track_*[]
|
||||
// - existing track → track_*_updates[trackId]
|
||||
function _editCloneTrackForm(prefix, langName, titleName, descName, audioName) {
|
||||
const tpl = document.getElementById('edit-extra-track-tpl');
|
||||
if (!tpl) return null;
|
||||
const html = tpl.innerHTML
|
||||
.replaceAll('__TPL__', prefix)
|
||||
.replaceAll('__LANGNAME__', langName)
|
||||
.replaceAll('__TITLENAME__', titleName)
|
||||
.replaceAll('__DESCNAME__', descName)
|
||||
.replaceAll('__AUDIONAME__', audioName);
|
||||
const tmp = document.createElement('div');
|
||||
tmp.innerHTML = html;
|
||||
const formEl = tmp.querySelector('.um-track-form');
|
||||
if (!formEl) return null;
|
||||
formEl.style.display = 'none';
|
||||
document.getElementById('edit-tf-extra').appendChild(formEl);
|
||||
_editSlidesData[prefix] = [];
|
||||
if (window.CSD) new CSD('csd_' + prefix);
|
||||
return formEl;
|
||||
const frag = window.cloneTrackFormTemplate({
|
||||
key: prefix, langName, titleName, descName, audioName,
|
||||
});
|
||||
if (!frag) return null;
|
||||
const host = document.getElementById('edit-tf-extra');
|
||||
host.appendChild(frag);
|
||||
// _slidesData is the shared state used by the shared slides-zone handlers.
|
||||
// Mirror the handle under _editSlidesData so the edit submit code stays
|
||||
// oblivious to the migration (one array, two aliases).
|
||||
window._slidesData = window._slidesData || {};
|
||||
_slidesData[prefix] = [];
|
||||
_editSlidesData[prefix] = _slidesData[prefix];
|
||||
window.initTrackFormNode(prefix, pfx => editUpdateTrackCard(pfx));
|
||||
return document.getElementById('um-tf-' + prefix);
|
||||
}
|
||||
|
||||
function _editTrackCard(n, trackId, isExisting) {
|
||||
@@ -990,34 +992,47 @@ function _editAddExistingTrack(track) {
|
||||
'track_description_updates[' + track.id + ']',
|
||||
'track_file_updates[' + track.id + ']'
|
||||
);
|
||||
const titleEl = document.getElementById('edit-' + pfx + '-title');
|
||||
// Shared partial field ids: track-title-{pfx} / track-desc-{pfx} / track-lang-{pfx}
|
||||
const titleEl = document.getElementById('track-title-' + pfx);
|
||||
if (titleEl) titleEl.value = track.title || '';
|
||||
const descEl = document.getElementById('edit-' + pfx + '-desc');
|
||||
const descEl = document.getElementById('track-desc-' + pfx);
|
||||
if (descEl) { descEl.value = track.description || ''; descEl.dispatchEvent(new Event('rte:refresh')); }
|
||||
if (track.language && window.CSD) {
|
||||
const hi = document.getElementById('csd_v_' + pfx);
|
||||
const wrap = document.getElementById('csd_' + pfx);
|
||||
const hi = document.getElementById('track-lang-' + pfx);
|
||||
const wrap = document.getElementById('lsd_track_' + pfx);
|
||||
if (hi) hi.value = track.language;
|
||||
if (wrap) _editSetLangCsd(wrap, track.language);
|
||||
}
|
||||
const hi = document.getElementById('csd_v_' + pfx);
|
||||
if (hi) hi.addEventListener('change', () => editUpdateTrackCard(pfx));
|
||||
editUpdateTrackCard(pfx);
|
||||
_editUpdateTrackPositions();
|
||||
}
|
||||
|
||||
function editDeleteTrack(trackId, n) {
|
||||
_editDeleteTrackIds.push(trackId);
|
||||
document.getElementById('edit-tc-e'+n)?.remove();
|
||||
document.getElementById('edit-tf-e'+n)?.remove();
|
||||
function _editRemoveTrackForms(n) {
|
||||
const pfx = 'e' + n;
|
||||
document.getElementById('edit-tc-' + pfx)?.remove();
|
||||
// Form id depends on which cloner built it: shared template → um-tf-{pfx},
|
||||
// legacy edit template → edit-tf-{pfx}. Clean up whichever exists.
|
||||
document.getElementById('um-tf-' + pfx)?.remove();
|
||||
document.getElementById('edit-tf-' + pfx)?.remove();
|
||||
delete _editSlidesData[pfx];
|
||||
delete (window._slidesData || {})[pfx];
|
||||
const popup = document.getElementById('edit-track-popup');
|
||||
if (popup.style.display !== 'none') {
|
||||
const vis = popup.querySelector('.um-track-form:not([style*="none"])');
|
||||
if (!vis || vis.id === 'edit-tf-e'+n) popup.style.display = 'none';
|
||||
if (!vis || vis.id === 'edit-tf-' + pfx || vis.id === 'um-tf-' + pfx) popup.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function editDeleteTrack(trackId, n) {
|
||||
_editDeleteTrackIds.push(trackId);
|
||||
_editRemoveTrackForms(n);
|
||||
_editUpdateTrackPositions();
|
||||
}
|
||||
|
||||
function editRemoveNewTrack(n) {
|
||||
_editRemoveTrackForms(n);
|
||||
}
|
||||
|
||||
function editAddExtraTrack() {
|
||||
const n = ++_editExtraCount;
|
||||
const pfx = 'e' + n;
|
||||
@@ -1029,23 +1044,10 @@ function editAddExtraTrack() {
|
||||
'extra_track_descriptions[]',
|
||||
'extra_track_files[]'
|
||||
);
|
||||
const hi = document.getElementById('csd_v_' + pfx);
|
||||
if (hi) hi.addEventListener('change', () => editUpdateTrackCard(pfx));
|
||||
_editUpdateTrackPositions();
|
||||
editOpenTrackPopup(pfx);
|
||||
}
|
||||
|
||||
function editRemoveNewTrack(n) {
|
||||
document.getElementById('edit-tc-e'+n)?.remove();
|
||||
document.getElementById('edit-tf-e'+n)?.remove();
|
||||
delete _editSlidesData['e'+n];
|
||||
const popup = document.getElementById('edit-track-popup');
|
||||
if (popup.style.display !== 'none') {
|
||||
const vis = popup.querySelector('.um-track-form:not([style*="none"])');
|
||||
if (!vis || vis.id === 'edit-tf-e'+n) popup.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Wire primary language → update card
|
||||
;(function() {
|
||||
const li = document.getElementById('edit_primary_language');
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
<!-- Header -->
|
||||
<header class="yt-header">
|
||||
<div class="yt-header-left">
|
||||
<button class="yt-menu-btn" onclick="toggleSidebar()">
|
||||
<i class="bi bi-list fs-5"></i>
|
||||
<button class="yt-menu-btn" onclick="toggleSidebar()" aria-label="Toggle sidebar menu" aria-controls="sidebar">
|
||||
<i class="bi bi-list fs-5" aria-hidden="true"></i>
|
||||
</button>
|
||||
<a href="{{ route('home') }}" class="yt-logo">
|
||||
{{-- Two responsive logos. The visible one at a given viewport
|
||||
carries the app name; its sibling is display:none and gets
|
||||
alt="" so a screen reader doesn't announce "Play Play". --}}
|
||||
<img src="{{ asset('storage/images/logo.png') }}" alt="{{ config('app.name') }}" class="d-md-none" style="height:30px;">
|
||||
<img src="{{ asset('storage/images/fullLogo.png') }}" alt="{{ config('app.name') }}" class="d-none d-md-block" style="height:30px;">
|
||||
<img src="{{ asset('storage/images/fullLogo.png') }}" alt="" class="d-none d-md-block" style="height:30px;">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="yt-header-center d-none d-md-flex">
|
||||
<div class="yt-search">
|
||||
<form action="{{ route('videos.search') }}" method="GET" class="yt-search-form">
|
||||
<input type="text" name="q" class="yt-search-input" placeholder="Search" value="{{ request('q') }}">
|
||||
<button type="submit" class="yt-search-btn"><i class="bi bi-search"></i></button>
|
||||
<form action="{{ route('search') }}" method="GET" class="yt-search-form" role="search">
|
||||
<label for="header-search-input" class="visually-hidden">Search</label>
|
||||
<input type="search" id="header-search-input" name="q" class="yt-search-input" placeholder="Search" value="{{ request('q') }}" aria-label="Search videos, channels and playlists">
|
||||
<button type="submit" class="yt-search-btn" aria-label="Submit search"><i class="bi bi-search" aria-hidden="true"></i></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="yt-header-right">
|
||||
<!-- Mobile search -->
|
||||
<button type="button" class="yt-icon-btn d-md-none" onclick="toggleMobileSearch()">
|
||||
<i class="bi bi-search"></i>
|
||||
<button type="button" class="yt-icon-btn d-md-none" onclick="toggleMobileSearch()" aria-label="Open search">
|
||||
<i class="bi bi-search" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
@auth
|
||||
@@ -48,7 +52,7 @@
|
||||
onclick="markAllRead()" style="display:none;">Mark all read</button>
|
||||
</div>
|
||||
<div class="yt-notif-list" id="notifList">
|
||||
<div class="yt-notif-empty">Loading…</div>
|
||||
<div class="yt-notif-empty" id="notifInitialState">No notifications</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,9 +60,10 @@
|
||||
<!-- User dropdown -->
|
||||
<div class="yt-user-dropdown" id="headerUserDropdown">
|
||||
<button class="yt-icon-btn" id="headerUserBtn"
|
||||
onclick="toggleHeaderDropdown()" aria-expanded="false" aria-haspopup="true">
|
||||
onclick="toggleHeaderDropdown()" aria-expanded="false" aria-haspopup="true"
|
||||
aria-label="Account menu for {{ Auth::user()->name }}">
|
||||
<img src="{{ Auth::user()->avatar_url }}"
|
||||
class="yt-user-avatar" alt="{{ Auth::user()->name }}">
|
||||
class="yt-user-avatar" alt="">
|
||||
</button>
|
||||
|
||||
<div class="yt-user-panel" id="headerUserPanel" role="menu">
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
<span>Trending</span>
|
||||
</a>
|
||||
@auth
|
||||
<a href="#" class="yt-sidebar-link">
|
||||
<a href="{{ route('subscriptions.feed') }}"
|
||||
class="yt-sidebar-link {{ request()->routeIs('subscriptions.feed') ? 'active' : '' }}">
|
||||
<i class="bi bi-collection-play-fill"></i>
|
||||
<span>Subscriptions</span>
|
||||
</a>
|
||||
@@ -56,6 +57,10 @@
|
||||
<i class="bi bi-hand-thumbs-up-fill"></i>
|
||||
<span>Liked Videos</span>
|
||||
</a>
|
||||
<a href="{{ route('studio') }}" class="yt-sidebar-link {{ request()->is('studio*') ? 'active' : '' }}">
|
||||
<i class="bi bi-sliders"></i>
|
||||
<span>Studio</span>
|
||||
</a>
|
||||
</div>
|
||||
@endauth
|
||||
|
||||
|
||||
@@ -879,8 +879,12 @@
|
||||
clearErrors();
|
||||
lockButtons(true);
|
||||
|
||||
// Edit mode → just update the record. Create mode → upload the video first.
|
||||
const chain = idInput.value ? Promise.resolve() : uploadVideoFirst();
|
||||
// Skip the video-upload step when:
|
||||
// - editing an existing match (matchId present), OR
|
||||
// - creating a match against an already-attached video (videoId present via
|
||||
// attachExistingVideo, e.g. clicking "Edit" on a match-type video that has
|
||||
// no SportsMatch record yet — the video is real, we're just adding match data).
|
||||
const chain = (idInput.value || videoIdInp.value) ? Promise.resolve() : uploadVideoFirst();
|
||||
chain
|
||||
.then(() => postMatch(intent))
|
||||
.catch(e => { if (e && e.message !== 'handled') toast((e && e.message) || 'Save failed', 'error'); })
|
||||
|
||||
@@ -338,9 +338,31 @@
|
||||
</div>
|
||||
</div>{{-- /um-tf-t1 --}}
|
||||
|
||||
{{-- Extra track forms appended by JS --}}
|
||||
{{-- Extra track forms appended by JS (cloned from the template below) --}}
|
||||
<div id="um-tf-extra"></div>
|
||||
|
||||
{{-- SHARED track form template.
|
||||
This is the single source of truth for the "Add Language Track"
|
||||
dialog used by BOTH the create (upload) modal AND the edit modal.
|
||||
Any CSS/markup/behaviour change here automatically applies to both.
|
||||
Cloners replace the placeholders at clone time:
|
||||
__N__ → track key (e.g. "e1" for create, "editE1" for edit)
|
||||
__LANGNAME__ → form field name for the language select
|
||||
__TITLENAME__ → form field name for the title input
|
||||
__DESCNAME__ → form field name for the description textarea
|
||||
__AUDIONAME__ → form field name for the audio file input --}}
|
||||
<template id="um-track-form-template">
|
||||
@include('layouts.partials.upload-modal.track-form', [
|
||||
'key' => '__N__',
|
||||
'showPrimaryNote' => false,
|
||||
'langName' => '__LANGNAME__',
|
||||
'titleName' => '__TITLENAME__',
|
||||
'descName' => '__DESCNAME__',
|
||||
'audioName' => '__AUDIONAME__',
|
||||
'langRequired' => true,
|
||||
])
|
||||
</template>
|
||||
|
||||
</div>{{-- /um-tp-body --}}
|
||||
<div class="um-tp-footer">
|
||||
<button type="button" class="action-btn action-btn-primary" onclick="closeTrackPopup()">
|
||||
@@ -1372,6 +1394,10 @@ function slidesZoneDrop(e, tid) {
|
||||
|
||||
function handleSlidesForTrack(tid, fileList) {
|
||||
_slidesCropStart(fileList, tid);
|
||||
// Reset the file input so picking the SAME filename a second time still
|
||||
// fires change (browsers suppress change when FileList is identical).
|
||||
const inp = document.getElementById('slides-input-' + tid);
|
||||
if (inp) inp.value = '';
|
||||
}
|
||||
|
||||
// ── Slides crop queue: every added image is cropped before it enters the strip ──
|
||||
@@ -1390,8 +1416,12 @@ function _slidesCropLoadNext() {
|
||||
if (!_slidesCropQueue.length) { window.closeCropperModal('slides_upload'); return; }
|
||||
const f = _slidesCropQueue.shift();
|
||||
if (typeof window.tcPreload_slides_upload === 'function') {
|
||||
window.tcPreload_slides_upload(f);
|
||||
// Open the modal FIRST so the canvas has a non-zero layout box when
|
||||
// initCropper measures clientWidth/clientHeight. Preloading first
|
||||
// (the previous order) caused the second batch to open with stale
|
||||
// zero-sized measurements and a Save button that silently did nothing.
|
||||
window.openCropperModal_slides_upload();
|
||||
requestAnimationFrame(() => window.tcPreload_slides_upload(f));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1400,7 +1430,13 @@ function uploadSlidesCropDone(file) {
|
||||
if (tid && file) {
|
||||
if (!_slidesData[tid]) _slidesData[tid] = [];
|
||||
if (_slidesData[tid].length < 10) {
|
||||
_slidesData[tid].push(file);
|
||||
// Cache a per-file object URL once so renderSlidesStrip can reuse it
|
||||
// on every reorder without spawning a fresh FileReader per slide.
|
||||
// The old approach (new FileReader per file per render) ran dozens
|
||||
// of readers after a reorder and silently dropped some under memory
|
||||
// pressure, leaving the strip with broken thumbnails.
|
||||
const url = URL.createObjectURL(file);
|
||||
_slidesData[tid].push({ file, url });
|
||||
renderSlidesStrip(tid);
|
||||
}
|
||||
}
|
||||
@@ -1422,7 +1458,10 @@ function renderSlidesStrip(tid) {
|
||||
if (zone) zone.style.borderColor = '';
|
||||
return;
|
||||
}
|
||||
files.forEach((f, index) => {
|
||||
files.forEach((item, index) => {
|
||||
// Entries are {file, url}; cached url is stable across reorders.
|
||||
const url = item.url || (item.file ? URL.createObjectURL(item.file) : '');
|
||||
if (item.file && !item.url) item.url = url;
|
||||
const wrap = document.createElement('div');
|
||||
wrap.style.cssText = 'position:relative;flex-shrink:0;cursor:grab;';
|
||||
wrap.draggable = true; wrap.dataset.index = index; wrap.dataset.trackId = tid;
|
||||
@@ -1441,10 +1480,15 @@ function renderSlidesStrip(tid) {
|
||||
const rb = document.createElement('button');
|
||||
rb.type='button'; rb.style.cssText='position:absolute;top:-4px;right:-4px;width:16px;height:16px;background:#e61e1e;border:none;border-radius:50%;color:#fff;font-size:8px;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;z-index:1;';
|
||||
rb.innerHTML='<i class="bi bi-x"></i>';
|
||||
rb.addEventListener('click', function(ev) { ev.stopPropagation(); _slidesData[tid].splice(index,1); renderSlidesStrip(tid); });
|
||||
rb.addEventListener('click', function(ev) {
|
||||
ev.stopPropagation();
|
||||
const removed = _slidesData[tid].splice(index,1)[0];
|
||||
if (removed && removed.url) URL.revokeObjectURL(removed.url);
|
||||
renderSlidesStrip(tid);
|
||||
});
|
||||
const img = document.createElement('img');
|
||||
img.style.cssText='width:52px;height:52px;object-fit:cover;border-radius:6px;border:1px solid rgba(255,255,255,.12);display:block;'; img.alt='';
|
||||
const reader = new FileReader(); reader.onload = ev2 => { img.src=ev2.target.result; }; reader.readAsDataURL(f);
|
||||
img.src = url;
|
||||
wrap.appendChild(img); wrap.appendChild(rb); strip.appendChild(wrap);
|
||||
});
|
||||
if (cnt) cnt.textContent = files.length===1 ? '1 image — static cover' : files.length+' images — will crossfade during playback';
|
||||
@@ -1455,6 +1499,7 @@ function renderSlidesStrip(tid) {
|
||||
|
||||
function clearSlidesForTrack(e, tid) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
(_slidesData[tid] || []).forEach(s => { if (s && s.url) URL.revokeObjectURL(s.url); });
|
||||
_slidesData[tid] = [];
|
||||
const input = document.getElementById('slides-input-' + tid);
|
||||
if (input) { const fresh=input.cloneNode(false); fresh.addEventListener('change',function(){handleSlidesForTrack(tid,this.files);}); input.parentNode.replaceChild(fresh,input); }
|
||||
@@ -1494,10 +1539,8 @@ function updateTrackCard(trackId) {
|
||||
langCode = li ? li.value : '';
|
||||
title = ti ? ti.value.trim() : '';
|
||||
} else {
|
||||
const n = trackId.replace('e', '');
|
||||
const uid = 'ltme_' + n;
|
||||
const li = document.getElementById('csd_v_' + uid);
|
||||
const ti = document.getElementById('um-tf-title-' + trackId);
|
||||
const li = document.getElementById('track-lang-' + trackId);
|
||||
const ti = document.getElementById('track-title-' + trackId);
|
||||
langCode = li ? li.value : '';
|
||||
title = ti ? ti.value.trim() : '';
|
||||
}
|
||||
@@ -1512,49 +1555,111 @@ function updateTrackCard(trackId) {
|
||||
if (card) card.style.display = hasContent ? '' : 'none';
|
||||
}
|
||||
|
||||
const flagEl = document.getElementById('um-tc-flag-' + trackId);
|
||||
const titleEl = document.getElementById('um-tc-title-' + trackId);
|
||||
if (flagEl) flagEl.className = 'fi fi-' + (lang ? lang.flag : 'xx') + ' um-tc-flag';
|
||||
if (titleEl) {
|
||||
if (lang && title) titleEl.textContent = title + ' — ' + lang.label;
|
||||
else if (lang) titleEl.textContent = lang.label;
|
||||
else if (title) titleEl.textContent = title;
|
||||
else titleEl.textContent = '';
|
||||
}
|
||||
// Update WHICHEVER card id convention exists — the create modal uses
|
||||
// `um-tc-*-${id}` and the edit modal uses `edit-tc-*-${id}`. Writing to
|
||||
// both keeps this helper a single source of truth for any shared template.
|
||||
const flagEls = [
|
||||
document.getElementById('um-tc-flag-' + trackId),
|
||||
document.getElementById('edit-tc-flag-' + trackId),
|
||||
];
|
||||
const titleEls = [
|
||||
document.getElementById('um-tc-title-' + trackId),
|
||||
document.getElementById('edit-tc-title-' + trackId),
|
||||
];
|
||||
const flagClass = 'fi fi-' + (lang ? lang.flag : 'xx') + ' um-tc-flag';
|
||||
let display;
|
||||
if (lang && title) display = title + ' — ' + lang.label;
|
||||
else if (lang) display = lang.label;
|
||||
else if (title) display = title;
|
||||
else display = '';
|
||||
flagEls.forEach(el => { if (el) el.className = flagClass; });
|
||||
titleEls.forEach(el => { if (el) el.textContent = display; });
|
||||
}
|
||||
|
||||
// ── Extra track management ────────────────────────────────────
|
||||
const LANG_OPTIONS_MODAL = @json(\App\Data\Languages::forLanguage());
|
||||
|
||||
function _ltBuildOptsHtml() {
|
||||
return LANG_OPTIONS_MODAL.map(o =>
|
||||
`<li class="csd-opt" role="option" tabindex="-1" data-v="${o.value}" data-s="${o.search}" aria-selected="false">
|
||||
<span class="csd-opt-ico"><span class="fi fi-${o.flag} lsd-flag"></span></span>
|
||||
<span class="csd-opt-main">${o.label}</span>
|
||||
<span class="csd-opt-sub">${o.native}</span>
|
||||
</li>`
|
||||
).join('');
|
||||
// Audio picker handler shared by every track form (primary + extras) cloned
|
||||
// from the <template id="um-track-form-template"> blade partial.
|
||||
function handleTrackAudio(key, input) {
|
||||
const file = input.files && input.files[0];
|
||||
const fnameEl = document.getElementById('track-audio-fname-' + key);
|
||||
const box = document.getElementById('track-audio-box-' + key);
|
||||
if (fnameEl) fnameEl.textContent = file ? file.name : 'Click to choose file…';
|
||||
if (box) box.style.borderColor = file ? '#22c55e' : '';
|
||||
|
||||
// Auto-fill the track title from the filename (same behavior as primary track)
|
||||
if (file) {
|
||||
const titleEl = document.getElementById('track-title-' + key);
|
||||
if (titleEl && !titleEl.value) {
|
||||
titleEl.value = file.name.replace(/\.[^/.]+$/, '').replace(/[-_]/g, ' ');
|
||||
updateTrackCard(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shared cloner for #um-track-form-template. Both the create modal and the
|
||||
// edit modal call this so there is ONE source for the track-form markup.
|
||||
// Returns a DocumentFragment with every placeholder (__N__, __LANGNAME__, …)
|
||||
// substituted. The caller is responsible for appending it + initialising the
|
||||
// language picker + RTE on the new nodes.
|
||||
window.cloneTrackFormTemplate = window.cloneTrackFormTemplate || function (opts) {
|
||||
const tpl = document.getElementById('um-track-form-template');
|
||||
if (!tpl) { console.error('[track-form] missing #um-track-form-template'); return null; }
|
||||
const frag = tpl.content.cloneNode(true);
|
||||
const subs = {
|
||||
'__N__': opts.key,
|
||||
'__LANGNAME__': opts.langName,
|
||||
'__TITLENAME__': opts.titleName,
|
||||
'__DESCNAME__': opts.descName,
|
||||
'__AUDIONAME__': opts.audioName,
|
||||
};
|
||||
const walker = document.createTreeWalker(frag, NodeFilter.SHOW_ELEMENT, null);
|
||||
const nodes = [];
|
||||
for (let el = walker.nextNode(); el; el = walker.nextNode()) nodes.push(el);
|
||||
nodes.forEach(node => {
|
||||
for (const attr of Array.from(node.attributes)) {
|
||||
let v = attr.value;
|
||||
for (const k in subs) if (v.indexOf(k) !== -1) v = v.split(k).join(subs[k]);
|
||||
if (v !== attr.value) node.setAttribute(attr.name, v);
|
||||
}
|
||||
});
|
||||
return frag;
|
||||
};
|
||||
|
||||
// Shared post-clone wiring: CSD language picker + RTE on fresh markup, plus
|
||||
// change listeners that keep whichever card is on-screen in sync with the form.
|
||||
window.initTrackFormNode = window.initTrackFormNode || function (key, onChange) {
|
||||
if (window.CSD) new CSD('lsd_track_' + key);
|
||||
const rteWrap = document.querySelector('#um-tf-' + key + ' .rte-wrap:not([data-rte-ready])');
|
||||
if (rteWrap && window.RTE && typeof window.RTE.init === 'function') window.RTE.init(rteWrap);
|
||||
const langInput = document.getElementById('track-lang-' + key);
|
||||
const titleInput = document.getElementById('track-title-' + key);
|
||||
const cb = onChange || (() => updateTrackCard(key));
|
||||
if (langInput) langInput.addEventListener('change', () => cb(key));
|
||||
if (titleInput) titleInput.addEventListener('input', () => cb(key));
|
||||
};
|
||||
|
||||
function addExtraTrackModal() {
|
||||
const n = ++_umExtraCount;
|
||||
const trackNum = n + 1;
|
||||
const uid = 'ltme_' + n;
|
||||
const key = 'e' + n;
|
||||
|
||||
// Track card (shown in the list on the main modal body)
|
||||
const card = document.createElement('div');
|
||||
card.className = 'um-track-card';
|
||||
card.id = 'um-tc-e' + n;
|
||||
card.id = 'um-tc-' + key;
|
||||
card.innerHTML = `
|
||||
<div class="um-tc-body">
|
||||
<div class="um-tc-left">
|
||||
<div class="um-tc-num">${trackNum}</div>
|
||||
<span class="fi fi-xx um-tc-flag" id="um-tc-flag-e${n}"></span>
|
||||
<span class="fi fi-xx um-tc-flag" id="um-tc-flag-${key}"></span>
|
||||
<div class="um-tc-info">
|
||||
<span class="um-tc-title" id="um-tc-title-e${n}">Track — select language</span>
|
||||
<span class="um-tc-title" id="um-tc-title-${key}">Track — select language</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="um-tc-right">
|
||||
<button type="button" class="action-btn" onclick="openTrackPopup('e${n}')">
|
||||
<button type="button" class="action-btn" onclick="openTrackPopup('${key}')">
|
||||
<i class="bi bi-pencil"></i> <span>Edit</span>
|
||||
</button>
|
||||
<button type="button" class="action-btn action-btn-danger icon-only" onclick="removeExtraTrackModal(${n})" title="Remove track">
|
||||
@@ -1564,100 +1669,24 @@ function addExtraTrackModal() {
|
||||
</div>`;
|
||||
document.getElementById('um-tc-extra').appendChild(card);
|
||||
|
||||
const formEl = document.createElement('div');
|
||||
formEl.className = 'um-track-form';
|
||||
formEl.id = 'um-tf-e' + n;
|
||||
formEl.style.display = 'none';
|
||||
formEl.innerHTML = `
|
||||
<div class="um-tf-row2" style="margin-bottom:14px;">
|
||||
<div>
|
||||
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">Language <span class="um-req">*</span></label>
|
||||
<div class="csd-wrap" id="${uid}">
|
||||
<button type="button" class="csd-btn" aria-haspopup="listbox" aria-expanded="false" aria-label="Select language">
|
||||
<span class="csd-ico"><span class="fi fi-xx lsd-flag"></span></span>
|
||||
<span class="csd-val ph">Select language</span>
|
||||
<i class="bi bi-chevron-down csd-arr"></i>
|
||||
</button>
|
||||
<div class="csd-panel" hidden role="listbox">
|
||||
<div class="csd-srch"><i class="bi bi-search"></i>
|
||||
<input class="csd-sinput" type="text" placeholder="Search language…" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<ul class="csd-list">${_ltBuildOptsHtml()}</ul>
|
||||
<p class="csd-empty" hidden>No results</p>
|
||||
</div>
|
||||
<input type="hidden" name="extra_track_languages[]" id="csd_v_${uid}">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">
|
||||
Title <span class="um-lbl-hint">optional</span>
|
||||
</label>
|
||||
<input type="text" name="extra_track_titles[]" id="um-tf-title-e${n}" class="um-input"
|
||||
style="font-size:13px;padding:9px 12px;" placeholder="Title in this language…">
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom:14px;">
|
||||
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">
|
||||
Description <span class="um-lbl-hint">optional</span>
|
||||
</label>
|
||||
<div class="rte-wrap" data-min-height="100px">
|
||||
<textarea class="rte-source" name="extra_track_descriptions[]" id="um-tf-desc-e${n}"
|
||||
placeholder="Description in this language…"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom:14px;">
|
||||
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">
|
||||
<i class="bi bi-music-note-beamed"></i> Audio File
|
||||
</label>
|
||||
<label class="lta-file-label"
|
||||
onmouseenter="this.style.borderColor='#e61e1e'"
|
||||
onmouseleave="this.style.borderColor='#222'">
|
||||
<i class="bi bi-music-note-beamed" style="color:#e61e1e;font-size:15px;flex-shrink:0;"></i>
|
||||
<span class="lta-file-name" id="um-tf-fname-e${n}">Choose audio file…</span>
|
||||
<input type="file" name="extra_track_files[]"
|
||||
accept="audio/*,.mp3,.m4a,.aac,.flac,.wav" style="display:none;"
|
||||
onchange="document.getElementById('um-tf-fname-e${n}').textContent=this.files[0]?.name||'Choose audio file…'">
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">
|
||||
Cover Slides <span class="um-lbl-hint">optional · drag to reorder</span>
|
||||
</label>
|
||||
<div id="slides-zone-e${n}" class="um-slides-zone"
|
||||
onclick="slidesZoneClick(event,'e${n}')"
|
||||
ondragover="slidesZoneDragover(event,'e${n}')"
|
||||
ondragleave="slidesZoneDragleave(event,'e${n}')"
|
||||
ondrop="slidesZoneDrop(event,'e${n}')">
|
||||
<input type="file" accept="image/*" multiple style="display:none" id="slides-input-e${n}"
|
||||
onchange="handleSlidesForTrack('e${n}',this.files)">
|
||||
<div class="um-slides-ph" id="slides-ph-e${n}">
|
||||
<i class="bi bi-images"></i>
|
||||
<span>Click or drag to add cover images</span>
|
||||
</div>
|
||||
<div id="slides-preview-e${n}" style="display:none;padding:10px 12px;width:100%;box-sizing:border-box;">
|
||||
<div id="slides-strip-e${n}" class="slides-strip" style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px;"></div>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<span id="slides-count-e${n}" style="font-size:12px;color:var(--text-secondary);"></span>
|
||||
<button type="button" onclick="clearSlidesForTrack(event,'e${n}')"
|
||||
style="background:none;border:none;color:var(--text-secondary);cursor:pointer;font-size:12px;padding:0;display:flex;align-items:center;gap:4px;">
|
||||
<i class="bi bi-x-lg"></i> Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
// Clone the SHARED track-form template and substitute placeholders. The same
|
||||
// template is cloned by the edit modal (editAddExtraTrack / existing-track
|
||||
// loader), so a change here automatically shows up in both flows.
|
||||
const frag = window.cloneTrackFormTemplate({
|
||||
key: key,
|
||||
langName: 'extra_track_languages[]',
|
||||
titleName: 'extra_track_titles[]',
|
||||
descName: 'extra_track_descriptions[]',
|
||||
audioName: 'extra_track_files[]',
|
||||
});
|
||||
if (!frag) return;
|
||||
|
||||
document.getElementById('um-tf-extra').appendChild(formEl);
|
||||
_slidesData['e' + n] = [];
|
||||
const host = document.getElementById('um-tf-extra');
|
||||
host.appendChild(frag);
|
||||
_slidesData[key] = [];
|
||||
|
||||
if (window.CSD) new CSD(uid);
|
||||
|
||||
const hiddenInput = document.getElementById('csd_v_' + uid);
|
||||
if (hiddenInput) {
|
||||
hiddenInput.addEventListener('change', function() { updateTrackCard('e' + n); });
|
||||
}
|
||||
|
||||
openTrackPopup('e' + n);
|
||||
window.initTrackFormNode(key);
|
||||
openTrackPopup(key);
|
||||
}
|
||||
|
||||
function removeExtraTrackModal(n) {
|
||||
@@ -1686,13 +1715,15 @@ function removeExtraTrackModal(n) {
|
||||
|
||||
// ── Chunked upload helper (bypasses Cloudflare's 100 MB per-request cap) ─
|
||||
async function _chunkedUploadVideo(file, onProgress) {
|
||||
const CHUNK = 8 * 1024 * 1024; // 8 MB — well under CF 100 MB limit
|
||||
const CHUNK = 2 * 1024 * 1024; // 2 MB per chunk
|
||||
const CONCURRENCY = 3; // parallel in-flight chunks
|
||||
const total = Math.max(1, Math.ceil(file.size / CHUNK));
|
||||
const uploadId = (window.crypto && crypto.randomUUID)
|
||||
? crypto.randomUUID()
|
||||
: (Date.now() + '-' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2));
|
||||
|
||||
for (let i = 0; i < total; i++) {
|
||||
let uploadedBytes = 0;
|
||||
const uploadOne = async (i) => {
|
||||
const start = i * CHUNK;
|
||||
const end = Math.min(start + CHUNK, file.size);
|
||||
const blob = file.slice(start, end);
|
||||
@@ -1700,6 +1731,7 @@ async function _chunkedUploadVideo(file, onProgress) {
|
||||
const fd = new FormData();
|
||||
fd.append('upload_id', uploadId);
|
||||
fd.append('chunk_index', i);
|
||||
fd.append('chunk_offset', start);
|
||||
fd.append('total_chunks', total);
|
||||
fd.append('filename', file.name);
|
||||
fd.append('chunk', blob, 'chunk');
|
||||
@@ -1715,8 +1747,20 @@ async function _chunkedUploadVideo(file, onProgress) {
|
||||
try { const j = await res.json(); if (j && j.message) msg = j.message; } catch (_) {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
if (onProgress) onProgress({ loaded: end, total: file.size });
|
||||
uploadedBytes += (end - start);
|
||||
if (onProgress) onProgress({ loaded: Math.min(uploadedBytes, file.size), total: file.size });
|
||||
};
|
||||
|
||||
let next = 0;
|
||||
const workers = Array.from({ length: Math.min(CONCURRENCY, total) }, async () => {
|
||||
while (true) {
|
||||
const i = next++;
|
||||
if (i >= total) return;
|
||||
await uploadOne(i);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
|
||||
return { uploadId: uploadId, filename: file.name };
|
||||
}
|
||||
|
||||
@@ -1753,8 +1797,9 @@ document.getElementById('upload-form-modal').addEventListener('submit', function
|
||||
}
|
||||
|
||||
const formData = new FormData(this);
|
||||
const _slideFile = s => (s && s.file) ? s.file : s; // tolerate legacy raw-File entries
|
||||
if (_isMusicMode) {
|
||||
(_slidesData['t1'] || []).forEach(f => formData.append('slides[]', f));
|
||||
(_slidesData['t1'] || []).forEach(s => formData.append('slides[]', _slideFile(s)));
|
||||
// Walk extra-track form sections in DOM order so the slide index lines up
|
||||
// with the positional `extra_track_files[]` the form already submits.
|
||||
var extraForms = document.querySelectorAll('#um-tf-extra .um-track-form');
|
||||
@@ -1763,8 +1808,8 @@ document.getElementById('upload-form-modal').addEventListener('submit', function
|
||||
if (!m) return;
|
||||
var tid = m[1];
|
||||
var files = _slidesData[tid] || [];
|
||||
files.forEach(function (f) {
|
||||
formData.append('extra_track_slides[' + domIdx + '][]', f);
|
||||
files.forEach(function (s) {
|
||||
formData.append('extra_track_slides[' + domIdx + '][]', _slideFile(s));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
<nav class="pag-nav" aria-label="Pagination">
|
||||
|
||||
{{-- Prev --}}
|
||||
{{-- First --}}
|
||||
@if ($paginator->onFirstPage())
|
||||
<span class="pag-item disabled" aria-disabled="true">
|
||||
<i class="bi bi-chevron-left" style="font-size:11px;"></i>
|
||||
<span class="pag-item disabled" aria-disabled="true" title="First page">
|
||||
<i class="bi bi-chevron-double-left" style="font-size:11px;" aria-hidden="true"></i>
|
||||
</span>
|
||||
@else
|
||||
<a class="pag-item" href="{{ $paginator->previousPageUrl() }}" rel="prev" aria-label="Previous">
|
||||
<i class="bi bi-chevron-left" style="font-size:11px;"></i>
|
||||
<a class="pag-item" href="{{ $paginator->url(1) }}" aria-label="First page" title="First page">
|
||||
<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>
|
||||
@endif
|
||||
|
||||
@@ -66,12 +77,23 @@ a.pag-item:hover {
|
||||
|
||||
{{-- Next --}}
|
||||
@if ($paginator->hasMorePages())
|
||||
<a class="pag-item" href="{{ $paginator->nextPageUrl() }}" rel="next" aria-label="Next">
|
||||
<i class="bi bi-chevron-right" style="font-size:11px;"></i>
|
||||
<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;" aria-hidden="true"></i>
|
||||
</a>
|
||||
@else
|
||||
<span class="pag-item disabled" aria-disabled="true">
|
||||
<i class="bi bi-chevron-right" style="font-size:11px;"></i>
|
||||
<span class="pag-item disabled" aria-disabled="true" title="Next page">
|
||||
<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>
|
||||
@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">
|
||||
<span class="ch-meta-item">
|
||||
<i class="bi bi-camera-video-fill"></i>
|
||||
{{ number_format($videos->count() + $shorts->count()) }} videos
|
||||
@php $vidTotal = $videos->count() + $shorts->count(); @endphp
|
||||
{{ number_format($vidTotal) }} {{ Str::plural('video', $vidTotal) }}
|
||||
</span>
|
||||
<span class="ch-meta-sep">·</span>
|
||||
<span class="ch-meta-item">
|
||||
@@ -136,7 +137,14 @@ $headerSocialMap = [
|
||||
<i class="bi bi-people-fill"></i>
|
||||
{{ number_format($user->subscriber_count) }} {{ Str::plural('subscriber', $user->subscriber_count) }}
|
||||
</span>
|
||||
@if($user->location)
|
||||
{{-- Location, age and gender used to render in the public
|
||||
header meta row — visible to every visitor. That's a
|
||||
privacy leak the user flagged; those three now show
|
||||
only to the owner here, and only in the About tab for
|
||||
everyone (via the existing ch-about-card below). Join
|
||||
month stays public because it's channel metadata, not
|
||||
personal data. --}}
|
||||
@if($isOwner && $user->location)
|
||||
<span class="ch-meta-sep">·</span>
|
||||
<span class="ch-meta-item">
|
||||
<i class="bi bi-geo-alt-fill"></i>
|
||||
@@ -148,7 +156,7 @@ $headerSocialMap = [
|
||||
<i class="bi bi-calendar3"></i>
|
||||
Joined {{ $user->created_at->format('M Y') }}
|
||||
</span>
|
||||
@if($user->birthday)
|
||||
@if($isOwner && $user->birthday)
|
||||
@php $age = \Carbon\Carbon::parse($user->birthday)->age; @endphp
|
||||
<span class="ch-meta-sep">·</span>
|
||||
<span class="ch-meta-item">
|
||||
@@ -156,13 +164,13 @@ $headerSocialMap = [
|
||||
{{ $age }} yrs
|
||||
</span>
|
||||
@endif
|
||||
@if($user->gender === 'male')
|
||||
@if($isOwner && $user->gender === 'male')
|
||||
<span class="ch-meta-sep">·</span>
|
||||
<span class="ch-meta-item" style="color:#3b82f6;font-weight:700;">
|
||||
<i class="bi bi-gender-male" style="font-size:14px;-webkit-text-stroke:.4px #3b82f6;filter:drop-shadow(0 0 6px #3b82f640);"></i>
|
||||
Male
|
||||
</span>
|
||||
@elseif($user->gender === 'female')
|
||||
@elseif($isOwner && $user->gender === 'female')
|
||||
<span class="ch-meta-sep">·</span>
|
||||
<span class="ch-meta-item" style="color:#ec4899;font-weight:700;">
|
||||
<i class="bi bi-gender-female" style="font-size:14px;-webkit-text-stroke:.4px #ec4899;filter:drop-shadow(0 0 6px #ec489940);"></i>
|
||||
@@ -181,18 +189,25 @@ $headerSocialMap = [
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Horoscope + Compatibility strip & Social buttons side by side --}}
|
||||
@if($horoscope || $socialLinks->isNotEmpty())
|
||||
{{-- Horoscope + Compatibility strip & Social buttons side by side.
|
||||
The horoscope portion is sign-derived from the birthday the
|
||||
owner entered; it only renders to the owner (or to a visitor
|
||||
who's seeing the compatibility match-up). Pure social buttons
|
||||
still show to visitors. --}}
|
||||
@php
|
||||
$hColor = $horoscope ? \App\Helpers\Horoscope::elementColor($horoscope['element']) : null;
|
||||
$showCompat = $horoscope && ($compatibility !== null && $viewerSign && !$isOwner);
|
||||
$showHoroStrip = $horoscope && ($isOwner || $showCompat);
|
||||
@endphp
|
||||
@if($showHoroStrip || $socialLinks->isNotEmpty())
|
||||
@php
|
||||
if ($showCompat) {
|
||||
$compatColor = $compatibility >= 75 ? '#22c55e' : ($compatibility >= 55 ? '#eab308' : '#ef4444');
|
||||
$compatLabel = $compatibility >= 85 ? 'Soulmates' : ($compatibility >= 70 ? 'Great match' : ($compatibility >= 55 ? 'Good vibes' : ($compatibility >= 40 ? 'Some friction' : 'Tough combo')));
|
||||
}
|
||||
@endphp
|
||||
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:14px;">
|
||||
@if($horoscope)
|
||||
@if($showHoroStrip)
|
||||
<div class="ch-horo-strip" style="margin-bottom:0; cursor:pointer;"
|
||||
onclick="switchTab('about',document.querySelector('[data-tab=about]'))" title="Click to view horoscope">
|
||||
<div class="ch-horo-sign-part">
|
||||
@@ -361,10 +376,15 @@ $headerSocialMap = [
|
||||
<span class="ch-tab-badge">{{ $videos->count() }}</span>
|
||||
@endif
|
||||
</button>
|
||||
@if($shorts->count() > 0)
|
||||
{{-- Always show the Shorts tab for the owner so the "My Shorts"
|
||||
sidebar link always lands somewhere; for visitors only when
|
||||
there's content to show. --}}
|
||||
@if($isOwner || $shorts->count() > 0)
|
||||
<button class="ch-tab" data-tab="shorts" onclick="switchTab('shorts', this)">
|
||||
Shorts
|
||||
@if($shorts->count() > 0)
|
||||
<span class="ch-tab-badge">{{ $shorts->count() }}</span>
|
||||
@endif
|
||||
</button>
|
||||
@endif
|
||||
@if(($playlists && $playlists->count() > 0) || $isOwner)
|
||||
@@ -394,9 +414,9 @@ $headerSocialMap = [
|
||||
{{-- ── LEFT: composer + feed ── --}}
|
||||
<div>
|
||||
|
||||
{{-- Composer (owner only) --}}
|
||||
{{-- Composer (owner only, hidden when previewing as a visitor) --}}
|
||||
@auth
|
||||
@if(Auth::id() === $user->id)
|
||||
@if(Auth::id() === $user->id && !$preview)
|
||||
<div class="ch-composer">
|
||||
<form action="{{ route('posts.store', $user->id) }}" method="POST" enctype="multipart/form-data" id="postForm">
|
||||
@csrf
|
||||
@@ -528,7 +548,7 @@ $headerSocialMap = [
|
||||
<div class="ch-post-video-info">
|
||||
<div class="ch-post-video-title">{{ $v->title }}</div>
|
||||
<div class="ch-post-video-meta">
|
||||
<span class="ch-post-video-badge"><i class="bi bi-eye"></i> {{ \Illuminate\Support\Number::abbreviate($v->view_count, precision: 1) }}</span>
|
||||
<span class="ch-post-video-badge"><i class="bi bi-eye"></i> {{ $v->view_count > 0 ? \Illuminate\Support\Number::abbreviate($v->view_count, precision: 1) : '0' }}</span>
|
||||
@if($v->duration)<span class="ch-post-video-badge"><i class="bi bi-clock"></i> {{ $v->formatted_duration }}</span>@endif
|
||||
@if($v->is_shorts)<span class="ch-post-video-badge" style="color:#e33;"><i class="bi bi-phone"></i> Short</span>@endif
|
||||
</div>
|
||||
@@ -663,24 +683,74 @@ $headerSocialMap = [
|
||||
<div class="ch-sort-bar" style="margin-bottom:0; flex-shrink:0;">
|
||||
<span class="ch-sort-label">Sort</span>
|
||||
<div class="ch-sort-pills">
|
||||
<a href="{{ request()->fullUrlWithQuery(['sort' => 'latest']) }}"
|
||||
<a href="{{ request()->fullUrlWithQuery(['sort' => 'latest']) . '#videos' }}"
|
||||
class="ch-sort-pill {{ $sort === 'latest' ? 'active' : '' }}">Latest</a>
|
||||
<a href="{{ request()->fullUrlWithQuery(['sort' => 'popular']) }}"
|
||||
<a href="{{ request()->fullUrlWithQuery(['sort' => 'popular']) . '#videos' }}"
|
||||
class="ch-sort-pill {{ $sort === 'popular' ? 'active' : '' }}">Popular</a>
|
||||
<a href="{{ request()->fullUrlWithQuery(['sort' => 'oldest']) }}"
|
||||
<a href="{{ request()->fullUrlWithQuery(['sort' => 'oldest']) . '#videos' }}"
|
||||
class="ch-sort-pill {{ $sort === 'oldest' ? 'active' : '' }}">Oldest</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="yt-video-grid" id="chVidGrid">
|
||||
@foreach($videos as $video)
|
||||
@include('components.video-card', ['video' => $video])
|
||||
@include('components.video-card', ['video' => $video, 'hideChannelInfo' => true])
|
||||
@endforeach
|
||||
<div class="ch-vid-no-results" id="chVidNoResults">
|
||||
<i class="bi bi-search"></i>
|
||||
<span>No videos match your search</span>
|
||||
</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
|
||||
<div class="ch-empty">
|
||||
<div class="ch-empty-icon"><i class="bi bi-camera-video"></i></div>
|
||||
@@ -699,8 +769,15 @@ $headerSocialMap = [
|
||||
</div>
|
||||
|
||||
{{-- ─────────────────────── SHORTS TAB ──────────────── --}}
|
||||
@if($shorts->count() > 0)
|
||||
@if($isOwner || $shorts->count() > 0)
|
||||
<div class="ch-tab-content" id="tab-shorts">
|
||||
@if($shorts->isEmpty())
|
||||
<div class="ch-empty">
|
||||
<div class="ch-empty-icon"><i class="bi bi-phone"></i></div>
|
||||
<h3>No shorts yet</h3>
|
||||
<p>Vertical videos under 60 seconds appear here.</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="ch-shorts-grid">
|
||||
@foreach($shorts as $short)
|
||||
<a href="{{ route('videos.show', $short) }}" class="ch-short-card">
|
||||
@@ -722,6 +799,7 @@ $headerSocialMap = [
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -753,8 +831,8 @@ $headerSocialMap = [
|
||||
@endif
|
||||
<div class="ch-playlist-overlay"></div>
|
||||
<div class="ch-playlist-count">
|
||||
<i class="bi bi-play-fill"></i>
|
||||
{{ $playlist->video_count }} videos
|
||||
<i class="bi bi-play-fill" aria-hidden="true"></i>
|
||||
{{ $playlist->video_count }} {{ Str::plural('video', $playlist->video_count) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ch-playlist-name">{{ $playlist->name }}</div>
|
||||
@@ -768,7 +846,13 @@ $headerSocialMap = [
|
||||
@else
|
||||
<i class="bi bi-globe"></i> Public
|
||||
@endif
|
||||
· <i class="bi bi-eye"></i> {{ \Illuminate\Support\Number::abbreviate($playlist->view_count, precision: 1) }}
|
||||
{{-- Hide the view-count row entirely when the playlist has
|
||||
never been viewed. Previously showed "· eye 0.0" because
|
||||
Number::abbreviate(0, precision: 1) prints "0.0". --}}
|
||||
@if($playlist->view_count > 0)
|
||||
· <i class="bi bi-eye" aria-hidden="true"></i>
|
||||
{{ \Illuminate\Support\Number::abbreviate($playlist->view_count, precision: 1) }}
|
||||
@endif
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
@@ -1057,7 +1141,7 @@ $headerSocialMap = [
|
||||
<div class="ch-about-card-title"><i class="bi bi-bar-chart-fill"></i> Stats</div>
|
||||
<div class="ch-stat-cards">
|
||||
<div class="ch-stat-card">
|
||||
<div class="ch-stat-card-val">{{ \Illuminate\Support\Number::abbreviate($totalViews, precision: 1) }}</div>
|
||||
<div class="ch-stat-card-val">{{ $totalViews > 0 ? \Illuminate\Support\Number::abbreviate($totalViews, precision: 1) : '0' }}</div>
|
||||
<div class="ch-stat-card-key">Total Views</div>
|
||||
</div>
|
||||
<div class="ch-stat-card">
|
||||
@@ -1171,7 +1255,13 @@ if ($oldLinks) {
|
||||
<div class="ch-settings-field">
|
||||
<label class="ch-settings-label">Gender</label>
|
||||
<select name="gender" class="ch-settings-input" style="cursor:pointer;">
|
||||
<option value="">Prefer not to say</option>
|
||||
{{-- Empty value stands for "not chosen yet"
|
||||
— different from the explicit "prefer not
|
||||
to say" option. Previously both rendered
|
||||
the same label, so users saw two
|
||||
identical choices and the stats grouping
|
||||
ambiguously bucketed one of them. --}}
|
||||
<option value="">Select…</option>
|
||||
<option value="male" {{ old('gender', $user->gender) === 'male' ? 'selected' : '' }}>Male</option>
|
||||
<option value="female" {{ old('gender', $user->gender) === 'female' ? 'selected' : '' }}>Female</option>
|
||||
<option value="prefer_not_to_say" {{ old('gender', $user->gender) === 'prefer_not_to_say' ? 'selected' : '' }}>Prefer not to say</option>
|
||||
@@ -1338,9 +1428,14 @@ if ($oldLinks) {
|
||||
@php
|
||||
$notifPrefs = $user->notification_preferences ?? [];
|
||||
$notifDefs = \App\Models\User::notifDefaults();
|
||||
// function_exists guard so a second render in the same PHP
|
||||
// process (feature tests fire two requests in one process)
|
||||
// doesn't fatal with "Cannot redeclare notifVal()".
|
||||
if (!function_exists('notifVal')) {
|
||||
function notifVal(array $prefs, array $defs, string $key): bool {
|
||||
return isset($prefs[$key]) ? (bool)$prefs[$key] : ($defs[$key] ?? true);
|
||||
}
|
||||
}
|
||||
@endphp
|
||||
<div class="ch-settings-card" id="notifPrefsCard">
|
||||
<div class="ch-settings-card-title"><i class="bi bi-bell-fill"></i> Notification Preferences</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Watch History | TAKEONE')
|
||||
@section('title', 'Watch History | ' . config('app.name'))
|
||||
|
||||
@section('extra_styles')
|
||||
<style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Liked Videos | TAKEONE')
|
||||
@section('title', 'Liked Videos | ' . config('app.name'))
|
||||
|
||||
@section('extra_styles')
|
||||
<style>
|
||||
@@ -146,7 +146,8 @@
|
||||
@endforeach
|
||||
</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
|
||||
@endsection
|
||||
|
||||
@@ -342,10 +342,17 @@
|
||||
/* ══════════════════════════════════════════════════
|
||||
VIDEO GRID
|
||||
══════════════════════════════════════════════════ */
|
||||
.yt-video-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
|
||||
@media (max-width: 1100px) { .yt-video-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
@media (max-width: 600px) { .yt-video-grid { grid-template-columns: 1fr; } }
|
||||
@media (min-width: 1500px) { .yt-video-grid { grid-template-columns: repeat(4, 1fr); } }
|
||||
/* Fluid grid: as many 260-px columns as fit, each one stretching to fill
|
||||
the leftover space. Removes the three fixed breakpoints that produced
|
||||
different card widths at 1101px vs 1500px, and `min-width:0` on every
|
||||
grid child so a long unbroken title (CJK, "SPARTAWORRIOR") can't widen
|
||||
its track past the configured 1fr. */
|
||||
.yt-video-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
.yt-video-grid > * { min-width: 0; }
|
||||
|
||||
/* ══════════════════════════════════════════════════
|
||||
SHORTS GRID
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Profile | TAKEONE')
|
||||
@section('title', 'Profile | ' . config('app.name'))
|
||||
|
||||
@section('extra_styles')
|
||||
<style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Settings | TAKEONE')
|
||||
@section('title', 'Settings | ' . config('app.name'))
|
||||
|
||||
@section('extra_styles')
|
||||
<style>
|
||||
|
||||
@@ -7,18 +7,28 @@
|
||||
/* ── Regular video grid ── */
|
||||
.yt-video-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
/* minmax(0, 1fr) keeps every column exactly equal in width.
|
||||
Plain 1fr lets a track grow if a child's min-content is wider
|
||||
than the share, which was making the 3rd column wider (and
|
||||
therefore taller, since the thumb is 16/9) than the first two. */
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-auto-rows: 1fr;
|
||||
align-items: stretch;
|
||||
gap: 20px;
|
||||
}
|
||||
@media (max-width: 992px) { .yt-video-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
@media (max-width: 576px) { .yt-video-grid { grid-template-columns: 1fr; gap: 14px; } }
|
||||
.yt-video-grid > .yt-video-card { height: 100%; min-width: 0; }
|
||||
@media (max-width: 992px) { .yt-video-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 576px) { .yt-video-grid { grid-template-columns: minmax(0, 1fr); gap: 14px; } }
|
||||
|
||||
|
||||
/* ── Thumbnail orientation fix ── */
|
||||
|
||||
|
||||
/* ── Thumbnail orientation fix ── */
|
||||
.yt-video-card .yt-video-thumb img { object-fit: cover; }
|
||||
/* ── Thumbnail orientation fix ──
|
||||
Always cover so every thumbnail visually fills its 16/9 box the
|
||||
same way. `!important` beats any leftover inline style from the
|
||||
old adjust-fit script (cached page in the wild). */
|
||||
.yt-video-card .yt-video-thumb img { object-fit: cover !important; }
|
||||
|
||||
/* ── Search result helpers ── */
|
||||
.search-info { margin-bottom: 20px; padding: 16px; background: var(--bg-secondary); border-radius: 12px; }
|
||||
@@ -32,6 +42,34 @@
|
||||
}
|
||||
.results-section-title::after { content: ''; flex: 1; height: 1px; background: var(--border-color); }
|
||||
|
||||
/* Channel results — card list sized to sit comfortably above the video
|
||||
grid. Avatar is a fixed 56px circle; name + handle sit to the right. */
|
||||
.search-channels-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.search-channel-card {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
text-decoration: none;
|
||||
color: var(--text-primary);
|
||||
transition: background .15s ease;
|
||||
}
|
||||
.search-channel-card:hover { background: rgba(255,255,255,.08); }
|
||||
.search-channel-card img,
|
||||
.search-channel-ph {
|
||||
width: 56px; height: 56px; border-radius: 50%; object-fit: cover; flex: 0 0 auto;
|
||||
background: #333; display: flex; align-items: center; justify-content: center;
|
||||
font-size: 22px; color: var(--text-secondary);
|
||||
}
|
||||
.search-channel-text { min-width: 0; }
|
||||
.search-channel-name { font-weight: 600; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.search-channel-handle { font-size: 13px; color: var(--text-secondary); }
|
||||
|
||||
.yt-empty { text-align: center; padding: 80px 20px; }
|
||||
|
||||
/* Language chips */
|
||||
@@ -49,21 +87,34 @@
|
||||
<div class="yt-filter-bar-wrap">
|
||||
<button type="button" class="yt-filter-nav left" aria-label="Scroll left" onclick="scrollFilterBar(-1)"><i class="bi bi-chevron-left"></i></button>
|
||||
<button type="button" class="yt-filter-nav right" aria-label="Scroll right" onclick="scrollFilterBar(1)"><i class="bi bi-chevron-right"></i></button>
|
||||
<div class="yt-filter-bar">
|
||||
<div class="yt-filter-bar" role="navigation" aria-label="Browse filters">
|
||||
{{-- aria-current="page" on the active chip so assistive tech can read
|
||||
the current filter. "New to You" was renamed to "Recent" — it was
|
||||
just the latest filter, not a personalised recommendation, so the
|
||||
old label overpromised. --}}
|
||||
@php
|
||||
$isAll = $activeFilter === 'all';
|
||||
$isTrending = request()->is('trending');
|
||||
$isMusic = $activeFilter === 'music';
|
||||
$isMatch = $activeFilter === 'match';
|
||||
$isShorts = request()->is('shorts');
|
||||
$isPlaylists = $activeFilter === 'playlists';
|
||||
$isRecent = $activeFilter === 'latest';
|
||||
@endphp
|
||||
<a href="{{ route('videos.index') }}"
|
||||
class="yt-chip {{ $activeFilter === 'all' ? 'active' : '' }}">All</a>
|
||||
class="yt-chip {{ $isAll ? 'active' : '' }}" @if($isAll) aria-current="page"@endif>All</a>
|
||||
<a href="{{ route('videos.trending') }}"
|
||||
class="yt-chip {{ request()->is('trending') ? 'active' : '' }}">Trending</a>
|
||||
class="yt-chip {{ $isTrending ? 'active' : '' }}" @if($isTrending) aria-current="page"@endif>Trending</a>
|
||||
<a href="{{ route('videos.index', ['filter' => 'music']) }}"
|
||||
class="yt-chip {{ $activeFilter === 'music' ? 'active' : '' }}">Music</a>
|
||||
class="yt-chip {{ $isMusic ? 'active' : '' }}" @if($isMusic) aria-current="page"@endif>Music</a>
|
||||
<a href="{{ route('videos.index', ['filter' => 'match']) }}"
|
||||
class="yt-chip {{ $activeFilter === 'match' ? 'active' : '' }}">Sports</a>
|
||||
class="yt-chip {{ $isMatch ? 'active' : '' }}" @if($isMatch) aria-current="page"@endif>Sports</a>
|
||||
<a href="{{ route('videos.shorts') }}"
|
||||
class="yt-chip {{ request()->is('shorts') ? 'active' : '' }}">Shorts</a>
|
||||
class="yt-chip {{ $isShorts ? 'active' : '' }}" @if($isShorts) aria-current="page"@endif>Shorts</a>
|
||||
<a href="{{ route('videos.index', ['filter' => 'playlists']) }}"
|
||||
class="yt-chip {{ $activeFilter === 'playlists' ? 'active' : '' }}">Playlists</a>
|
||||
class="yt-chip {{ $isPlaylists ? 'active' : '' }}" @if($isPlaylists) aria-current="page"@endif>Playlists</a>
|
||||
<a href="{{ route('videos.index', ['filter' => 'latest']) }}"
|
||||
class="yt-chip {{ $activeFilter === 'latest' ? 'active' : '' }}">New to You</a>
|
||||
class="yt-chip {{ $isRecent ? 'active' : '' }}" @if($isRecent) aria-current="page"@endif>Recent</a>
|
||||
|
||||
@if(!empty($langChips ?? []))
|
||||
@php $activeLang = str_starts_with($activeFilter, 'lang:') ? substr($activeFilter, 5) : null; @endphp
|
||||
@@ -71,6 +122,7 @@
|
||||
@foreach($langChips as $chip)
|
||||
<a href="{{ route('videos.index', ['filter' => 'lang:' . $chip['code']]) }}"
|
||||
class="yt-chip yt-chip-lang {{ $activeLang === $chip['code'] ? 'active' : '' }}"
|
||||
@if($activeLang === $chip['code']) aria-current="page"@endif
|
||||
title="{{ $chip['name'] }} ({{ $chip['count'] }})">
|
||||
<span class="fi fi-{{ $chip['flag'] }}"></span>
|
||||
<span>{{ $chip['name'] }}</span>
|
||||
@@ -90,7 +142,8 @@
|
||||
@php
|
||||
$searchShorts = $shorts ?? collect();
|
||||
$searchPlaylists = $playlists ?? collect();
|
||||
$totalResults = $videos->count() + $searchShorts->count() + $searchPlaylists->count();
|
||||
$searchChannels = $channels ?? collect();
|
||||
$totalResults = $videos->count() + $searchShorts->count() + $searchPlaylists->count() + $searchChannels->count();
|
||||
@endphp
|
||||
|
||||
<div class="search-info">
|
||||
@@ -98,11 +151,33 @@
|
||||
<p>
|
||||
@if($videos->count()) {{ $videos->count() }} video{{ $videos->count() !== 1 ? 's' : '' }} @endif
|
||||
@if($searchShorts->count()) · {{ $searchShorts->count() }} short{{ $searchShorts->count() !== 1 ? 's' : '' }} @endif
|
||||
@if($searchChannels->count()) · {{ $searchChannels->count() }} channel{{ $searchChannels->count() !== 1 ? 's' : '' }} @endif
|
||||
@if($searchPlaylists->count()) · {{ $searchPlaylists->count() }} playlist{{ $searchPlaylists->count() !== 1 ? 's' : '' }} @endif
|
||||
@if($totalResults === 0) No results found @endif
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if($searchChannels->count())
|
||||
<div class="results-section-title"><i class="bi bi-person-circle"></i> Channels</div>
|
||||
<div class="search-channels-grid">
|
||||
@foreach($searchChannels as $channel)
|
||||
<a href="{{ route('channel', $channel->channel) }}" class="search-channel-card">
|
||||
@if($channel->avatar_url)
|
||||
<img src="{{ $channel->avatar_url }}" alt="{{ $channel->name }}" width="56" height="56" loading="lazy">
|
||||
@else
|
||||
<div class="search-channel-ph"><i class="bi bi-person"></i></div>
|
||||
@endif
|
||||
<div class="search-channel-text">
|
||||
<div class="search-channel-name">{{ $channel->name }}</div>
|
||||
@if($channel->username)
|
||||
<div class="search-channel-handle">@{{ $channel->username }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($videos->count())
|
||||
<div class="results-section-title"><i class="bi bi-play-circle"></i> Videos</div>
|
||||
<div class="yt-video-grid">
|
||||
@@ -162,6 +237,60 @@
|
||||
══════════════════════════════════════════════ --}}
|
||||
@elseif(!isset($query) && (!isset($filter) || $filter === 'all'))
|
||||
|
||||
@if(!empty($homeShorts) && $homeShorts->count())
|
||||
{{-- Horizontally-scrolling Shorts shelf. Sits above the main feed grid
|
||||
so the user sees fresh short-form content without the feed being
|
||||
drowned in 60-second portrait clips. --}}
|
||||
<section class="home-shorts-shelf" aria-labelledby="home-shorts-heading">
|
||||
<div class="home-shorts-shelf-head">
|
||||
<h2 id="home-shorts-heading"><i class="bi bi-lightning-charge-fill" aria-hidden="true"></i> Shorts</h2>
|
||||
<a href="{{ route('videos.shorts') }}" class="home-shorts-see-all">See all</a>
|
||||
</div>
|
||||
<div class="home-shorts-shelf-track">
|
||||
@foreach($homeShorts as $s)
|
||||
<a class="home-short-card" href="{{ route('videos.shorts.focused', $s) }}"
|
||||
aria-label="Open short: {{ $s->title }}">
|
||||
<img src="{{ $s->thumbnail ? route('media.thumbnail', $s->thumbnail) : 'https://picsum.photos/seed/'.$s->id.'/180/320' }}"
|
||||
alt="" width="150" height="266" loading="lazy" decoding="async">
|
||||
<div class="home-short-card-overlay">
|
||||
<div class="home-short-card-title">{{ Str::limit($s->title, 40) }}</div>
|
||||
<div class="home-short-card-views">{{ number_format($s->view_count) }} views</div>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
<style>
|
||||
.home-shorts-shelf { margin: 8px 0 24px; }
|
||||
.home-shorts-shelf-head { display:flex; justify-content:space-between; align-items:baseline; margin-bottom:12px; }
|
||||
.home-shorts-shelf-head h2 { font-size:18px; font-weight:600; margin:0; display:flex; align-items:center; gap:8px; }
|
||||
.home-shorts-shelf-head h2 i { color:#ef4444; }
|
||||
.home-shorts-see-all { color: var(--text-secondary); font-size:13px; text-decoration:none; }
|
||||
.home-shorts-see-all:hover { color: var(--text-primary); text-decoration: underline; }
|
||||
.home-shorts-shelf-track {
|
||||
display:flex; gap:12px; overflow-x:auto; scroll-snap-type: x proximity;
|
||||
scrollbar-width: none; -ms-overflow-style: none;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.home-shorts-shelf-track::-webkit-scrollbar { display:none; }
|
||||
.home-short-card {
|
||||
flex: 0 0 150px; aspect-ratio: 9/16; border-radius: 10px;
|
||||
overflow: hidden; position: relative; scroll-snap-align: start;
|
||||
text-decoration: none; color: inherit; background: #1a1a1a;
|
||||
transition: transform .15s ease;
|
||||
}
|
||||
.home-short-card:hover { transform: translateY(-2px); }
|
||||
.home-short-card img { width:100%; height:100%; object-fit: cover; display:block; }
|
||||
.home-short-card-overlay {
|
||||
position:absolute; left:0; right:0; bottom:0; padding: 20px 8px 8px;
|
||||
background: linear-gradient(transparent, rgba(0,0,0,.75));
|
||||
color: #fff;
|
||||
}
|
||||
.home-short-card-title { font-size:12px; font-weight:600; line-height:1.3; overflow:hidden; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; }
|
||||
.home-short-card-views { font-size:11px; opacity:.8; margin-top:2px; }
|
||||
</style>
|
||||
@endif
|
||||
|
||||
@if(isset($feedItems) && $feedItems->count())
|
||||
<div class="yt-video-grid">
|
||||
@foreach($feedItems as $entry)
|
||||
@@ -201,24 +330,28 @@
|
||||
@endif
|
||||
|
||||
@endif
|
||||
|
||||
{{-- Infinite-scroll sentinel. The IntersectionObserver down in @section('scripts')
|
||||
fetches the next cursor page when this comes into view and appends the
|
||||
rendered cards to the grid above. A visible "Load more" button is the
|
||||
always-available fallback. --}}
|
||||
<div id="feed-sentinel"
|
||||
data-feed-filter="{{ $filter ?? 'all' }}"
|
||||
style="height:1px;"></div>
|
||||
<div id="feed-loadmore-wrap" style="display:none; text-align:center; padding:16px 0;">
|
||||
<button type="button" id="feed-loadmore" class="action-btn">Load more</button>
|
||||
</div>
|
||||
</div>{{-- /filter-content --}}
|
||||
|
||||
@endsection
|
||||
|
||||
@section('scripts')
|
||||
<script>
|
||||
(function () {
|
||||
function adjustPlThumb(img) {
|
||||
img.style.objectFit = img.naturalWidth < img.naturalHeight ? 'contain' : 'cover';
|
||||
}
|
||||
document.querySelectorAll('.yt-video-thumb img').forEach(function (img) {
|
||||
if (img.complete && img.naturalWidth) {
|
||||
adjustPlThumb(img);
|
||||
} else {
|
||||
img.addEventListener('load', function () { adjustPlThumb(img); });
|
||||
}
|
||||
});
|
||||
})();
|
||||
/* Thumbnails render at a uniform size: the .yt-video-thumb box is a
|
||||
strict 16/9 container and every image inside it uses `object-fit:
|
||||
cover` (set in the shared card CSS) so portrait/square art fills the
|
||||
frame instead of shrinking with letterboxing. No per-image adjustment
|
||||
needed. */
|
||||
|
||||
/* ─── Filter-bar horizontal scroll ─── */
|
||||
(function () {
|
||||
@@ -301,13 +434,9 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Re-run any grid-scoped init that the page's other IIFE did on first load.
|
||||
function reinitGrid(root) {
|
||||
root.querySelectorAll('.yt-video-thumb img').forEach(function (img) {
|
||||
function fit() { img.style.objectFit = img.naturalWidth < img.naturalHeight ? 'contain' : 'cover'; }
|
||||
if (img.complete && img.naturalWidth) fit(); else img.addEventListener('load', fit);
|
||||
});
|
||||
}
|
||||
// No per-image sizing needed — CSS `object-fit: cover` on
|
||||
// `.yt-video-thumb img` gives every card the same visible image size.
|
||||
function reinitGrid(_root) {}
|
||||
|
||||
var currentReq = 0;
|
||||
async function swapTo(url, pushHist) {
|
||||
@@ -355,5 +484,69 @@
|
||||
if (e.state && e.state.filterUrl) swapTo(e.state.filterUrl, false);
|
||||
});
|
||||
})();
|
||||
|
||||
/* ─── Cursor-paginated infinite scroll ───────────────────────────────────
|
||||
The server renders the first page of 60 cards. Everything after that is
|
||||
fetched here, one 24-card page at a time, when the #feed-sentinel scrolls
|
||||
into view. The sentinel sits right after the grid, so we never fetch
|
||||
until the user has actually scrolled past the first page.
|
||||
|
||||
State lives on #feed-sentinel.dataset so a SPA filter-swap can reset it
|
||||
without having to rebind listeners. Fallback: an always-visible "Load
|
||||
more" button that users without IntersectionObserver can click. */
|
||||
(function () {
|
||||
const sentinel = document.getElementById('feed-sentinel');
|
||||
const grid = document.querySelector('.yt-video-grid');
|
||||
const btnWrap = document.getElementById('feed-loadmore-wrap');
|
||||
const btn = document.getElementById('feed-loadmore');
|
||||
if (!sentinel || !grid) return;
|
||||
|
||||
let cursor = ''; // null token = next request is the first continuation page
|
||||
let busy = false;
|
||||
let done = false;
|
||||
const filter = sentinel.dataset.feedFilter || 'all';
|
||||
|
||||
async function loadMore() {
|
||||
if (busy || done) return;
|
||||
busy = true;
|
||||
try {
|
||||
const url = new URL("{{ route('videos.feed.more') }}", window.location.origin);
|
||||
url.searchParams.set('filter', filter);
|
||||
if (cursor) url.searchParams.set('cursor', cursor);
|
||||
const resp = await fetch(url.toString(), {
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (!resp.ok) throw new Error('bad status: ' + resp.status);
|
||||
const data = await resp.json();
|
||||
if (data.items) {
|
||||
grid.insertAdjacentHTML('beforeend', data.items);
|
||||
}
|
||||
cursor = data.next_cursor || '';
|
||||
done = !data.has_more;
|
||||
} catch (e) {
|
||||
// Reveal the manual Load More so a flaky network doesn't dead-end the user.
|
||||
btnWrap.style.display = 'block';
|
||||
} finally {
|
||||
busy = false;
|
||||
if (done) {
|
||||
btnWrap.style.display = 'none';
|
||||
sentinel.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
btn?.addEventListener('click', loadMore);
|
||||
|
||||
if ('IntersectionObserver' in window) {
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
if (entries.some(e => e.isIntersecting)) loadMore();
|
||||
}, { rootMargin: '400px 0px' });
|
||||
io.observe(sentinel);
|
||||
} else {
|
||||
// No IntersectionObserver → surface the Load More fallback immediately.
|
||||
btnWrap.style.display = 'block';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@endsection
|
||||
@@ -7,7 +7,12 @@
|
||||
$isVideoOwner = Auth::check() && Auth::id() === $video->user_id;
|
||||
$renderedDescription = \App\Support\HtmlSanitizer::render($video->description ?? '');
|
||||
$hasDesc = $renderedDescription !== '' || isset($descriptionSlot);
|
||||
$showBox = $hasDesc || $isVideoOwner;
|
||||
$llmEnabledVdb = $hasDesc && app(\App\Services\LlmLyricsService::class)->isEnabled();
|
||||
// Optional pre-rendered block shown above the description inside the About
|
||||
// panel. Only the `match` type passes it (the bout summary card); when it is
|
||||
// unset every other type renders exactly as before.
|
||||
$aboutPrepend = $aboutPrepend ?? null;
|
||||
$showBox = $hasDesc || $isVideoOwner || $aboutPrepend !== null;
|
||||
@endphp
|
||||
@if ($showBox)
|
||||
<style>
|
||||
@@ -36,6 +41,27 @@
|
||||
.vdb-show-more:hover { background:var(--bg-hover, rgba(127,127,127,.12)); }
|
||||
.vdb-show-more i { font-size:14px; transition:transform .2s ease; }
|
||||
.vdb-show-more.expanded i { transform:rotate(180deg); }
|
||||
.vdb-translate-btn { display:inline-flex; align-items:center; gap:6px; background:none; border:1px solid var(--border-color); color:var(--text-primary); font-size:12px; font-weight:600; padding:5px 12px; border-radius:16px; cursor:pointer; margin-top:10px; transition:background .15s, color .15s; font-family:inherit; }
|
||||
.vdb-translate-btn:hover { background:rgba(255,255,255,.08); }
|
||||
.vdb-translate-btn.loading { opacity:.6; cursor:wait; }
|
||||
.vdb-translate-btn.active { color:#3ea6ff; border-color:rgba(62,166,255,.4); }
|
||||
.vdb-translate-btn svg { width:14px; height:14px; fill:currentColor; }
|
||||
.vdb-desc-split { display:block; }
|
||||
/* Line-paired view: each row = one original line + its translation, aligned on
|
||||
the same baseline so skimming across the divider stays easy. */
|
||||
.vdb-desc-split.has-translation #vdbDescShort { display:none; }
|
||||
.vdb-translation-grid { display:grid; grid-template-columns:1fr 1fr; column-gap:16px; row-gap:4px; font-size:14px; line-height:1.6; color:var(--text-primary); word-break:break-word; }
|
||||
.vdb-translation-meta { grid-column:1 / -1; font-size:11px; color:var(--text-secondary); margin:0 0 4px; text-transform:uppercase; letter-spacing:.4px; }
|
||||
.vdb-tr-row { display:contents; }
|
||||
.vdb-tr-cell { padding:4px 0; white-space:pre-wrap; }
|
||||
.vdb-tr-cell.vdb-tr-orig { padding-right:16px; border-right:1px solid var(--border-color); }
|
||||
.vdb-tr-cell.vdb-tr-trans { padding-left:4px; }
|
||||
.vdb-tr-cell.vdb-tr-empty { min-height:1em; }
|
||||
@media (max-width:768px) {
|
||||
.vdb-translation-grid { grid-template-columns:1fr; row-gap:2px; }
|
||||
.vdb-tr-cell.vdb-tr-orig { padding:4px 0 2px; border-right:none; }
|
||||
.vdb-tr-cell.vdb-tr-trans { padding:0 0 10px 0; color:#9ad1ff; border-bottom:1px dashed var(--border-color); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="vdb-wrap" id="vdbWrap">
|
||||
@@ -51,6 +77,9 @@
|
||||
</div>
|
||||
|
||||
<div class="vdb-panel active" id="vdb-about">
|
||||
@if($aboutPrepend !== null)
|
||||
{!! $aboutPrepend !!}
|
||||
@endif
|
||||
<div class="vdb-meta">
|
||||
<span><i class="bi bi-eye" style="margin-right:4px;"></i>{{ number_format($video->view_count) }} views</span>
|
||||
<span>•</span>
|
||||
@@ -60,11 +89,21 @@
|
||||
@if(isset($descriptionSlot))
|
||||
{!! $descriptionSlot !!}
|
||||
@elseif($renderedDescription !== '')
|
||||
<div id="vdbDescSplit" class="vdb-desc-split">
|
||||
<div id="vdbDescShort" class="vdb-desc-text vdb-clamp">{!! $renderedDescription !!}</div>
|
||||
</div>
|
||||
<button id="vdbShowMore" class="vdb-show-more" style="display:none;" onclick="toggleVdbDesc(this)"><span>Show more</span> <i class="bi bi-chevron-down"></i></button>
|
||||
@else
|
||||
<p style="font-size:13px;color:var(--text-secondary);margin:0;">No description added.</p>
|
||||
@endif
|
||||
@if($llmEnabledVdb)
|
||||
<button type="button" class="vdb-translate-btn" id="vdbTranslateBtn"
|
||||
data-video-key="{{ $video->getRouteKey() }}"
|
||||
data-csrf="{{ csrf_token() }}">
|
||||
<svg viewBox="0 0 24 24"><path d="M12.87 15.07l-2.54-2.51.03-.03c1.74-1.94 2.98-4.17 3.71-6.53H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z"/></svg>
|
||||
<span class="vdb-translate-label">Translate</span>
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if($isVideoOwner)
|
||||
@@ -176,5 +215,92 @@ window._vdbScheduleOverflowCheck = window._vdbScheduleOverflowCheck || function
|
||||
};
|
||||
document.addEventListener('DOMContentLoaded', window._vdbScheduleOverflowCheck);
|
||||
window.addEventListener('load', window._vdbScheduleOverflowCheck);
|
||||
|
||||
// ── Description translate ─────────────────────────────
|
||||
// Lives on the About tab. Falls back to navigator.language for the target.
|
||||
function _vdbTargetLang() {
|
||||
return ((navigator.language || 'en').toLowerCase().split('-')[0]) || 'en';
|
||||
}
|
||||
window._vdbToggleTranslate = window._vdbToggleTranslate || async function (btn) {
|
||||
const split = document.getElementById('vdbDescSplit');
|
||||
const showMore = document.getElementById('vdbShowMore');
|
||||
const label = btn.querySelector('.vdb-translate-label');
|
||||
if (!split) return;
|
||||
let grid = document.getElementById('vdbTranslationGrid');
|
||||
|
||||
const showTranslation = () => {
|
||||
split.classList.add('has-translation');
|
||||
if (grid) grid.style.display = 'grid';
|
||||
btn.classList.add('active');
|
||||
if (label) label.textContent = 'Show original';
|
||||
if (showMore) showMore.style.display = 'none';
|
||||
};
|
||||
const hideTranslation = () => {
|
||||
split.classList.remove('has-translation');
|
||||
if (grid) grid.style.display = 'none';
|
||||
btn.classList.remove('active');
|
||||
if (label) label.textContent = 'Translate';
|
||||
if (window._vdbScheduleOverflowCheck) window._vdbScheduleOverflowCheck();
|
||||
};
|
||||
|
||||
if (grid && grid.dataset.loaded === '1') {
|
||||
split.classList.contains('has-translation') ? hideTranslation() : showTranslation();
|
||||
return;
|
||||
}
|
||||
|
||||
btn.classList.add('loading');
|
||||
if (label) label.textContent = 'Translating…';
|
||||
try {
|
||||
const r = await fetch('/videos/' + btn.dataset.videoKey + '/description/translate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': btn.dataset.csrf, 'Accept': 'application/json' },
|
||||
body: JSON.stringify({ target: _vdbTargetLang() })
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!r.ok || !d.success) throw new Error(d.error || 'Translation failed');
|
||||
const segs = Array.isArray(d.segments) ? d.segments : [];
|
||||
if (!segs.length) throw new Error('Translation failed');
|
||||
|
||||
// Build the line-paired grid. Each original line and its translation are
|
||||
// siblings in the same CSS grid, so a short line on one side still shares
|
||||
// its row with its counterpart on the other side.
|
||||
if (!grid) {
|
||||
grid = document.createElement('div');
|
||||
grid.id = 'vdbTranslationGrid';
|
||||
grid.className = 'vdb-translation-grid';
|
||||
split.appendChild(grid);
|
||||
}
|
||||
grid.innerHTML = '';
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'vdb-translation-meta';
|
||||
meta.textContent = 'Translated → ' + (d.target || _vdbTargetLang()).toUpperCase();
|
||||
grid.appendChild(meta);
|
||||
|
||||
segs.forEach(seg => {
|
||||
const origText = (seg.original ?? '').toString();
|
||||
const trText = (seg.translated ?? '').toString();
|
||||
const o = document.createElement('div');
|
||||
o.className = 'vdb-tr-cell vdb-tr-orig' + (origText.trim() === '' ? ' vdb-tr-empty' : '');
|
||||
o.textContent = origText;
|
||||
const t = document.createElement('div');
|
||||
t.className = 'vdb-tr-cell vdb-tr-trans' + (trText.trim() === '' ? ' vdb-tr-empty' : '');
|
||||
t.textContent = trText;
|
||||
grid.appendChild(o);
|
||||
grid.appendChild(t);
|
||||
});
|
||||
grid.dataset.loaded = '1';
|
||||
showTranslation();
|
||||
} catch (err) {
|
||||
if (window.showToast) window.showToast(err.message || 'Translation failed', 'error');
|
||||
if (label) label.textContent = 'Translate';
|
||||
} finally {
|
||||
btn.classList.remove('loading');
|
||||
}
|
||||
};
|
||||
// Delegate so SPA swaps that re-insert #vdbWrap keep working.
|
||||
document.addEventListener('click', function (e) {
|
||||
const b = e.target.closest('#vdbTranslateBtn');
|
||||
if (b) { e.preventDefault(); window._vdbToggleTranslate(b); }
|
||||
});
|
||||
</script>
|
||||
@endif
|
||||
@@ -37,10 +37,10 @@
|
||||
{{-- RED / AKA --}}
|
||||
<div style="flex:1 1 0;min-width:0;transform:skewX(-9deg);overflow:hidden;background:linear-gradient(90deg, rgba(122,26,22,.94), rgba(180,52,44,.9));border-bottom:3px solid #ff6a5e">
|
||||
<div style="transform:skewX(9deg);height:100%;padding:0 16px;display:flex;align-items:center;gap:12px">
|
||||
<div data-msb="redClub" data-msb-part="clubs" style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:8px;letter-spacing:.05em;color:rgba(255,255,255,.65);text-align:center;line-height:1.25">CLUB<br>LOGO</div>
|
||||
<div data-msb="redClub" data-msb-part="clubs" style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:8px;letter-spacing:.05em;color:rgba(255,255,255,.65);text-align:center;line-height:1.25" aria-hidden="true"></div>
|
||||
<div style="flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:3px">
|
||||
<div style="display:flex;align-items:center;gap:10px;min-width:0">
|
||||
<span data-msb="redFlag" data-msb-part="flags" style="width:26px;height:17px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 4px,rgba(255,255,255,.1) 4px 8px);box-shadow:0 0 0 1px rgba(255,255,255,.3);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:7px;color:rgba(255,255,255,.85)">FLAG</span>
|
||||
<span data-msb="redFlag" data-msb-part="flags" style="width:26px;height:17px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 4px,rgba(255,255,255,.1) 4px 8px);box-shadow:0 0 0 1px rgba(255,255,255,.3);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:7px;color:rgba(255,255,255,.85)" aria-hidden="true"></span>
|
||||
<span data-msb="redName" style="flex:1 1 auto;min-width:0;font-size:25px;line-height:1;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $msbState['red']['name'] ?: 'RED' }}</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;min-width:0">
|
||||
@@ -76,14 +76,14 @@
|
||||
<div style="flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:3px;align-items:flex-end;text-align:right">
|
||||
<div style="display:flex;align-items:center;gap:10px;min-width:0">
|
||||
<span data-msb="blueName" style="flex:1 1 auto;min-width:0;font-size:25px;line-height:1;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $msbState['blue']['name'] ?: 'BLUE' }}</span>
|
||||
<span data-msb="blueFlag" data-msb-part="flags" style="width:26px;height:17px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 4px,rgba(255,255,255,.1) 4px 8px);box-shadow:0 0 0 1px rgba(255,255,255,.3);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:7px;color:rgba(255,255,255,.85)">FLAG</span>
|
||||
<span data-msb="blueFlag" data-msb-part="flags" style="width:26px;height:17px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 4px,rgba(255,255,255,.1) 4px 8px);box-shadow:0 0 0 1px rgba(255,255,255,.3);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:7px;color:rgba(255,255,255,.85)" aria-hidden="true"></span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;min-width:0">
|
||||
<span data-msb="bluePenalty" data-msb-part="penalties" style="display:none;font-size:10px;letter-spacing:.18em;padding:1px 6px;background:#c8492f;color:#fff;font-weight:700;flex:none">C1</span>
|
||||
<span data-msb="blueClubName" data-msb-part="clubs" style="flex:1 1 auto;min-width:0;font-size:12px;letter-spacing:.03em;color:rgba(226,238,255,.85);text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $msbState['blue']['club'] ?? '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div data-msb="blueClub" data-msb-part="clubs" style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:8px;letter-spacing:.05em;color:rgba(255,255,255,.65);text-align:center;line-height:1.25">CLUB<br>LOGO</div>
|
||||
<div data-msb="blueClub" data-msb-part="clubs" style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:8px;letter-spacing:.05em;color:rgba(255,255,255,.65);text-align:center;line-height:1.25" aria-hidden="true"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -78,8 +78,10 @@
|
||||
el.textContent = val || '';
|
||||
if (hideIfEmpty) el.toggleAttribute('data-msb-empty', !val);
|
||||
};
|
||||
// Replace the placeholder "CLUB LOGO" text with a real <img>
|
||||
// (or leave the placeholder if no image). Never touches inline styles.
|
||||
// When a club_logo path is set, drop a real <img> into the empty
|
||||
// placeholder box. When it isn't, the box stays empty — the
|
||||
// inline-styled background keeps the slot visible without
|
||||
// showing the stale "CLUB LOGO" text that used to render there.
|
||||
const setImage = (name, path) => {
|
||||
const el = $(name); if (!el) return;
|
||||
if (!path) return;
|
||||
@@ -90,8 +92,9 @@
|
||||
img.style.cssText = 'width:100%;height:100%;object-fit:contain;display:block';
|
||||
el.appendChild(img);
|
||||
};
|
||||
// Swap the placeholder "FLAG" box for a real flag image by pointing
|
||||
// its background-image at the self-hosted flag-icons SVG.
|
||||
// When an ISO2 is set, point the flag box's background-image at
|
||||
// the self-hosted flag-icons SVG. When it isn't, the empty box
|
||||
// stays empty — no "FLAG" placeholder text.
|
||||
const setFlag = (name, iso2) => {
|
||||
const el = $(name); if (!el) return;
|
||||
const code = (iso2 || '').toLowerCase();
|
||||
@@ -268,7 +271,13 @@
|
||||
// Keeps the score bar, ticker, and identity at their pixel-perfect
|
||||
// proportions no matter how wide the actual player is.
|
||||
function initScale() {
|
||||
const wrap = document.getElementById('ytpWrap');
|
||||
// Scale against #videoContainer (the .ytp inner box) — that's the
|
||||
// element that gets squeezed when the fullscreen highlights drawer
|
||||
// opens (CSS: `.ytp-wrap.ytp-fullscreen.hl-drawer-open .ytp { width:
|
||||
// calc(100% - var(--hl-drawer-w)) }`). Using #ytpWrap here would
|
||||
// keep the overlay at full-screen width and clip its right edge
|
||||
// behind the drawer.
|
||||
const wrap = document.getElementById('videoContainer') || document.getElementById('ytpWrap');
|
||||
const layer = document.getElementById('msbScale');
|
||||
if (!wrap || !layer) return;
|
||||
const DESIGN_W = 1150;
|
||||
@@ -278,10 +287,17 @@
|
||||
const s = w / DESIGN_W;
|
||||
layer.style.transform = 'scale(' + s + ')';
|
||||
layer.style.height = (h / s) + 'px';
|
||||
// Toggle the "small player" state once we're below the design
|
||||
// brief's ~700 px breakpoint (in real pixels).
|
||||
document.body.classList.toggle('msb-small', w < 700);
|
||||
// "small player" auto-hides identity + feed. Skip it in
|
||||
// fullscreen — the highlights drawer squeezes the container
|
||||
// width but the user still wants to see the live feed next
|
||||
// to the drawer.
|
||||
const inFs = !!(document.fullscreenElement || document.webkitFullscreenElement);
|
||||
document.body.classList.toggle('msb-small', !inFs && w < 700);
|
||||
};
|
||||
// Re-run when entering/exiting fullscreen so the msb-small flag
|
||||
// updates immediately.
|
||||
document.addEventListener('fullscreenchange', update);
|
||||
document.addEventListener('webkitfullscreenchange', update);
|
||||
update();
|
||||
if (typeof ResizeObserver === 'function') {
|
||||
new ResizeObserver(update).observe(wrap);
|
||||
|
||||
@@ -40,20 +40,9 @@
|
||||
/* transform + height set by JS */
|
||||
}
|
||||
|
||||
/* Scrims for legibility */
|
||||
.msb-scrim {
|
||||
position: absolute; inset: 0; pointer-events: none;
|
||||
background:
|
||||
linear-gradient(to top, rgba(6,6,8,.9) 0%, rgba(6,6,8,.45) 15%, rgba(6,6,8,0) 30%),
|
||||
linear-gradient(to bottom, rgba(6,6,8,.85) 0%, rgba(6,6,8,.4) 14%, rgba(6,6,8,0) 30%);
|
||||
opacity: 0;
|
||||
transition: opacity .3s ease;
|
||||
}
|
||||
/* Only show the scrims when there is at least one overlay part visible */
|
||||
.msb-has-scorebar .msb-scrim,
|
||||
.msb-has-feed .msb-scrim,
|
||||
.msb-has-info .msb-scrim,
|
||||
.msb-has-timeline .msb-scrim { opacity: 1; }
|
||||
/* Scrim disabled — every scoreboard text element has its own text-shadow
|
||||
for legibility, so we don't tint the video at all. */
|
||||
.msb-scrim { display: none; }
|
||||
|
||||
/* ────────────────────────────── match info (top-left) ────────────────── */
|
||||
.msb-info { position: absolute; top: 22px; left: 26px; display: flex; align-items: stretch; gap: 12px; }
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
{{-- ══════════════════════════════════════════════════════════════════════
|
||||
VS OG — 1200×630 standalone page consumed by Browsershot for the
|
||||
social-share preview image. Renders the exact same .vs-mini
|
||||
composition used on video cards, but at fixed 1200×630 with
|
||||
animations disabled (via .vs-mini-static) so the screenshot always
|
||||
captures the final composed frame.
|
||||
|
||||
Public but noindex — only invoked by the internal ogImage()
|
||||
controller through a loopback HTTP request from Browsershot.
|
||||
════════════════════════════════════════════════════════════════════════ --}}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>OG Preview</title>
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Anton&family=Barlow+Condensed:wght@400;600;700;800&display=swap">
|
||||
{{-- Force http:// on the flag CSS URL — Laravel's asset() caches the
|
||||
scheme on first use (https from AppServiceProvider::forceScheme) and
|
||||
URL::forceScheme('http') in the controller can't override that
|
||||
cache. Chromium hits this endpoint via loopback where https isn't
|
||||
reachable, so we build the URL ourselves. --}}
|
||||
<link rel="stylesheet" href="http://{{ request()->getHost() }}/vendor/flag-icons/css/flag-icons.min.css">
|
||||
<style>
|
||||
/* Canvas is a true 16:9 at 1200×675 so the 1920×1080 vs-mini stage
|
||||
maps cleanly with scale 0.625 (no overflow on either axis). WhatsApp/
|
||||
Facebook accept any aspect ratio for og:image; 1200×675 is inside
|
||||
their recommended range and preserves the full VS composition. */
|
||||
html, body {
|
||||
margin: 0; padding: 0;
|
||||
width: 1200px; height: 675px;
|
||||
background: #050507;
|
||||
overflow: hidden;
|
||||
}
|
||||
.og-frame {
|
||||
position: relative;
|
||||
width: 1200px; height: 675px;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* Same wrapper the vs-mini expects — .yt-video-thumb is what the mini's
|
||||
`position: absolute; inset: 0` anchors to, and its aspect drives fit(). */
|
||||
.og-frame .yt-video-thumb {
|
||||
position: relative;
|
||||
width: 100%; height: 100%;
|
||||
overflow: hidden;
|
||||
background: #050507;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="og-frame">
|
||||
<div class="yt-video-thumb">
|
||||
{{-- vs-mini reads $video->sportsMatch->headerData() to compose. --}}
|
||||
@include('components.partials.vs-mini', ['video' => $video])
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Force the mini into its final composed state — no animation-delay
|
||||
// window, no ResizeObserver races. We also inline the correct scale for
|
||||
// a 1200×630 frame (1200/1920 = 0.625) so we don't depend on the fit()
|
||||
// callback firing before Browsershot captures.
|
||||
(function () {
|
||||
const el = document.querySelector('.vs-mini');
|
||||
if (!el) return;
|
||||
el.classList.add('vs-mini-static', 'vs-mini-fit');
|
||||
el.style.setProperty('--vs-mini-scale', String(1200 / 1920));
|
||||
// Signal readiness for Browsershot's waitUntilNetworkIdle to observe
|
||||
// once webfonts are done rendering.
|
||||
if (document.fonts && document.fonts.ready) {
|
||||
document.fonts.ready.then(() => { window.__vsOgReady = true; });
|
||||
} else {
|
||||
window.__vsOgReady = true;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,561 @@
|
||||
{{-- ══════════════════════════════════════════════════════════════════════
|
||||
VS INTRO SCREEN — arena card overlaid on the first 5 s of the match.
|
||||
Rendered as a FIXED 1920×1080 (landscape) or 1080×1920 (portrait)
|
||||
canvas that is transform-scaled to fit the parent player box. Every
|
||||
child uses ABSOLUTE PIXELS on that canvas so the layout is identical
|
||||
at every zoom level and never scales off the viewport width.
|
||||
══════════════════════════════════════════════════════════════════════ --}}
|
||||
@php
|
||||
$vsRed = $hd['red'] ?? null;
|
||||
$vsBlue = $hd['blue'] ?? null;
|
||||
$vsShow = $hd && (($vsRed['name'] ?? null) || ($vsBlue['name'] ?? null));
|
||||
|
||||
$vsFlagBg = fn(?string $c) => (($c = strtolower(trim((string) $c))) !== '' ? $c : 'xx');
|
||||
$vsCountryName = fn (?string $code) => \App\Data\Countries::name($code);
|
||||
$vsImg = fn(?string $path) => $path ? route('media.thumbnail', $path) : null;
|
||||
|
||||
// Every placeholder from the artifact renders regardless of whether the
|
||||
// data exists — CSS `.vs-nullable:empty { display: none }` hides any
|
||||
// element that came back empty, so the layout adapts but the DOM shape
|
||||
// matches the original design file 1:1.
|
||||
$vsStr = fn ($x) => (is_string($x) && trim($x) !== '') ? trim($x) : '';
|
||||
|
||||
$vsEvent = $vsStr($hd['championship'] ?? '');
|
||||
// Sub-header stage between the two yellow lines above the weight class.
|
||||
// Sourced from headerData().stage (championship_name → division → format).
|
||||
$vsStage = $vsStr($hd['stage'] ?? '');
|
||||
$vsWeight = $vsStr($hd['weight_category'] ?? '');
|
||||
$vsMatchNo = $vsStr($hd['match_number'] ?? '');
|
||||
$vsCourt = $vsStr($hd['court'] ?? '');
|
||||
$vsRef = $vsStr($hd['referee']['name'] ?? '');
|
||||
$vsRefFlag = $vsStr($hd['referee']['flag'] ?? '');
|
||||
$vsVenue = $vsStr($hd['venue']['name'] ?? '');
|
||||
|
||||
// Extra per-fighter chips from the artifact (record / rank / stats).
|
||||
// headerData() doesn't expose them today; fall back to the raw
|
||||
// participants JSON so the placeholders wire up the moment the fields
|
||||
// start being written by the uploader form.
|
||||
$vsParts = $video->sportsMatch?->participants ?? [];
|
||||
$vsRedRecord = $vsStr($vsParts['p2_record'] ?? '');
|
||||
$vsRedRank = $vsStr($vsParts['p2_rank'] ?? '');
|
||||
$vsRedStats = $vsStr($vsParts['p2_stats'] ?? '');
|
||||
$vsBlueRecord = $vsStr($vsParts['p1_record'] ?? '');
|
||||
$vsBlueRank = $vsStr($vsParts['p1_rank'] ?? '');
|
||||
$vsBlueStats = $vsStr($vsParts['p1_stats'] ?? '');
|
||||
|
||||
// Fighter identity strings.
|
||||
$vsRedName = $vsStr($vsRed['name'] ?? '');
|
||||
$vsBlueName = $vsStr($vsBlue['name'] ?? '');
|
||||
$vsRedClub = $vsStr($vsRed['club'] ?? '');
|
||||
$vsBlueClub = $vsStr($vsBlue['club'] ?? '');
|
||||
$vsRedFlag = $vsStr($vsRed['flag'] ?? '');
|
||||
$vsBlueFlag = $vsStr($vsBlue['flag'] ?? '');
|
||||
$vsRedLogo = $vsImg($vsRed['club_logo'] ?? null);
|
||||
$vsBlueLogo = $vsImg($vsBlue['club_logo'] ?? null);
|
||||
@endphp
|
||||
|
||||
@if ($vsShow)
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Anton&family=Barlow+Condensed:wght@400;600;700;800&display=swap">
|
||||
|
||||
<div id="vsScreen" class="vs-screen" role="dialog" aria-label="Match introduction">
|
||||
<button type="button" class="vs-skip" id="vsSkip" aria-label="Skip intro">
|
||||
Skip <span id="vsSkipTimer">6</span>
|
||||
</button>
|
||||
|
||||
{{-- Fixed 1920×1080 (or 1080×1920 in portrait) canvas.
|
||||
JS sets --vs-scale on #vsScreen to fit whichever parent this sits in. --}}
|
||||
<div class="vs-stage">
|
||||
|
||||
{{-- RED side panel --}}
|
||||
<div class="vs-panel vs-panel-red">
|
||||
<div class="vs-panel-photo vs-panel-photo-red"
|
||||
@if($vsImg($vsRed['headshot'] ?? null)) style="background-image:url('{{ $vsImg($vsRed['headshot']) }}')"@endif></div>
|
||||
<div class="vs-panel-scrim vs-panel-scrim-red"></div>
|
||||
<div class="vs-panel-gradient"></div>
|
||||
</div>
|
||||
|
||||
{{-- BLUE side panel --}}
|
||||
<div class="vs-panel vs-panel-blue">
|
||||
<div class="vs-panel-photo vs-panel-photo-blue"
|
||||
@if($vsImg($vsBlue['headshot'] ?? null)) style="background-image:url('{{ $vsImg($vsBlue['headshot']) }}')"@endif></div>
|
||||
<div class="vs-panel-scrim vs-panel-scrim-blue"></div>
|
||||
<div class="vs-panel-gradient"></div>
|
||||
</div>
|
||||
|
||||
{{-- Center divider glow line --}}
|
||||
<div class="vs-divider"></div>
|
||||
|
||||
{{-- ══ RED fighter identity (left) — every placeholder from the
|
||||
original artifact is always in the DOM. Empty ones are hidden
|
||||
by `.vs-nullable:empty { display: none }` in the CSS below. ══ --}}
|
||||
<div class="vs-info vs-info-red">
|
||||
<div class="vs-corner-tag vs-corner-tag-red">AKA · RED</div>
|
||||
<div class="vs-flag-row vs-nullable-row">
|
||||
<span class="vs-flag fi fi-{{ $vsFlagBg($vsRedFlag) }} vs-flag-slot {{ $vsRedFlag === '' ? 'vs-nullable-hide' : '' }}"></span>
|
||||
<div class="vs-country vs-country-red vs-nullable">{{ $vsRedFlag !== '' ? strtoupper($vsCountryName($vsRedFlag) ?? $vsRedFlag) : '' }}</div>
|
||||
</div>
|
||||
<div class="vs-name vs-nullable">{{ $vsRedName }}</div>
|
||||
<div class="vs-club-row vs-nullable-row">
|
||||
<div class="vs-club-logo vs-club-logo-slot {{ $vsRedLogo ? '' : 'vs-nullable-hide' }}"
|
||||
@if($vsRedLogo) style="background-image:url('{{ $vsRedLogo }}')"@endif></div>
|
||||
<div class="vs-club vs-nullable">{{ $vsRedClub }}</div>
|
||||
</div>
|
||||
<div class="vs-chips-row vs-nullable-row">
|
||||
<div class="vs-chip-fighter vs-nullable">{{ $vsRedRecord }}</div>
|
||||
<div class="vs-chip-fighter vs-chip-fighter-gold vs-nullable">{{ $vsRedRank }}</div>
|
||||
<div class="vs-chip-fighter vs-nullable">{{ $vsRedStats }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ══ BLUE fighter identity (right) ══ --}}
|
||||
<div class="vs-info vs-info-blue">
|
||||
<div class="vs-corner-tag vs-corner-tag-blue">AO · BLUE</div>
|
||||
<div class="vs-flag-row vs-flag-row-r vs-nullable-row">
|
||||
<span class="vs-flag fi fi-{{ $vsFlagBg($vsBlueFlag) }} vs-flag-slot {{ $vsBlueFlag === '' ? 'vs-nullable-hide' : '' }}"></span>
|
||||
<div class="vs-country vs-country-blue vs-nullable">{{ $vsBlueFlag !== '' ? strtoupper($vsCountryName($vsBlueFlag) ?? $vsBlueFlag) : '' }}</div>
|
||||
</div>
|
||||
<div class="vs-name vs-nullable">{{ $vsBlueName }}</div>
|
||||
<div class="vs-club-row vs-club-row-r vs-nullable-row">
|
||||
<div class="vs-club-logo vs-club-logo-slot {{ $vsBlueLogo ? '' : 'vs-nullable-hide' }}"
|
||||
@if($vsBlueLogo) style="background-image:url('{{ $vsBlueLogo }}')"@endif></div>
|
||||
<div class="vs-club vs-nullable">{{ $vsBlueClub }}</div>
|
||||
</div>
|
||||
<div class="vs-chips-row vs-nullable-row">
|
||||
<div class="vs-chip-fighter vs-nullable">{{ $vsBlueRecord }}</div>
|
||||
<div class="vs-chip-fighter vs-chip-fighter-gold vs-nullable">{{ $vsBlueRank }}</div>
|
||||
<div class="vs-chip-fighter vs-nullable">{{ $vsBlueStats }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Top: event + stage + weight (all three always in DOM) --}}
|
||||
<div class="vs-top">
|
||||
<div class="vs-top-event vs-nullable">{{ $vsEvent }}</div>
|
||||
<div class="vs-top-stage-row {{ $vsStage === '' ? 'vs-nullable-hide' : '' }}">
|
||||
<div class="vs-top-stage-line vs-top-stage-line-l"></div>
|
||||
<div class="vs-top-stage">{{ $vsStage }}</div>
|
||||
<div class="vs-top-stage-line vs-top-stage-line-r"></div>
|
||||
</div>
|
||||
<div class="vs-top-weight vs-nullable">{{ $vsWeight }}</div>
|
||||
</div>
|
||||
|
||||
{{-- Center VS --}}
|
||||
<div class="vs-center">
|
||||
<div class="vs-word">VS
|
||||
<div class="vs-shine-wrap"><div class="vs-shine"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vs-flash"></div>
|
||||
|
||||
{{-- Bottom: match + court + referee chips. All three chips + both
|
||||
diamond separators are always in the DOM; each chip hides when
|
||||
its value is empty and each diamond hides when either neighbour
|
||||
is hidden (via :has() below). --}}
|
||||
<div class="vs-bottom">
|
||||
<div class="vs-chip vs-nullable-chip" data-vs-empty="{{ $vsMatchNo === '' ? '1' : '0' }}">
|
||||
<span class="vs-chip-label">Match</span>
|
||||
<span class="vs-chip-value vs-nullable">{{ $vsMatchNo }}</span>
|
||||
</div>
|
||||
<div class="vs-diamond vs-diamond-match-court"></div>
|
||||
<div class="vs-chip vs-nullable-chip" data-vs-empty="{{ $vsCourt === '' ? '1' : '0' }}">
|
||||
<span class="vs-chip-label">Court</span>
|
||||
<span class="vs-chip-value vs-nullable">{{ $vsCourt }}</span>
|
||||
</div>
|
||||
<div class="vs-diamond vs-diamond-court-ref"></div>
|
||||
<div class="vs-chip vs-nullable-chip" data-vs-empty="{{ $vsRef === '' ? '1' : '0' }}">
|
||||
<span class="vs-chip-label">Referee</span>
|
||||
<span class="vs-chip-value vs-chip-value-name vs-nullable">{{ $vsRef }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
Fixed 1920×1080 canvas, transform-scaled to fit its parent (.ytp
|
||||
inside the player, or .vs-preview-stage on the preview page). Every
|
||||
child dimension is in ABSOLUTE PIXELS on that canvas — never vw/vh —
|
||||
so the layout is identical whether the box is 400 px or 4000 px wide.
|
||||
JS writes --vs-scale via ResizeObserver on the parent.
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
#vsScreen.vs-screen {
|
||||
position: absolute; inset: 0; z-index: 40;
|
||||
background: #050507;
|
||||
font-family: 'Barlow Condensed', sans-serif;
|
||||
color: #e8e6e0;
|
||||
overflow: hidden;
|
||||
opacity: 1;
|
||||
transition: opacity .35s ease;
|
||||
--vs-scale: 1;
|
||||
--vs-stage-w: 1920px;
|
||||
--vs-stage-h: 1080px;
|
||||
}
|
||||
#vsScreen.vs-hide { opacity: 0; pointer-events: none; }
|
||||
|
||||
#vsScreen .vs-stage {
|
||||
position: absolute;
|
||||
left: 50%; top: 50%;
|
||||
width: var(--vs-stage-w); height: var(--vs-stage-h);
|
||||
transform: translate(-50%, -50%) scale(var(--vs-scale));
|
||||
transform-origin: center center;
|
||||
background: radial-gradient(120% 90% at 50% 40%, #16161f 0%, #0a0a0e 65%, #050507 100%);
|
||||
overflow: hidden;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* Skip button lives OUTSIDE the scaled stage so it stays at real pixels
|
||||
in the top-right of the video area at any container size. */
|
||||
#vsScreen .vs-skip {
|
||||
position: absolute; top: 12px; right: 14px; z-index: 50;
|
||||
background: rgba(10,10,14,.72);
|
||||
color: #fff; border: 1px solid rgba(255,255,255,.28);
|
||||
padding: 6px 14px;
|
||||
font: 700 12px/1 'Barlow Condensed', sans-serif;
|
||||
letter-spacing: .22em; text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(6px);
|
||||
transition: background .15s, border-color .15s;
|
||||
}
|
||||
#vsScreen .vs-skip:hover { background: rgba(10,10,14,.92); border-color: rgba(255,255,255,.55); }
|
||||
#vsScreen .vs-skip #vsSkipTimer { display: inline-block; margin-left: 6px; opacity: .8; }
|
||||
|
||||
/* ══ EVERYTHING BELOW: absolute pixels on the 1920×1080 canvas ══ */
|
||||
|
||||
/* ── Side panels ── */
|
||||
#vsScreen .vs-panel { position: absolute; overflow: hidden; }
|
||||
#vsScreen .vs-panel-red {
|
||||
inset: 0 auto 0 0; width: 56%;
|
||||
background: oklch(0.28 0.09 25);
|
||||
clip-path: polygon(0 0, 100% 0, 82% 100%, 0 100%);
|
||||
animation: vsPanelL .9s cubic-bezier(.22,1,.36,1) both;
|
||||
}
|
||||
#vsScreen .vs-panel-blue {
|
||||
inset: 0 0 0 auto; width: 56%;
|
||||
background: oklch(0.28 0.09 255);
|
||||
clip-path: polygon(18% 0, 100% 0, 100% 100%, 0 100%);
|
||||
animation: vsPanelR .9s cubic-bezier(.22,1,.36,1) both;
|
||||
}
|
||||
#vsScreen .vs-panel-photo {
|
||||
position: absolute; inset: 0;
|
||||
background-size: cover; background-position: center;
|
||||
animation: vsSlowDrift 18s ease-in-out infinite;
|
||||
}
|
||||
#vsScreen .vs-panel-photo-blue { animation-direction: reverse; }
|
||||
#vsScreen .vs-panel-scrim-red { position: absolute; inset: 0; pointer-events: none;
|
||||
background: linear-gradient(115deg, oklch(0.45 0.18 25 / 0.55) 0%, transparent 55%); }
|
||||
#vsScreen .vs-panel-scrim-blue { position: absolute; inset: 0; pointer-events: none;
|
||||
background: linear-gradient(245deg, oklch(0.45 0.15 255 / 0.55) 0%, transparent 55%); }
|
||||
#vsScreen .vs-panel-gradient {
|
||||
position: absolute; inset: 0; pointer-events: none;
|
||||
background:
|
||||
linear-gradient(to top, rgba(5,5,7,.95) 0%, rgba(5,5,7,.72) 22%, rgba(5,5,7,.30) 42%, transparent 62%),
|
||||
linear-gradient(to bottom, rgba(5,5,7,.82) 0%, rgba(5,5,7,.35) 18%, transparent 32%);
|
||||
}
|
||||
|
||||
/* ── Divider ── */
|
||||
#vsScreen .vs-divider {
|
||||
position: absolute; top: -6%; bottom: -6%; left: 50%;
|
||||
width: 3px; margin-left: -1.5px;
|
||||
transform: rotate(10.15deg);
|
||||
background: linear-gradient(to bottom, transparent, oklch(0.85 0.16 85 / .9) 20%, oklch(0.85 0.16 85 / .9) 80%, transparent);
|
||||
pointer-events: none; filter: blur(1px);
|
||||
}
|
||||
|
||||
/* ── Fighter identity blocks ── (absolute px on the 1920×1080 canvas) */
|
||||
#vsScreen .vs-info {
|
||||
position: absolute; z-index: 6; max-width: 44%;
|
||||
display: flex; flex-direction: column; gap: 13px;
|
||||
}
|
||||
#vsScreen .vs-info-red { left: 43.2px; bottom: 118.8px; align-items: flex-start; animation: vsRiseUp .8s .7s cubic-bezier(.22,1,.36,1) both; }
|
||||
#vsScreen .vs-info-blue { right: 43.2px; bottom: 118.8px; align-items: flex-end; text-align: right; animation: vsRiseUp .8s .85s cubic-bezier(.22,1,.36,1) both; }
|
||||
|
||||
#vsScreen .vs-corner-tag {
|
||||
font: 800 21.6px/1 'Barlow Condensed', sans-serif;
|
||||
letter-spacing: .35em; color: #fff;
|
||||
padding: 5.4px 15.1px 5.4px 18.9px;
|
||||
}
|
||||
#vsScreen .vs-corner-tag-red { background: oklch(0.55 0.20 25); }
|
||||
#vsScreen .vs-corner-tag-blue { background: oklch(0.50 0.16 255); }
|
||||
|
||||
#vsScreen .vs-flag-row { display: flex; align-items: center; gap: 15.1px; }
|
||||
#vsScreen .vs-flag-row-r { flex-direction: row-reverse; }
|
||||
#vsScreen .vs-flag {
|
||||
width: 56.2px; height: auto; aspect-ratio: 4/3;
|
||||
background-size: cover !important; background-position: center !important;
|
||||
border: 1px solid rgba(255,255,255,.35);
|
||||
box-shadow: 0 4px 18px rgba(0,0,0,.6);
|
||||
display: inline-block; line-height: 0;
|
||||
}
|
||||
#vsScreen .vs-country { font: 700 32.4px/1 'Barlow Condensed', sans-serif; letter-spacing: .28em; }
|
||||
#vsScreen .vs-country-red { color: oklch(0.85 0.05 25); }
|
||||
#vsScreen .vs-country-blue { color: oklch(0.85 0.05 255); }
|
||||
|
||||
#vsScreen .vs-name {
|
||||
font-family: 'Anton', 'Barlow Condensed', sans-serif;
|
||||
font-size: 71.3px; line-height: .95;
|
||||
text-transform: uppercase; color: #fff;
|
||||
text-shadow: 0 6px 30px rgba(0,0,0,.8);
|
||||
}
|
||||
|
||||
#vsScreen .vs-club-row { display: flex; align-items: center; gap: 13px; margin-top: 4.3px; }
|
||||
#vsScreen .vs-club-row-r { flex-direction: row-reverse; }
|
||||
#vsScreen .vs-club-logo {
|
||||
width: 69.1px; height: 69.1px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,.06);
|
||||
background-size: cover; background-position: center;
|
||||
border: 1px solid rgba(255,255,255,.2);
|
||||
}
|
||||
#vsScreen .vs-club {
|
||||
font: 600 30.2px/1.1 'Barlow Condensed', sans-serif;
|
||||
letter-spacing: .12em; text-transform: uppercase;
|
||||
color: rgba(232,230,224,.9);
|
||||
}
|
||||
|
||||
/* Fighter chips row (Record / Rank / Stats) — from the original artifact. */
|
||||
#vsScreen .vs-chips-row {
|
||||
display: flex; flex-wrap: wrap; gap: 9.7px; margin-top: 5.4px;
|
||||
}
|
||||
#vsScreen .vs-info-blue .vs-chips-row { justify-content: flex-end; }
|
||||
#vsScreen .vs-chip-fighter {
|
||||
font: 700 23.8px/1 'Barlow Condensed', sans-serif;
|
||||
letter-spacing: .12em; text-transform: uppercase;
|
||||
padding: 5.4px 14px;
|
||||
background: rgba(10,10,14,.62);
|
||||
border: 1px solid rgba(255,255,255,.25);
|
||||
color: #fff;
|
||||
}
|
||||
#vsScreen .vs-chip-fighter-gold {
|
||||
border-color: oklch(0.85 0.16 85 / .6);
|
||||
color: oklch(0.87 0.14 85);
|
||||
}
|
||||
|
||||
/* ══ Placeholder hiding ══
|
||||
Every placeholder DOM element is always rendered. Empty ones are hidden
|
||||
here — this keeps the design's HTML shape intact (same as the artifact)
|
||||
while adapting the visible layout to whatever data actually exists. */
|
||||
#vsScreen .vs-nullable:empty { display: none; }
|
||||
/* When every direct child of a "row" wrapper is hidden, the row collapses too. */
|
||||
#vsScreen .vs-nullable-row:not(:has(> :not(.vs-nullable-hide):not(.vs-nullable:empty))) { display: none; }
|
||||
/* Image slots explicitly marked as missing (flag / club logo without data). */
|
||||
#vsScreen .vs-nullable-hide { display: none; }
|
||||
|
||||
/* Bottom chips: whole chip hides when its value is empty, and the
|
||||
diamond separators next to a hidden chip hide too. */
|
||||
#vsScreen .vs-chip.vs-nullable-chip[data-vs-empty="1"] { display: none; }
|
||||
#vsScreen .vs-bottom .vs-diamond-match-court:has(+ .vs-chip[data-vs-empty="1"]),
|
||||
#vsScreen .vs-bottom .vs-chip[data-vs-empty="1"] + .vs-diamond-court-ref,
|
||||
#vsScreen .vs-bottom .vs-chip[data-vs-empty="1"] + .vs-diamond-match-court,
|
||||
#vsScreen .vs-bottom .vs-diamond-court-ref:has(+ .vs-chip[data-vs-empty="1"]) { display: none; }
|
||||
|
||||
/* ── Top (event / stage / weight) ── */
|
||||
#vsScreen .vs-top {
|
||||
position: absolute; top: 34.6px; left: 50%; transform: translateX(-50%);
|
||||
display: flex; flex-direction: column; align-items: center; gap: 10.8px;
|
||||
z-index: 8; width: 92%; pointer-events: none;
|
||||
animation: vsDropIn .8s .5s cubic-bezier(.22,1,.36,1) both;
|
||||
}
|
||||
#vsScreen .vs-top-event {
|
||||
font: 700 30.2px/1.05 'Barlow Condensed', sans-serif;
|
||||
letter-spacing: .42em; text-transform: uppercase;
|
||||
color: rgba(232,230,224,.92); text-align: center;
|
||||
text-shadow: 0 2px 14px rgba(0,0,0,.9);
|
||||
}
|
||||
#vsScreen .vs-top-stage-row { display: flex; align-items: center; gap: 17.3px; }
|
||||
#vsScreen .vs-top-stage-line { height: 2px; width: 64.8px; }
|
||||
#vsScreen .vs-top-stage-line-l { background: linear-gradient(to left, oklch(0.85 0.16 85), transparent); }
|
||||
#vsScreen .vs-top-stage-line-r { background: linear-gradient(to right, oklch(0.85 0.16 85), transparent); }
|
||||
#vsScreen .vs-top-stage {
|
||||
font-family: 'Anton', 'Barlow Condensed', sans-serif;
|
||||
font-size: 38.9px;
|
||||
letter-spacing: .30em; padding-left: .3em;
|
||||
color: oklch(0.85 0.16 85); text-transform: uppercase;
|
||||
}
|
||||
#vsScreen .vs-top-weight {
|
||||
font: 700 42px/1.1 'Anton', 'Barlow Condensed', sans-serif;
|
||||
letter-spacing: .28em; padding-left: .28em; text-transform: uppercase;
|
||||
color: #ffffff;
|
||||
text-shadow: 0 2px 14px rgba(0,0,0,.9);
|
||||
}
|
||||
|
||||
/* ── Center VS ── */
|
||||
#vsScreen .vs-center {
|
||||
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -52%);
|
||||
z-index: 7; pointer-events: none;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
animation: vsSlam .7s 1.1s cubic-bezier(.22,1,.36,1) both;
|
||||
}
|
||||
#vsScreen .vs-word {
|
||||
position: relative;
|
||||
font-family: 'Anton', 'Barlow Condensed', sans-serif;
|
||||
font-size: 183.6px;
|
||||
font-style: italic;
|
||||
color: #fffdf5; line-height: 1;
|
||||
-webkit-text-stroke: 2px oklch(0.85 0.16 85 / 0.6);
|
||||
animation: vsPulse 2.4s ease-in-out infinite;
|
||||
overflow: visible;
|
||||
}
|
||||
#vsScreen .vs-shine-wrap { position: absolute; inset: -10% -20%; overflow: hidden; pointer-events: none; }
|
||||
#vsScreen .vs-shine {
|
||||
position: absolute; top: 0; bottom: 0; width: 34%;
|
||||
background: linear-gradient(to right, transparent, rgba(255,255,255,.16), transparent);
|
||||
animation: vsShine 5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
#vsScreen .vs-flash {
|
||||
position: absolute; inset: 0; background: #fff;
|
||||
opacity: 0; pointer-events: none; z-index: 9;
|
||||
animation: vsFlash .9s 1.5s ease-out both;
|
||||
}
|
||||
|
||||
/* ── Bottom chips ── */
|
||||
#vsScreen .vs-bottom {
|
||||
position: absolute; bottom: 32.4px; left: 50%; transform: translateX(-50%);
|
||||
display: flex; gap: 17.3px; z-index: 8; align-items: center;
|
||||
flex-wrap: wrap; justify-content: center; max-width: 94%;
|
||||
animation: vsRiseC .8s 1.3s cubic-bezier(.22,1,.36,1) both;
|
||||
}
|
||||
#vsScreen .vs-chip {
|
||||
display: flex; align-items: baseline; gap: 8.6px;
|
||||
background: rgba(10,10,14,.72);
|
||||
border: 1px solid oklch(0.85 0.16 85 / .45);
|
||||
padding: 10.8px 23.8px; backdrop-filter: blur(6px);
|
||||
}
|
||||
#vsScreen .vs-chip-label {
|
||||
font: 600 23.8px/1 'Barlow Condensed', sans-serif;
|
||||
letter-spacing: .30em; text-transform: uppercase;
|
||||
color: rgba(232,230,224,.65);
|
||||
}
|
||||
#vsScreen .vs-chip-value {
|
||||
font-family: 'Anton', 'Barlow Condensed', sans-serif;
|
||||
font-size: 34.6px; color: #fff;
|
||||
}
|
||||
#vsScreen .vs-chip-value-name {
|
||||
font-family: 'Barlow Condensed', sans-serif;
|
||||
font-weight: 700; letter-spacing: .08em; text-transform: uppercase;
|
||||
font-size: 28.1px;
|
||||
}
|
||||
#vsScreen .vs-diamond {
|
||||
width: 6px; height: 6px; transform: rotate(45deg);
|
||||
background: oklch(0.85 0.16 85);
|
||||
}
|
||||
|
||||
/* ── Portrait canvas (1080×1920): stack layout ── */
|
||||
#vsScreen.vs-portrait { --vs-stage-w: 1080px; --vs-stage-h: 1920px; }
|
||||
#vsScreen.vs-portrait .vs-panel-red { inset: 0 0 auto 0; width: 100%; height: 56%;
|
||||
clip-path: polygon(0 0, 100% 0, 100% 82%, 0 96%); animation-name: vsPanelT; }
|
||||
#vsScreen.vs-portrait .vs-panel-blue { inset: auto 0 0 0; width: 100%; height: 56%;
|
||||
clip-path: polygon(0 18%, 100% 4%, 100% 100%, 0 100%); animation-name: vsPanelB; }
|
||||
#vsScreen.vs-portrait .vs-divider {
|
||||
left: -4%; right: -4%; top: 50%; bottom: auto;
|
||||
width: auto; height: 3px; margin-top: -1.5px; margin-left: 0;
|
||||
transform: rotate(-4.48deg);
|
||||
background: linear-gradient(to right, transparent, oklch(0.85 0.16 85 / .9) 20%, oklch(0.85 0.16 85 / .9) 80%, transparent);
|
||||
}
|
||||
#vsScreen.vs-portrait .vs-info-red { left: 43.2px; top: 129.6px; bottom: auto; }
|
||||
#vsScreen.vs-portrait .vs-info-blue { right: 43.2px; bottom: 129.6px; top: auto; }
|
||||
|
||||
/* ── Animations ── */
|
||||
@keyframes vsPulse {
|
||||
0%,100% { text-shadow: 0 0 30px rgba(255,215,120,.55), 0 0 90px rgba(255,170,60,.3); transform: scale(1); }
|
||||
50% { text-shadow: 0 0 65px rgba(255,220,130,1), 0 0 160px rgba(255,170,60,.7); transform: scale(1.045); }
|
||||
}
|
||||
@keyframes vsShine { 0% { transform: translateX(-130%) skewX(-18deg); } 60%,100% { transform: translateX(230%) skewX(-18deg); } }
|
||||
@keyframes vsSlowDrift { 0% { transform: translate3d(0,0,0) scale(1.02); } 50% { transform: translate3d(0,-1.2%,0) scale(1.05); } 100% { transform: translate3d(0,0,0) scale(1.02); } }
|
||||
@keyframes vsPanelL { from { transform: translateX(-105%); } to { transform: translateX(0); } }
|
||||
@keyframes vsPanelR { from { transform: translateX( 105%); } to { transform: translateX(0); } }
|
||||
@keyframes vsPanelT { from { transform: translateY(-105%); } to { transform: translateY(0); } }
|
||||
@keyframes vsPanelB { from { transform: translateY( 105%); } to { transform: translateY(0); } }
|
||||
@keyframes vsRiseUp { from { opacity: 0; transform: translateY(43.2px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes vsRiseC { from { opacity: 0; transform: translate(-50%, 43.2px); } to { opacity: 1; transform: translate(-50%, 0); } }
|
||||
@keyframes vsDropIn { from { opacity: 0; transform: translate(-50%, -32.4px); } to { opacity: 1; transform: translate(-50%, 0); } }
|
||||
@keyframes vsSlam {
|
||||
0% { opacity: 0; transform: translate(-50%,-52%) scale(3.4) rotate(-6deg); }
|
||||
60% { opacity: 1; transform: translate(-50%,-52%) scale(.92) rotate(1deg); }
|
||||
80% { transform: translate(-50%,-52%) scale(1.06); }
|
||||
100% { opacity: 1; transform: translate(-50%,-52%) scale(1) rotate(0deg); }
|
||||
}
|
||||
@keyframes vsFlash { 0% { opacity: 0; } 12% { opacity: .85; } 100% { opacity: 0; } }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const VS_INTRO_SECONDS = 6;
|
||||
|
||||
const vs = document.getElementById('vsScreen');
|
||||
if (!vs) return;
|
||||
const video = document.getElementById('videoPlayer');
|
||||
const timerEl = document.getElementById('vsSkipTimer');
|
||||
let hidden = false, tickInt = null;
|
||||
|
||||
// ── Fit the fixed 1920×1080 (or 1080×1920) canvas to the parent box.
|
||||
function fit() {
|
||||
const host = vs.parentElement;
|
||||
if (!host) return;
|
||||
const w = host.clientWidth, h = host.clientHeight;
|
||||
if (!w || !h) return;
|
||||
const portrait = h > w * 1.05;
|
||||
vs.classList.toggle('vs-portrait', portrait);
|
||||
const sw = portrait ? 1080 : 1920;
|
||||
const sh = portrait ? 1920 : 1080;
|
||||
const scale = Math.min(w / sw, h / sh);
|
||||
vs.style.setProperty('--vs-scale', scale);
|
||||
}
|
||||
fit();
|
||||
if (window.ResizeObserver && vs.parentElement) {
|
||||
new ResizeObserver(fit).observe(vs.parentElement);
|
||||
} else {
|
||||
window.addEventListener('resize', fit);
|
||||
}
|
||||
|
||||
// ── Hold the video at t=0 while the intro counts down ──────────
|
||||
// Autoplay may already have kicked in before this script ran; pause
|
||||
// it, keep it pinned at the start, and only release it at 0.
|
||||
function pinVideo() {
|
||||
if (!video) return;
|
||||
try { video.pause(); } catch (_) {}
|
||||
try { video.currentTime = 0; } catch (_) {}
|
||||
}
|
||||
pinVideo();
|
||||
// Some browsers fire 'play' immediately after we pause (autoplay
|
||||
// retry). Re-pause until the intro finishes.
|
||||
const playGuard = () => { if (!hidden) pinVideo(); };
|
||||
if (video) video.addEventListener('play', playGuard);
|
||||
|
||||
function releaseVideo() {
|
||||
if (!video) return;
|
||||
video.removeEventListener('play', playGuard);
|
||||
try { video.currentTime = 0; } catch (_) {}
|
||||
const p = video.play();
|
||||
if (p && typeof p.catch === 'function') p.catch(() => {});
|
||||
}
|
||||
|
||||
// ── Dismiss overlay + start playback ───────────────────────────
|
||||
function hide() {
|
||||
if (hidden) return;
|
||||
hidden = true;
|
||||
if (tickInt) { clearInterval(tickInt); tickInt = null; }
|
||||
releaseVideo();
|
||||
vs.classList.add('vs-hide');
|
||||
setTimeout(() => { vs.remove(); }, 400);
|
||||
}
|
||||
|
||||
document.getElementById('vsSkip')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation(); e.preventDefault(); hide();
|
||||
});
|
||||
|
||||
// ── Real-time countdown, independent of video timeline ─────────
|
||||
const started = performance.now();
|
||||
function tick() {
|
||||
const elapsed = (performance.now() - started) / 1000;
|
||||
const left = Math.max(0, VS_INTRO_SECONDS - elapsed);
|
||||
if (timerEl) timerEl.textContent = Math.ceil(left);
|
||||
if (left <= 0) hide();
|
||||
}
|
||||
tick();
|
||||
tickInt = setInterval(tick, 100);
|
||||
})();
|
||||
</script>
|
||||
@endif
|
||||
@@ -1,6 +1,6 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Shorts - ' . config('app.name'))
|
||||
@section('title', 'Shorts | ' . config('app.name'))
|
||||
|
||||
@section('main_class', 'shorts-view-page')
|
||||
|
||||
@@ -64,6 +64,20 @@
|
||||
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 ── */
|
||||
.short-pause-icon {
|
||||
position: absolute;
|
||||
@@ -337,18 +351,21 @@
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Video player --}}
|
||||
<div class="short-player-wrap" id="wrap-{{ $index }}" onclick="toggleShortPlay({{ $index }})">
|
||||
{{-- Video player wrap. The actual <video> element is a SINGLE shared
|
||||
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
|
||||
id="sv-{{ $index }}"
|
||||
src="{{ $src }}"
|
||||
poster="{{ $thumb }}"
|
||||
loop
|
||||
playsinline
|
||||
preload="{{ $loop->first ? 'auto' : 'none' }}"
|
||||
style="width:100%;height:100%;object-fit:cover;">
|
||||
</video>
|
||||
{{-- Poster shim: covers the slot before the shared <video> moves in.
|
||||
Also stays visible on inactive slots so the viewport doesn't
|
||||
go blank when the user is between shorts. --}}
|
||||
<div class="short-poster"
|
||||
@if($thumb) style="background-image:url('{{ $thumb }}');" @endif></div>
|
||||
|
||||
{{-- Pause icon --}}
|
||||
<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>
|
||||
</button>
|
||||
|
||||
{{-- Comments --}}
|
||||
<a class="short-action-btn" href="{{ route('videos.show', $video) }}#comments">
|
||||
<div class="short-action-icon"><i class="bi bi-chat-fill"></i></div>
|
||||
{{-- Comments — opens the side panel on desktop, bottom sheet on
|
||||
mobile (CSS does the breakpoint; the button toggles a shared
|
||||
.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>
|
||||
</a>
|
||||
</button>
|
||||
|
||||
{{-- Share --}}
|
||||
<button class="short-action-btn" onclick="shortShare('{{ $vKey }}', {{ json_encode($video->title) }})">
|
||||
@@ -426,36 +446,182 @@
|
||||
|
||||
@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
|
||||
|
||||
@section('scripts')
|
||||
<script>
|
||||
(function () {
|
||||
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;
|
||||
|
||||
// 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 ─────────
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
entries.forEach(entry => {
|
||||
const item = entry.target;
|
||||
const idx = parseInt(item.dataset.index);
|
||||
const video = document.getElementById('sv-' + idx);
|
||||
if (!video) return;
|
||||
const wrap = document.getElementById('wrap-' + idx);
|
||||
if (!wrap) return;
|
||||
|
||||
if (entry.isIntersecting && entry.intersectionRatio >= 0.6) {
|
||||
if (current !== -1 && current !== idx) pauseShort(current);
|
||||
current = idx;
|
||||
|
||||
// ── Reflect the active short in the URL (no history entry) ──
|
||||
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.volume = 1;
|
||||
video.play().catch(() => {
|
||||
// autoplay blocked — start muted then unmute
|
||||
// autoplay blocked — fall back to muted
|
||||
video.muted = true;
|
||||
muted = true;
|
||||
localStorage.setItem('shortsMuted', '1');
|
||||
updateMuteIcon(idx);
|
||||
video.play().catch(() => {});
|
||||
});
|
||||
document.getElementById('wrap-' + idx).classList.remove('paused');
|
||||
wrap.classList.remove('paused');
|
||||
|
||||
_primePreloaders(idx);
|
||||
} else {
|
||||
pauseShort(idx);
|
||||
}
|
||||
@@ -466,9 +632,8 @@
|
||||
|
||||
// ── Play / pause on tap ────────────────────────────────────
|
||||
window.toggleShortPlay = function(idx) {
|
||||
const video = document.getElementById('sv-' + idx);
|
||||
const wrap = document.getElementById('wrap-' + idx);
|
||||
if (!video) return;
|
||||
if (!wrap || video.parentElement !== wrap) return;
|
||||
if (video.paused) {
|
||||
video.play().catch(() => {});
|
||||
wrap.classList.remove('paused');
|
||||
@@ -478,30 +643,23 @@
|
||||
}
|
||||
};
|
||||
|
||||
function pauseShort(idx) {
|
||||
const video = document.getElementById('sv-' + idx);
|
||||
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-', '');
|
||||
// ── Progress bar — a single listener on the shared video updates the
|
||||
// bar in whichever slot it's currently docked. --
|
||||
video.addEventListener('timeupdate', () => {
|
||||
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);
|
||||
if (prog) prog.style.width = ((video.currentTime / video.duration) * 100) + '%';
|
||||
});
|
||||
});
|
||||
|
||||
// ── Mute toggle ────────────────────────────────────────────
|
||||
window.toggleMute = function(idx) {
|
||||
muted = !muted;
|
||||
const video = document.getElementById('sv-' + idx);
|
||||
if (video) video.muted = muted;
|
||||
localStorage.setItem('shortsMuted', muted ? '1' : '0');
|
||||
// The shared video is the only one playing; mute it directly.
|
||||
video.muted = muted;
|
||||
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 ───────────────────────────────────────────────────
|
||||
window.shortLike = function(videoId) {
|
||||
@auth
|
||||
@@ -552,6 +726,37 @@
|
||||
@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 ──────────────────────────────────────────────────
|
||||
window.shortShare = function(videoKey, title) {
|
||||
const url = window.location.origin + '/videos/' + videoKey;
|
||||
|
||||
@@ -836,15 +836,22 @@
|
||||
@if ($recommendedVideos && $recommendedVideos->count() > 0)
|
||||
<div class="recommended-videos-list">
|
||||
@foreach ($recommendedVideos as $recVideo)
|
||||
<div class="sidebar-video-card"
|
||||
onclick="window.location.href='{{ route('videos.show', $recVideo) }}'">
|
||||
{{-- Real <a href> cards so right-click / open-in-new-tab /
|
||||
middle-click and screen readers all work. The SPA
|
||||
transition hooks in types/*.blade.php still intercept
|
||||
these clicks via data-rec-url for a soft load. --}}
|
||||
<a class="sidebar-video-card"
|
||||
href="{{ route('videos.show', $recVideo) }}"
|
||||
data-rec-url="{{ route('videos.show', $recVideo) }}">
|
||||
<div class="sidebar-thumb" style="position: relative;">
|
||||
@if ($recVideo->thumbnail)
|
||||
<img src="{{ route('media.thumbnail', $recVideo->thumbnail) }}"
|
||||
alt="{{ $recVideo->title }}">
|
||||
alt="{{ $recVideo->title }}" width="168" height="94"
|
||||
loading="lazy" decoding="async">
|
||||
@else
|
||||
<img src="https://picsum.photos/seed/{{ $recVideo->id }}/320/180"
|
||||
alt="{{ $recVideo->title }}">
|
||||
alt="{{ $recVideo->title }}" width="168" height="94"
|
||||
loading="lazy" decoding="async">
|
||||
@endif
|
||||
@if ($recVideo->duration)
|
||||
<span class="yt-video-duration">{{ gmdate('i:s', $recVideo->duration) }}</span>
|
||||
@@ -852,14 +859,15 @@
|
||||
@if ($recVideo->is_shorts)
|
||||
<span class="yt-shorts-badge"
|
||||
style="position: absolute; top: 8px; left: 8px; font-size: 10px; padding: 2px 6px;">
|
||||
<i class="bi bi-collection-play-fill"></i> SHORTS
|
||||
<i class="bi bi-collection-play-fill" aria-hidden="true"></i> SHORTS
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="sidebar-info">
|
||||
<div class="sidebar-title">
|
||||
<i class="bi {{ match ($recVideo->type) {'music' => 'bi-music-note','match' => 'bi-trophy',default => 'bi-film'} }}"
|
||||
style="color: #ef4444; margin-right: 4px; font-size: 12px;"></i>
|
||||
style="color: #ef4444; margin-right: 4px; font-size: 12px;"
|
||||
aria-hidden="true"></i>
|
||||
{{ Str::limit($recVideo->title, 60) }}
|
||||
</div>
|
||||
<div class="sidebar-meta">
|
||||
@@ -868,7 +876,7 @@
|
||||
{{ $recVideo->created_at->diffForHumans() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
|
||||
@@ -177,9 +177,13 @@
|
||||
<div class="trending-header">
|
||||
<h1><i class="bi bi-fire trending-icon"></i> Trending</h1>
|
||||
<div class="trending-filters">
|
||||
<a href="{{ route('videos.trending', ['hours' => 24]) }}" class="{{ $hours == 24 ? 'active' : '' }}">Today</a>
|
||||
<a href="{{ route('videos.trending', ['hours' => 48]) }}" class="{{ $hours == 48 ? 'active' : '' }}">This Week</a>
|
||||
<a href="{{ route('videos.trending', ['hours' => 168]) }}" class="{{ $hours == 168 ? 'active' : '' }}">This Month</a>
|
||||
{{-- Hours line up with the labels: 24 = Today, 168 = This Week
|
||||
(7×24), 720 = This Month (30×24). The old 24/48/168 trio
|
||||
claimed Month but silently showed Week data because the
|
||||
controller clamped the max to 168. --}}
|
||||
<a href="{{ route('videos.trending', ['hours' => 24]) }}" class="{{ $hours == 24 ? 'active' : '' }}" @if($hours == 24) aria-current="page"@endif>Today</a>
|
||||
<a href="{{ route('videos.trending', ['hours' => 168]) }}" class="{{ $hours == 168 ? 'active' : '' }}" @if($hours == 168) aria-current="page"@endif>This Week</a>
|
||||
<a href="{{ route('videos.trending', ['hours' => 720]) }}" class="{{ $hours == 720 ? 'active' : '' }}" @if($hours == 720) aria-current="page"@endif>This Month</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -307,7 +307,10 @@
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
transition: background .15s;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.sidebar-video-card:hover { background: rgba(255,255,255,.05); }
|
||||
|
||||
.sidebar-video-card.current-video {
|
||||
background: rgba(239,68,68,.12);
|
||||
@@ -366,6 +369,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Narrow desktop: too little room for a side rail ──────────────
|
||||
Between 992px and 1300px the left nav (240px) and the 300px rail
|
||||
leave the video column under ~700px, so the Up Next titles collapse
|
||||
into a ~124px channel. Trigger the SAME stacking the ≤991px layout
|
||||
already does, just earlier. Nothing is removed — the ≤991px block
|
||||
still owns the phone treatment (thumb-on-top cards). */
|
||||
@media (max-width: 1300px) {
|
||||
.video-layout-container {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.yt-video-section {
|
||||
width: 100% !important;
|
||||
flex: none !important;
|
||||
}
|
||||
|
||||
.yt-sidebar-container {
|
||||
width: 100% !important;
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.yt-main {
|
||||
margin-left: 0;
|
||||
@@ -954,9 +980,13 @@
|
||||
@if ($recommendedVideos && $recommendedVideos->count() > 0)
|
||||
<div class="recommended-videos-list" id="recList">
|
||||
@foreach ($recommendedVideos as $recVideo)
|
||||
<div class="sidebar-video-card"
|
||||
{{-- Real <a href> so right-click / middle-click /
|
||||
open-in-new-tab works. recGoTo() still runs
|
||||
on a plain left-click for the SPA swap. --}}
|
||||
<a class="sidebar-video-card"
|
||||
href="{{ route('videos.show', $recVideo) }}"
|
||||
data-rec-url="{{ route('videos.show', $recVideo) }}"
|
||||
onclick="recGoTo('{{ route('videos.show', $recVideo) }}')"
|
||||
onclick="if (event.button===0 && !event.metaKey && !event.ctrlKey && !event.shiftKey) { event.preventDefault(); recGoTo('{{ route('videos.show', $recVideo) }}'); return false; }"
|
||||
style="cursor:pointer;">
|
||||
<div class="sidebar-thumb" style="position: relative;">
|
||||
@if ($recVideo->thumbnail)
|
||||
@@ -990,7 +1020,7 @@
|
||||
{{ $recVideo->created_at->diffForHumans() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
|
||||
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user