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) <[email protected]>
This commit is contained in:
ghassanandClaude Opus 4.7 committed 2026-08-09 23:47:59 +03:00
1 parent 19741f489b
commit 2b5e480c9a
9 files changed
+235 -323

No files matched your search

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