feat: image cropper in admin + fix ba-label responsive centering
- 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:
@@ -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; }
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user