VS intro screen (arena design), card VS mini + live scorebar, full country names, sports-match edit fix
Match player + preview
- Rebuild VS intro as a 1920×1080 scaled canvas (arena design from drafts/):
angled RED/BLUE panels with clip-paths, slam-in VS with pulse+shine,
event/stage/weight header, Match/Court/Referee chips, per-fighter
Record/Rank/Stats chip row. Every placeholder from the artifact is
always in the DOM; empty ones are hidden by `.vs-nullable:empty`
and `.vs-nullable-hide` so the layout adapts without losing the
design's HTML shape. Country codes always render as full country
names (via new App\Data\Countries::name()).
- Real-time 6s countdown Skip button; video pinned at t=0 while
the intro runs, released to play at zero or on Skip.
- Standalone preview page at /videos/{video}/vs-preview.
Video cards (home + everywhere)
- Match cards get a `vs-mini` overlay of the same arena design
(fixed canvas, per-card ResizeObserver, animations replay every
time the mouse leaves the thumb). On hover the mini explodes
outward (panels fly out, VS scales+blurs+fades) and the hover
video plays over the cleared thumb.
- Match cards also render a `scorebar-mini` overlay (verbatim of
the full-player msb-scale scorebar + msb-feed) that shows
during hover playback. Live-scoring is wired per-card via a
data-sbm-state payload of rounds+points; RED/BLUE score, round
label, per-round clock, and the top-right Live Scoring ticker
(4 entries, sport-aware Yuko/Waza-ari/Ippon or Punch/Body
kick/… labels) all update from the mini video's timeupdate.
Bug fixes
- Hover video wasn't playing sound: dropped the `muted` attribute
I'd added while debugging match previews.
- White flash strobing on the left of match cards: removed the
`.vs-mini-flash` element (intro-only flourish that fired on
every mouseleave replay).
- Sports-match modal was showing "Please complete the required
fields" when saving basic info for match videos that had no
SportsMatch row yet: the submit gate only checked matchId, not
the attached videoId; now it correctly skips uploadVideoFirst()
in both edit and attach-existing-video modes.
Country names project-wide
- New helper App\Data\Countries::name($iso2) → full country name.
- Swapped `?? country` fallbacks to `?? Countries::name(country)`
in admin dashboard, video-analytics, video-insights payloads
(VideoController + SuperAdminController), and the VS partials.
Now user-visible country labels always show "Bahrain" instead
of "BH", including chart tooltips and modal titles.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
5b6bd86d70
commit
04d63cccdb
16 files changed
+2351
-411
No files matched your search
@@ -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)
|
||||
|
||||
@@ -506,7 +506,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']++;
|
||||
}
|
||||
|
||||
@@ -3383,7 +3383,7 @@ class VideoController extends Controller
|
||||
$totalGeo = $rawCountries->sum('cnt');
|
||||
$countries = $rawCountries->map(fn ($c) => [
|
||||
'code' => $c->country,
|
||||
'name' => $c->country_name,
|
||||
'name' => $c->country_name ?: \App\Data\Countries::name($c->country),
|
||||
'count' => (int) $c->cnt,
|
||||
'pct' => $totalGeo > 0 ? round($c->cnt / $totalGeo * 100) : 0,
|
||||
])->values();
|
||||
@@ -3722,7 +3722,7 @@ class VideoController extends Controller
|
||||
: asset('images/default-avatar.svg'))
|
||||
: null,
|
||||
'country' => $topCountry ? $topCountry->country : null,
|
||||
'country_name' => $topCountry ? $topCountry->country_name : null,
|
||||
'country_name' => $topCountry ? ($topCountry->country_name ?: \App\Data\Countries::name($topCountry->country)) : null,
|
||||
'reach' => $accesses,
|
||||
'created_at' => $s->created_at,
|
||||
];
|
||||
@@ -3868,7 +3868,7 @@ class VideoController extends Controller
|
||||
|
||||
return response()->json([
|
||||
'country' => $country,
|
||||
'country_name' => $countryName ?? $country,
|
||||
'country_name' => $countryName ?: (\App\Data\Countries::name($country) ?? $country),
|
||||
'total_views' => $totalViews,
|
||||
'registered_users' => $registeredUsers,
|
||||
'guest_count' => $guestCount,
|
||||
@@ -3943,7 +3943,7 @@ class VideoController extends Controller
|
||||
->orderByDesc('cnt')
|
||||
->limit(5)
|
||||
->get()
|
||||
->map(fn ($c) => ['code' => $c->country, 'name' => $c->country_name, 'count' => (int) $c->cnt]);
|
||||
->map(fn ($c) => ['code' => $c->country, 'name' => $c->country_name ?: \App\Data\Countries::name($c->country), 'count' => (int) $c->cnt]);
|
||||
|
||||
return response()->json([
|
||||
'date' => $day->format('M d, Y'),
|
||||
@@ -3971,7 +3971,7 @@ class VideoController extends Controller
|
||||
->map(fn ($r) => [
|
||||
'type' => $r->type,
|
||||
'country' => $r->country,
|
||||
'country_name' => $r->country_name,
|
||||
'country_name' => $r->country_name ?: \App\Data\Countries::name($r->country),
|
||||
'at' => $r->downloaded_at,
|
||||
]);
|
||||
|
||||
@@ -4101,7 +4101,7 @@ class VideoController extends Controller
|
||||
foreach ($accesses as $a) {
|
||||
$code = $a->country ?: 'XX';
|
||||
if (! isset($countries[$code])) {
|
||||
$countries[$code] = ['code' => $code, 'name' => $a->country_name ?: $code, 'count' => 0];
|
||||
$countries[$code] = ['code' => $code, 'name' => $a->country_name ?: (\App\Data\Countries::name($code) ?? $code), 'count' => 0];
|
||||
}
|
||||
$countries[$code]['count']++;
|
||||
|
||||
@@ -4199,7 +4199,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']++;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -1,391 +0,0 @@
|
||||
<!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">
|
||||
{"988d5bb0-6b8b-4461-9834-9044add50567":{"mime":"image/webp","compressed":false,"data":"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 truncated
|
||||
</script>
|
||||
|
||||
<script type="__bundler/ext_resources">
|
||||
[{"id":"flagJp","uuid":"e8a5751b-1895-421b-81c6-cd1cf33b847a"},{"id":"flagBr","uuid":"58ea924b-4a72-4929-b5d3-4a14f87fd42c"},{"id":"https://unpkg.com/[email protected]/umd/react.production.min.js","uuid":"4baee0a2-533d-4dff-bd16-9d81d6c1eb89"},{"id":"https://unpkg.com/[email protected]/umd/react-dom.production.min.js","uuid":"2e74a05a-6804-4936-b0c7-eaf1671e7e52"}]
|
||||
</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=\"747f7f54-d026-461a-bd1b-90b39d183fdb\"><\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(\"900148d0-667d-400a-b330-963febd8c5b7\") 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(\"dbb9047d-6d42-4837-aba8-da02b29f3838\") 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(\"20e37ef7-5c67-4418-9666-37b77a11316c\") 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(\"eb55a667-15bd-4d2f-a0a8-a4ccc63b24c3\") 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(\"0d8cbe9f-19c6-464a-a95f-b6a56839cc7b\") 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(\"1fef2be4-b4a3-4c6a-9dbd-d9420a9cf334\") 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(\"30f96a20-a27e-4679-a9bc-ea15f4801539\") 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(\"f1f6b5da-6de2-47db-91d1-83b5fff71933\") 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(\"22af4e83-40e9-4a4d-b8a3-989817c34d0b\") 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(\"12f21ea0-47ed-485f-8600-808366fae9ba\") 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(\"84dab13f-1bc8-4907-b693-6e7814587327\") 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(\"5f76a04e-2585-4eff-9f56-642d5978e73e\") 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":"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 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>
|
||||
@@ -479,10 +479,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>
|
||||
@@ -742,7 +742,11 @@ new Chart(document.getElementById('typeChart'), {
|
||||
// ── Country Chart ───────────────────────────────────────────────
|
||||
@if($viewsByCountry->isNotEmpty())
|
||||
(function() {
|
||||
const countryData = @json($viewsByCountry);
|
||||
const countryData = @json($viewsByCountry->map(fn($r) => [
|
||||
'country' => $r->country,
|
||||
'country_name' => $r->country_name ?? \App\Data\Countries::name($r->country),
|
||||
'total' => $r->total,
|
||||
])->values());
|
||||
const labels = countryData.map(r => r.country_name || r.country || '');
|
||||
const values = countryData.map(r => r.total);
|
||||
const maxVal = Math.max(...values);
|
||||
@@ -1015,7 +1019,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];
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,536 @@
|
||||
{{-- ══════════════════════════════════════════════════════════════════════
|
||||
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['division'] ?? '') ?: $vsm_str($vsm_hd['format'] ?? '');
|
||||
$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;
|
||||
}
|
||||
|
||||
/* 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));
|
||||
}
|
||||
|
||||
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. removing + re-adding the class in the
|
||||
// next frame forces the CSS animation to play from the start.
|
||||
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');
|
||||
// Reflow-then-reapply — CSS animations only restart when the
|
||||
// running class is removed and reintroduced across a frame.
|
||||
void vsm.offsetWidth;
|
||||
vsm.classList.add('vs-mini-run');
|
||||
}, true);
|
||||
})();
|
||||
</script>
|
||||
@endonce
|
||||
@@ -54,11 +54,21 @@ $sizeClasses = match($size) {
|
||||
<div class="yt-video-thumb" onmouseenter="playVideo(this)" onmouseleave="stopVideo(this)"
|
||||
data-audio="{{ $video && $video->isAudioOnly() ? 'true' : 'false' }}">
|
||||
<img src="{{ $thumbnailUrl }}" alt="{{ $video->title ?? 'Video' }}" loading="lazy" decoding="async" onload="this.classList.add('loaded');this.closest('.yt-video-thumb').classList.add('loaded')">
|
||||
@if($video && $video->type === 'match' && $video->sportsMatch)
|
||||
@include('components.partials.vs-mini', ['video' => $video])
|
||||
@endif
|
||||
@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
|
||||
@if($video && $video->type === 'match' && $video->sportsMatch)
|
||||
@include('components.partials.scorebar-mini', ['video' => $video])
|
||||
@endif
|
||||
{{-- Equalizer overlay shown when audio-only track is previewing --}}
|
||||
<div class="audio-preview-overlay">
|
||||
<div class="audio-eq">
|
||||
|
||||
@@ -879,8 +879,12 @@
|
||||
clearErrors();
|
||||
lockButtons(true);
|
||||
|
||||
// Edit mode → just update the record. Create mode → upload the video first.
|
||||
const chain = idInput.value ? Promise.resolve() : uploadVideoFirst();
|
||||
// Skip the video-upload step when:
|
||||
// - editing an existing match (matchId present), OR
|
||||
// - creating a match against an already-attached video (videoId present via
|
||||
// attachExistingVideo, e.g. clicking "Edit" on a match-type video that has
|
||||
// no SportsMatch record yet — the video is real, we're just adding match data).
|
||||
const chain = (idInput.value || videoIdInp.value) ? Promise.resolve() : uploadVideoFirst();
|
||||
chain
|
||||
.then(() => postMatch(intent))
|
||||
.catch(e => { if (e && e.message !== 'handled') toast((e && e.message) || 'Save failed', 'error'); })
|
||||
|
||||
@@ -0,0 +1,559 @@
|
||||
{{-- ══════════════════════════════════════════════════════════════════════
|
||||
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'] ?? '');
|
||||
$vsStage = $vsStr($hd['division'] ?? '') ?: $vsStr($hd['format'] ?? '');
|
||||
$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
|
||||
@@ -2230,8 +2230,10 @@
|
||||
<span class="replay-badge-word">REPLAY</span>
|
||||
<span class="replay-badge-speed" id="replayBadgeSpeed">×1</span>
|
||||
</div>
|
||||
{{-- Karate/Taekwondo scoreboard overlay + VS intro + gear-menu toggles --}}
|
||||
{{-- Karate/Taekwondo scoreboard overlay + gear-menu toggles --}}
|
||||
@include('videos.partials.match.scoreboard.index')
|
||||
{{-- VS intro card — first-play arena screen (skippable) --}}
|
||||
@include('videos.partials.match.vs.index')
|
||||
</x-slot:overlay>
|
||||
</x-video-player>
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
{{-- Standalone preview page for the VS intro overlay. Renders the same
|
||||
partial that appears over the match player, in a fixed 16:9 stage
|
||||
centered on a blank page, with no video element behind it. --}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>VS Preview — {{ $video->title }}</title>
|
||||
<link rel="stylesheet" href="{{ asset('vendor/flag-icons/css/flag-icons.min.css') }}">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.min.css">
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; background: #050507; height: 100%; overflow: hidden; }
|
||||
body { display: flex; align-items: center; justify-content: center; font-family: system-ui, sans-serif; }
|
||||
.vs-preview-stage {
|
||||
position: relative;
|
||||
width: min(100vw, calc(100vh * 16 / 9));
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #000;
|
||||
box-shadow: 0 30px 80px rgba(0,0,0,.6);
|
||||
}
|
||||
/* The partial's #vsScreen uses position:absolute + inset:0, so it fills
|
||||
the stage exactly like it does inside .ytp on the real player. */
|
||||
#videoPlayer { display: none; } /* partial's script looks up this id, keep it inert */
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{-- The partial's script pauses/plays #videoPlayer to hold playback
|
||||
until the countdown ends. On the preview page there is no source
|
||||
to play, but the countdown itself is real-time so the overlay
|
||||
still ticks 5 → 0 and dismisses correctly. --}}
|
||||
<video id="videoPlayer" preload="none"></video>
|
||||
<div class="vs-preview-stage">
|
||||
@php $hd = $video->sportsMatch->headerData(); @endphp
|
||||
@include('videos.partials.match.vs.index')
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -40,6 +40,10 @@ Route::get('/videos/{numericId}', function ($numericId) {
|
||||
|
||||
Route::get('/videos/share/{token}', [VideoController::class, 'showByToken'])->name('videos.showByToken');
|
||||
Route::get('/videos/{video}', [VideoController::class, 'show'])->name('videos.show');
|
||||
Route::get('/videos/{video}/vs-preview', function (\App\Models\Video $video) {
|
||||
abort_unless($video->type === 'match' && $video->sportsMatch, 404);
|
||||
return view('videos.vs-preview', ['video' => $video]);
|
||||
})->name('videos.vsPreview');
|
||||
Route::get('/videos/{video}/stream', [VideoController::class, 'stream'])->name('videos.stream');
|
||||
Route::get('/videos/{video}/audio-track/{track}', [VideoController::class, 'streamAudioTrack'])->name('videos.audio-track');
|
||||
Route::get('/videos/{video}/hls/{file?}', [VideoController::class, 'hls'])->where(['file' => '.*'])->name('videos.hls');
|
||||
|
||||
Reference in new issue
Block a user