feat(blog): in-content image carousel/slider
CI/CD / CI · dotnet build (push) Successful in 6m28s
CI/CD / Deploy · drsousan (push) Successful in 29s

Editor: new 🎠 اسلایدر toolbar button — pick multiple images (min 2),
uploads them all, inserts a <div class="post-carousel" data-carousel>
block at the cursor. Editor preview shows a tidy filmstrip with the
non-functional arrows/dots hidden.

Public post page: carousel CSS (scroll-snap track) + JS that wires up
prev/next arrows, clickable dots, and native touch swipe. Single-image
blocks auto-collapse their controls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-11 11:55:33 +03:30
parent 9c93b4e51a
commit 99a54be3ac
2 changed files with 79 additions and 0 deletions
+40
View File
@@ -72,6 +72,18 @@
.article-content a{color:var(--gold);border-bottom:1px solid var(--gold-pale)}
.article-content blockquote{border-right:4px solid var(--gold);padding:.8rem 1.2rem;background:var(--gold-pale);border-radius:0 8px 8px 0;margin:1.2rem 0;font-style:italic;color:var(--mid)}
.article-content img{max-width:100%;height:auto;border-radius:12px;margin:1.2rem 0;display:block}
/* ─── In-content image carousel ───────────────────────────────── */
.post-carousel{position:relative;margin:1.5rem 0;border-radius:14px;overflow:hidden;background:#111}
.post-carousel .pc-track{display:flex;direction:ltr;overflow-x:auto;scroll-snap-type:x mandatory;scroll-behavior:smooth;-webkit-overflow-scrolling:touch;scrollbar-width:none}
.post-carousel .pc-track::-webkit-scrollbar{display:none}
.post-carousel .pc-track img{flex:0 0 100%;width:100%;max-height:480px;object-fit:cover;scroll-snap-align:center;display:block;margin:0 !important;border-radius:0 !important}
.post-carousel .pc-prev,.post-carousel .pc-next{position:absolute;top:50%;transform:translateY(-50%);background:rgba(0,0,0,.45);color:#fff;border:none;width:40px;height:40px;border-radius:50%;font-size:1.5rem;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2;transition:background .2s}
.post-carousel .pc-prev:hover,.post-carousel .pc-next:hover{background:rgba(0,0,0,.72)}
.post-carousel .pc-prev{left:10px}
.post-carousel .pc-next{right:10px}
.post-carousel .pc-dots{position:absolute;bottom:10px;left:0;right:0;display:flex;gap:6px;justify-content:center;z-index:2}
.post-carousel .pc-dots span{width:8px;height:8px;border-radius:50%;background:rgba(255,255,255,.5);cursor:pointer;transition:background .2s}
.post-carousel .pc-dots span.active{background:#fff;width:20px;border-radius:4px}
/* ─── Tags ─────────────────────────────────────────────────────── */
.article-tags{margin-top:2rem;padding-top:1.5rem;border-top:1px solid var(--border);display:flex;gap:.5rem;flex-wrap:wrap}
.tag{background:var(--bg);border:1px solid var(--border);padding:.25rem .75rem;border-radius:50px;font-size:.78rem;color:var(--mid)}
@@ -300,3 +312,31 @@
</div>
</aside>
</div>
@section Scripts {
<script>
// Initialise any in-content image carousels (swipe + arrows + dots)
document.querySelectorAll('[data-carousel]').forEach(c => {
const track = c.querySelector('.pc-track');
if (!track) return;
const imgs = [...track.querySelectorAll('img')];
const dots = c.querySelector('.pc-dots');
if (imgs.length <= 1) {
c.querySelector('.pc-prev')?.remove();
c.querySelector('.pc-next')?.remove();
dots?.remove();
return;
}
if (dots) dots.innerHTML = imgs.map((_, i) => `<span data-i="${i}"></span>`).join('');
const dotEls = dots ? [...dots.querySelectorAll('span')] : [];
const current = () => Math.round(track.scrollLeft / track.clientWidth);
const update = () => { const i = current(); dotEls.forEach((d, di) => d.classList.toggle('active', di === i)); };
const go = (i) => { i = Math.max(0, Math.min(imgs.length - 1, i)); track.scrollTo({ left: i * track.clientWidth, behavior: 'smooth' }); };
c.querySelector('.pc-prev')?.addEventListener('click', () => go(current() - 1));
c.querySelector('.pc-next')?.addEventListener('click', () => go(current() + 1));
dotEls.forEach((d, di) => d.addEventListener('click', () => go(di)));
track.addEventListener('scroll', () => { clearTimeout(track._t); track._t = setTimeout(update, 60); });
update();
});
</script>
}
+39
View File
@@ -184,6 +184,12 @@ tr:hover td{background:#FAFBFC}
.editor-toolbar button{background:none;border:none;padding:.3rem .5rem;border-radius:4px;cursor:pointer;font-size:.82rem;font-family:inherit;color:var(--mid)}
.editor-toolbar button:hover{background:var(--gold-pale);color:var(--gold)}
.editor-content{border:1.5px solid var(--border);border-radius:0 0 8px 8px;padding:.8rem 1rem;min-height:250px;outline:none;font-size:.9rem;line-height:1.8;direction:rtl}
.editor-content img{max-width:100%;height:auto;border-radius:8px;margin:.6rem 0}
/* Carousel preview inside the editor: tidy filmstrip, controls hidden */
.editor-content .post-carousel{border:1px dashed var(--gold);border-radius:10px;padding:5px;background:#faf7f0;margin:.8rem 0}
.editor-content .post-carousel .pc-track{display:flex;gap:6px;overflow-x:auto}
.editor-content .post-carousel .pc-track img{flex:0 0 46%;max-height:170px;object-fit:cover;border-radius:6px;margin:0}
.editor-content .post-carousel .pc-prev,.editor-content .post-carousel .pc-next,.editor-content .post-carousel .pc-dots{display:none}
/* ── Pages ── */
.page{display:none}
@@ -1028,6 +1034,7 @@ tr:hover td{background:#FAFBFC}
<button onclick="fmt('insertOrderedList')">۱. لیست</button>
<button onclick="insLink()">🔗 لینک</button>
<button onclick="insImage()" title="درج تصویر در متن">🖼 تصویر</button>
<button onclick="insCarousel()" title="درج اسلایدر چند تصویر">🎠 اسلایدر</button>
</div>
<div class="editor-content" id="post-content" contenteditable="true" dir="rtl"></div>
</div>
@@ -2270,6 +2277,38 @@ function insImage(){
};
fileInput.click();
}
// Insert a multi-image carousel/slider into the post content
function insCarousel(){
const editor=document.getElementById('post-content');
editor.focus();
const sel=window.getSelection();
const savedRange=sel.rangeCount?sel.getRangeAt(0):null;
const fileInput=document.createElement('input');
fileInput.type='file';
fileInput.accept='image/jpeg,image/png,image/webp,image/gif';
fileInput.multiple=true;
fileInput.onchange=async()=>{
const files=[...fileInput.files];if(!files.length)return;
if(files.length<2){toast('برای اسلایدر حداقل ۲ تصویر انتخاب کنید','error');return;}
toast(`در حال آپلود ${files.length} تصویر...`);
try{
const urls=[];
for(const file of files){
const fd=new FormData();fd.append('file',file);
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();urls.push(url);
}
const imgs=urls.map(u=>`<img src="${u}" alt=""/>`).join('');
const html=`<div class="post-carousel" data-carousel><div class="pc-track">${imgs}</div><button class="pc-prev" type="button" contenteditable="false"></button><button class="pc-next" type="button" contenteditable="false"></button><div class="pc-dots" contenteditable="false"></div></div><p><br></p>`;
editor.focus();
if(savedRange){sel.removeAllRanges();sel.addRange(savedRange);}
document.execCommand('insertHTML',false,html);
toast(`اسلایدر با ${urls.length} تصویر درج شد ✓`);
}catch(e){toast('خطا در آپلود: '+e.message,'error');}
};
fileInput.click();
}
// ── Modal helpers ─────────────────────────────────────────────────────────────
function closeModal(id){document.getElementById(id).classList.add('hidden');}