feat: image cropper in admin + fix ba-label responsive centering
CI/CD / CI · dotnet build (push) Successful in 23s
CI/CD / Deploy · drsousan (push) Successful in 41s

- Admin: all upload buttons now open a crop-before-upload modal
  - Canvas-based cropper (no external library)
  - Ratio presets: 1:1, 4:3, 16:9, 3:4, free
  - Drag to move crop box, drag corners to resize
  - Touch support for mobile
  - Crops client-side then uploads the result
- Frontend gallery: ba-label (قبل/بعد) now:
  - Centered horizontally (block + width 100%)
  - Wraps to multiple lines (white-space:normal)
  - Responsive — never overflows or gets clipped

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-02 11:01:12 +03:30
parent 6f39e47aaa
commit b3467fb663
2 changed files with 210 additions and 20 deletions
+1 -1
View File
@@ -123,7 +123,7 @@
.gallery-item.before-after .ba-half { flex:1; position:relative; overflow:hidden; }
.gallery-item.before-after .ba-half img { width:100%; height:100%; object-fit:cover; display:block; transition:transform 0.4s; }
.gallery-item.before-after:hover .ba-half img { transform:scale(1.05); }
.gallery-item.before-after .ba-label { position:absolute; bottom:6px; left:50%; transform:translateX(-50%); background:rgba(0,0,0,0.55); color:#fff; font-size:0.65rem; padding:2px 8px; border-radius:20px; white-space:nowrap; pointer-events:none; }
.gallery-item.before-after .ba-label { position:absolute; bottom:6px; left:50%; transform:translateX(-50%); background:rgba(0,0,0,0.58); color:#fff; font-size:0.72rem; line-height:1.35; padding:4px 10px; border-radius:14px; white-space:normal; word-break:break-word; text-align:center; width:calc(100% - 10px); max-width:90%; pointer-events:none; box-sizing:border-box; display:block; }
.gallery-item.before-after .ba-divider { width:2px; background:rgba(255,255,255,0.7); flex-shrink:0; }
.gallery-caption { position:absolute; bottom:0; left:0; right:0; background:linear-gradient(transparent,rgba(0,0,0,0.5)); color:#fff; font-size:0.75rem; padding:1.2rem 0.8rem 0.5rem; text-align:center; pointer-events:none; }
.gallery-item[style*="display:none"] { display:none !important; }
+208 -18
View File
@@ -127,6 +127,24 @@ tr:hover td{background:#FAFBFC}
/* ── Service Before/After pair ── */
.ba-pair{display:grid;grid-template-columns:1fr 1fr;gap:.8rem}
.ba-pair .upload-preview{max-height:140px}
/* ── Image Cropper Modal ── */
.cropper-overlay{position:fixed;inset:0;background:rgba(0,0,0,.72);z-index:500;display:flex;align-items:center;justify-content:center;padding:1rem}
.cropper-modal{background:#1a1a1a;border-radius:18px;width:100%;max-width:680px;display:flex;flex-direction:column;box-shadow:0 24px 80px rgba(0,0,0,.5);overflow:hidden;max-height:92vh}
.cropper-head{padding:.85rem 1.2rem;display:flex;align-items:center;gap:.7rem;border-bottom:1px solid #333}
.cropper-head-title{color:#fff;font-size:.95rem;font-weight:600;flex:1}
.cropper-stage{flex:1;overflow:hidden;position:relative;background:#111;cursor:crosshair;user-select:none;min-height:320px}
.cropper-stage canvas{display:block;width:100%;height:100%;object-fit:contain}
.cropper-box{position:absolute;border:2px solid #B8955A;box-shadow:0 0 0 9999px rgba(0,0,0,.52);cursor:move;box-sizing:border-box}
.cropper-handle{position:absolute;width:10px;height:10px;background:#B8955A;border-radius:50%;z-index:2}
.cropper-handle.nw{top:-5px;left:-5px;cursor:nw-resize}
.cropper-handle.ne{top:-5px;right:-5px;cursor:ne-resize}
.cropper-handle.sw{bottom:-5px;left:-5px;cursor:sw-resize}
.cropper-handle.se{bottom:-5px;right:-5px;cursor:se-resize}
.cropper-foot{padding:.85rem 1.2rem;display:flex;gap:.6rem;align-items:center;border-top:1px solid #333;flex-wrap:wrap}
.cropper-hint{color:#888;font-size:.78rem;flex:1}
.cropper-ratio-btns{display:flex;gap:.4rem}
.cropper-ratio-btn{background:#2a2a2a;border:1.5px solid #444;color:#ccc;padding:.35rem .7rem;border-radius:8px;font-size:.75rem;cursor:pointer;transition:all .2s;font-family:inherit}
.cropper-ratio-btn.active,.cropper-ratio-btn:hover{background:#B8955A;border-color:#B8955A;color:#fff}
/* ── Icon Picker ── */
.icon-picker-section{margin-top:.9rem}
.icon-picker-section>label{display:block;font-size:.82rem;font-weight:600;color:var(--mid);margin-bottom:.55rem}
@@ -1140,36 +1158,177 @@ async function deleteSvc(id) {
}
// ── Image Upload Helper ───────────────────────────────────────────────────────
async function uploadImage(inputId, previewId) {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'image/jpeg,image/png,image/webp,image/gif';
fileInput.onchange = async () => {
const file = fileInput.files[0];
if (!file) return;
// ── Image Cropper ─────────────────────────────────────────────────────────────
const cropper = {
inputId: null, previewId: null,
img: null, naturalW: 0, naturalH: 0,
scale: 1, offsetX: 0, offsetY: 0,
ratio: 1, // 0 = free
box: { x:0, y:0, w:0, h:0 }, // in canvas-display px
drag: null // {type, startX,startY,box0}
};
function openCropper(inputId, previewId, file) {
cropper.inputId = inputId; cropper.previewId = previewId;
const reader = new FileReader();
reader.onload = e => {
const img = new Image();
img.onload = () => {
cropper.img = img;
cropper.naturalW = img.naturalWidth;
cropper.naturalH = img.naturalHeight;
document.getElementById('cropperModal').classList.remove('hidden');
requestAnimationFrame(() => initCropperCanvas());
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
function initCropperCanvas() {
const stage = document.getElementById('cropperStage');
const canvas = document.getElementById('cropperCanvas');
const sw = stage.clientWidth, sh = stage.clientHeight || 400;
canvas.width = sw; canvas.height = sh;
// fit image
const scaleX = sw / cropper.naturalW, scaleY = sh / cropper.naturalH;
cropper.scale = Math.min(scaleX, scaleY);
const dw = cropper.naturalW * cropper.scale;
const dh = cropper.naturalH * cropper.scale;
cropper.offsetX = (sw - dw) / 2;
cropper.offsetY = (sh - dh) / 2;
// default crop = center square (or full if free)
const bSize = Math.min(dw, dh) * 0.8;
cropper.box = { x: cropper.offsetX + (dw - bSize)/2, y: cropper.offsetY + (dh - bSize)/2, w: bSize, h: bSize };
if (cropper.ratio !== 1) applyCropRatioToBox();
drawCropperCanvas();
positionCropBox();
document.getElementById('cropBox').classList.remove('hidden');
bindCropperEvents();
}
function drawCropperCanvas() {
const canvas = document.getElementById('cropperCanvas');
const ctx = canvas.getContext('2d');
ctx.clearRect(0,0,canvas.width,canvas.height);
ctx.drawImage(cropper.img, cropper.offsetX, cropper.offsetY,
cropper.naturalW * cropper.scale, cropper.naturalH * cropper.scale);
}
function positionCropBox() {
const box = document.getElementById('cropBox');
const b = cropper.box;
box.style.cssText = `left:${b.x}px;top:${b.y}px;width:${b.w}px;height:${b.h}px;position:absolute;`;
}
function clampBox(b) {
const ox=cropper.offsetX, oy=cropper.offsetY;
const mw=cropper.naturalW*cropper.scale, mh=cropper.naturalH*cropper.scale;
b.w = Math.max(40, Math.min(b.w, mw)); b.h = Math.max(40, Math.min(b.h, mh));
b.x = Math.max(ox, Math.min(b.x, ox+mw-b.w));
b.y = Math.max(oy, Math.min(b.y, oy+mh-b.h));
return b;
}
function applyCropRatioToBox() {
if (!cropper.ratio) return;
const b = cropper.box;
b.h = b.w / cropper.ratio;
cropper.box = clampBox(b);
}
function setCropRatio(w, h) {
cropper.ratio = (w && h) ? w/h : 0;
document.querySelectorAll('.cropper-ratio-btn').forEach(btn => btn.classList.remove('active'));
event.target.classList.add('active');
if (cropper.img) { applyCropRatioToBox(); positionCropBox(); }
}
function bindCropperEvents() {
const box = document.getElementById('cropBox');
const stage = document.getElementById('cropperStage');
// Handles resize
box.querySelectorAll('.cropper-handle').forEach(h => {
h.addEventListener('mousedown', ev => {
ev.preventDefault(); ev.stopPropagation();
cropper.drag = {type:'resize', handle:h.dataset.h, startX:ev.clientX, startY:ev.clientY, box0:{...cropper.box}};
});
h.addEventListener('touchstart', ev => {
ev.preventDefault(); ev.stopPropagation();
const t=ev.touches[0];
cropper.drag = {type:'resize', handle:h.dataset.h, startX:t.clientX, startY:t.clientY, box0:{...cropper.box}};
});
});
// Box drag move
box.addEventListener('mousedown', ev => {
if (ev.target.classList.contains('cropper-handle')) return;
ev.preventDefault();
cropper.drag = {type:'move', startX:ev.clientX, startY:ev.clientY, box0:{...cropper.box}};
});
box.addEventListener('touchstart', ev => {
if (ev.target.classList.contains('cropper-handle')) return;
ev.preventDefault();
const t=ev.touches[0];
cropper.drag = {type:'move', startX:t.clientX, startY:t.clientY, box0:{...cropper.box}};
});
const onMove = (cx, cy) => {
if (!cropper.drag) return;
const dx=cx-cropper.drag.startX, dy=cy-cropper.drag.startY;
const b0=cropper.drag.box0, r=cropper.ratio;
let nb={...b0};
if (cropper.drag.type==='move') {
nb.x=b0.x+dx; nb.y=b0.y+dy;
} else {
const h=cropper.drag.handle;
if (h==='se') { nb.w=Math.max(40,b0.w+dx); nb.h=r?nb.w/r:Math.max(40,b0.h+dy); }
else if (h==='sw') { nb.w=Math.max(40,b0.w-dx); nb.x=b0.x+b0.w-nb.w; nb.h=r?nb.w/r:Math.max(40,b0.h+dy); }
else if (h==='ne') { nb.w=Math.max(40,b0.w+dx); nb.h=r?nb.w/r:Math.max(40,b0.h-dy); nb.y=b0.y+b0.h-nb.h; }
else if (h==='nw') { nb.w=Math.max(40,b0.w-dx); nb.x=b0.x+b0.w-nb.w; nb.h=r?nb.w/r:Math.max(40,b0.h-dy); nb.y=b0.y+b0.h-nb.h; }
}
cropper.box=clampBox(nb); positionCropBox();
};
window.addEventListener('mousemove', ev => onMove(ev.clientX, ev.clientY));
window.addEventListener('touchmove', ev => { const t=ev.touches[0]; onMove(t.clientX,t.clientY); }, {passive:true});
window.addEventListener('mouseup', () => cropper.drag=null);
window.addEventListener('touchend', () => cropper.drag=null);
}
function closeCropper() {
document.getElementById('cropperModal').classList.add('hidden');
document.getElementById('cropBox').classList.add('hidden');
cropper.drag = null;
}
async function applyCrop() {
const b = cropper.box;
// Convert display px → natural image px
const sx = (b.x - cropper.offsetX) / cropper.scale;
const sy = (b.y - cropper.offsetY) / cropper.scale;
const sw = b.w / cropper.scale;
const sh = b.h / cropper.scale;
const out = document.createElement('canvas');
out.width = Math.round(sw); out.height = Math.round(sh);
out.getContext('2d').drawImage(cropper.img, sx, sy, sw, sh, 0, 0, out.width, out.height);
out.toBlob(async blob => {
closeCropper();
const inputId = cropper.inputId, previewId = cropper.previewId;
const btn = document.getElementById(`upload-btn-${inputId}`);
const orig = btn.innerHTML;
btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="animation:spin .8s linear infinite"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg> در حال آپلود...';
btn.disabled = true;
try {
const fd = new FormData();
fd.append('file', file);
const r = await fetch('/api/upload', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: fd
});
fd.append('file', new File([blob], 'crop.jpg', {type:'image/jpeg'}));
const r = await fetch('/api/upload', {method:'POST', headers:{'Authorization':`Bearer ${token}`}, body:fd});
if (!r.ok) throw new Error(await r.text());
const { url } = await r.json();
document.getElementById(inputId).value = url;
showPreview(previewId, url);
toast('تصویر با موفقیت آپلود شد ✓');
} catch(e) {
toast('خطا در آپلود: ' + e.message, 'error');
} finally {
btn.innerHTML = orig;
btn.disabled = false;
} catch(e) { toast('خطا در آپلود: '+e.message,'error'); }
finally { btn.innerHTML=orig; btn.disabled=false; }
}, 'image/jpeg', 0.92);
}
async function uploadImage(inputId, previewId) {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'image/jpeg,image/png,image/webp,image/gif';
fileInput.onchange = () => {
const file = fileInput.files[0];
if (!file) return;
openCropper(inputId, previewId, file);
};
fileInput.click();
}
@@ -1585,6 +1744,37 @@ async function init(){
if(token) init();
</script>
<!-- ── Image Cropper Modal ──────────────────────────────────────────────────── -->
<div class="cropper-overlay hidden" id="cropperModal">
<div class="cropper-modal">
<div class="cropper-head">
<span class="cropper-head-title">✂️ برش تصویر</span>
<div class="cropper-ratio-btns">
<button class="cropper-ratio-btn active" onclick="setCropRatio(1,1)" title="مربع">۱:۱</button>
<button class="cropper-ratio-btn" onclick="setCropRatio(4,3)" title="افقی">۴:۳</button>
<button class="cropper-ratio-btn" onclick="setCropRatio(16,9)" title="پانورامیک">۱۶:۹</button>
<button class="cropper-ratio-btn" onclick="setCropRatio(3,4)" title="عمودی">۳:۴</button>
<button class="cropper-ratio-btn" onclick="setCropRatio(0,0)" title="آزاد">آزاد</button>
</div>
<button class="modal-close" style="color:#ccc" onclick="closeCropper()"></button>
</div>
<div class="cropper-stage" id="cropperStage">
<canvas id="cropperCanvas"></canvas>
<div class="cropper-box hidden" id="cropBox">
<div class="cropper-handle nw" data-h="nw"></div>
<div class="cropper-handle ne" data-h="ne"></div>
<div class="cropper-handle sw" data-h="sw"></div>
<div class="cropper-handle se" data-h="se"></div>
</div>
</div>
<div class="cropper-foot">
<span class="cropper-hint">برای تنظیم ناحیه برش بکشید یا گوشه‌ها را تغییر دهید</span>
<button class="btn btn-secondary btn-sm" onclick="closeCropper()">انصراف</button>
<button class="btn btn-primary btn-sm" onclick="applyCrop()">✓ برش و آپلود</button>
</div>
</div>
</div>
<!-- ── File Manager Modal ─────────────────────────────────────────────────── -->
<div class="fm-overlay hidden" id="fileMgr" onclick="if(event.target===this)closeFileMgr()">
<div class="fm-modal">