From 2b5e480c9aa4cd75d1cd9a82a54c7feb7ded2744 Mon Sep 17 00:00:00 2001 From: ghassan Date: Sun, 9 Aug 2026 23:47:59 +0300 Subject: [PATCH] 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 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) --- .../Controllers/SportsMatchController.php | 4 +- app/Models/SportsMatch.php | 11 +- .../views/components/image-cropper.blade.php | 273 ++++++++++++++---- .../views/components/video-card.blade.php | 1 + .../partials/sports-match-modal.blade.php | 6 +- .../partials/match/scoreboard/index.blade.php | 64 +--- .../match/scoreboard/script.blade.php | 70 ----- .../match/scoreboard/styles.blade.php | 23 -- .../match/scoreboard/vs-intro.blade.php | 106 ------- 9 files changed, 235 insertions(+), 323 deletions(-) delete mode 100644 resources/views/videos/partials/match/scoreboard/vs-intro.blade.php diff --git a/app/Http/Controllers/SportsMatchController.php b/app/Http/Controllers/SportsMatchController.php index b08d92c..3313224 100644 --- a/app/Http/Controllers/SportsMatchController.php +++ b/app/Http/Controllers/SportsMatchController.php @@ -166,7 +166,9 @@ class SportsMatchController extends Controller // Media text fields (caption/alt/credit/public). Preserve existing image // paths already on the record; handleImages() overwrites any replaced ones. $existingMedia = $match->media ?? []; - $mediaText = $this->clean($request->input('media', [])); + // clean() returns null when the array is entirely empty — coerce back to [] + // so array_merge() doesn't blow up on PHP 8's stricter type checking. + $mediaText = $this->clean($request->input('media', [])) ?? []; if (isset($mediaText['public'])) { $mediaText['public'] = filter_var($mediaText['public'], FILTER_VALIDATE_BOOLEAN); } diff --git a/app/Models/SportsMatch.php b/app/Models/SportsMatch.php index ced4290..0c89f34 100644 --- a/app/Models/SportsMatch.php +++ b/app/Models/SportsMatch.php @@ -53,15 +53,18 @@ class SportsMatch extends Model 'name' => $trim($this->participant1_name), 'club' => $trim($p['p1_club'] ?? null), 'flag' => $this->flagFor($p['p1_country'] ?? null), - 'club_logo' => $trim($m['p1_club_logo'] ?? null), - 'headshot' => $trim($m['p1_headshot'] ?? null), + // Uploader form (sports-match modal) stores these under the + // canonical media.* keys — mirror them to headerData() so the + // scoreboard/VS partials get real image paths. + 'club_logo' => $trim($m['club1_logo'] ?? $m['p1_club_logo'] ?? null), + 'headshot' => $trim($m['participant1_photo'] ?? $m['p1_headshot'] ?? null), ], 'red' => [ 'name' => $trim($this->participant2_name), 'club' => $trim($p['p2_club'] ?? null), 'flag' => $this->flagFor($p['p2_country'] ?? null), - 'club_logo' => $trim($m['p2_club_logo'] ?? null), - 'headshot' => $trim($m['p2_headshot'] ?? null), + 'club_logo' => $trim($m['club2_logo'] ?? $m['p2_club_logo'] ?? null), + 'headshot' => $trim($m['participant2_photo'] ?? $m['p2_headshot'] ?? null), ], 'weight_category' => $trim($p['weight_class'] ?? null), 'division' => $trim($c['division'] ?? null), diff --git a/resources/views/components/image-cropper.blade.php b/resources/views/components/image-cropper.blade.php index 0b5b3d4..0893e58 100644 --- a/resources/views/components/image-cropper.blade.php +++ b/resources/views/components/image-cropper.blade.php @@ -122,7 +122,12 @@ /* ── Open / close ── */ function openModal() { - document.getElementById('tcOverlay_' + id).classList.add('open'); + var ov = document.getElementById('tcOverlay_' + id); + // Reparent to 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 ── */ diff --git a/resources/views/components/video-card.blade.php b/resources/views/components/video-card.blade.php index 7703733..dc2231b 100644 --- a/resources/views/components/video-card.blade.php +++ b/resources/views/components/video-card.blade.php @@ -46,6 +46,7 @@ $sizeClasses = match($size) { 'small' => 'yt-video-card-sm', default => '', }; + @endphp
diff --git a/resources/views/layouts/partials/sports-match-modal.blade.php b/resources/views/layouts/partials/sports-match-modal.blade.php index 369b239..a8b46e1 100644 --- a/resources/views/layouts/partials/sports-match-modal.blade.php +++ b/resources/views/layouts/partials/sports-match-modal.blade.php @@ -417,8 +417,10 @@ {{-- ── Image croppers (outside the form so their inner file inputs aren't submitted) ── Six form-mode croppers write the cropped file straight onto each hidden input; one callback-mode cropper serves all dynamic official rows. --}} - - +{{-- Fighter photos: 3:4 portrait crop to match the VS card frame (228×304). + Club logos + referee: 1:1 square. --}} + +