Remove VS screen; fix cropper + sports edit save + headshot/logo mapping

- Remove VS intro from player and thumbnail VS card from match video cards.
  All references (`msb-vs*`, `initVsIntro`, `setVsImg`, `HAS_VS`, `$vs`,
  `$msbThumbVs`, `$hasMatchMeta`) are stripped from scoreboard partials,
  video-card component, and scoreboard script. Related CSS keyframes
  (`msbFadeIn/Out`, `msbDriftA/B`, `msbSweep`) removed.

- Image cropper (resources/views/components/image-cropper.blade.php):
  replace unreliable third-party Cropme integration with a DIY cropper.
  Pan/zoom/rotate wired directly with pointer + wheel events; on save,
  the visible viewport is rendered to an offscreen canvas at the configured
  output-width (with circular clip for `shape="circle"`). Also reparent the
  overlay to <body> on open to escape any transformed Bootstrap-modal
  ancestor's stacking context that clipped it.

- Sports match modal (resources/views/layouts/partials/sports-match-modal.blade.php):
  set fighter photo croppers to 3:4 portrait (285x380) to match the VS
  card's 228x304 frame; club logos + referee stay 1:1 square.

- SportsMatchController::fillFromRequest (line 174): coerce
  `$this->clean($request->input('media', []))` to `[]` when it returns null
  so array_merge doesn't crash under PHP 8's stricter types.

- SportsMatch::headerData(): read fighter headshots + club logos from the
  canonical media.* keys (participant1_photo / participant2_photo /
  club1_logo / club2_logo) that the uploader modal actually saves, with the
  older p1_* / p2_* names kept as a secondary fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ghassan 2026-08-09 23:47:59 +03:00
parent 19741f489b
commit 2b5e480c9a
9 changed files with 235 additions and 323 deletions

View File

@ -166,7 +166,9 @@ class SportsMatchController extends Controller
// Media text fields (caption/alt/credit/public). Preserve existing image // Media text fields (caption/alt/credit/public). Preserve existing image
// paths already on the record; handleImages() overwrites any replaced ones. // paths already on the record; handleImages() overwrites any replaced ones.
$existingMedia = $match->media ?? []; $existingMedia = $match->media ?? [];
$mediaText = $this->clean($request->input('media', [])); // clean() returns null when the array is entirely empty — coerce back to []
// so array_merge() doesn't blow up on PHP 8's stricter type checking.
$mediaText = $this->clean($request->input('media', [])) ?? [];
if (isset($mediaText['public'])) { if (isset($mediaText['public'])) {
$mediaText['public'] = filter_var($mediaText['public'], FILTER_VALIDATE_BOOLEAN); $mediaText['public'] = filter_var($mediaText['public'], FILTER_VALIDATE_BOOLEAN);
} }

View File

@ -53,15 +53,18 @@ class SportsMatch extends Model
'name' => $trim($this->participant1_name), 'name' => $trim($this->participant1_name),
'club' => $trim($p['p1_club'] ?? null), 'club' => $trim($p['p1_club'] ?? null),
'flag' => $this->flagFor($p['p1_country'] ?? null), 'flag' => $this->flagFor($p['p1_country'] ?? null),
'club_logo' => $trim($m['p1_club_logo'] ?? null), // Uploader form (sports-match modal) stores these under the
'headshot' => $trim($m['p1_headshot'] ?? null), // canonical media.* keys — mirror them to headerData() so the
// scoreboard/VS partials get real image paths.
'club_logo' => $trim($m['club1_logo'] ?? $m['p1_club_logo'] ?? null),
'headshot' => $trim($m['participant1_photo'] ?? $m['p1_headshot'] ?? null),
], ],
'red' => [ 'red' => [
'name' => $trim($this->participant2_name), 'name' => $trim($this->participant2_name),
'club' => $trim($p['p2_club'] ?? null), 'club' => $trim($p['p2_club'] ?? null),
'flag' => $this->flagFor($p['p2_country'] ?? null), 'flag' => $this->flagFor($p['p2_country'] ?? null),
'club_logo' => $trim($m['p2_club_logo'] ?? null), 'club_logo' => $trim($m['club2_logo'] ?? $m['p2_club_logo'] ?? null),
'headshot' => $trim($m['p2_headshot'] ?? null), 'headshot' => $trim($m['participant2_photo'] ?? $m['p2_headshot'] ?? null),
], ],
'weight_category' => $trim($p['weight_class'] ?? null), 'weight_category' => $trim($p['weight_class'] ?? null),
'division' => $trim($c['division'] ?? null), 'division' => $trim($c['division'] ?? null),

View File

@ -122,7 +122,12 @@
/* ── Open / close ── */ /* ── Open / close ── */
function openModal() { function openModal() {
document.getElementById('tcOverlay_' + id).classList.add('open'); var ov = document.getElementById('tcOverlay_' + id);
// Reparent to <body> at open time — a transformed ancestor (e.g. an
// opening Bootstrap modal) would otherwise constrain our position:fixed
// overlay to its own stacking context and clip Cropme's canvas.
if (ov && ov.parentNode !== document.body) document.body.appendChild(ov);
ov.classList.add('open');
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
} }
window['openCropperModal_' + id] = openModal; window['openCropperModal_' + id] = openModal;
@ -135,42 +140,161 @@
if (e.target === this) window.closeCropperModal(id); if (e.target === this) window.closeCropperModal(id);
}); });
/* ── Preload a File into the cropper ── */ /* ── Preload a File into the cropper ─────────────────────────────────
* Uses URL.createObjectURL (fast, memory-friendly) instead of FileReader
* data URLs, which are slow / can silently fail with large images.
*/
function preloadFile(file) { function preloadFile(file) {
if (!file) return; if (!file) return;
originalFile = file; originalFile = file;
document.getElementById('tcFileName_' + id).textContent = file.name; document.getElementById('tcFileName_' + id).textContent = file.name;
var reader = new FileReader(); var url = URL.createObjectURL(file);
reader.onload = function (e) { initCropper(e.target.result); }; initCropper(url);
reader.readAsDataURL(file);
} }
window['tcPreload_' + id] = preloadFile; window['tcPreload_' + id] = preloadFile;
function initCropper(dataUrl) { /*
* ───────────────────────────────────────────────────────────────────
* DIY cropper no library. Renders the loaded image in the canvas
* with a viewport frame of the desired vw:vh aspect (dashed red
* border, dark scrim outside via box-shadow). Pan by dragging the
* image, zoom via the slider (or wheel), rotate via the slider.
* Save clips the visible viewport region to an offscreen canvas.
* ───────────────────────────────────────────────────────────────────
*/
var _st = null; // cropper state — replaces Cropme's `cropperInst`
function initCropper(imageUrl) {
document.getElementById('tcPlaceholder_' + id).style.display = 'none'; document.getElementById('tcPlaceholder_' + id).style.display = 'none';
document.getElementById('tcSaveBtn_' + id).disabled = false; document.getElementById('tcSaveBtn_' + id).disabled = false;
document.getElementById('tcAsIsBtn_' + id).disabled = false; document.getElementById('tcAsIsBtn_' + id).disabled = false;
var canvas = document.getElementById('tcCanvas_' + id); var canvas = document.getElementById('tcCanvas_' + id);
if (cropperInst) { cropperInst.destroy(); cropperInst = null; } canvas.innerHTML = '';
canvas.className = 'tc-canvas tc-canvas-active';
cropperInst = new Cropme(canvas, { // ── layout: image element + viewport frame (frame drawn via box-shadow scrim) ──
container: { width: '100%', height: 320 }, var img = document.createElement('img');
viewport: { img.alt = '';
width: vw, height: vh, img.style.cssText =
type: shape, 'position:absolute;top:50%;left:50%;transform-origin:center center;' +
border: { enable: true, width: 2, color: '#ef4444' } 'user-select:none;-webkit-user-drag:none;pointer-events:none;max-width:none;';
}, var frame = document.createElement('div');
transformOrigin: 'viewport', frame.style.cssText =
zoom: { min: zoomMin, max: zoomMax, enable: true, mouseWheel: true, slider: false }, 'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);' +
rotation: { enable: true, slider: false } 'border:2px dashed #ef4444;box-sizing:border-box;pointer-events:none;' +
}); 'box-shadow:0 0 0 2000px rgba(0,0,0,.55);' +
cropperInst.bind({ url: dataUrl }).then(function () { (shape === 'circle' ? 'border-radius:50%;' : '');
document.getElementById('tcZoom_' + id).value = 0; canvas.appendChild(img);
document.getElementById('tcRot_' + id).value = 0; canvas.appendChild(frame);
});
// Reset sliders
var zoomEl = document.getElementById('tcZoom_' + id);
var rotEl = document.getElementById('tcRot_' + id);
zoomEl.value = 0; rotEl.value = 0;
// Init state
_st = {
img: img, frame: frame, canvas: canvas,
imgW: 0, imgH: 0,
canvasW: 0, canvasH: 0,
vpW: 0, vpH: 0, // viewport display size (px inside canvas)
baseScale: 1, // scale that makes image "cover" the viewport
scale: 1, deg: 0, tx: 0, ty: 0,
ready: false,
imageUrl: imageUrl,
};
img.onload = function () {
_st.imgW = img.naturalWidth || 1;
_st.imgH = img.naturalHeight || 1;
layoutCropper();
_st.ready = true;
};
img.onerror = function (e) {
console.error('[image-cropper]', id, 'image failed to load', e);
};
img.src = imageUrl;
// If already cached, onload may have fired synchronously — no-op if not.
if (img.complete && img.naturalWidth > 0) img.onload();
} }
// Fit the viewport inside the canvas, then fit the image to "cover" the viewport.
function layoutCropper() {
var s = _st; if (!s) return;
s.canvasW = s.canvas.clientWidth || 320;
s.canvasH = s.canvas.clientHeight || 320;
// Viewport: same aspect as vw:vh, fits inside canvas with ~10% padding.
var pad = 20;
var maxW = s.canvasW - pad * 2;
var maxH = s.canvasH - pad * 2;
var vAspect = vw / vh;
if (maxW / vAspect <= maxH) { s.vpW = maxW; s.vpH = Math.round(maxW / vAspect); }
else { s.vpH = maxH; s.vpW = Math.round(maxH * vAspect); }
s.frame.style.width = s.vpW + 'px';
s.frame.style.height = s.vpH + 'px';
// Base scale: image must at least cover the viewport.
s.baseScale = Math.max(s.vpW / s.imgW, s.vpH / s.imgH);
s.scale = s.baseScale;
s.tx = 0; s.ty = 0; s.deg = 0;
applyTransform();
}
function applyTransform() {
var s = _st; if (!s) return;
// Image is centered via top/left 50% translate + own translate + scale + rotate.
// Set explicit width so scale multiplies pixels rather than object-fit.
s.img.style.width = s.imgW + 'px';
s.img.style.height = s.imgH + 'px';
s.img.style.marginLeft = (-s.imgW / 2) + 'px';
s.img.style.marginTop = (-s.imgH / 2) + 'px';
s.img.style.transform =
'translate(' + s.tx + 'px,' + s.ty + 'px)' +
' rotate(' + s.deg + 'deg)' +
' scale(' + s.scale + ')';
}
// Drag to pan (mouse + touch)
(function bindPan() {
var canvas = document.getElementById('tcCanvas_' + id);
var dragging = false, sx = 0, sy = 0, otx = 0, oty = 0;
function down(e) {
if (!_st || !_st.ready) return;
dragging = true;
var p = e.touches ? e.touches[0] : e;
sx = p.clientX; sy = p.clientY; otx = _st.tx; oty = _st.ty;
canvas.style.cursor = 'grabbing';
e.preventDefault();
}
function move(e) {
if (!dragging || !_st) return;
var p = e.touches ? e.touches[0] : e;
_st.tx = otx + (p.clientX - sx);
_st.ty = oty + (p.clientY - sy);
applyTransform();
}
function up() { dragging = false; canvas.style.cursor = 'grab'; }
canvas.addEventListener('mousedown', down);
canvas.addEventListener('touchstart', down, { passive: false });
window.addEventListener('mousemove', move);
window.addEventListener('touchmove', move, { passive: false });
window.addEventListener('mouseup', up);
window.addEventListener('touchend', up);
// Wheel zoom
canvas.addEventListener('wheel', function (e) {
if (!_st || !_st.ready) return;
e.preventDefault();
var delta = e.deltaY < 0 ? 1.08 : 1 / 1.08;
_st.scale = Math.max(_st.baseScale, Math.min(_st.baseScale * 6, _st.scale * delta));
var pct = Math.round(((_st.scale - _st.baseScale) / (_st.baseScale * 5)) * 100);
document.getElementById('tcZoom_' + id).value = pct;
applyTransform();
}, { passive: false });
canvas.style.cursor = 'grab';
})();
/* ── Internal file input (the "Choose image" button inside the modal) ── */ /* ── Internal file input (the "Choose image" button inside the modal) ── */
document.getElementById('tcInput_' + id).addEventListener('change', function () { document.getElementById('tcInput_' + id).addEventListener('change', function () {
if (this.files && this.files[0]) preloadFile(this.files[0]); if (this.files && this.files[0]) preloadFile(this.files[0]);
@ -182,14 +306,16 @@
/* ── Zoom / rotate sliders ── */ /* ── Zoom / rotate sliders ── */
document.getElementById('tcZoom_' + id).addEventListener('input', function () { document.getElementById('tcZoom_' + id).addEventListener('input', function () {
if (!cropperInst || !cropperInst.properties.image) return; if (!_st || !_st.ready) return;
// 0 → baseScale (fit), 100 → baseScale * 6 (max zoom-in)
var p = parseFloat(this.value) / 100; var p = parseFloat(this.value) / 100;
cropperInst.properties.scale = zoomMin + (zoomMax - zoomMin) * p; _st.scale = _st.baseScale + (_st.baseScale * 5) * p;
var s = cropperInst.properties; applyTransform();
s.image.style.transform = 'translate3d(' + s.x + 'px,' + s.y + 'px,0) scale(' + s.scale + ') rotate(' + s.deg + 'deg)';
}); });
document.getElementById('tcRot_' + id).addEventListener('input', function () { document.getElementById('tcRot_' + id).addEventListener('input', function () {
if (cropperInst) cropperInst.rotate(parseInt(this.value, 10)); if (!_st || !_st.ready) return;
_st.deg = parseInt(this.value, 10) || 0;
applyTransform();
}); });
/* ── Helpers ── */ /* ── Helpers ── */
@ -250,43 +376,82 @@
.catch(onFail); .catch(onFail);
} }
/* ── Crop & Save ── */ /* ── Crop & Save ─────────────────────────────────────────────────────
* Renders the visible viewport region to an offscreen canvas at the
* output size, then returns the result as a JPEG data URL.
* The image is transformed by (translate rotate scale) the
* offscreen canvas replays those same transforms scaled up to the
* output resolution so nothing is lost.
*/
function renderCroppedBase64() {
var s = _st; if (!s || !s.ready) return null;
var outW = outputWidth > 0 ? outputWidth : vw;
var outH = Math.round(outW * (vh / vw));
var cv = document.createElement('canvas');
cv.width = outW;
cv.height = outH;
var ctx = cv.getContext('2d');
// Circle crop mask (matches the round viewport look on save)
if (shape === 'circle') {
ctx.save();
ctx.beginPath();
ctx.arc(outW / 2, outH / 2, Math.min(outW, outH) / 2, 0, Math.PI * 2);
ctx.closePath();
ctx.clip();
}
// 1 display px inside the viewport == (outW / s.vpW) output px
var pxRatio = outW / s.vpW;
ctx.translate(outW / 2, outH / 2);
ctx.scale(pxRatio, pxRatio);
ctx.translate(s.tx, s.ty);
ctx.rotate(s.deg * Math.PI / 180);
ctx.scale(s.scale, s.scale);
ctx.drawImage(s.img, -s.imgW / 2, -s.imgH / 2);
if (shape === 'circle') ctx.restore();
return cv.toDataURL('image/jpeg', 0.92);
}
window['tcSave_' + id] = function () { window['tcSave_' + id] = function () {
if (!cropperInst) return; if (!_st || !_st.ready) return;
var btn = document.getElementById('tcSaveBtn_' + id); var btn = document.getElementById('tcSaveBtn_' + id);
var txt = document.getElementById('tcSaveBtnText_' + id); var txt = document.getElementById('tcSaveBtnText_' + id);
btn.disabled = true; btn.disabled = true;
txt.textContent = 'Saving…'; txt.textContent = 'Saving…';
var cropOpts = outputWidth > 0 ? { type: 'base64', width: outputWidth } : { type: 'base64' }; var base64 = renderCroppedBase64();
if (!base64) { btn.disabled = false; txt.textContent = 'Crop & Save'; return; }
cropperInst.crop(cropOpts).then(function (base64) { if (isCallbackMode) {
if (isCallbackMode) { var cbName = originalFile ? originalFile.name : 'cropped.jpg';
var cbName = originalFile ? originalFile.name : 'cropped.png'; deliverResult(base64ToFile(base64, cbName));
deliverResult(base64ToFile(base64, cbName)); return;
return; }
} if (isFormMode) {
if (isFormMode) { var fname = originalFile ? originalFile.name : 'cropped.jpg';
var fname = originalFile ? originalFile.name : 'cropped.png'; setOnTargetInput(base64ToFile(base64, fname));
setOnTargetInput(base64ToFile(base64, fname)); window.closeCropperModal(id);
if (typeof window.showToast === 'function') window.showToast('Image ready!', 'success');
btn.disabled = false;
txt.textContent = 'Crop & Save';
} else {
uploadToServer(base64, function (res) {
window.closeCropperModal(id); window.closeCropperModal(id);
if (typeof window.showToast === 'function') window.showToast('Image ready!', 'success'); if (typeof window.showToast === 'function') window.showToast('Saved!', 'success');
if (callbackFn && typeof window[callbackFn] === 'function') window[callbackFn](res.url);
btn.disabled = false; btn.disabled = false;
txt.textContent = 'Crop & Save'; txt.textContent = 'Crop & Save';
} else { }, function (err) {
uploadToServer(base64, function (res) { if (typeof window.showToast === 'function') window.showToast(err.message || 'Upload failed', 'error');
window.closeCropperModal(id); btn.disabled = false;
if (typeof window.showToast === 'function') window.showToast('Saved!', 'success'); txt.textContent = 'Crop & Save';
if (callbackFn && typeof window[callbackFn] === 'function') window[callbackFn](res.url); });
btn.disabled = false; }
txt.textContent = 'Crop & Save';
}, function (err) {
if (typeof window.showToast === 'function') window.showToast(err.message || 'Upload failed', 'error');
btn.disabled = false;
txt.textContent = 'Crop & Save';
});
}
});
}; };
/* ── Upload as-is ── */ /* ── Upload as-is ── */

View File

@ -46,6 +46,7 @@ $sizeClasses = match($size) {
'small' => 'yt-video-card-sm', 'small' => 'yt-video-card-sm',
default => '', default => '',
}; };
@endphp @endphp
<div class="yt-video-card {{ $sizeClasses }}" data-video-url="{{ $videoUrl }}"> <div class="yt-video-card {{ $sizeClasses }}" data-video-url="{{ $videoUrl }}">

View File

@ -417,8 +417,10 @@
{{-- ── Image croppers (outside the form so their inner file inputs aren't submitted) ── {{-- ── Image croppers (outside the form so their inner file inputs aren't submitted) ──
Six form-mode croppers write the cropped file straight onto each hidden input; Six form-mode croppers write the cropped file straight onto each hidden input;
one callback-mode cropper serves all dynamic official rows. --}} one callback-mode cropper serves all dynamic official rows. --}}
<x-image-cropper id="smc_media_participant1_photo" :width="380" :height="380" shape="square" output-width="600" title="Crop Participant 1 photo" target-input="sm-file-media_participant1_photo" preview-img="sm-prev-media_participant1_photo" /> {{-- Fighter photos: 3:4 portrait crop to match the VS card frame (228×304).
<x-image-cropper id="smc_media_participant2_photo" :width="380" :height="380" shape="square" output-width="600" title="Crop Participant 2 photo" target-input="sm-file-media_participant2_photo" preview-img="sm-prev-media_participant2_photo" /> Club logos + referee: 1:1 square. --}}
<x-image-cropper id="smc_media_participant1_photo" :width="285" :height="380" shape="square" output-width="600" title="Crop Participant 1 photo" target-input="sm-file-media_participant1_photo" preview-img="sm-prev-media_participant1_photo" />
<x-image-cropper id="smc_media_participant2_photo" :width="285" :height="380" shape="square" output-width="600" title="Crop Participant 2 photo" target-input="sm-file-media_participant2_photo" preview-img="sm-prev-media_participant2_photo" />
<x-image-cropper id="smc_media_referee_photo" :width="380" :height="380" shape="square" output-width="600" title="Crop Referee photo" target-input="sm-file-media_referee_photo" preview-img="sm-prev-media_referee_photo" /> <x-image-cropper id="smc_media_referee_photo" :width="380" :height="380" shape="square" output-width="600" title="Crop Referee photo" target-input="sm-file-media_referee_photo" preview-img="sm-prev-media_referee_photo" />
<x-image-cropper id="smc_media_club1_logo" :width="380" :height="380" shape="square" output-width="600" title="Crop Club / team 1 logo" target-input="sm-file-media_club1_logo" preview-img="sm-prev-media_club1_logo" /> <x-image-cropper id="smc_media_club1_logo" :width="380" :height="380" shape="square" output-width="600" title="Crop Club / team 1 logo" target-input="sm-file-media_club1_logo" preview-img="sm-prev-media_club1_logo" />
<x-image-cropper id="smc_media_club2_logo" :width="380" :height="380" shape="square" output-width="600" title="Crop Club / team 2 logo" target-input="sm-file-media_club2_logo" preview-img="sm-prev-media_club2_logo" /> <x-image-cropper id="smc_media_club2_logo" :width="380" :height="380" shape="square" output-width="600" title="Crop Club / team 2 logo" target-input="sm-file-media_club2_logo" preview-img="sm-prev-media_club2_logo" />

View File

@ -14,14 +14,6 @@
!empty($hd['court']) ? ('Tatami '.$hd['court']) : null, !empty($hd['court']) ? ('Tatami '.$hd['court']) : null,
], fn ($p) => is_string($p) && trim($p) !== ''); ], fn ($p) => is_string($p) && trim($p) !== '');
/* ── Country display names (from Countries::all()) ───────────────── */
$countries = \App\Data\Countries::all();
$countryName = function (?string $iso2) use ($countries) {
if (!$iso2) return null;
$key = strtoupper($iso2);
return $countries[$key]['name'] ?? null;
};
/* ── Playback-time-driven data (rounds + points) ─────────────────── */ /* ── Playback-time-driven data (rounds + points) ─────────────────── */
$msbRounds = $video->matchRounds() $msbRounds = $video->matchRounds()
->orderBy('round_number') ->orderBy('round_number')
@ -75,64 +67,10 @@
'rounds' => $msbRounds ?: [['n' => 1, 'name' => '', 'start' => 0]], 'rounds' => $msbRounds ?: [['n' => 1, 'name' => '', 'start' => 0]],
'points' => $msbPoints, 'points' => $msbPoints,
'defaults' => is_array($hd['scoreboard_defaults'] ?? null) ? $hd['scoreboard_defaults'] : [], 'defaults' => is_array($hd['scoreboard_defaults'] ?? null) ? $hd['scoreboard_defaults'] : [],
'vs' => null, // populated below once $vs is built
]; ];
/* ── VS card — only render if there is anything to show ─────────── */
$vs = [
'event' => $hd['championship'] ?? null,
'division' => $hd['division'] ?? null,
'category' => $hd['weight_category'] ?? null,
'round' => null,
'red' => [
'name' => $hd['red']['name'] ?? null,
'flag' => $hd['red']['flag'] ?? null,
'country_name' => $countryName($hd['red']['flag'] ?? null),
'club' => $hd['red']['club'] ?? null,
'club_logo' => $hd['red']['club_logo'] ?? null,
'headshot' => $hd['red']['headshot'] ?? null,
],
'blue' => [
'name' => $hd['blue']['name'] ?? null,
'flag' => $hd['blue']['flag'] ?? null,
'country_name' => $countryName($hd['blue']['flag'] ?? null),
'club' => $hd['blue']['club'] ?? null,
'club_logo' => $hd['blue']['club_logo'] ?? null,
'headshot' => $hd['blue']['headshot'] ?? null,
],
'weight_category' => $hd['weight_category'] ?? null,
'format_line' => $hd['format'] ?? null,
'match_number' => $hd['match_number'] ?? null,
'court' => !empty($hd['court']) ? ('Tatami '.$hd['court']) : null,
'match_date' => $hd['match_date'] ?? null,
'venue_name' => $hd['venue']['name'] ?? null,
];
// Enough metadata for a VS card? Need at least one athlete name or headshot.
$hasMatchMeta = ($vs['red']['name'] || $vs['red']['headshot'] ||
$vs['blue']['name'] || $vs['blue']['headshot']);
// Feed image paths (headshot/logo) already routed to media.thumbnail for the
// JS to consume — mirror them into $msbState.vs so setVsImg() can use them.
$msbState['vs'] = [
'red' => [
'name' => $vs['red']['name'] ?? null,
'flag' => $vs['red']['flag'] ?? null,
'club_logo' => !empty($vs['red']['club_logo']) ? route('media.thumbnail', $vs['red']['club_logo']) : null,
'headshot' => !empty($vs['red']['headshot']) ? route('media.thumbnail', $vs['red']['headshot']) : null,
],
'blue' => [
'name' => $vs['blue']['name'] ?? null,
'flag' => $vs['blue']['flag'] ?? null,
'club_logo' => !empty($vs['blue']['club_logo']) ? route('media.thumbnail', $vs['blue']['club_logo']) : null,
'headshot' => !empty($vs['blue']['headshot']) ? route('media.thumbnail', $vs['blue']['headshot']) : null,
],
];
@endphp @endphp
@include('videos.partials.match.scoreboard.styles') @include('videos.partials.match.scoreboard.styles')
@include('videos.partials.match.scoreboard.overlay') @include('videos.partials.match.scoreboard.overlay')
@if ($hasMatchMeta) @include('videos.partials.match.scoreboard.script', ['msbState' => $msbState])
@include('videos.partials.match.scoreboard.vs-intro', ['vs' => $vs])
@endif
@include('videos.partials.match.scoreboard.script', ['msbState' => $msbState, 'hasMatchMeta' => $hasMatchMeta])

View File

@ -13,10 +13,8 @@
<script> <script>
(function () { (function () {
const MSB_STATE = @json($msbState); const MSB_STATE = @json($msbState);
const HAS_VS = @json($hasMatchMeta);
const VIDEO_ID = @json($video->id); const VIDEO_ID = @json($video->id);
const LS_KEY = 'msb_prefs'; const LS_KEY = 'msb_prefs';
const SS_VS_KEY = 'msb_vs_shown_v' + VIDEO_ID;
const RUN = function () { const RUN = function () {
const root = document.getElementById('msbRoot'); const root = document.getElementById('msbRoot');
@ -130,31 +128,6 @@
setFlag('blueFlag', MSB_STATE.blue.flag); setFlag('blueFlag', MSB_STATE.blue.flag);
setImage('redClub', MSB_STATE.red.club_logo); setImage('redClub', MSB_STATE.red.club_logo);
setImage('blueClub', MSB_STATE.blue.club_logo); setImage('blueClub', MSB_STATE.blue.club_logo);
// ── VS card image slots (photo, crest, flag) ────────────────
// If data exists, swap the placeholder for a real image.
// If no data, hide the placeholder box entirely (never leave
// "FIGHTER PHOTO" / "CLUB LOGO" / "FLAG" text visible in prod).
const VS = (MSB_STATE.vs || {});
const setVsImg = (name, path, hideWhenEmpty = true) => {
const el = $(name); if (!el) return;
if (path) {
el.textContent = '';
el.style.background = '#0d0d10';
const img = document.createElement('img');
img.src = path; img.alt = '';
img.style.cssText = 'width:100%;height:100%;object-fit:cover;display:block';
el.appendChild(img);
} else if (hideWhenEmpty) {
el.style.display = 'none';
}
};
setVsImg('vsRedPhoto', VS.red && VS.red.headshot);
setVsImg('vsBluePhoto', VS.blue && VS.blue.headshot);
setVsImg('vsRedCrest', VS.red && VS.red.club_logo);
setVsImg('vsBlueCrest', VS.blue && VS.blue.club_logo);
setFlag('vsRedFlag', VS.red && VS.red.flag);
setFlag('vsBlueFlag', VS.blue && VS.blue.flag);
} }
// ── Playback-time-driven render (memoised) ──────────────────────── // ── Playback-time-driven render (memoised) ────────────────────────
@ -291,48 +264,6 @@
} }
tryInject(30); tryInject(30);
// ── VS intro lifecycle ────────────────────────────────────────────
function initVsIntro() {
if (!HAS_VS) return;
const vs = document.getElementById('msbVs');
const v = player();
if (!vs || !v) return;
let alreadyShown = false;
try { alreadyShown = sessionStorage.getItem(SS_VS_KEY) === '1'; } catch (e) {}
if (alreadyShown) return;
let shown = false;
const show = () => {
if (shown) return;
shown = true;
try { sessionStorage.setItem(SS_VS_KEY, '1'); } catch (e) {}
vs.hidden = false;
vs.dataset.visible = 'true';
try { v.pause(); } catch (e) {}
const dismiss = (playToo = true) => {
if (!vs || vs.classList.contains('msb-vs-out')) return;
vs.classList.add('msb-vs-out');
if (playToo) { try { v.play(); } catch (e) {} }
setTimeout(() => { if (vs && vs.parentNode) vs.parentNode.removeChild(vs); }, 520);
document.removeEventListener('keydown', onKey);
};
vs.addEventListener('click', () => dismiss(true));
vs.addEventListener('touchstart', () => dismiss(true), { passive: true });
const onKey = () => dismiss(true);
document.addEventListener('keydown', onKey);
setTimeout(() => dismiss(true), 2000);
};
// Trigger on the first play attempt (autoplay-friendly).
const onPlay = () => { v.removeEventListener('play', onPlay); v.removeEventListener('playing', onPlay); show(); };
v.addEventListener('play', onPlay, { once: true });
v.addEventListener('playing', onPlay, { once: true });
// If autoplay is already firing when we arrive:
if (!v.paused && v.currentTime < 0.5) show();
}
// ── Overlay scaling to a fixed 1150 px design canvas ────────────── // ── Overlay scaling to a fixed 1150 px design canvas ──────────────
// Keeps the score bar, ticker, and identity at their pixel-perfect // Keeps the score bar, ticker, and identity at their pixel-perfect
// proportions no matter how wide the actual player is. // proportions no matter how wide the actual player is.
@ -372,7 +303,6 @@
v.addEventListener('loadedmetadata', tick); v.addEventListener('loadedmetadata', tick);
v.addEventListener('ratechange', tick); v.addEventListener('ratechange', tick);
tick(); tick();
initVsIntro();
} }
attachPlayer(); attachPlayer();
}; };

View File

@ -12,16 +12,6 @@
/* ────────────────────────────── keyframes ────────────────────────────── */ /* ────────────────────────────── keyframes ────────────────────────────── */
@keyframes msbRiseIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } } @keyframes msbRiseIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
@keyframes msbPulse { 0%, 100% { opacity: 1; } 50% { opacity: .25; } } @keyframes msbPulse { 0%, 100% { opacity: 1; } 50% { opacity: .25; } }
@keyframes msbFadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes msbFadeOut { from { opacity: 1; } to { opacity: 0; } }
@keyframes msbDriftA { 0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
50% { transform: translate3d(60px, -28px, 0) scale(1.14); } }
@keyframes msbDriftB { 0%, 100% { transform: translate3d(0, 0, 0) scale(1.08); }
50% { transform: translate3d(-54px, 26px, 0) scale(1); } }
@keyframes msbSweep { 0% { transform: translateX(-60%) skewX(-14deg); opacity: 0; }
25% { opacity: .5; }
60% { opacity: 0; }
100% { transform: translateX(160%) skewX(-14deg); opacity: 0; } }
/* ────────────────────────────── root layer ───────────────────────────── /* ────────────────────────────── root layer ─────────────────────────────
No z-index on the root: source order inside #ytpWrap places the video No z-index on the root: source order inside #ytpWrap places the video
@ -128,19 +118,6 @@ body.msb-off-penalties [data-msb-part="penalties"] { display: none !important; }
body.msb-small .msb-info, body.msb-small .msb-info,
body.msb-small .msb-feed { display: none !important; } body.msb-small .msb-feed { display: none !important; }
/*
* VS intro card visuals are entirely inline styles on the verbatim markup
* from drafts/vs-screen.html. Only the outer wrapper + fade in/out lives
* here; keyframes (msbDriftA/B, msbSweep, msbFadeIn/Out) are up top.
*/
.msb-vs {
position: absolute; inset: 0; z-index: 12;
background: #08080a; overflow: hidden;
pointer-events: auto;
animation: msbFadeIn .3s ease both;
}
.msb-vs.msb-vs-out { animation: msbFadeOut .5s ease both; }
/* ────────────────────────────── injected gear rows ──────────────────── */ /* ────────────────────────────── injected gear rows ──────────────────── */
/* Matches the design prototype's custom look (26×14 track, 10 px bone knob), /* Matches the design prototype's custom look (26×14 track, 10 px bone knob),
scoped so it never touches the platform's other rows. */ scoped so it never touches the platform's other rows. */

View File

@ -1,106 +0,0 @@
{{--
VS SCREEN verbatim markup from drafts/vs-screen.html. Only {{ }} holes
filled with Blade data. `data-msb` hooks on the photo, crest, and flag
boxes let the script swap in real images at runtime. Missing fields are
hidden by inline display:none (never substituted with placeholder text).
--}}
@php
$rc = strtoupper(trim($vs['red']['flag'] ?? ''));
$bc = strtoupper(trim($vs['blue']['flag'] ?? ''));
$eventName = $vs['event'] ?? '';
$category = $vs['category'] ?? '';
$division = $vs['division'] ?? '';
$roundName = $vs['round'] ?? '';
$redName = $vs['red']['name'] ?? '';
$redCountry = $vs['red']['country_name'] ?? '';
$redClub = $vs['red']['club'] ?? '';
$blueName = $vs['blue']['name'] ?? '';
$blueCountry= $vs['blue']['country_name'] ?? '';
$blueClub = $vs['blue']['club'] ?? '';
$weightClass= $vs['weight_category'] ?? '';
$format = $vs['format_line'] ?? '';
$bout = !empty($vs['match_number']) ? ('Bout '.$vs['match_number']) : '';
$tatami = $vs['court'] ?? '';
$dateVenue = trim(($vs['match_date'] ?? '').' '.($vs['venue_name'] ? '· '.$vs['venue_name'] : ''));
$hide = fn($v) => (is_string($v) && trim($v) === '') ? 'display:none;' : '';
@endphp
<div class="msb-vs" id="msbVs" hidden data-visible="false">
<div style="position:relative;width:100%;height:100%;overflow:hidden;background:#08080a">
<div style="position:absolute;inset:-12%;width:70%;left:-6%;background:radial-gradient(closest-side, rgba(168,42,34,.62), rgba(8,8,10,0) 72%);animation:msbDriftA 19s ease-in-out infinite"></div>
<div style="position:absolute;inset:-12%;width:70%;left:36%;background:radial-gradient(closest-side, rgba(28,64,136,.62), rgba(8,8,10,0) 72%);animation:msbDriftB 23s ease-in-out infinite"></div>
<div style="position:absolute;top:0;bottom:0;left:0;width:26%;background:linear-gradient(90deg, rgba(255,255,255,0), rgba(255,255,255,.09), rgba(255,255,255,0));animation:msbSweep 11s linear infinite"></div>
<div style="position:absolute;top:0;bottom:0;left:0;width:54%;clip-path:polygon(0 0, 100% 0, 78% 100%, 0 100%);background:linear-gradient(120deg, rgba(122,26,22,.5), rgba(8,8,10,0) 76%)"></div>
<div style="position:absolute;top:0;bottom:0;right:0;width:54%;clip-path:polygon(22% 0, 100% 0, 100% 100%, 0 100%);background:linear-gradient(300deg, rgba(24,52,110,.5), rgba(8,8,10,0) 76%)"></div>
{{-- event header --}}
<div style="position:absolute;top:34px;left:0;right:0;display:flex;flex-direction:column;align-items:center;gap:8px">
<div style="font-family:'Zen Old Mincho',serif;font-size:24px;letter-spacing:.44em;color:#efe9e0;text-transform:uppercase;text-indent:.44em;{{ $hide($eventName) }}">{{ $eventName }}</div>
<div style="display:flex;align-items:center;gap:14px;font-size:12px;letter-spacing:.34em;color:#8f8a83;text-transform:uppercase">
<span style="{{ $hide($category) }}">{{ $category }}</span>
<span style="width:4px;height:4px;background:#e8534a;{{ $hide($category).$hide($division) }}"></span>
<span style="{{ $hide($division) }}">{{ $division }}</span>
<span style="width:4px;height:4px;background:#6aa6ff;{{ $hide($division).$hide($roundName) }}"></span>
<span style="{{ $hide($roundName) }}">{{ $roundName }}</span>
</div>
</div>
<div style="position:absolute;inset:96px 0 54px;display:grid;grid-template-columns:1fr 200px 1fr;align-items:start">
{{-- RED fighter --}}
<div style="display:flex;flex-direction:column;align-items:center;padding:34px 24px 0;min-width:0">
<div style="position:relative;width:228px;height:304px">
<div data-msb="vsRedPhoto" style="position:absolute;inset:0;background:repeating-linear-gradient(135deg,rgba(255,255,255,.1) 0 6px,rgba(255,255,255,.03) 6px 12px);border:1px solid rgba(255,255,255,.16);display:flex;align-items:flex-end;justify-content:center;padding-bottom:12px;font-family:ui-monospace,monospace;font-size:10px;letter-spacing:.1em;color:rgba(255,255,255,.6)">FIGHTER PHOTO</div>
<div data-msb="vsRedCrest" style="position:absolute;bottom:-26px;right:-26px;width:104px;height:104px;border-radius:50%;background:#0d0d10;border:1px solid rgba(255,255,255,.28);box-shadow:0 10px 30px rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:9px;letter-spacing:.06em;color:rgba(255,255,255,.7);text-align:center;line-height:1.35">CLUB<br>LOGO</div>
</div>
<div style="margin-top:34px;padding-top:12px;border-top:3px solid #e8534a;display:flex;flex-direction:column;align-items:center;gap:8px">
<div style="font-size:42px;line-height:.95;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap">{{ $redName ?: 'RED' }}</div>
<div style="display:flex;align-items:center;gap:12px;{{ $hide($redCountry).($rc ? '' : $hide('')) }}">
<span data-msb="vsRedFlag" style="width:40px;height:27px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 5px,rgba(255,255,255,.1) 5px 10px);box-shadow:0 0 0 1px rgba(255,255,255,.32);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:8px;color:rgba(255,255,255,.85)">FLAG</span>
<span style="font-size:14px;letter-spacing:.3em;color:#d3b3ad;text-transform:uppercase;{{ $hide($redCountry) }}">{{ $redCountry }}</span>
</div>
<div style="display:flex;align-items:center;gap:10px;margin-top:2px;{{ $hide($redClub) }}">
<span style="font-size:13px;letter-spacing:.18em;color:#9d9691;text-transform:uppercase">{{ $redClub }}</span>
</div>
</div>
</div>
{{-- centre --}}
<div style="display:flex;flex-direction:column;align-items:center;gap:12px;padding-top:96px">
<span style="font-family:'Zen Old Mincho',serif;font-size:88px;line-height:.8;font-weight:700;color:#efe9e0;text-shadow:0 8px 34px rgba(0,0,0,.75)">VS</span>
<span style="padding:7px 16px;border:1px solid rgba(255,255,255,.3);font-size:14px;letter-spacing:.32em;color:#efe9e0;text-transform:uppercase;{{ $hide($weightClass) }}">{{ $weightClass }}</span>
<span style="font-size:11px;letter-spacing:.34em;color:#8f8a83;text-transform:uppercase;{{ $hide($format) }}">{{ $format }}</span>
</div>
{{-- BLUE fighter mirror --}}
<div style="display:flex;flex-direction:column;align-items:center;padding:34px 24px 0;min-width:0">
<div style="position:relative;width:228px;height:304px">
<div data-msb="vsBluePhoto" style="position:absolute;inset:0;background:repeating-linear-gradient(135deg,rgba(255,255,255,.1) 0 6px,rgba(255,255,255,.03) 6px 12px);border:1px solid rgba(255,255,255,.16);display:flex;align-items:flex-end;justify-content:center;padding-bottom:12px;font-family:ui-monospace,monospace;font-size:10px;letter-spacing:.1em;color:rgba(255,255,255,.6)">FIGHTER PHOTO</div>
<div data-msb="vsBlueCrest" style="position:absolute;bottom:-26px;left:-26px;width:104px;height:104px;border-radius:50%;background:#0d0d10;border:1px solid rgba(255,255,255,.28);box-shadow:0 10px 30px rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:9px;letter-spacing:.06em;color:rgba(255,255,255,.7);text-align:center;line-height:1.35">CLUB<br>LOGO</div>
</div>
<div style="margin-top:34px;padding-top:12px;border-top:3px solid #6aa6ff;display:flex;flex-direction:column;align-items:center;gap:8px">
<div style="font-size:42px;line-height:.95;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap">{{ $blueName ?: 'BLUE' }}</div>
<div style="display:flex;align-items:center;gap:12px;{{ $hide($blueCountry) }}">
<span style="font-size:14px;letter-spacing:.3em;color:#a8b6cf;text-transform:uppercase;{{ $hide($blueCountry) }}">{{ $blueCountry }}</span>
<span data-msb="vsBlueFlag" style="width:40px;height:27px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 5px,rgba(255,255,255,.1) 5px 10px);box-shadow:0 0 0 1px rgba(255,255,255,.32);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:8px;color:rgba(255,255,255,.85)">FLAG</span>
</div>
<div style="display:flex;align-items:center;gap:10px;margin-top:2px;{{ $hide($blueClub) }}">
<span style="font-size:13px;letter-spacing:.18em;color:#9d9691;text-transform:uppercase">{{ $blueClub }}</span>
</div>
</div>
</div>
</div>
{{-- bout line --}}
<div style="position:absolute;bottom:26px;left:0;right:0;display:flex;align-items:center;justify-content:center;gap:16px;font-size:11px;letter-spacing:.32em;color:#8f8a83;text-transform:uppercase">
<span style="{{ $hide($bout) }}">{{ $bout }}</span>
<span style="width:4px;height:4px;background:#e8534a;{{ $hide($bout).$hide($tatami) }}"></span>
<span style="{{ $hide($tatami) }}">{{ $tatami }}</span>
<span style="width:4px;height:4px;background:#6aa6ff;{{ $hide($tatami).$hide($dateVenue) }}"></span>
<span style="{{ $hide($dateVenue) }}">{{ $dateVenue }}</span>
</div>
</div>
</div>