Compare commits
79
Commits
76f5ef0c84
..
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 | ||
|
|
5d038488fb | ||
|
|
2b5e480c9a | ||
|
|
19741f489b | ||
|
|
ed921a4ccf | ||
|
|
1c4e4986b5 | ||
|
|
55247828e1 | ||
|
|
cba8e01d56 | ||
|
|
716693a74f | ||
|
|
057ce87564 | ||
|
|
402c6dc89f | ||
|
|
ad6cbaf742 |
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(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ class MatchEventController extends Controller
|
||||
$request->validate([
|
||||
'round_number' => 'required|integer|min:1',
|
||||
'name' => 'nullable|string|max:50',
|
||||
'start_time_seconds' => 'nullable|integer|min:0',
|
||||
'start_time_seconds' => 'nullable|numeric|min:0',
|
||||
]);
|
||||
|
||||
// Check if user owns the video
|
||||
@@ -45,7 +45,7 @@ class MatchEventController extends Controller
|
||||
$request->validate([
|
||||
'round_number' => 'sometimes|integer|min:1',
|
||||
'name' => 'required|string|max:50',
|
||||
'start_time_seconds' => 'nullable|integer|min:0',
|
||||
'start_time_seconds' => 'nullable|numeric|min:0',
|
||||
]);
|
||||
|
||||
// Check if user owns the video
|
||||
@@ -87,7 +87,7 @@ class MatchEventController extends Controller
|
||||
{
|
||||
$request->validate([
|
||||
'round_id' => 'required|exists:match_rounds,id',
|
||||
'timestamp_seconds' => 'required|integer|min:0',
|
||||
'timestamp_seconds' => 'required|numeric|min:0',
|
||||
'action' => 'required|string|max:255',
|
||||
'points' => 'required|integer|min:1',
|
||||
'competitor' => 'required|in:blue,red',
|
||||
@@ -99,31 +99,10 @@ class MatchEventController extends Controller
|
||||
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
// Get ALL previous points in this round (ordered by timestamp)
|
||||
$previousPoints = MatchPoint::where('match_round_id', $request->round_id)
|
||||
->where('timestamp_seconds', '<', $request->timestamp_seconds)
|
||||
->orderBy('timestamp_seconds', 'asc')
|
||||
->pluck('points', 'competitor')
|
||||
->toArray();
|
||||
|
||||
// Calculate cumulative scores by summing each point value
|
||||
$scoreBlue = 0;
|
||||
$scoreRed = 0;
|
||||
|
||||
if (isset($previousPoints['blue'])) {
|
||||
$scoreBlue += $previousPoints['blue'];
|
||||
}
|
||||
if (isset($previousPoints['red'])) {
|
||||
$scoreRed += $previousPoints['red'];
|
||||
}
|
||||
|
||||
// Add current point
|
||||
if ($request->competitor === 'blue') {
|
||||
$scoreBlue += $request->points;
|
||||
} else {
|
||||
$scoreRed += $request->points;
|
||||
}
|
||||
|
||||
// Create the row first with placeholder scores, then recompute the whole
|
||||
// round's running scores. This is the only correct approach when two
|
||||
// points can share a timestamp (Blue + Red at the same moment) or when
|
||||
// a point is inserted between existing ones.
|
||||
$point = MatchPoint::create([
|
||||
'video_id' => $video->id,
|
||||
'match_round_id' => $request->round_id,
|
||||
@@ -132,21 +111,46 @@ class MatchEventController extends Controller
|
||||
'points' => $request->points,
|
||||
'competitor' => $request->competitor,
|
||||
'notes' => $request->notes,
|
||||
'score_blue' => $scoreBlue,
|
||||
'score_red' => $scoreRed,
|
||||
'score_blue' => 0,
|
||||
'score_red' => 0,
|
||||
]);
|
||||
|
||||
$this->recomputeRoundScores($request->round_id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'point' => $point,
|
||||
'point' => $point->fresh(),
|
||||
'message' => 'Point added successfully!',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute cumulative score_blue/score_red for every point in a round,
|
||||
* ordered by (timestamp_seconds, id). Handles same-timestamp ties by
|
||||
* insertion order, so Blue and Red saved at the same moment both end up
|
||||
* reflecting the running score after that moment.
|
||||
*/
|
||||
private function recomputeRoundScores(int $roundId): void
|
||||
{
|
||||
$blue = 0;
|
||||
$red = 0;
|
||||
MatchPoint::where('match_round_id', $roundId)
|
||||
->orderBy('timestamp_seconds', 'asc')
|
||||
->orderBy('id', 'asc')
|
||||
->get()
|
||||
->each(function (MatchPoint $p) use (&$blue, &$red) {
|
||||
if ($p->competitor === 'blue') $blue += (int) $p->points;
|
||||
else $red += (int) $p->points;
|
||||
if ((int) $p->score_blue !== $blue || (int) $p->score_red !== $red) {
|
||||
$p->update(['score_blue' => $blue, 'score_red' => $red]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function updatePoint(Request $request, MatchPoint $point)
|
||||
{
|
||||
$request->validate([
|
||||
'timestamp_seconds' => 'required|integer|min:0',
|
||||
'timestamp_seconds' => 'required|numeric|min:0',
|
||||
'action' => 'required|string|max:255',
|
||||
'points' => 'required|integer|min:1',
|
||||
'competitor' => 'required|in:blue,red',
|
||||
@@ -158,6 +162,7 @@ class MatchEventController extends Controller
|
||||
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$roundId = $point->match_round_id;
|
||||
$point->update([
|
||||
'timestamp_seconds' => $request->timestamp_seconds,
|
||||
'action' => $request->action,
|
||||
@@ -166,9 +171,11 @@ class MatchEventController extends Controller
|
||||
'notes' => $request->notes,
|
||||
]);
|
||||
|
||||
$this->recomputeRoundScores($roundId);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'point' => $point,
|
||||
'point' => $point->fresh(),
|
||||
'message' => 'Point updated successfully!',
|
||||
]);
|
||||
}
|
||||
@@ -180,7 +187,9 @@ class MatchEventController extends Controller
|
||||
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$roundId = $point->match_round_id;
|
||||
$point->delete();
|
||||
$this->recomputeRoundScores($roundId);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
@@ -193,11 +202,13 @@ class MatchEventController extends Controller
|
||||
public function storeReview(Request $request, Video $video)
|
||||
{
|
||||
$request->validate([
|
||||
'start_time_seconds' => 'required|integer|min:0',
|
||||
'end_time_seconds' => 'nullable|integer|min:0',
|
||||
'start_time_seconds' => 'required|numeric|min:0',
|
||||
'end_time_seconds' => 'nullable|numeric|min:0',
|
||||
'note' => 'required|string|max:1000',
|
||||
'coach_name' => 'required|string|max:100',
|
||||
'emoji' => 'nullable|string|max:10',
|
||||
'position_x' => 'nullable|numeric|between:0,1',
|
||||
'position_y' => 'nullable|numeric|between:0,1',
|
||||
]);
|
||||
|
||||
// Check if user owns the video
|
||||
@@ -213,6 +224,8 @@ class MatchEventController extends Controller
|
||||
'note' => $request->note,
|
||||
'coach_name' => $request->coach_name,
|
||||
'emoji' => $request->emoji ?? '🔥',
|
||||
'position_x' => $request->position_x,
|
||||
'position_y' => $request->position_y,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
@@ -225,11 +238,13 @@ class MatchEventController extends Controller
|
||||
public function updateReview(Request $request, CoachReview $review)
|
||||
{
|
||||
$request->validate([
|
||||
'start_time_seconds' => 'required|integer|min:0',
|
||||
'end_time_seconds' => 'nullable|integer|min:0',
|
||||
'start_time_seconds' => 'required|numeric|min:0',
|
||||
'end_time_seconds' => 'nullable|numeric|min:0',
|
||||
'note' => 'required|string|max:1000',
|
||||
'coach_name' => 'required|string|max:100',
|
||||
'emoji' => 'nullable|string|max:10',
|
||||
'position_x' => 'nullable|numeric|between:0,1',
|
||||
'position_y' => 'nullable|numeric|between:0,1',
|
||||
]);
|
||||
|
||||
// Check if user owns the video
|
||||
@@ -243,6 +258,8 @@ class MatchEventController extends Controller
|
||||
'note' => $request->note,
|
||||
'coach_name' => $request->coach_name,
|
||||
'emoji' => $request->emoji,
|
||||
'position_x' => $request->position_x,
|
||||
'position_y' => $request->position_y,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
|
||||
@@ -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.',
|
||||
@@ -166,7 +199,9 @@ class SportsMatchController extends Controller
|
||||
// Media text fields (caption/alt/credit/public). Preserve existing image
|
||||
// paths already on the record; handleImages() overwrites any replaced ones.
|
||||
$existingMedia = $match->media ?? [];
|
||||
$mediaText = $this->clean($request->input('media', []));
|
||||
// clean() returns null when the array is entirely empty — coerce back to []
|
||||
// so array_merge() doesn't blow up on PHP 8's stricter type checking.
|
||||
$mediaText = $this->clean($request->input('media', [])) ?? [];
|
||||
if (isset($mediaText['public'])) {
|
||||
$mediaText['public'] = filter_var($mediaText['public'], FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
@@ -1133,6 +1321,9 @@ class VideoController extends Controller
|
||||
'slides' => $slides,
|
||||
'language' => $video->language,
|
||||
'audio_tracks' => $audioTracks,
|
||||
'sports_match_id'=> $video->type === 'match'
|
||||
? \App\Models\SportsMatch::where('video_id', $video->id)->value('id')
|
||||
: null,
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -1948,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();
|
||||
|
||||
@@ -1999,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)
|
||||
@@ -2008,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)
|
||||
@@ -3123,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();
|
||||
@@ -3380,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();
|
||||
@@ -3719,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,
|
||||
];
|
||||
@@ -3865,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,
|
||||
@@ -3940,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'),
|
||||
@@ -3968,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,
|
||||
]);
|
||||
|
||||
@@ -4098,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']++;
|
||||
|
||||
@@ -4196,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']++;
|
||||
}
|
||||
@@ -4238,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,15 @@ class CoachReview extends Model
|
||||
'note',
|
||||
'coach_name',
|
||||
'emoji',
|
||||
'position_x',
|
||||
'position_y',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'position_x' => 'float',
|
||||
'position_y' => 'float',
|
||||
'start_time_seconds' => 'float',
|
||||
'end_time_seconds' => 'float',
|
||||
];
|
||||
|
||||
public function video(): BelongsTo
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,10 @@ class MatchPoint extends Model
|
||||
'score_red',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'timestamp_seconds' => 'float',
|
||||
];
|
||||
|
||||
public function video(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Video::class);
|
||||
|
||||
@@ -19,7 +19,7 @@ class MatchRound extends Model
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'start_time_seconds' => 'integer',
|
||||
'start_time_seconds' => 'float',
|
||||
];
|
||||
|
||||
public function video(): BelongsTo
|
||||
|
||||
+39
-12
@@ -44,31 +44,58 @@ class SportsMatch extends Model
|
||||
$p = $this->participants ?? [];
|
||||
$c = $this->competition ?? [];
|
||||
$v = $this->venue ?? [];
|
||||
$m = $this->media ?? [];
|
||||
$trim = fn($x) => (is_string($x) && trim($x) !== '') ? trim($x) : null;
|
||||
|
||||
return [
|
||||
'sport' => $trim($this->sport),
|
||||
'blue' => [
|
||||
'name' => $this->participant1_name ?: null,
|
||||
'club' => $p['p1_club'] ?? null,
|
||||
'name' => $trim($this->participant1_name),
|
||||
'club' => $trim($p['p1_club'] ?? null),
|
||||
'flag' => $this->flagFor($p['p1_country'] ?? null),
|
||||
// Uploader form (sports-match modal) stores these under the
|
||||
// canonical media.* keys — mirror them to headerData() so the
|
||||
// scoreboard/VS partials get real image paths.
|
||||
'club_logo' => $trim($m['club1_logo'] ?? $m['p1_club_logo'] ?? null),
|
||||
'headshot' => $trim($m['participant1_photo'] ?? $m['p1_headshot'] ?? null),
|
||||
],
|
||||
'red' => [
|
||||
'name' => $this->participant2_name ?: null,
|
||||
'club' => $p['p2_club'] ?? null,
|
||||
'name' => $trim($this->participant2_name),
|
||||
'club' => $trim($p['p2_club'] ?? null),
|
||||
'flag' => $this->flagFor($p['p2_country'] ?? null),
|
||||
'club_logo' => $trim($m['club2_logo'] ?? $m['p2_club_logo'] ?? null),
|
||||
'headshot' => $trim($m['participant2_photo'] ?? $m['p2_headshot'] ?? null),
|
||||
],
|
||||
'weight_category' => $p['weight_class'] ?? null,
|
||||
'championship' => $c['championship_name'] ?? ($this->event_name ?: null),
|
||||
'match_number' => $c['match_number'] ?? null,
|
||||
'court' => $c['court'] ?? null,
|
||||
'weight_category' => $trim($p['weight_class'] ?? null),
|
||||
'division' => $trim($c['division'] ?? null),
|
||||
// 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", …
|
||||
'match_date' => $this->match_date?->format('Y-m-d'),
|
||||
'referee' => [
|
||||
'name' => $this->referee_name ?: null,
|
||||
'name' => $trim($this->referee_name),
|
||||
'flag' => $this->flagFor($p['referee_country'] ?? null),
|
||||
],
|
||||
'venue' => [
|
||||
'name' => $this->venue_name ?: ($v['name'] ?? null),
|
||||
'map_link' => $v['map_link'] ?? null,
|
||||
'name' => $trim($this->venue_name) ?? $trim($v['name'] ?? null),
|
||||
'map_link' => $trim($v['map_link'] ?? null),
|
||||
],
|
||||
'event_logo' => $this->media['event_poster'] ?? null, // rel path or null
|
||||
'event_logo' => $trim($m['event_poster'] ?? null),
|
||||
/*
|
||||
* Per-video overlay defaults set by the uploader. Shape:
|
||||
* ['scorebar'=>bool, 'feed'=>bool, 'matchInfo'=>bool, 'timeline'=>bool,
|
||||
* 'clubs'=>bool, 'flags'=>bool, 'penalties'=>bool, 'cornerLabels'=>'auto|redblue|karate|taekwondo']
|
||||
* Anything not set falls back to the app defaults in the JS.
|
||||
*/
|
||||
'scoreboard_defaults' => is_array($c['scoreboard'] ?? null) ? $c['scoreboard'] : [],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Add drag-and-dropped overlay position for coach notes. Stored as normalized
|
||||
* 0..1 coordinates of the overlay's CENTER within the video area so it
|
||||
* survives any viewport / fullscreen resize.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('coach_reviews', function (Blueprint $table) {
|
||||
$table->decimal('position_x', 6, 4)->nullable()->after('emoji');
|
||||
$table->decimal('position_y', 6, 4)->nullable()->after('position_x');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('coach_reviews', function (Blueprint $table) {
|
||||
$table->dropColumn(['position_x', 'position_y']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Frame-accurate timestamps: the match highlights scrubber lands on
|
||||
* frame boundaries (1/fps seconds). Storing these as INTEGER seconds
|
||||
* throws away sub-second precision, so we promote the columns to
|
||||
* decimal(10,3) — 3 decimals cover up to 1000 fps and centuries of
|
||||
* runtime, and existing integer values migrate losslessly.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// MatchRound.start_time_seconds is used to jump to a round's opening
|
||||
// frame too, so bring it along.
|
||||
Schema::table('match_rounds', function (Blueprint $table) {
|
||||
$table->decimal('start_time_seconds', 10, 3)->nullable()->change();
|
||||
});
|
||||
|
||||
Schema::table('match_points', function (Blueprint $table) {
|
||||
$table->decimal('timestamp_seconds', 10, 3)->change();
|
||||
});
|
||||
|
||||
Schema::table('coach_reviews', function (Blueprint $table) {
|
||||
$table->decimal('start_time_seconds', 10, 3)->change();
|
||||
$table->decimal('end_time_seconds', 10, 3)->nullable()->change();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Round back to integer on rollback — sub-second data would be
|
||||
// truncated but nothing catastrophic happens.
|
||||
Schema::table('match_rounds', function (Blueprint $table) {
|
||||
$table->integer('start_time_seconds')->nullable()->change();
|
||||
});
|
||||
Schema::table('match_points', function (Blueprint $table) {
|
||||
$table->integer('timestamp_seconds')->change();
|
||||
});
|
||||
Schema::table('coach_reviews', function (Blueprint $table) {
|
||||
$table->integer('start_time_seconds')->change();
|
||||
$table->integer('end_time_seconds')->nullable()->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
```
|
||||
@@ -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>
|
||||
@@ -0,0 +1,94 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Score bar — 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:#111;font-family:'Barlow Condensed',sans-serif}</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!--
|
||||
SCORE BAR — copy this markup verbatim. Do not restructure it.
|
||||
Placement: inside the player container, which must be position:relative.
|
||||
Data holes are marked with {{ }} — replace ONLY those, nothing else.
|
||||
Hide a part by setting its element's display to none (never remove the element's siblings' flex sizing).
|
||||
|
||||
Rules that break the design if changed:
|
||||
- the panel has transform:skewX(-9deg); ONE inner row has transform:skewX(9deg). Never counter-skew individual children.
|
||||
- every text span keeps flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis
|
||||
- the logo (46px) and score (60px) boxes keep flex:none
|
||||
- bottom:56px keeps the bar above the 48px control band
|
||||
-->
|
||||
|
||||
<div style="position:relative;width:1150px;height:200px;background:#1b1b1e">
|
||||
<div style="position:absolute;left:0;right:0;bottom:56px;padding:0 26px;display:flex;align-items:stretch;gap:0;height:84px;z-index:2;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);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 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: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="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">{{ redName }}</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">{{ redClub }}</span>
|
||||
<span style="display:none;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)">{{ redLabel }}</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)">{{ redScore }}</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 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>
|
||||
|
||||
<!-- BLUE / AO — exact mirror -->
|
||||
<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)">{{ blueLabel }}</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)">{{ blueScore }}</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">{{ blueName }}</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: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 style="display:flex;align-items:center;gap:8px;min-width:0">
|
||||
<span style="display:none;font-size:10px;letter-spacing:.18em;padding:1px 6px;background:#c8492f;color:#fff;font-weight:700;flex:none">C1</span>
|
||||
<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">{{ blueClub }}</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: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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- POINT TIMELINE — one segment per point event, red/blue, #2a2724 when empty -->
|
||||
<div style="position:absolute;left:26px;right:26px;bottom:48px;height:5px;display:flex;gap:2px;z-index:2;pointer-events:none">
|
||||
<div style="flex:1;background:#e8534a"></div><div style="flex:1;background:#2a2724"></div>
|
||||
<div style="flex:1;background:#6aa6ff"></div><div style="flex:1;background:#e8534a"></div>
|
||||
<div style="flex:1;background:#2a2724"></div><div style="flex:1;background:#2a2724"></div>
|
||||
<div style="flex:1;background:#6aa6ff"></div><div style="flex:1;background:#e8534a"></div>
|
||||
<div style="flex:1;background:#2a2724"></div><div style="flex:1;background:#e8534a"></div>
|
||||
<div style="flex:1;background:#6aa6ff"></div><div style="flex:1;background:#2a2724"></div>
|
||||
<div style="flex:1;background:#e8534a"></div><div style="flex:1;background:#2a2724"></div>
|
||||
</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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Coach Review — Design Mockups</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.min.css">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f0f0f;
|
||||
--bg-panel: #181818;
|
||||
--text-primary: #f1f1f1;
|
||||
--text-secondary: #aaa;
|
||||
--brand-red: #e61e1e;
|
||||
--border: #262626;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text-primary);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
padding: 32px 16px;
|
||||
}
|
||||
.page {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 28px;
|
||||
}
|
||||
@media (max-width: 900px) { .page { grid-template-columns: 1fr; } }
|
||||
|
||||
.mockup {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
}
|
||||
.mockup h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 15px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .1em;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 700;
|
||||
}
|
||||
.mockup h2 .letter {
|
||||
display: inline-block;
|
||||
background: var(--brand-red);
|
||||
color: #fff;
|
||||
width: 24px; height: 24px;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
line-height: 24px;
|
||||
font-size: 13px;
|
||||
margin-right: 8px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.mockup .vibe { font-size: 12px; color: var(--text-secondary); margin: 0 0 18px; }
|
||||
.mockup .stage {
|
||||
background: #0f0f0f;
|
||||
border: 1px solid #202020;
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
min-height: 260px;
|
||||
}
|
||||
.mockup .stage > * + * { margin-top: 8px; }
|
||||
|
||||
.pill { font-size: 10px; padding: 2px 6px; border-radius: 4px; font-weight: 600; text-transform: uppercase; }
|
||||
|
||||
/* ─── Option A: Sticky-note ─────────────────────────── */
|
||||
.a-card {
|
||||
display: flex; gap: 14px;
|
||||
background: rgba(234, 179, 8, .05);
|
||||
border: 1px solid rgba(234, 179, 8, .18);
|
||||
border-left: 4px solid rgba(234, 179, 8, .7);
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.a-card .emoji { font-size: 26px; line-height: 1; flex-shrink: 0; }
|
||||
.a-card .body { flex: 1; min-width: 0; }
|
||||
.a-card .note { font-size: 15px; font-weight: 600; color: #f1f1f1; margin: 0 0 6px; }
|
||||
.a-card .divider { height: 1px; background: rgba(255,255,255,.08); margin: 8px 0; }
|
||||
.a-card .meta { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.a-card .meta-left { font-size: 12px; color: var(--text-secondary); }
|
||||
.a-card .meta-left b { color: #f1f1f1; font-weight: 500; }
|
||||
.a-card .meta-left .dot { margin: 0 6px; opacity: .5; }
|
||||
.a-card .tools { display: inline-flex; gap: 4px; }
|
||||
|
||||
/* ─── Option B: Timeline dot ────────────────────────── */
|
||||
.b-timeline {
|
||||
position: relative;
|
||||
padding-left: 32px;
|
||||
}
|
||||
.b-timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 12px; top: 0; bottom: 0;
|
||||
width: 2px; background: rgba(255,255,255,.08);
|
||||
}
|
||||
.b-item { position: relative; margin-bottom: 22px; }
|
||||
.b-item:last-child { margin-bottom: 0; }
|
||||
.b-dot {
|
||||
position: absolute; left: -26px; top: 2px;
|
||||
width: 12px; height: 12px; border-radius: 50%;
|
||||
background: var(--brand-red);
|
||||
box-shadow: 0 0 0 3px rgba(230,30,30,.2), 0 0 0 6px var(--bg-panel);
|
||||
}
|
||||
.b-time {
|
||||
font-size: 11px; font-weight: 700; letter-spacing: .06em;
|
||||
color: var(--brand-red); text-transform: uppercase;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.b-card {
|
||||
background: rgba(255,255,255,.03);
|
||||
border: 1px solid rgba(255,255,255,.06);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
display: flex; align-items: flex-start; gap: 10px;
|
||||
}
|
||||
.b-card .emoji { font-size: 18px; line-height: 1.2; flex-shrink: 0; }
|
||||
.b-card .body { flex: 1; min-width: 0; }
|
||||
.b-card .note { font-size: 14px; font-weight: 500; color: #f1f1f1; margin: 0 0 4px; }
|
||||
.b-card .author { font-size: 11.5px; color: var(--text-secondary); }
|
||||
.b-card .tools { display: inline-flex; gap: 4px; align-self: center; }
|
||||
|
||||
/* ─── Option C: Media card ──────────────────────────── */
|
||||
.c-card {
|
||||
display: flex; gap: 14px;
|
||||
background: #141414;
|
||||
border: 1px solid rgba(255,255,255,.08);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
.c-thumb {
|
||||
width: 72px; height: 72px; flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, rgba(230,30,30,.28), rgba(230,30,30,.08));
|
||||
border: 1px solid rgba(230,30,30,.35);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 30px;
|
||||
}
|
||||
.c-body { flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: space-between; }
|
||||
.c-head { display: flex; align-items: center; gap: 8px; }
|
||||
.c-time {
|
||||
font-size: 11px; font-weight: 700; letter-spacing: .04em;
|
||||
color: #3ea6ff;
|
||||
background: rgba(62, 166, 255, .12);
|
||||
padding: 2px 8px; border-radius: 4px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.c-note { font-size: 14px; font-weight: 600; color: #f1f1f1; margin: 4px 0; line-height: 1.3; }
|
||||
.c-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.c-author { font-size: 11.5px; color: var(--text-secondary); }
|
||||
.c-tools { display: inline-flex; gap: 4px; }
|
||||
|
||||
/* ─── Option D: Chat-bubble ─────────────────────────── */
|
||||
.d-row { display: flex; gap: 10px; align-items: flex-end; }
|
||||
.d-avatar {
|
||||
width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0;
|
||||
background: linear-gradient(135deg, #1f2937, #111827);
|
||||
border: 2px solid var(--brand-red);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 20px; line-height: 1;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,.4);
|
||||
}
|
||||
.d-bubble {
|
||||
background: rgba(255,255,255,.05);
|
||||
border: 1px solid rgba(255,255,255,.08);
|
||||
border-radius: 14px 14px 14px 4px;
|
||||
padding: 10px 14px;
|
||||
flex: 1; min-width: 0;
|
||||
}
|
||||
.d-bubble .note { font-size: 14px; font-weight: 500; color: #f1f1f1; margin: 0 0 6px; line-height: 1.35; }
|
||||
.d-bubble .meta {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
padding-top: 6px; border-top: 1px solid rgba(255,255,255,.06);
|
||||
}
|
||||
.d-bubble .meta-left { font-size: 11px; color: var(--text-secondary); }
|
||||
.d-bubble .meta-left b { color: #f1f1f1; font-weight: 500; }
|
||||
.d-bubble .meta-left .dot { margin: 0 6px; opacity: .5; }
|
||||
.d-tools { display: inline-flex; gap: 4px; }
|
||||
|
||||
/* ─── Shared button styling for mockups ─────────────── */
|
||||
.btn-icon {
|
||||
width: 26px; height: 26px; padding: 0;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
border-radius: 5px; border: none;
|
||||
background: rgba(255,255,255,.06);
|
||||
color: var(--text-primary); cursor: pointer;
|
||||
font-size: 12px; line-height: 1;
|
||||
transition: background .12s;
|
||||
}
|
||||
.btn-icon:hover { background: rgba(255,255,255,.14); }
|
||||
.btn-slowmo {
|
||||
width: auto; padding: 0 10px; gap: 4px;
|
||||
background: rgba(230,30,30,.16); color: var(--brand-red);
|
||||
font-size: 11px;
|
||||
}
|
||||
.btn-slowmo:hover { background: rgba(230,30,30,.28); color: #fff; }
|
||||
|
||||
/* header */
|
||||
.header {
|
||||
text-align: center; margin-bottom: 32px;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 26px;
|
||||
color: #fff;
|
||||
}
|
||||
.header p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<h1>Coach Review Card — 4 design directions</h1>
|
||||
<p>Same content, four different visual approaches. Pick one and I'll build it.</p>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<!-- ═══ Option A: Sticky-note ═══════════════════════════ -->
|
||||
<div class="mockup">
|
||||
<h2><span class="letter">A</span> Sticky-note</h2>
|
||||
<p class="vibe">Warm amber tint, chunky emoji, feels like a coach's Post-it on a locker.</p>
|
||||
<div class="stage">
|
||||
<div class="a-card">
|
||||
<span class="emoji">👀</span>
|
||||
<div class="body">
|
||||
<p class="note">Keep Your Hand Up</p>
|
||||
<div class="divider"></div>
|
||||
<div class="meta">
|
||||
<div class="meta-left">
|
||||
<b>Ghassan</b><span class="dot">·</span>00:48 – 00:52
|
||||
</div>
|
||||
<div class="tools">
|
||||
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
|
||||
<button class="btn-icon">✏️</button>
|
||||
<button class="btn-icon">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="a-card">
|
||||
<span class="emoji">🤔</span>
|
||||
<div class="body">
|
||||
<p class="note">Missed the counter opportunity — great angle but no follow-up</p>
|
||||
<div class="divider"></div>
|
||||
<div class="meta">
|
||||
<div class="meta-left">
|
||||
<b>Coach Sara</b><span class="dot">·</span>02:05 – 02:20
|
||||
</div>
|
||||
<div class="tools">
|
||||
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
|
||||
<button class="btn-icon">✏️</button>
|
||||
<button class="btn-icon">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Option B: Timeline dot ═══════════════════════════ -->
|
||||
<div class="mockup">
|
||||
<h2><span class="letter">B</span> Timeline dot</h2>
|
||||
<p class="vibe">Vertical spine with red dots. Reads like a session log — nice for many notes.</p>
|
||||
<div class="stage">
|
||||
<div class="b-timeline">
|
||||
<div class="b-item">
|
||||
<span class="b-dot"></span>
|
||||
<div class="b-time">00:48 — 00:52</div>
|
||||
<div class="b-card">
|
||||
<span class="emoji">👀</span>
|
||||
<div class="body">
|
||||
<p class="note">Keep Your Hand Up</p>
|
||||
<div class="author">Ghassan</div>
|
||||
</div>
|
||||
<div class="tools">
|
||||
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
|
||||
<button class="btn-icon">✏️</button>
|
||||
<button class="btn-icon">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="b-item">
|
||||
<span class="b-dot"></span>
|
||||
<div class="b-time">02:05 — 02:20</div>
|
||||
<div class="b-card">
|
||||
<span class="emoji">🤔</span>
|
||||
<div class="body">
|
||||
<p class="note">Missed the counter — no follow-up after the angle change</p>
|
||||
<div class="author">Coach Sara</div>
|
||||
</div>
|
||||
<div class="tools">
|
||||
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
|
||||
<button class="btn-icon">✏️</button>
|
||||
<button class="btn-icon">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="b-item">
|
||||
<span class="b-dot"></span>
|
||||
<div class="b-time">03:25</div>
|
||||
<div class="b-card">
|
||||
<span class="emoji">😄</span>
|
||||
<div class="body">
|
||||
<p class="note">Excellent exit and re-entry — perfect execution</p>
|
||||
<div class="author">Ghassan</div>
|
||||
</div>
|
||||
<div class="tools">
|
||||
<button class="btn-icon">✏️</button>
|
||||
<button class="btn-icon">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Option C: Media card ═══════════════════════════ -->
|
||||
<div class="mockup">
|
||||
<h2><span class="letter">C</span> Media card</h2>
|
||||
<p class="vibe">Big emoji tile on the left, blue time badge — feels like a video-platform "content card".</p>
|
||||
<div class="stage">
|
||||
<div class="c-card">
|
||||
<div class="c-thumb">👀</div>
|
||||
<div class="c-body">
|
||||
<div class="c-head"><span class="c-time">00:48 → 00:52</span></div>
|
||||
<p class="c-note">Keep Your Hand Up</p>
|
||||
<div class="c-foot">
|
||||
<span class="c-author">Ghassan</span>
|
||||
<div class="c-tools">
|
||||
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
|
||||
<button class="btn-icon">✏️</button>
|
||||
<button class="btn-icon">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="c-card">
|
||||
<div class="c-thumb">🤔</div>
|
||||
<div class="c-body">
|
||||
<div class="c-head"><span class="c-time">02:05 → 02:20</span></div>
|
||||
<p class="c-note">Missed the counter — no follow-up after the angle change</p>
|
||||
<div class="c-foot">
|
||||
<span class="c-author">Coach Sara</span>
|
||||
<div class="c-tools">
|
||||
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
|
||||
<button class="btn-icon">✏️</button>
|
||||
<button class="btn-icon">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Option D: Chat-bubble ═══════════════════════════ -->
|
||||
<div class="mockup">
|
||||
<h2><span class="letter">D</span> Chat-bubble</h2>
|
||||
<p class="vibe">Emoji as coach's avatar + message bubble. Feels like WhatsApp — casual + dense.</p>
|
||||
<div class="stage">
|
||||
<div class="d-row">
|
||||
<div class="d-avatar">👀</div>
|
||||
<div class="d-bubble">
|
||||
<p class="note">Keep Your Hand Up</p>
|
||||
<div class="meta">
|
||||
<div class="meta-left"><b>Ghassan</b><span class="dot">·</span>00:48–00:52</div>
|
||||
<div class="d-tools">
|
||||
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
|
||||
<button class="btn-icon">✏️</button>
|
||||
<button class="btn-icon">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-row" style="margin-top: 14px;">
|
||||
<div class="d-avatar">🤔</div>
|
||||
<div class="d-bubble">
|
||||
<p class="note">Missed the counter — no follow-up after the angle change</p>
|
||||
<div class="meta">
|
||||
<div class="meta-left"><b>Coach Sara</b><span class="dot">·</span>02:05–02:20</div>
|
||||
<div class="d-tools">
|
||||
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
|
||||
<button class="btn-icon">✏️</button>
|
||||
<button class="btn-icon">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-row" style="margin-top: 14px;">
|
||||
<div class="d-avatar">😄</div>
|
||||
<div class="d-bubble">
|
||||
<p class="note">Excellent exit and re-entry — perfect execution</p>
|
||||
<div class="meta">
|
||||
<div class="meta-left"><b>Ghassan</b><span class="dot">·</span>03:25</div>
|
||||
<div class="d-tools">
|
||||
<button class="btn-icon">✏️</button>
|
||||
<button class="btn-icon">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -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">
|
||||
|
||||
@@ -122,7 +122,12 @@
|
||||
|
||||
/* ── Open / close ── */
|
||||
function openModal() {
|
||||
document.getElementById('tcOverlay_' + id).classList.add('open');
|
||||
var ov = document.getElementById('tcOverlay_' + id);
|
||||
// Reparent to <body> at open time — a transformed ancestor (e.g. an
|
||||
// opening Bootstrap modal) would otherwise constrain our position:fixed
|
||||
// overlay to its own stacking context and clip Cropme's canvas.
|
||||
if (ov && ov.parentNode !== document.body) document.body.appendChild(ov);
|
||||
ov.classList.add('open');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
window['openCropperModal_' + id] = openModal;
|
||||
@@ -135,42 +140,161 @@
|
||||
if (e.target === this) window.closeCropperModal(id);
|
||||
});
|
||||
|
||||
/* ── Preload a File into the cropper ── */
|
||||
/* ── Preload a File into the cropper ─────────────────────────────────
|
||||
* Uses URL.createObjectURL (fast, memory-friendly) instead of FileReader
|
||||
* data URLs, which are slow / can silently fail with large images.
|
||||
*/
|
||||
function preloadFile(file) {
|
||||
if (!file) return;
|
||||
originalFile = file;
|
||||
document.getElementById('tcFileName_' + id).textContent = file.name;
|
||||
var reader = new FileReader();
|
||||
reader.onload = function (e) { initCropper(e.target.result); };
|
||||
reader.readAsDataURL(file);
|
||||
var url = URL.createObjectURL(file);
|
||||
initCropper(url);
|
||||
}
|
||||
window['tcPreload_' + id] = preloadFile;
|
||||
|
||||
function initCropper(dataUrl) {
|
||||
/*
|
||||
* ───────────────────────────────────────────────────────────────────
|
||||
* DIY cropper — no library. Renders the loaded image in the canvas
|
||||
* with a viewport frame of the desired vw:vh aspect (dashed red
|
||||
* border, dark scrim outside via box-shadow). Pan by dragging the
|
||||
* image, zoom via the slider (or wheel), rotate via the slider.
|
||||
* Save clips the visible viewport region to an offscreen canvas.
|
||||
* ───────────────────────────────────────────────────────────────────
|
||||
*/
|
||||
var _st = null; // cropper state — replaces Cropme's `cropperInst`
|
||||
|
||||
function initCropper(imageUrl) {
|
||||
document.getElementById('tcPlaceholder_' + id).style.display = 'none';
|
||||
document.getElementById('tcSaveBtn_' + id).disabled = false;
|
||||
document.getElementById('tcAsIsBtn_' + id).disabled = false;
|
||||
|
||||
var canvas = document.getElementById('tcCanvas_' + id);
|
||||
if (cropperInst) { cropperInst.destroy(); cropperInst = null; }
|
||||
canvas.innerHTML = '';
|
||||
canvas.className = 'tc-canvas tc-canvas-active';
|
||||
|
||||
cropperInst = new Cropme(canvas, {
|
||||
container: { width: '100%', height: 320 },
|
||||
viewport: {
|
||||
width: vw, height: vh,
|
||||
type: shape,
|
||||
border: { enable: true, width: 2, color: '#ef4444' }
|
||||
},
|
||||
transformOrigin: 'viewport',
|
||||
zoom: { min: zoomMin, max: zoomMax, enable: true, mouseWheel: true, slider: false },
|
||||
rotation: { enable: true, slider: false }
|
||||
});
|
||||
cropperInst.bind({ url: dataUrl }).then(function () {
|
||||
document.getElementById('tcZoom_' + id).value = 0;
|
||||
document.getElementById('tcRot_' + id).value = 0;
|
||||
});
|
||||
// ── layout: image element + viewport frame (frame drawn via box-shadow scrim) ──
|
||||
var img = document.createElement('img');
|
||||
img.alt = '';
|
||||
img.style.cssText =
|
||||
'position:absolute;top:50%;left:50%;transform-origin:center center;' +
|
||||
'user-select:none;-webkit-user-drag:none;pointer-events:none;max-width:none;';
|
||||
var frame = document.createElement('div');
|
||||
frame.style.cssText =
|
||||
'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);' +
|
||||
'border:2px dashed #ef4444;box-sizing:border-box;pointer-events:none;' +
|
||||
'box-shadow:0 0 0 2000px rgba(0,0,0,.55);' +
|
||||
(shape === 'circle' ? 'border-radius:50%;' : '');
|
||||
canvas.appendChild(img);
|
||||
canvas.appendChild(frame);
|
||||
|
||||
// Reset sliders
|
||||
var zoomEl = document.getElementById('tcZoom_' + id);
|
||||
var rotEl = document.getElementById('tcRot_' + id);
|
||||
zoomEl.value = 0; rotEl.value = 0;
|
||||
|
||||
// Init state
|
||||
_st = {
|
||||
img: img, frame: frame, canvas: canvas,
|
||||
imgW: 0, imgH: 0,
|
||||
canvasW: 0, canvasH: 0,
|
||||
vpW: 0, vpH: 0, // viewport display size (px inside canvas)
|
||||
baseScale: 1, // scale that makes image "cover" the viewport
|
||||
scale: 1, deg: 0, tx: 0, ty: 0,
|
||||
ready: false,
|
||||
imageUrl: imageUrl,
|
||||
};
|
||||
|
||||
img.onload = function () {
|
||||
_st.imgW = img.naturalWidth || 1;
|
||||
_st.imgH = img.naturalHeight || 1;
|
||||
layoutCropper();
|
||||
_st.ready = true;
|
||||
};
|
||||
img.onerror = function (e) {
|
||||
console.error('[image-cropper]', id, 'image failed to load', e);
|
||||
};
|
||||
img.src = imageUrl;
|
||||
// If already cached, onload may have fired synchronously — no-op if not.
|
||||
if (img.complete && img.naturalWidth > 0) img.onload();
|
||||
}
|
||||
|
||||
// Fit the viewport inside the canvas, then fit the image to "cover" the viewport.
|
||||
function layoutCropper() {
|
||||
var s = _st; if (!s) return;
|
||||
s.canvasW = s.canvas.clientWidth || 320;
|
||||
s.canvasH = s.canvas.clientHeight || 320;
|
||||
|
||||
// Viewport: same aspect as vw:vh, fits inside canvas with ~10% padding.
|
||||
var pad = 20;
|
||||
var maxW = s.canvasW - pad * 2;
|
||||
var maxH = s.canvasH - pad * 2;
|
||||
var vAspect = vw / vh;
|
||||
if (maxW / vAspect <= maxH) { s.vpW = maxW; s.vpH = Math.round(maxW / vAspect); }
|
||||
else { s.vpH = maxH; s.vpW = Math.round(maxH * vAspect); }
|
||||
s.frame.style.width = s.vpW + 'px';
|
||||
s.frame.style.height = s.vpH + 'px';
|
||||
|
||||
// Base scale: image must at least cover the viewport.
|
||||
s.baseScale = Math.max(s.vpW / s.imgW, s.vpH / s.imgH);
|
||||
s.scale = s.baseScale;
|
||||
s.tx = 0; s.ty = 0; s.deg = 0;
|
||||
applyTransform();
|
||||
}
|
||||
|
||||
function applyTransform() {
|
||||
var s = _st; if (!s) return;
|
||||
// Image is centered via top/left 50% translate + own translate + scale + rotate.
|
||||
// Set explicit width so scale multiplies pixels rather than object-fit.
|
||||
s.img.style.width = s.imgW + 'px';
|
||||
s.img.style.height = s.imgH + 'px';
|
||||
s.img.style.marginLeft = (-s.imgW / 2) + 'px';
|
||||
s.img.style.marginTop = (-s.imgH / 2) + 'px';
|
||||
s.img.style.transform =
|
||||
'translate(' + s.tx + 'px,' + s.ty + 'px)' +
|
||||
' rotate(' + s.deg + 'deg)' +
|
||||
' scale(' + s.scale + ')';
|
||||
}
|
||||
|
||||
// Drag to pan (mouse + touch)
|
||||
(function bindPan() {
|
||||
var canvas = document.getElementById('tcCanvas_' + id);
|
||||
var dragging = false, sx = 0, sy = 0, otx = 0, oty = 0;
|
||||
function down(e) {
|
||||
if (!_st || !_st.ready) return;
|
||||
dragging = true;
|
||||
var p = e.touches ? e.touches[0] : e;
|
||||
sx = p.clientX; sy = p.clientY; otx = _st.tx; oty = _st.ty;
|
||||
canvas.style.cursor = 'grabbing';
|
||||
e.preventDefault();
|
||||
}
|
||||
function move(e) {
|
||||
if (!dragging || !_st) return;
|
||||
var p = e.touches ? e.touches[0] : e;
|
||||
_st.tx = otx + (p.clientX - sx);
|
||||
_st.ty = oty + (p.clientY - sy);
|
||||
applyTransform();
|
||||
}
|
||||
function up() { dragging = false; canvas.style.cursor = 'grab'; }
|
||||
canvas.addEventListener('mousedown', down);
|
||||
canvas.addEventListener('touchstart', down, { passive: false });
|
||||
window.addEventListener('mousemove', move);
|
||||
window.addEventListener('touchmove', move, { passive: false });
|
||||
window.addEventListener('mouseup', up);
|
||||
window.addEventListener('touchend', up);
|
||||
// Wheel zoom
|
||||
canvas.addEventListener('wheel', function (e) {
|
||||
if (!_st || !_st.ready) return;
|
||||
e.preventDefault();
|
||||
var delta = e.deltaY < 0 ? 1.08 : 1 / 1.08;
|
||||
_st.scale = Math.max(_st.baseScale, Math.min(_st.baseScale * 6, _st.scale * delta));
|
||||
var pct = Math.round(((_st.scale - _st.baseScale) / (_st.baseScale * 5)) * 100);
|
||||
document.getElementById('tcZoom_' + id).value = pct;
|
||||
applyTransform();
|
||||
}, { passive: false });
|
||||
canvas.style.cursor = 'grab';
|
||||
})();
|
||||
|
||||
/* ── Internal file input (the "Choose image" button inside the modal) ── */
|
||||
document.getElementById('tcInput_' + id).addEventListener('change', function () {
|
||||
if (this.files && this.files[0]) preloadFile(this.files[0]);
|
||||
@@ -182,14 +306,16 @@
|
||||
|
||||
/* ── Zoom / rotate sliders ── */
|
||||
document.getElementById('tcZoom_' + id).addEventListener('input', function () {
|
||||
if (!cropperInst || !cropperInst.properties.image) return;
|
||||
if (!_st || !_st.ready) return;
|
||||
// 0 → baseScale (fit), 100 → baseScale * 6 (max zoom-in)
|
||||
var p = parseFloat(this.value) / 100;
|
||||
cropperInst.properties.scale = zoomMin + (zoomMax - zoomMin) * p;
|
||||
var s = cropperInst.properties;
|
||||
s.image.style.transform = 'translate3d(' + s.x + 'px,' + s.y + 'px,0) scale(' + s.scale + ') rotate(' + s.deg + 'deg)';
|
||||
_st.scale = _st.baseScale + (_st.baseScale * 5) * p;
|
||||
applyTransform();
|
||||
});
|
||||
document.getElementById('tcRot_' + id).addEventListener('input', function () {
|
||||
if (cropperInst) cropperInst.rotate(parseInt(this.value, 10));
|
||||
if (!_st || !_st.ready) return;
|
||||
_st.deg = parseInt(this.value, 10) || 0;
|
||||
applyTransform();
|
||||
});
|
||||
|
||||
/* ── Helpers ── */
|
||||
@@ -250,24 +376,64 @@
|
||||
.catch(onFail);
|
||||
}
|
||||
|
||||
/* ── Crop & Save ── */
|
||||
/* ── Crop & Save ─────────────────────────────────────────────────────
|
||||
* Renders the visible viewport region to an offscreen canvas at the
|
||||
* output size, then returns the result as a JPEG data URL.
|
||||
* The image is transformed by (translate → rotate → scale) — the
|
||||
* offscreen canvas replays those same transforms scaled up to the
|
||||
* output resolution so nothing is lost.
|
||||
*/
|
||||
function renderCroppedBase64() {
|
||||
var s = _st; if (!s || !s.ready) return null;
|
||||
var outW = outputWidth > 0 ? outputWidth : vw;
|
||||
var outH = Math.round(outW * (vh / vw));
|
||||
|
||||
var cv = document.createElement('canvas');
|
||||
cv.width = outW;
|
||||
cv.height = outH;
|
||||
var ctx = cv.getContext('2d');
|
||||
|
||||
// Circle crop mask (matches the round viewport look on save)
|
||||
if (shape === 'circle') {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.arc(outW / 2, outH / 2, Math.min(outW, outH) / 2, 0, Math.PI * 2);
|
||||
ctx.closePath();
|
||||
ctx.clip();
|
||||
}
|
||||
|
||||
// 1 display px inside the viewport == (outW / s.vpW) output px
|
||||
var pxRatio = outW / s.vpW;
|
||||
|
||||
ctx.translate(outW / 2, outH / 2);
|
||||
ctx.scale(pxRatio, pxRatio);
|
||||
ctx.translate(s.tx, s.ty);
|
||||
ctx.rotate(s.deg * Math.PI / 180);
|
||||
ctx.scale(s.scale, s.scale);
|
||||
ctx.drawImage(s.img, -s.imgW / 2, -s.imgH / 2);
|
||||
|
||||
if (shape === 'circle') ctx.restore();
|
||||
|
||||
return cv.toDataURL('image/jpeg', 0.92);
|
||||
}
|
||||
|
||||
window['tcSave_' + id] = function () {
|
||||
if (!cropperInst) return;
|
||||
if (!_st || !_st.ready) return;
|
||||
var btn = document.getElementById('tcSaveBtn_' + id);
|
||||
var txt = document.getElementById('tcSaveBtnText_' + id);
|
||||
btn.disabled = true;
|
||||
txt.textContent = 'Saving…';
|
||||
|
||||
var cropOpts = outputWidth > 0 ? { type: 'base64', width: outputWidth } : { type: 'base64' };
|
||||
var base64 = renderCroppedBase64();
|
||||
if (!base64) { btn.disabled = false; txt.textContent = 'Crop & Save'; return; }
|
||||
|
||||
cropperInst.crop(cropOpts).then(function (base64) {
|
||||
if (isCallbackMode) {
|
||||
var cbName = originalFile ? originalFile.name : 'cropped.png';
|
||||
var cbName = originalFile ? originalFile.name : 'cropped.jpg';
|
||||
deliverResult(base64ToFile(base64, cbName));
|
||||
return;
|
||||
}
|
||||
if (isFormMode) {
|
||||
var fname = originalFile ? originalFile.name : 'cropped.png';
|
||||
var fname = originalFile ? originalFile.name : 'cropped.jpg';
|
||||
setOnTargetInput(base64ToFile(base64, fname));
|
||||
window.closeCropperModal(id);
|
||||
if (typeof window.showToast === 'function') window.showToast('Image ready!', 'success');
|
||||
@@ -286,7 +452,6 @@
|
||||
txt.textContent = 'Crop & Save';
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/* ── Upload as-is ── */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -71,10 +71,14 @@
|
||||
color: var(--brand-red) !important;
|
||||
}
|
||||
|
||||
/* Single Action dropdown for every viewport — the individual
|
||||
.desktop-action buttons are collapsed into this one menu. */
|
||||
.mobile-action-dropdown {
|
||||
display: none;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
margin-left: auto;
|
||||
}
|
||||
.video-actions > .desktop-action { display: none !important; }
|
||||
|
||||
.mobile-action-dropdown .dropdown-menu {
|
||||
right: 0;
|
||||
@@ -154,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())
|
||||
@@ -447,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,23 +41,74 @@ $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',
|
||||
default => '',
|
||||
};
|
||||
|
||||
@endphp
|
||||
|
||||
<div class="yt-video-card {{ $sizeClasses }}" data-video-url="{{ $videoUrl }}">
|
||||
<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">
|
||||
@@ -89,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
|
||||
@@ -124,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) }})">
|
||||
@@ -142,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>
|
||||
@@ -165,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')
|
||||
|
||||
@@ -346,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,19 +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;
|
||||
}
|
||||
/* 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;
|
||||
}
|
||||
.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; }
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
@@ -530,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;
|
||||
@@ -587,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; }
|
||||
}
|
||||
|
||||
@@ -1279,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 ────────────────────────────────
|
||||
@@ -1314,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>
|
||||
@@ -1362,10 +1377,18 @@
|
||||
<i class="bi bi-fire"></i>
|
||||
<span>Trending</span>
|
||||
</a>
|
||||
<a href="{{ auth()->check() ? route('videos.create') : route('login') }}" class="yt-bottom-nav-item {{ request()->routeIs('videos.create') ? 'active' : '' }}">
|
||||
@auth
|
||||
<a href="#" class="yt-bottom-nav-item {{ request()->routeIs('videos.create') ? 'active' : '' }}"
|
||||
onclick="event.preventDefault(); openUploadChooser();">
|
||||
<i class="bi bi-plus-circle-fill"></i>
|
||||
<span>Upload</span>
|
||||
</a>
|
||||
@else
|
||||
<a href="{{ route('login') }}" class="yt-bottom-nav-item">
|
||||
<i class="bi bi-plus-circle-fill"></i>
|
||||
<span>Upload</span>
|
||||
</a>
|
||||
@endauth
|
||||
<a href="{{ route('history') }}" class="yt-bottom-nav-item {{ request()->routeIs('history') ? 'active' : '' }}">
|
||||
<i class="bi bi-collection-play-fill"></i>
|
||||
<span>History</span>
|
||||
@@ -1689,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; }
|
||||
@@ -233,12 +220,8 @@ window._editCurrentVideoId = null;
|
||||
|
||||
// ── Modal open / close ────────────────────────────────────────────────────────
|
||||
function openEditVideoModal(videoId) {
|
||||
if (window.innerWidth < 992) {
|
||||
window.location.href = `/videos/${videoId}/edit`;
|
||||
return;
|
||||
}
|
||||
window._editCurrentVideoId = videoId;
|
||||
|
||||
// Always resolve type first so a match video opens the sports-match sheet
|
||||
// instead of the generic video-edit modal — same for mobile.
|
||||
fetch(`/videos/${videoId}/edit`, {
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
@@ -251,6 +234,25 @@ function openEditVideoModal(videoId) {
|
||||
if (!data.success) { showToast('Failed to load video data', 'error'); return; }
|
||||
const v = data.video;
|
||||
|
||||
// Match videos always route to the sports-match sheet — edit the existing
|
||||
// match if one is linked, otherwise open create-mode with the video already
|
||||
// attached so the user can add the match details.
|
||||
if (v.type === 'match' && typeof window.openSportsMatchModal === 'function') {
|
||||
if (v.sports_match_id) {
|
||||
window.openSportsMatchModal(v.sports_match_id);
|
||||
} else {
|
||||
window.openSportsMatchModal({ videoId: v.id, videoTitle: v.title });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Mobile fallback for non-match videos: full-page edit view
|
||||
if (window.innerWidth < 992) {
|
||||
window.location.href = `/videos/${videoId}/edit`;
|
||||
return;
|
||||
}
|
||||
window._editCurrentVideoId = videoId;
|
||||
|
||||
document.getElementById('edit-form').action = `/videos/${videoId}`;
|
||||
|
||||
// Header
|
||||
@@ -705,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 ──
|
||||
@@ -725,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -812,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) {
|
||||
@@ -870,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';
|
||||
@@ -887,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') {
|
||||
@@ -895,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() : '';
|
||||
}
|
||||
@@ -917,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) {
|
||||
@@ -975,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;
|
||||
@@ -1014,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
|
||||
|
||||
|
||||
@@ -417,8 +417,10 @@
|
||||
{{-- ── Image croppers (outside the form so their inner file inputs aren't submitted) ──
|
||||
Six form-mode croppers write the cropped file straight onto each hidden input;
|
||||
one callback-mode cropper serves all dynamic official rows. --}}
|
||||
<x-image-cropper id="smc_media_participant1_photo" :width="380" :height="380" shape="square" output-width="600" title="Crop Participant 1 photo" target-input="sm-file-media_participant1_photo" preview-img="sm-prev-media_participant1_photo" />
|
||||
<x-image-cropper id="smc_media_participant2_photo" :width="380" :height="380" shape="square" output-width="600" title="Crop Participant 2 photo" target-input="sm-file-media_participant2_photo" preview-img="sm-prev-media_participant2_photo" />
|
||||
{{-- Fighter photos: 3:4 portrait crop to match the VS card frame (228×304).
|
||||
Club logos + referee: 1:1 square. --}}
|
||||
<x-image-cropper id="smc_media_participant1_photo" :width="285" :height="380" shape="square" output-width="600" title="Crop Participant 1 photo" target-input="sm-file-media_participant1_photo" preview-img="sm-prev-media_participant1_photo" />
|
||||
<x-image-cropper id="smc_media_participant2_photo" :width="285" :height="380" shape="square" output-width="600" title="Crop Participant 2 photo" target-input="sm-file-media_participant2_photo" preview-img="sm-prev-media_participant2_photo" />
|
||||
<x-image-cropper id="smc_media_referee_photo" :width="380" :height="380" shape="square" output-width="600" title="Crop Referee photo" target-input="sm-file-media_referee_photo" preview-img="sm-prev-media_referee_photo" />
|
||||
<x-image-cropper id="smc_media_club1_logo" :width="380" :height="380" shape="square" output-width="600" title="Crop Club / team 1 logo" target-input="sm-file-media_club1_logo" preview-img="sm-prev-media_club1_logo" />
|
||||
<x-image-cropper id="smc_media_club2_logo" :width="380" :height="380" shape="square" output-width="600" title="Crop Club / team 2 logo" target-input="sm-file-media_club2_logo" preview-img="sm-prev-media_club2_logo" />
|
||||
@@ -524,6 +526,72 @@
|
||||
.sm-img-avatar .sm-img-ph { min-height: 74px; gap: 2px; font-size: 10px; }
|
||||
.sm-img-avatar .sm-img-ph i { font-size: 16px; }
|
||||
.sm-img-avatar .sm-img-preview { width: 74px; height: 74px; max-height: 74px; }
|
||||
|
||||
/* ── Mobile: render as a bottom sheet ─────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
#sportsMatchModal .modal-dialog {
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
#sportsMatchModal .sm-content {
|
||||
border-radius: 20px 20px 0 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
width: 100%;
|
||||
max-height: 92vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 -12px 40px rgba(0,0,0,.6);
|
||||
transform: translateY(100%);
|
||||
transition: transform .3s cubic-bezier(.22,1,.36,1);
|
||||
}
|
||||
#sportsMatchModal.show .sm-content { transform: translateY(0); }
|
||||
|
||||
/* Grabber handle at the top */
|
||||
#sportsMatchModal .sm-header {
|
||||
position: relative;
|
||||
padding-top: 18px;
|
||||
}
|
||||
#sportsMatchModal .sm-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 6px; left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 40px; height: 4px;
|
||||
background: #333; border-radius: 2px;
|
||||
}
|
||||
|
||||
#sportsMatchModal .modal-body {
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
flex: 1 1 auto;
|
||||
padding-bottom: calc(16px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
#sportsMatchModal .sm-footer {
|
||||
padding: 10px 14px calc(10px + env(safe-area-inset-bottom));
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#sportsMatchModal .sm-footer .btn {
|
||||
flex: 1 1 auto;
|
||||
font-size: 13px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
/* Slide-up animation on the modal fade class as well */
|
||||
#sportsMatchModal.fade .modal-dialog { transform: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
#sportsMatchModal .sm-header h5 { font-size: 15px; }
|
||||
#sportsMatchModal .sm-header-icon { width: 34px; height: 34px; font-size: 16px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
@@ -555,12 +623,28 @@
|
||||
function token() { return form.querySelector('[name="_token"]').value; }
|
||||
|
||||
// ── Public entry point ───────────────────────────────────────────────
|
||||
window.openSportsMatchModal = function (matchId) {
|
||||
// - openSportsMatchModal(matchId) → edit existing match
|
||||
// - openSportsMatchModal({videoId, videoTitle}) → create against an existing video
|
||||
// - openSportsMatchModal() → fresh create (upload a new video)
|
||||
window.openSportsMatchModal = function (arg) {
|
||||
resetForm();
|
||||
if (matchId) loadMatch(matchId);
|
||||
if (typeof arg === 'number' || typeof arg === 'string') {
|
||||
loadMatch(arg);
|
||||
} else if (arg && typeof arg === 'object' && arg.videoId) {
|
||||
attachExistingVideo(arg.videoId, arg.videoTitle || '');
|
||||
}
|
||||
getModal().show();
|
||||
};
|
||||
|
||||
function attachExistingVideo(videoId, videoTitle) {
|
||||
videoIdInp.value = videoId;
|
||||
document.getElementById('sm-video-create').classList.add('d-none');
|
||||
document.getElementById('sm-video-attached').classList.remove('d-none');
|
||||
document.getElementById('sm-video-attached-title').textContent = videoTitle || ('Video #' + videoId);
|
||||
// Advanced fields are also useful when the video already exists.
|
||||
document.getElementById('sm-advanced').classList.remove('d-none');
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.reset();
|
||||
idInput.value = '';
|
||||
@@ -795,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
|
||||
@@ -0,0 +1,76 @@
|
||||
@php
|
||||
/*
|
||||
* Match scoreboard entry partial. Builds the JSON payload the client
|
||||
* script consumes ($msbState) plus a rich $vs payload for the VS card.
|
||||
* Every value is null-safe — missing fields become empty strings so
|
||||
* the CSS :empty rules hide their elements (no placeholder text).
|
||||
*/
|
||||
$hd = $video->sportsMatch?->headerData();
|
||||
|
||||
/* ── Discipline / subtitle text ───────────────────────────────────── */
|
||||
$subtitlePieces = array_filter([
|
||||
$hd['championship'] ?? null,
|
||||
$hd['weight_category'] ?? null,
|
||||
!empty($hd['court']) ? ('Tatami '.$hd['court']) : null,
|
||||
], fn ($p) => is_string($p) && trim($p) !== '');
|
||||
|
||||
/* ── Playback-time-driven data (rounds + points) ─────────────────── */
|
||||
$msbRounds = $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();
|
||||
|
||||
$msbPoints = $video->matchPoints()
|
||||
->orderBy('timestamp_seconds')
|
||||
->get(['timestamp_seconds', 'action', 'points', 'competitor', 'score_red', 'score_blue'])
|
||||
->flatMap(function ($p) {
|
||||
// "Both" events (if ever recorded) emit two feed rows — one per corner.
|
||||
$rows = [];
|
||||
$rows[] = [
|
||||
't' => (float) $p->timestamp_seconds,
|
||||
'action' => (string) ($p->action ?? 'Point'),
|
||||
'pts' => (int) $p->points,
|
||||
'side' => $p->competitor === 'both' ? 'red' : $p->competitor,
|
||||
'sr' => (int) ($p->score_red ?? 0),
|
||||
'sb' => (int) ($p->score_blue ?? 0),
|
||||
];
|
||||
if ($p->competitor === 'both') {
|
||||
$rows[] = $rows[0];
|
||||
$rows[1]['side'] = 'blue';
|
||||
$rows[1]['t'] = (float) $p->timestamp_seconds + 0.001;
|
||||
}
|
||||
return $rows;
|
||||
})->values()->all();
|
||||
|
||||
$msbState = [
|
||||
'sport' => $hd['sport'] ?? null,
|
||||
'discipline' => null,
|
||||
'subtitle' => implode(' · ', $subtitlePieces),
|
||||
'red' => [
|
||||
'name' => $hd['red']['name'] ?? null,
|
||||
'flag' => $hd['red']['flag'] ?? null,
|
||||
'club' => $hd['red']['club'] ?? null,
|
||||
'club_logo' => !empty($hd['red']['club_logo'])
|
||||
? route('media.thumbnail', $hd['red']['club_logo']) : null,
|
||||
],
|
||||
'blue' => [
|
||||
'name' => $hd['blue']['name'] ?? null,
|
||||
'flag' => $hd['blue']['flag'] ?? null,
|
||||
'club' => $hd['blue']['club'] ?? null,
|
||||
'club_logo' => !empty($hd['blue']['club_logo'])
|
||||
? route('media.thumbnail', $hd['blue']['club_logo']) : null,
|
||||
],
|
||||
'rounds' => $msbRounds ?: [['n' => 1, 'name' => '', 'start' => 0]],
|
||||
'points' => $msbPoints,
|
||||
'defaults' => is_array($hd['scoreboard_defaults'] ?? null) ? $hd['scoreboard_defaults'] : [],
|
||||
];
|
||||
|
||||
@endphp
|
||||
|
||||
@include('videos.partials.match.scoreboard.styles')
|
||||
@include('videos.partials.match.scoreboard.overlay')
|
||||
@include('videos.partials.match.scoreboard.script', ['msbState' => $msbState])
|
||||
@@ -0,0 +1,99 @@
|
||||
{{--
|
||||
Overlay markup. The score bar + timeline blocks are the design's exact
|
||||
verbatim inline-styled markup from drafts/score-bar.html — only the
|
||||
{{ }} data holes have been filled and `data-msb` attributes added for
|
||||
the JS. `data-msb-part` attributes let the gear-menu toggles hide any
|
||||
part with CSS `display: none` without touching the inline styles.
|
||||
--}}
|
||||
<div class="msb-root" id="msbRoot" data-video-id="{{ $video->id }}">
|
||||
<div class="msb-scrim"></div>
|
||||
|
||||
{{-- Fixed-width scale layer (1150 px design canvas). JS scales it to
|
||||
fit the actual player, so the score bar keeps its pixel-perfect
|
||||
proportions at any player size. --}}
|
||||
<div class="msb-scale" id="msbScale">
|
||||
|
||||
{{-- Match info (top-left) --}}
|
||||
<div class="msb-info" data-msb="info" data-msb-part="info">
|
||||
<div class="rule"></div>
|
||||
<div class="txt">
|
||||
<div class="disc" data-msb="disc"></div>
|
||||
<div class="sub" data-msb="sub"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Live scoring feed (top-right) --}}
|
||||
<div class="msb-feed" data-msb="feedWrap" data-msb-part="feed">
|
||||
<div class="hdr"><span class="dot"></span><span>Live scoring</span></div>
|
||||
<div class="list" data-msb="feed"></div>
|
||||
</div>
|
||||
|
||||
{{-- ═══════════════════════════════════════════════════════════════════
|
||||
SCORE BAR — verbatim from drafts/score-bar.html (design handoff).
|
||||
Only {{ }} holes replaced with Blade data + data-msb hooks.
|
||||
═══════════════════════════════════════════════════════════════ --}}
|
||||
<div data-msb-part="scorebar" style="position:absolute;left:0;right:0;bottom:56px;padding:0 26px;display:flex;align-items:stretch;gap:0;height:84px;z-index:2;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 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)" 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">
|
||||
<span data-msb="redClubName" data-msb-part="clubs" 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">{{ $msbState['red']['club'] ?? '' }}</span>
|
||||
<span data-msb="redSenshu" data-msb-part="penalties" style="display:none;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 data-msb="redCorner" style="font-size:11px;letter-spacing:.24em;color:rgba(255,236,232,.7)">AKA</span>
|
||||
<span data-msb="redScore" 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-msb="roundLabel" style="transform:skewX(9deg);font-size:11px;letter-spacing:.3em;color:#8f8a83;text-transform:uppercase">Round 1</div>
|
||||
<div data-msb="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 — exact mirror --}}
|
||||
<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 data-msb="blueCorner" style="font-size:11px;letter-spacing:.24em;color:rgba(226,238,255,.7)">AO</span>
|
||||
<span data-msb="blueScore" 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 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)" 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" aria-hidden="true"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- POINT TIMELINE — one segment per point event, red/blue, #2a2724 when empty --}}
|
||||
<div data-msb="timeline" data-msb-part="timeline" style="position:absolute;left:26px;right:26px;bottom:48px;height:5px;display:flex;gap:2px;z-index:2;pointer-events:none">
|
||||
@for ($i = 0; $i < 14; $i++)
|
||||
<div style="flex:1;background:#2a2724"></div>
|
||||
@endfor
|
||||
</div>
|
||||
|
||||
</div>{{-- /.msb-scale --}}
|
||||
</div>
|
||||
@@ -0,0 +1,332 @@
|
||||
{{--
|
||||
Scoreboard runtime — one IIFE, deferred to DOMContentLoaded.
|
||||
|
||||
Consumes the JSON blob `MSB_STATE` (produced by index.blade.php) and:
|
||||
• updates score/round/clock/feed/timeline every time the video's
|
||||
currentTime changes (memoised so the ticker animation only replays
|
||||
when the visible entries change);
|
||||
• injects a "Scoreboard" section into #ytpSettingsPanel with a toggle
|
||||
row for each part;
|
||||
• persists toggle state in localStorage['msb_prefs'];
|
||||
• drives the VS intro card (first play of a session, skippable).
|
||||
--}}
|
||||
<script>
|
||||
(function () {
|
||||
const MSB_STATE = @json($msbState);
|
||||
const VIDEO_ID = @json($video->id);
|
||||
const LS_KEY = 'msb_prefs';
|
||||
|
||||
const RUN = function () {
|
||||
const root = document.getElementById('msbRoot');
|
||||
if (!root) return;
|
||||
const $ = (name) => document.querySelector('[data-msb="' + name + '"]');
|
||||
const player = () => document.getElementById('videoPlayer');
|
||||
|
||||
// ── Preferences (per-user, persisted) ─────────────────────────────
|
||||
const APP_DEFAULTS = { scorebar: true, feed: true, info: true, timeline: true, clubs: true, flags: true, penalties: false };
|
||||
const VIDEO_DEFAULTS = MSB_STATE.defaults || {};
|
||||
const DEFAULTS = Object.assign({}, APP_DEFAULTS, VIDEO_DEFAULTS);
|
||||
|
||||
let prefs;
|
||||
try { prefs = Object.assign({}, DEFAULTS, JSON.parse(localStorage.getItem(LS_KEY) || '{}')); }
|
||||
catch (e) { prefs = { ...DEFAULTS }; }
|
||||
|
||||
const savePrefs = () => { try { localStorage.setItem(LS_KEY, JSON.stringify(prefs)); } catch (e) {} };
|
||||
const applyPrefsClasses = () => {
|
||||
for (const key of Object.keys(APP_DEFAULTS)) {
|
||||
document.body.classList.toggle('msb-off-' + key, !prefs[key]);
|
||||
}
|
||||
// Signal to the scrim which parts are visible
|
||||
root.classList.toggle('msb-has-scorebar', !!prefs.scorebar);
|
||||
root.classList.toggle('msb-has-feed', !!prefs.feed);
|
||||
root.classList.toggle('msb-has-info', !!prefs.info);
|
||||
root.classList.toggle('msb-has-timeline', !!prefs.timeline);
|
||||
};
|
||||
|
||||
// ── Corner-label localisation ─────────────────────────────────────
|
||||
const CORNER_LABELS = {
|
||||
redblue: { red: 'Red', blue: 'Blue', disc: '' },
|
||||
karate: { red: 'Aka', blue: 'Ao', disc: 'Karate Kumite' },
|
||||
taekwondo: { red: 'Hong', blue: 'Chung', disc: 'Taekwondo Kyorugi' },
|
||||
};
|
||||
function labelSet() {
|
||||
const sport = (MSB_STATE.sport || 'karate').toLowerCase();
|
||||
if (sport === 'taekwondo' || sport === 'taekwondo_kyorugi') return CORNER_LABELS.taekwondo;
|
||||
if (sport === 'karate' || sport === 'karate_kumite') return CORNER_LABELS.karate;
|
||||
return CORNER_LABELS.redblue;
|
||||
}
|
||||
function pointLabel(p) {
|
||||
const sport = (MSB_STATE.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';
|
||||
}
|
||||
const colorFor = (side) => side === 'red' ? '#ff6a5e' : '#6aa6ff';
|
||||
|
||||
// ── Fixed-value paints (only once per load) ───────────────────────
|
||||
function paintFixed() {
|
||||
const labels = labelSet();
|
||||
const setText = (name, val, hideIfEmpty = true) => {
|
||||
const el = $(name); if (!el) return;
|
||||
el.textContent = val || '';
|
||||
if (hideIfEmpty) el.toggleAttribute('data-msb-empty', !val);
|
||||
};
|
||||
// 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;
|
||||
el.textContent = '';
|
||||
el.style.background = 'rgba(0,0,0,.32)';
|
||||
const img = document.createElement('img');
|
||||
img.src = path; img.alt = '';
|
||||
img.style.cssText = 'width:100%;height:100%;object-fit:contain;display:block';
|
||||
el.appendChild(img);
|
||||
};
|
||||
// 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();
|
||||
if (!/^[a-z]{2}$/.test(code) || code === 'xx') return;
|
||||
el.textContent = '';
|
||||
el.style.background = '#000';
|
||||
el.style.backgroundImage = 'url({{ asset('vendor/flag-icons/flags/4x3') }}/' + code + '.svg)';
|
||||
el.style.backgroundSize = 'cover';
|
||||
el.style.backgroundPosition = 'center';
|
||||
};
|
||||
|
||||
// Identity — default to karate discipline label when sport unknown
|
||||
setText('disc', labels.disc || MSB_STATE.discipline || '');
|
||||
setText('sub', MSB_STATE.subtitle || '');
|
||||
// Hide the whole info block (rule + text) when both rows are empty
|
||||
const infoBlock = document.querySelector('[data-msb="info"]');
|
||||
if (infoBlock) {
|
||||
const empty = !$('disc').textContent && !$('sub').textContent;
|
||||
infoBlock.style.display = empty ? 'none' : '';
|
||||
}
|
||||
|
||||
// Athletes — brief §2: "no athlete name → show the corner label
|
||||
// ('RED' / 'BLUE') in the name slot" (always uppercase, not the
|
||||
// sport-flavoured AKA/AO which would collide with the corner label
|
||||
// that already sits above the numeral).
|
||||
setText('redName', MSB_STATE.red.name || 'RED');
|
||||
setText('blueName', MSB_STATE.blue.name || 'BLUE');
|
||||
setText('redClubName', MSB_STATE.red.club || '');
|
||||
setText('blueClubName', MSB_STATE.blue.club || '');
|
||||
setText('redCorner', labels.red.toUpperCase(), false);
|
||||
setText('blueCorner', labels.blue.toUpperCase(), false);
|
||||
|
||||
setFlag('redFlag', MSB_STATE.red.flag);
|
||||
setFlag('blueFlag', MSB_STATE.blue.flag);
|
||||
setImage('redClub', MSB_STATE.red.club_logo);
|
||||
setImage('blueClub', MSB_STATE.blue.club_logo);
|
||||
}
|
||||
|
||||
// ── Playback-time-driven render (memoised) ────────────────────────
|
||||
function currentRound(t) {
|
||||
const rs = MSB_STATE.rounds || [];
|
||||
if (!rs.length) return { n: 1, name: '', start: 0 };
|
||||
let cur = rs[0];
|
||||
for (const r of rs) { if (t >= (r.start || 0)) cur = r; }
|
||||
return cur;
|
||||
}
|
||||
function pointsUpTo(t) {
|
||||
return (MSB_STATE.points || []).filter(p => p.t <= t + 0.001);
|
||||
}
|
||||
function fmtClock(sec) {
|
||||
sec = Math.max(0, Math.floor(sec || 0));
|
||||
return Math.floor(sec / 60) + ':' + String(sec % 60).padStart(2, '0');
|
||||
}
|
||||
function esc(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||
|
||||
let lastRound = '', lastClock = '', lastScore = '', lastFeed = '', lastTl = '';
|
||||
function updateForTime(t) {
|
||||
const round = currentRound(t);
|
||||
const totalRounds = Math.max(1, (MSB_STATE.rounds || []).length);
|
||||
const sport = (MSB_STATE.sport || '').toLowerCase();
|
||||
|
||||
// Round label
|
||||
let roundText = round.name || '';
|
||||
if (sport.startsWith('taekwondo')) roundText = 'Round ' + round.n + ' / ' + totalRounds;
|
||||
else if (sport.startsWith('karate')) roundText = roundText || 'Kumite';
|
||||
if (roundText !== lastRound) { $('roundLabel').textContent = roundText; lastRound = roundText; }
|
||||
|
||||
// Clock = time since current round's start (or since t=0 if no round starts)
|
||||
const inRound = Math.max(0, t - (round.start || 0));
|
||||
const clockStr = fmtClock(inRound);
|
||||
if (clockStr !== lastClock) { $('clock').textContent = clockStr; lastClock = clockStr; }
|
||||
|
||||
const seen = pointsUpTo(t);
|
||||
const last = seen[seen.length - 1];
|
||||
const red = last ? last.sr : 0;
|
||||
const blue = last ? last.sb : 0;
|
||||
const sSig = red + '|' + blue;
|
||||
if (sSig !== lastScore) {
|
||||
$('redScore').textContent = red;
|
||||
$('blueScore').textContent = blue;
|
||||
lastScore = sSig;
|
||||
}
|
||||
|
||||
// Timeline segments across the duration — writes into the design's
|
||||
// verbatim inline-styled container (14 pre-rendered <div>s).
|
||||
const SEG_COUNT = Math.max(14, (MSB_STATE.points || []).length);
|
||||
const tlSig = SEG_COUNT + '|' + seen.map(p => p.side[0]).join('');
|
||||
if (tlSig !== lastTl) {
|
||||
const tl = $('timeline');
|
||||
if (tl) {
|
||||
let html = '';
|
||||
for (let i = 0; i < SEG_COUNT; i++) {
|
||||
const p = seen[i];
|
||||
const color = p ? colorFor(p.side) : '#2a2724';
|
||||
html += '<div style="flex:1;background:' + color + '"></div>';
|
||||
}
|
||||
tl.innerHTML = html;
|
||||
}
|
||||
lastTl = tlSig;
|
||||
}
|
||||
|
||||
// Ticker: last 4, newest first
|
||||
const last4 = seen.slice(-4).reverse();
|
||||
const fSig = last4.map(p => p.t + ':' + p.side + ':' + p.pts).join(',');
|
||||
if (fSig !== lastFeed) {
|
||||
const feed = $('feed');
|
||||
if (feed) {
|
||||
feed.innerHTML = last4.map(p => {
|
||||
const col = colorFor(p.side);
|
||||
return '<div class="entry msb-new" style="border-color:' + col + '">'
|
||||
+ '<span class="ts">' + fmtClock(p.t) + '</span>'
|
||||
+ '<span class="name">' + esc(pointLabel(p)) + '</span>'
|
||||
+ '<span class="pts" style="color:' + col + '">+' + p.pts + '</span>'
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
}
|
||||
lastFeed = fSig;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gear-menu injection ───────────────────────────────────────────
|
||||
// Custom row markup (per design): compact label + 26×14 track + 10 px
|
||||
// bone knob. Nothing on the platform side changes.
|
||||
function injectGearRows() {
|
||||
const panel = document.getElementById('ytpSettingsPanel');
|
||||
if (!panel) return false;
|
||||
if (panel.dataset.msbInjected) return true;
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'msb-gear-header';
|
||||
header.textContent = 'Scoreboard';
|
||||
panel.appendChild(header);
|
||||
|
||||
const ROWS = [
|
||||
['scorebar', 'Score bar'],
|
||||
['feed', 'Live scoring feed'],
|
||||
['info', 'Match info'],
|
||||
['timeline', 'Point timeline'],
|
||||
['clubs', 'Club logos'],
|
||||
['flags', 'Country flags'],
|
||||
['penalties', 'Penalties'],
|
||||
];
|
||||
for (const [key, label] of ROWS) {
|
||||
const row = document.createElement('button');
|
||||
row.type = 'button';
|
||||
row.className = 'msb-gear-row' + (prefs[key] ? ' on' : '');
|
||||
row.dataset.msbToggle = key;
|
||||
row.innerHTML =
|
||||
'<span>' + label + '</span>' +
|
||||
'<span class="msb-gear-track"><span class="msb-gear-knob"></span></span>';
|
||||
row.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
prefs[key] = !prefs[key];
|
||||
row.classList.toggle('on', prefs[key]);
|
||||
savePrefs(); applyPrefsClasses();
|
||||
});
|
||||
panel.appendChild(row);
|
||||
}
|
||||
panel.dataset.msbInjected = '1';
|
||||
|
||||
// Design: gear icon turns red while the menu is open.
|
||||
const sync = () => document.body.classList.toggle('msb-menu-open', panel.classList.contains('open'));
|
||||
new MutationObserver(sync).observe(panel, { attributes: true, attributeFilter: ['class'] });
|
||||
sync();
|
||||
return true;
|
||||
}
|
||||
function tryInject(attemptsLeft) {
|
||||
if (injectGearRows()) return;
|
||||
if (attemptsLeft > 0) setTimeout(() => tryInject(attemptsLeft - 1), 200);
|
||||
}
|
||||
tryInject(30);
|
||||
|
||||
// ── Overlay scaling to a fixed 1150 px design canvas ──────────────
|
||||
// Keeps the score bar, ticker, and identity at their pixel-perfect
|
||||
// proportions no matter how wide the actual player is.
|
||||
function initScale() {
|
||||
// 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;
|
||||
const update = () => {
|
||||
const w = wrap.clientWidth || 1;
|
||||
const h = wrap.clientHeight || 1;
|
||||
const s = w / DESIGN_W;
|
||||
layer.style.transform = 'scale(' + s + ')';
|
||||
layer.style.height = (h / s) + 'px';
|
||||
// "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);
|
||||
}
|
||||
window.addEventListener('resize', update);
|
||||
}
|
||||
|
||||
// ── Wire everything ───────────────────────────────────────────────
|
||||
applyPrefsClasses();
|
||||
paintFixed();
|
||||
initScale();
|
||||
|
||||
function attachPlayer() {
|
||||
const v = player();
|
||||
if (!v) { setTimeout(attachPlayer, 250); return; }
|
||||
const tick = () => updateForTime(v.currentTime || 0);
|
||||
v.addEventListener('timeupdate', tick);
|
||||
v.addEventListener('seeked', tick);
|
||||
v.addEventListener('loadedmetadata', tick);
|
||||
v.addEventListener('ratechange', tick);
|
||||
tick();
|
||||
}
|
||||
attachPlayer();
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', RUN);
|
||||
} else {
|
||||
setTimeout(RUN, 0);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,142 @@
|
||||
{{--
|
||||
Karate/Taekwondo match scoreboard overlay — all CSS in one place.
|
||||
Class prefix: msb- (match scoreboard). Nothing else in the codebase uses it.
|
||||
Everything lives inside #msbRoot which is `position:absolute; inset:0; pointer-events:none`
|
||||
and is NOT z-indexed on purpose, so #ytpControls (emitted after the slot) paints on top.
|
||||
--}}
|
||||
<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>
|
||||
/* ────────────────────────────── keyframes ────────────────────────────── */
|
||||
@keyframes msbRiseIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
|
||||
@keyframes msbPulse { 0%, 100% { opacity: 1; } 50% { opacity: .25; } }
|
||||
|
||||
/* ────────────────────────────── root layer ─────────────────────────────
|
||||
No z-index on the root: source order inside #ytpWrap places the video
|
||||
controls AFTER the overlay slot, so they paint on top and stay clickable.
|
||||
------------------------------------------------------------------------ */
|
||||
.msb-root {
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
font-family: 'Barlow Condensed', sans-serif;
|
||||
color: #efe9e0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow: hidden;
|
||||
}
|
||||
.msb-root * { box-sizing: border-box; }
|
||||
|
||||
/*
|
||||
* Fixed 1150 px design canvas. `msb-scale` is positioned at (0, 0), sized
|
||||
* to 1150 px wide, and JS-scaled by `transform: scale(playerWidth / 1150)`
|
||||
* so every child element (positioned in that 1150 px coord space) shrinks
|
||||
* or grows proportionally with the player. Height is set at runtime.
|
||||
*/
|
||||
.msb-scale {
|
||||
position: absolute; top: 0; left: 0;
|
||||
width: 1150px;
|
||||
transform-origin: top left;
|
||||
/* transform + height set by JS */
|
||||
}
|
||||
|
||||
/* 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; }
|
||||
.msb-info .rule { width: 4px; background: linear-gradient(#e8534a, #7c1d18); }
|
||||
.msb-info .txt { display: flex; flex-direction: column; gap: 2px; }
|
||||
.msb-info .disc {
|
||||
font-family: 'Zen Old Mincho', serif; font-size: 15px; letter-spacing: .34em;
|
||||
color: #efe9e0; text-transform: uppercase;
|
||||
}
|
||||
.msb-info .sub {
|
||||
font-size: 13px; letter-spacing: .28em; color: #cfc9c1;
|
||||
text-transform: uppercase; text-shadow: 0 1px 6px rgba(0,0,0,.9);
|
||||
}
|
||||
/* Auto-hide row when its text is empty (server empties data-msb-empty) */
|
||||
.msb-info [data-msb-empty] { display: none; }
|
||||
|
||||
/* ────────────────────────────── live scoring ticker (top-right) ──────── */
|
||||
/* top:56px clears the existing Highlights toggle chip that sits at top:8/right:8 (h=24) */
|
||||
.msb-feed { position: absolute; top: 56px; right: 24px; width: 262px; display: flex; flex-direction: column; gap: 7px; }
|
||||
.msb-feed .hdr {
|
||||
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);
|
||||
}
|
||||
.msb-feed .hdr .dot {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: #e8534a; animation: msbPulse 1.6s infinite;
|
||||
}
|
||||
.msb-feed .list { display: flex; flex-direction: column; gap: 7px; }
|
||||
.msb-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;
|
||||
}
|
||||
.msb-feed .entry.msb-new { animation: msbRiseIn .45s ease both; }
|
||||
.msb-feed .entry .ts { font-size: 12px; line-height: 1; letter-spacing: .16em; color: #79736c; }
|
||||
.msb-feed .entry .name { font-size: 17px; line-height: 1; letter-spacing: .12em; font-weight: 700; color: #efe9e0; text-transform: uppercase; }
|
||||
.msb-feed .entry .pts { font-family: 'Zen Old Mincho', serif; font-size: 19px; line-height: 1; font-weight: 700; }
|
||||
|
||||
/*
|
||||
* Score bar + timeline visuals live entirely as inline styles on the verbatim
|
||||
* markup from drafts/score-bar.html. Nothing here targets those elements
|
||||
* except the visibility toggles below (via data-msb-part), so the design's
|
||||
* exact pixel measurements are never overridden.
|
||||
*/
|
||||
|
||||
/* ────────────────────────────── visibility toggles ──────────────────── */
|
||||
/* Body-level classes hide any part with the matching data-msb-part attr
|
||||
without touching its inline styles. Master-off hides everything. */
|
||||
body.msb-off-scorebar [data-msb-part="scorebar"],
|
||||
body.msb-off-timeline [data-msb-part="timeline"],
|
||||
body.msb-off-info [data-msb-part="info"],
|
||||
body.msb-off-feed [data-msb-part="feed"],
|
||||
body.msb-off-clubs [data-msb-part="clubs"],
|
||||
body.msb-off-flags [data-msb-part="flags"],
|
||||
body.msb-off-penalties [data-msb-part="penalties"] { display: none !important; }
|
||||
|
||||
/* ────────────────────────────── responsive ──────────────────────────── */
|
||||
/* Below ~700 px actual player width the identity + ticker disappear
|
||||
(brief §7). The `msb-small` class is toggled by JS off ResizeObserver. */
|
||||
body.msb-small .msb-info,
|
||||
body.msb-small .msb-feed { display: none !important; }
|
||||
|
||||
/* ────────────────────────────── injected gear rows ──────────────────── */
|
||||
/* Matches the design prototype's custom look (26×14 track, 10 px bone knob),
|
||||
scoped so it never touches the platform's other rows. */
|
||||
.msb-gear-header {
|
||||
padding: 10px 14px;
|
||||
font-size: 11px; letter-spacing: .3em; color: #8f8a83; text-transform: uppercase;
|
||||
border-top: 1px solid rgba(255,255,255,.1); margin-top: 4px;
|
||||
font-family: 'Barlow Condensed', sans-serif;
|
||||
}
|
||||
.msb-gear-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 12px; padding: 9px 14px;
|
||||
background: transparent; border: 0; width: 100%; cursor: pointer;
|
||||
font-family: 'Barlow Condensed', sans-serif;
|
||||
font-size: 14px; letter-spacing: .14em; color: #efe9e0;
|
||||
text-transform: uppercase; text-align: left;
|
||||
transition: background .15s ease;
|
||||
}
|
||||
.msb-gear-row:hover { background: rgba(255,255,255,.07); }
|
||||
.msb-gear-track {
|
||||
position: relative; width: 26px; height: 14px; flex: none;
|
||||
background: #3a3734; transition: background .18s ease;
|
||||
}
|
||||
.msb-gear-knob {
|
||||
position: absolute; top: 2px; left: 2px;
|
||||
width: 10px; height: 10px; background: #efe9e0;
|
||||
transition: left .18s ease;
|
||||
}
|
||||
.msb-gear-row.on .msb-gear-track { background: #e8534a; }
|
||||
.msb-gear-row.on .msb-gear-knob { left: 14px; }
|
||||
/* Turn the gear icon red while the settings panel is open — matches design */
|
||||
body.msb-menu-open #ytpSettingsBtn svg { fill: #e8534a; }
|
||||
</style>
|
||||
@@ -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
|
||||
|
||||
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user