diff --git a/DrSousan.Api/Pages/Index.cshtml b/DrSousan.Api/Pages/Index.cshtml index e0b0c37..025490f 100644 --- a/DrSousan.Api/Pages/Index.cshtml +++ b/DrSousan.Api/Pages/Index.cshtml @@ -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; } diff --git a/DrSousan.Api/wwwroot/admin/index.html b/DrSousan.Api/wwwroot/admin/index.html index 7c26908..fe92bf1 100644 --- a/DrSousan.Api/wwwroot/admin/index.html +++ b/DrSousan.Api/wwwroot/admin/index.html @@ -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 = ' در حال آپلود...'; 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(); + + +