- 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>
497 lines
22 KiB
PHP
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>
|