chore(website): add BlogImageSlider component and demo usage in blog components

This commit is contained in:
4gray committed 2026-02-15 01:18:39 +01:00
1 parent 69db46e122
commit bdf0e3265a
2 files changed
+157

No files matched your search

@@ -0,0 +1,134 @@
---
interface SliderImage {
src: string;
alt: string;
caption?: string;
}
interface Props {
images: SliderImage[];
thumbPosition?: 'bottom' | 'right';
showArrows?: boolean;
}
const { images = [], thumbPosition = 'bottom', showArrows = true } = Astro.props;
const sliderId = `slider-${Math.random().toString(36).slice(2, 10)}`;
---
{images.length > 0 && (
<div
class="not-prose my-6"
data-blog-slider={sliderId}
tabindex="0"
>
<div class="group relative overflow-hidden rounded-xl border border-dashed border-surface-700/70 bg-surface-900/50">
<img
src={images[0].src}
alt={images[0].alt}
data-slider-main-img
class="aspect-video w-full object-cover"
loading="lazy"
/>
{showArrows && images.length > 1 && (
<>
<button
type="button"
class="slider-prev absolute left-2 top-1/2 -translate-y-1/2 rounded-md border border-surface-700/70 bg-surface-950/70 px-2 py-1.5 text-surface-200 transition-colors hover:text-accent-300"
aria-label="Previous image"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
type="button"
class="slider-next absolute right-2 top-1/2 -translate-y-1/2 rounded-md border border-surface-700/70 bg-surface-950/70 px-2 py-1.5 text-surface-200 transition-colors hover:text-accent-300"
aria-label="Next image"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</>
)}
</div>
<p data-slider-caption class="mt-2 text-sm text-surface-500">{images[0].caption ?? ''}</p>
{images.length > 1 && (
<div class="mt-3 flex gap-2 overflow-x-auto pb-1" role="tablist" aria-label="Image thumbnails">
{images.map((image, index) => (
<button
type="button"
data-slider-thumb
data-index={index}
data-src={image.src}
data-alt={image.alt}
data-caption={image.caption ?? ''}
class={`shrink-0 overflow-hidden rounded-md border ${index === 0 ? 'border-accent-500/60' : 'border-surface-700/70'} bg-surface-900/50 transition-all duration-200 hover:border-accent-500/50`}
aria-label={`Show image ${index + 1}`}
aria-selected={index === 0 ? 'true' : 'false'}
>
<img src={image.src} alt={image.alt} loading="lazy" class="h-12 w-20 object-cover sm:h-14 sm:w-24" />
</button>
))}
</div>
)}
</div>
)}
<script>
const initBlogSliders = () => {
document.querySelectorAll('[data-blog-slider]').forEach((slider) => {
if (slider.getAttribute('data-bound') === 'true') return;
slider.setAttribute('data-bound', 'true');
const main = slider.querySelector('[data-slider-main-img]');
const caption = slider.querySelector('[data-slider-caption]');
const thumbs = Array.from(slider.querySelectorAll('[data-slider-thumb]'));
const prev = slider.querySelector('.slider-prev');
const next = slider.querySelector('.slider-next');
if (!main || thumbs.length === 0) return;
let current = 0;
const setActive = (idx) => {
current = (idx + thumbs.length) % thumbs.length;
const activeBtn = thumbs[current];
const src = activeBtn.getAttribute('data-src') || '';
const alt = activeBtn.getAttribute('data-alt') || '';
const cap = activeBtn.getAttribute('data-caption') || '';
main.setAttribute('src', src);
main.setAttribute('alt', alt);
if (caption) caption.textContent = cap;
thumbs.forEach((btn, i) => {
const selected = i === current;
btn.setAttribute('aria-selected', selected ? 'true' : 'false');
btn.classList.toggle('border-accent-500/60', selected);
btn.classList.toggle('border-surface-700/70', !selected);
});
};
thumbs.forEach((btn, i) => {
btn.addEventListener('click', () => setActive(i));
});
prev?.addEventListener('click', () => setActive(current - 1));
next?.addEventListener('click', () => setActive(current + 1));
slider.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft') setActive(current - 1);
if (e.key === 'ArrowRight') setActive(current + 1);
});
});
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initBlogSliders);
} else {
initBlogSliders();
}
</script>
@@ -21,6 +21,7 @@ import StatusPill from '../../components/blog/StatusPill.astro';
import ChangeTable from '../../components/blog/ChangeTable.astro';
import CopyCommand from '../../components/blog/CopyCommand.astro';
import ImageGallery from '../../components/blog/ImageGallery.astro';
import BlogImageSlider from '../../components/blog/BlogImageSlider.astro';
import Spoiler from '../../components/blog/Spoiler.astro';
import FaqAccordion from '../../components/blog/FaqAccordion.astro';
@@ -176,6 +177,28 @@ $ xattr -c /Applications/IPTVnator.app
]}
/>
## Image slider
<BlogImageSlider
images={[
{
src: '/iptvnator/blog/iptvnator-v18.jpeg',
alt: 'IPTVnator v0.18 UI screenshot',
caption: 'Main app interface in v0.18.',
},
{
src: '/iptvnator/blog/macos-app-damaged.jpg',
alt: 'macOS app damaged warning dialog',
caption: 'Gatekeeper warning on unsigned app launch.',
},
{
src: '/iptvnator/blog/epg.png',
alt: 'EPG guide screenshot',
caption: 'Electronic Program Guide screen in IPTVnator.',
},
]}
/>
## Spoiler block
<Spoiler title="Show advanced troubleshooting note">