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:
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 }}">
|
||||
|
||||
Reference in new issue
Block a user