ghassan 2b5e480c9a 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>
2026-08-09 23:47:59 +03:00

497 lines
22 KiB
PHP

@php
$id = $attributes->get('id', 'cropper_' . Str::random(6));
$width = (int) $attributes->get('width', 300);
$height = (int) $attributes->get('height', 300);
$shape = $attributes->get('shape', 'circle'); // 'circle' or 'square'
$folder = $attributes->get('folder', 'uploads');
$filename = $attributes->get('filename', 'image_' . time());
$callback = $attributes->get('callback', ''); // JS function called with (url) after server upload
$updateUrl = $attributes->get('update-url', ''); // POST {path} here after server upload
$title = $attributes->get('title', $shape === 'circle' ? 'Change Photo' : 'Crop Image');
$outputWidth = (int) $attributes->get('output-width', 0); // final output px width (0 = viewport size)
$targetInput = $attributes->get('target-input', ''); // form mode: ID of file input to set result on
$previewImg = $attributes->get('preview-img', ''); // ID of img to update with preview
$resultCallback = $attributes->get('result-callback', ''); // callback mode: JS fn called with the cropped File
@endphp
{{-- Cropme assets + .tc-* styles now live in layouts/app.blade.php <head>
so they survive SPA-nav innerHTML swaps on #main. --}}
{{-- Modal --}}
<div class="tc-overlay" id="tcOverlay_{{ $id }}" role="dialog" aria-modal="true">
<div class="tc-modal">
<div class="tc-modal-header">
<span class="tc-modal-title">
<i class="bi bi-crop"></i>
{{ $title }}
</span>
<button class="tc-modal-close" onclick="closeCropperModal('{{ $id }}')" aria-label="Close">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="tc-modal-body">
<div class="tc-file-row">
<label class="tc-file-label" for="tcInput_{{ $id }}">
<i class="bi bi-upload"></i>
Choose image
</label>
<span class="tc-file-name" id="tcFileName_{{ $id }}">No file chosen</span>
<input type="file" id="tcInput_{{ $id }}" accept="image/*" style="display:none" aria-label="Select image file">
</div>
<div class="tc-canvas" id="tcCanvas_{{ $id }}">
<div class="tc-placeholder" id="tcPlaceholder_{{ $id }}">
<i class="bi bi-image"></i>
<span>Upload an image to start cropping</span>
</div>
</div>
<div class="tc-controls">
<div class="tc-control">
<label class="tc-control-label" for="tcZoom_{{ $id }}">
<i class="bi bi-zoom-in"></i> Zoom
</label>
<input type="range" class="tc-range" id="tcZoom_{{ $id }}" min="0" max="100" step="1" value="0">
</div>
<div class="tc-control">
<label class="tc-control-label" for="tcRot_{{ $id }}">
<i class="bi bi-arrow-clockwise"></i> Rotate
</label>
<input type="range" class="tc-range" id="tcRot_{{ $id }}" min="-180" max="180" step="1" value="0">
</div>
</div>
</div>
<div class="tc-modal-footer">
<button class="tc-btn tc-btn-as-is" id="tcAsIsBtn_{{ $id }}" disabled
onclick="tcUploadAsIs_{{ $id }}()">
<i class="bi bi-image"></i>
Upload as-is
</button>
<button class="tc-btn tc-btn-ghost" onclick="closeCropperModal('{{ $id }}')">
Cancel
</button>
<button class="tc-btn tc-btn-primary" id="tcSaveBtn_{{ $id }}" disabled
onclick="tcSave_{{ $id }}()">
<i class="bi bi-check-lg"></i>
<span id="tcSaveBtnText_{{ $id }}">Crop & Save</span>
</button>
</div>
</div>
</div>
<script>
(function () {
var cropperInst = null;
var originalFile = null;
var _intercept = false; // flag: we are programmatically setting the file, skip interceptor
var zoomMin = 0.01, zoomMax = 3;
var id = '{{ $id }}';
var vw = {{ $width }};
var vh = {{ $height }};
var shape = '{{ $shape }}';
var folder = '{{ $folder }}';
var filename = '{{ $filename }}';
var uploadUrl = '{{ route('image.upload') }}';
var updateUrl = '{{ $updateUrl }}';
var callbackFn = '{{ $callback }}';
var outputWidth = {{ $outputWidth > 0 ? $outputWidth : 0 }};
var targetInputId = '{{ $targetInput }}'; // form mode: ID of the file input to intercept
var previewImgId = '{{ $previewImg }}';
var resultCbName = '{{ $resultCallback }}'; // callback mode: name of a global fn given the cropped File
var isFormMode = targetInputId !== '';
var isCallbackMode = resultCbName !== '';
// In callback mode the host function decides when to close / advance, so we
// never auto-close here — we just hand the File back and reset the button.
function deliverResult(file) {
if (typeof window[resultCbName] === 'function') window[resultCbName](file);
var sb = document.getElementById('tcSaveBtn_' + id);
var st = document.getElementById('tcSaveBtnText_' + id);
var ab = document.getElementById('tcAsIsBtn_' + id);
if (sb) sb.disabled = false;
if (ab) ab.disabled = false;
if (st) st.textContent = 'Crop & Save';
}
function getCsrf() {
var m = document.querySelector('meta[name="csrf-token"]');
return m ? m.content : '';
}
/* ── Open / close ── */
function openModal() {
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;
window.closeCropperModal = window.closeCropperModal || function (i) {
var el = document.getElementById('tcOverlay_' + i);
if (el) { el.classList.remove('open'); document.body.style.overflow = ''; }
};
document.getElementById('tcOverlay_' + id).addEventListener('click', function (e) {
if (e.target === this) window.closeCropperModal(id);
});
/* ── 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 url = URL.createObjectURL(file);
initCropper(url);
}
window['tcPreload_' + id] = preloadFile;
/*
* ───────────────────────────────────────────────────────────────────
* 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);
canvas.innerHTML = '';
canvas.className = 'tc-canvas tc-canvas-active';
// ── 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]);
});
/* ── Form-mode: expose open helper for external callers ── */
// Callers should invoke window['openCropperModal_' + id]() directly from their
// dropzone click/drop handlers rather than relying on event interception.
/* ── Zoom / rotate sliders ── */
document.getElementById('tcZoom_' + id).addEventListener('input', function () {
if (!_st || !_st.ready) return;
// 0 → baseScale (fit), 100 → baseScale * 6 (max zoom-in)
var p = parseFloat(this.value) / 100;
_st.scale = _st.baseScale + (_st.baseScale * 5) * p;
applyTransform();
});
document.getElementById('tcRot_' + id).addEventListener('input', function () {
if (!_st || !_st.ready) return;
_st.deg = parseInt(this.value, 10) || 0;
applyTransform();
});
/* ── Helpers ── */
function base64ToFile(base64, name) {
var arr = base64.split(',');
var mime = (arr[0].match(/:(.*?);/) || [])[1] || 'image/png';
var bin = atob(arr[1]);
var u8 = new Uint8Array(bin.length);
for (var i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
return new File([u8], name || 'cropped.png', { type: mime });
}
function setOnTargetInput(file) {
var el = document.getElementById(targetInputId);
if (!el) return;
try {
var dt = new DataTransfer();
dt.items.add(file);
_intercept = true;
el.files = dt.files;
el.dispatchEvent(new Event('change', { bubbles: true }));
} finally {
_intercept = false;
}
if (previewImgId) {
var prev = document.getElementById(previewImgId);
if (prev) prev.src = URL.createObjectURL(file);
}
}
function uploadToServer(base64, onDone, onFail) {
fetch(uploadUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': getCsrf() },
body: JSON.stringify({ image: base64, folder: folder, filename: filename })
})
.then(function (r) { return r.json(); })
.then(function (res) {
if (!res.success) throw new Error(res.message || 'Upload failed');
if (updateUrl) {
return fetch(updateUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': getCsrf() },
body: JSON.stringify({ path: res.path })
})
.then(function (r) { return r.json().catch(function () { return {}; }); })
.then(function (upd) {
// The update step moves the temp upload to its final home (e.g. NAS)
// and deletes the temp, so res.url is now dead. Prefer the canonical
// URL the update endpoint returns; fall back to res.url otherwise.
if (upd && upd.url) res = Object.assign({}, res, { url: upd.url });
return res;
});
}
return res;
})
.then(onDone)
.catch(onFail);
}
/* ── 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 (!_st || !_st.ready) return;
var btn = document.getElementById('tcSaveBtn_' + id);
var txt = document.getElementById('tcSaveBtnText_' + id);
btn.disabled = true;
txt.textContent = 'Saving…';
var base64 = renderCroppedBase64();
if (!base64) { btn.disabled = false; txt.textContent = 'Crop & Save'; return; }
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('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';
});
}
};
/* ── Upload as-is ── */
window['tcUploadAsIs_' + id] = function () {
if (!originalFile) { window.closeCropperModal(id); return; }
if (isCallbackMode) {
deliverResult(originalFile);
} else if (isFormMode) {
// Put the original (un-cropped) file on the target input so the form sees it.
setOnTargetInput(originalFile);
window.closeCropperModal(id);
} else {
// Server mode: read original file as base64 and upload unchanged
var btn = document.getElementById('tcSaveBtn_' + id);
var txt = document.getElementById('tcSaveBtnText_' + id);
var aisBtn = document.getElementById('tcAsIsBtn_' + id);
btn.disabled = true;
aisBtn.disabled = true;
txt.textContent = 'Uploading…';
var reader = new FileReader();
reader.onload = function (e) {
uploadToServer(e.target.result, 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;
aisBtn.disabled = false;
txt.textContent = 'Crop & Save';
}, function (err) {
if (typeof window.showToast === 'function') window.showToast(err.message || 'Upload failed', 'error');
btn.disabled = false;
aisBtn.disabled = false;
txt.textContent = 'Crop & Save';
});
};
reader.readAsDataURL(originalFile);
}
};
})();
</script>