feat: add immersive case study gallery
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, ArrowRight } from 'lucide-react';
|
||||
import { messagesByLocale, type Locale } from '@/i18n/content';
|
||||
import { getProjectPath, type Project } from '@/data/projects';
|
||||
import { CaseStudyGallery } from './CaseStudyGallery';
|
||||
import styles from './CaseStudyDrawer.module.css';
|
||||
|
||||
export function CaseStudyContent({
|
||||
@@ -27,6 +27,11 @@ export function CaseStudyContent({
|
||||
<h1 id="case-study-title" className={styles.title}>
|
||||
{project.title}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<CaseStudyGallery key={project.id} items={caseStudy.gallery} locale={locale} />
|
||||
|
||||
<div className={styles.overview}>
|
||||
<p id="case-study-introduction" className={styles.introduction}>{caseStudy.introduction}</p>
|
||||
<dl className={styles.facts}>
|
||||
<div><dt>{copy.roleLabel}</dt><dd>{caseStudy.role}</dd></div>
|
||||
@@ -34,29 +39,7 @@ export function CaseStudyContent({
|
||||
<div><dt>{copy.statusLabel}</dt><dd>{caseStudy.status}</dd></div>
|
||||
{caseStudy.team && <div><dt>{copy.teamLabel}</dt><dd>{caseStudy.team}</dd></div>}
|
||||
</dl>
|
||||
</header>
|
||||
|
||||
<section className={styles.section} aria-labelledby="case-study-gallery">
|
||||
<h2 id="case-study-gallery" className={styles.sectionTitle}>{copy.galleryLabel}</h2>
|
||||
<div className={styles.gallery}>
|
||||
{caseStudy.gallery.map((item, index) => (
|
||||
<figure key={`${item.image.src}-${index}`} className={index === 0 ? styles.featuredMedia : styles.media}>
|
||||
<div className={styles.imageFrame}>
|
||||
<Image
|
||||
src={item.image.src}
|
||||
width={item.image.width}
|
||||
height={item.image.height}
|
||||
alt={item.alt}
|
||||
sizes={index === 0 ? '(max-width: 650px) 100vw, 68rem' : '(max-width: 650px) 100vw, 34rem'}
|
||||
className={styles.galleryImage}
|
||||
priority={index === 0}
|
||||
/>
|
||||
</div>
|
||||
<figcaption>{item.caption}</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className={styles.storyGrid}>
|
||||
<section className={styles.section} aria-labelledby="case-study-challenge">
|
||||
|
||||
@@ -170,14 +170,19 @@
|
||||
}
|
||||
|
||||
.content {
|
||||
width: min(68rem, 100%);
|
||||
width: 100%;
|
||||
margin-inline: auto;
|
||||
padding: var(--space-8) var(--space-7) calc(var(--space-8) + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.hero {
|
||||
max-width: 52rem;
|
||||
margin-bottom: var(--space-8);
|
||||
width: 100%;
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.overview {
|
||||
width: 100%;
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
|
||||
.date {
|
||||
@@ -259,41 +264,277 @@
|
||||
line-height: var(--line-height-body-default);
|
||||
}
|
||||
|
||||
.gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
.visuallyHidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.featuredMedia {
|
||||
grid-column: 1 / -1;
|
||||
.gallerySection,
|
||||
.galleryFigure {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.imageFrame {
|
||||
.galleryStage {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-height: 10rem;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
max-height: 68dvh;
|
||||
padding: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-default);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-blue-950);
|
||||
cursor: zoom-in;
|
||||
transition:
|
||||
border-color var(--motion-duration-fast) ease,
|
||||
transform var(--motion-duration-fast) var(--motion-ease-out);
|
||||
}
|
||||
|
||||
.galleryImage {
|
||||
.galleryStageImage {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.gallery figcaption {
|
||||
padding: var(--space-2) var(--space-1) 0;
|
||||
.expandBadge {
|
||||
position: absolute;
|
||||
right: var(--space-3);
|
||||
bottom: var(--space-3);
|
||||
display: grid;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.26);
|
||||
border-radius: var(--radius-pill);
|
||||
background: rgba(1, 13, 39, 0.76);
|
||||
color: white;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.galleryCaption {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-3) var(--space-1) 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-body-small);
|
||||
line-height: var(--line-height-body-small);
|
||||
}
|
||||
|
||||
.galleryCaption span:last-child {
|
||||
flex: 0 0 auto;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.thumbnailRail {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-4);
|
||||
padding: var(--space-1);
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-inline: contain;
|
||||
scroll-snap-type: inline proximity;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.thumbnailButton,
|
||||
.viewerThumbnail {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 0 0 7rem;
|
||||
aspect-ratio: 16 / 9;
|
||||
padding: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
scroll-snap-align: start;
|
||||
border: 2px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-blue-950);
|
||||
cursor: pointer;
|
||||
opacity: 0.64;
|
||||
transition:
|
||||
border-color var(--motion-duration-fast) ease,
|
||||
opacity var(--motion-duration-fast) ease,
|
||||
transform var(--motion-duration-fast) var(--motion-ease-out);
|
||||
}
|
||||
|
||||
.thumbnailButton[data-selected='true'],
|
||||
.viewerThumbnail[data-selected='true'] {
|
||||
border-color: var(--accent-default);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.thumbnailImage {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.viewerDialog {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100vw;
|
||||
max-width: none;
|
||||
height: 100dvh;
|
||||
max-height: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
background: rgba(0, 0, 0, 0.98);
|
||||
color: white;
|
||||
opacity: 0;
|
||||
transition: opacity 200ms var(--motion-ease-out);
|
||||
}
|
||||
|
||||
.viewerDialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.92);
|
||||
}
|
||||
|
||||
.viewerDialog[data-state='open'] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.viewerDialog[data-state='closing'] {
|
||||
opacity: 0;
|
||||
transition-duration: 160ms;
|
||||
}
|
||||
|
||||
.viewerPanel {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px);
|
||||
}
|
||||
|
||||
.viewerHeader {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
min-height: 4.5rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-3) var(--space-5);
|
||||
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.78), transparent);
|
||||
}
|
||||
|
||||
.viewerHeader > div {
|
||||
display: grid;
|
||||
max-width: min(48rem, calc(100vw - 6rem));
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.viewerHeader strong {
|
||||
overflow: hidden;
|
||||
font-size: var(--font-size-body-small);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.viewerHeader span {
|
||||
color: rgba(255, 255, 255, 0.66);
|
||||
font-size: var(--font-size-label-small);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.viewerIconButton,
|
||||
.viewerNavButton {
|
||||
display: grid;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
flex: 0 0 auto;
|
||||
padding: 0;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.24);
|
||||
border-radius: var(--radius-pill);
|
||||
background: rgba(19, 28, 45, 0.72);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(10px);
|
||||
transition:
|
||||
background-color var(--motion-duration-fast) ease,
|
||||
border-color var(--motion-duration-fast) ease,
|
||||
transform var(--motion-duration-fast) var(--motion-ease-out);
|
||||
}
|
||||
|
||||
.viewerCanvas {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
padding: var(--space-3) 5rem;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.viewerImage {
|
||||
display: block;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.viewerNavButton {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.viewerNavButton:active {
|
||||
transform: translateY(-50%) scale(0.97);
|
||||
}
|
||||
|
||||
.viewerPrevious {
|
||||
inset-inline-start: var(--space-4);
|
||||
}
|
||||
|
||||
.viewerNext {
|
||||
inset-inline-end: var(--space-4);
|
||||
}
|
||||
|
||||
.viewerFilmstrip {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-5) var(--space-5);
|
||||
overflow-x: auto;
|
||||
justify-content: safe center;
|
||||
overscroll-behavior-inline: contain;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.viewerThumbnail {
|
||||
flex-basis: 6rem;
|
||||
border-color: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.galleryStage:active,
|
||||
.thumbnailButton:active,
|
||||
.viewerThumbnail:active,
|
||||
.viewerIconButton:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.storyGrid,
|
||||
.stackGrid {
|
||||
display: grid;
|
||||
@@ -500,26 +741,63 @@
|
||||
}
|
||||
|
||||
.facts,
|
||||
.gallery,
|
||||
.storyGrid,
|
||||
.stackGrid,
|
||||
.projectNavigation {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.featuredMedia {
|
||||
grid-column: auto;
|
||||
.galleryStage {
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.galleryCaption {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.thumbnailRail {
|
||||
gap: var(--space-2);
|
||||
margin-inline: calc(var(--space-1) * -1);
|
||||
}
|
||||
|
||||
.thumbnailButton {
|
||||
flex-basis: 5.75rem;
|
||||
}
|
||||
|
||||
.viewerHeader {
|
||||
padding-inline: var(--space-4);
|
||||
}
|
||||
|
||||
.viewerCanvas {
|
||||
padding-inline: var(--space-3);
|
||||
}
|
||||
|
||||
.viewerNavButton {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.viewerFilmstrip {
|
||||
justify-content: flex-start;
|
||||
padding-inline: var(--space-3);
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.actionButton:hover,
|
||||
.iconButton:hover,
|
||||
.projectNavLink:hover {
|
||||
.projectNavLink:hover,
|
||||
.galleryStage:hover,
|
||||
.viewerIconButton:hover,
|
||||
.viewerNavButton:hover {
|
||||
border-color: var(--accent-default);
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.thumbnailButton:hover,
|
||||
.viewerThumbnail:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.projectNavLink:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
@@ -539,4 +817,12 @@
|
||||
.dialog::backdrop {
|
||||
transition-duration: 120ms;
|
||||
}
|
||||
|
||||
.viewerDialog {
|
||||
transition-duration: 120ms;
|
||||
}
|
||||
|
||||
.thumbnailRail {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
|
||||
274
src/components/CaseStudyGallery.tsx
Normal file
274
src/components/CaseStudyGallery.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Expand,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type PointerEvent,
|
||||
} from 'react';
|
||||
import type { CaseStudyGalleryItem } from '@/data/projects';
|
||||
import { messagesByLocale, type Locale } from '@/i18n/content';
|
||||
import styles from './CaseStudyDrawer.module.css';
|
||||
|
||||
export function CaseStudyGallery({
|
||||
items,
|
||||
locale,
|
||||
}: {
|
||||
items: CaseStudyGalleryItem[];
|
||||
locale: Locale;
|
||||
}) {
|
||||
const copy = messagesByLocale[locale].projects;
|
||||
const isRtl = locale === 'fa';
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [viewerOpen, setViewerOpen] = useState(false);
|
||||
const viewerRef = useRef<HTMLDialogElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
const stageRef = useRef<HTMLButtonElement>(null);
|
||||
const thumbnailRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
const swipeStart = useRef<{ pointerId: number; x: number; y: number } | null>(null);
|
||||
const selected = items[selectedIndex];
|
||||
|
||||
const selectImage = useCallback((index: number) => {
|
||||
const normalizedIndex = (index + items.length) % items.length;
|
||||
setSelectedIndex(normalizedIndex);
|
||||
thumbnailRefs.current[normalizedIndex]?.scrollIntoView({
|
||||
behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
|
||||
block: 'nearest',
|
||||
inline: 'nearest',
|
||||
});
|
||||
}, [items.length]);
|
||||
|
||||
const showPrevious = useCallback(() => selectImage(selectedIndex - 1), [selectImage, selectedIndex]);
|
||||
const showNext = useCallback(() => selectImage(selectedIndex + 1), [selectImage, selectedIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = viewerRef.current;
|
||||
if (!dialog) return;
|
||||
|
||||
if (viewerOpen && !dialog.open) {
|
||||
dialog.showModal();
|
||||
requestAnimationFrame(() => {
|
||||
dialog.dataset.state = 'open';
|
||||
closeRef.current?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
if (!viewerOpen && dialog.open) dialog.close();
|
||||
}, [viewerOpen]);
|
||||
|
||||
const closeViewer = useCallback(() => {
|
||||
const dialog = viewerRef.current;
|
||||
if (!dialog?.open) return;
|
||||
|
||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
if (reduceMotion) {
|
||||
dialog.close();
|
||||
setViewerOpen(false);
|
||||
stageRef.current?.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
|
||||
dialog.dataset.state = 'closing';
|
||||
window.setTimeout(() => {
|
||||
dialog.close();
|
||||
setViewerOpen(false);
|
||||
stageRef.current?.focus({ preventScroll: true });
|
||||
}, 160);
|
||||
}, []);
|
||||
|
||||
const preloadAdjacentImages = () => {
|
||||
if (items.length < 2) return;
|
||||
const adjacent = [
|
||||
items[(selectedIndex - 1 + items.length) % items.length],
|
||||
items[(selectedIndex + 1) % items.length],
|
||||
];
|
||||
adjacent.forEach((item) => {
|
||||
const image = new window.Image();
|
||||
image.src = item.image.src;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSwipeStart = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!event.isPrimary || event.pointerType === 'mouse' || items.length < 2) return;
|
||||
swipeStart.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY };
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
};
|
||||
|
||||
const handleSwipeEnd = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const start = swipeStart.current;
|
||||
if (!start || start.pointerId !== event.pointerId) return;
|
||||
swipeStart.current = null;
|
||||
const deltaX = event.clientX - start.x;
|
||||
const deltaY = event.clientY - start.y;
|
||||
if (Math.abs(deltaX) < 48 || Math.abs(deltaX) <= Math.abs(deltaY)) return;
|
||||
|
||||
const advance = isRtl ? deltaX > 0 : deltaX < 0;
|
||||
if (advance) showNext();
|
||||
else showPrevious();
|
||||
};
|
||||
|
||||
if (!selected) return null;
|
||||
|
||||
const previousIcon = isRtl ? ChevronRight : ChevronLeft;
|
||||
const nextIcon = isRtl ? ChevronLeft : ChevronRight;
|
||||
const PreviousIcon = previousIcon;
|
||||
const NextIcon = nextIcon;
|
||||
|
||||
return (
|
||||
<section className={styles.gallerySection} aria-labelledby="case-study-gallery">
|
||||
<h2 id="case-study-gallery" className={styles.visuallyHidden}>{copy.galleryLabel}</h2>
|
||||
<figure className={styles.galleryFigure}>
|
||||
<button
|
||||
ref={stageRef}
|
||||
type="button"
|
||||
className={styles.galleryStage}
|
||||
onClick={() => setViewerOpen(true)}
|
||||
aria-label={copy.openGalleryImage(selected.caption)}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<Image
|
||||
key={selected.image.src}
|
||||
src={selected.image.src}
|
||||
width={selected.image.width}
|
||||
height={selected.image.height}
|
||||
alt={selected.alt}
|
||||
sizes="(max-width: 650px) calc(100vw - 2rem), (max-width: 1100px) calc(100vw - 4rem), 68rem"
|
||||
className={styles.galleryStageImage}
|
||||
unoptimized
|
||||
onLoad={preloadAdjacentImages}
|
||||
/>
|
||||
<span className={styles.expandBadge} aria-hidden="true"><Expand size={18} /></span>
|
||||
</button>
|
||||
<figcaption className={styles.galleryCaption}>
|
||||
<span>{selected.caption}</span>
|
||||
<span>{copy.imageCounter(selectedIndex + 1, items.length)}</span>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
{items.length > 1 && (
|
||||
<div className={styles.thumbnailRail} aria-label={copy.galleryLabel}>
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={item.image.src}
|
||||
ref={(node) => { thumbnailRefs.current[index] = node; }}
|
||||
type="button"
|
||||
className={styles.thumbnailButton}
|
||||
data-selected={index === selectedIndex}
|
||||
onClick={() => selectImage(index)}
|
||||
aria-label={copy.selectGalleryImage(index + 1, item.caption)}
|
||||
aria-pressed={index === selectedIndex}
|
||||
>
|
||||
<Image
|
||||
src={item.image.src}
|
||||
width={item.image.width}
|
||||
height={item.image.height}
|
||||
alt=""
|
||||
sizes="7rem"
|
||||
quality={60}
|
||||
className={styles.thumbnailImage}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<dialog
|
||||
ref={viewerRef}
|
||||
className={styles.viewerDialog}
|
||||
aria-label={copy.imageViewerLabel}
|
||||
data-state="opening"
|
||||
onCancel={(event) => {
|
||||
event.preventDefault();
|
||||
closeViewer();
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) closeViewer();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
if (isRtl) showNext();
|
||||
else showPrevious();
|
||||
}
|
||||
if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
if (isRtl) showPrevious();
|
||||
else showNext();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={styles.viewerPanel} onClick={(event) => event.stopPropagation()}>
|
||||
<header className={styles.viewerHeader}>
|
||||
<div>
|
||||
<strong>{selected.caption}</strong>
|
||||
<span>{copy.imageCounter(selectedIndex + 1, items.length)}</span>
|
||||
</div>
|
||||
<button
|
||||
ref={closeRef}
|
||||
type="button"
|
||||
className={styles.viewerIconButton}
|
||||
onClick={closeViewer}
|
||||
aria-label={copy.closeImageViewer}
|
||||
>
|
||||
<X size={23} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className={styles.viewerCanvas}
|
||||
onPointerDown={handleSwipeStart}
|
||||
onPointerUp={handleSwipeEnd}
|
||||
onPointerCancel={() => { swipeStart.current = null; }}
|
||||
>
|
||||
<Image
|
||||
key={`viewer-${selected.image.src}`}
|
||||
src={selected.image.src}
|
||||
width={selected.image.width}
|
||||
height={selected.image.height}
|
||||
alt={selected.alt}
|
||||
sizes="100vw"
|
||||
className={styles.viewerImage}
|
||||
unoptimized
|
||||
/>
|
||||
{items.length > 1 && (
|
||||
<>
|
||||
<button type="button" className={`${styles.viewerNavButton} ${styles.viewerPrevious}`} onClick={showPrevious} aria-label={copy.previousImage}>
|
||||
<PreviousIcon aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" className={`${styles.viewerNavButton} ${styles.viewerNext}`} onClick={showNext} aria-label={copy.nextImage}>
|
||||
<NextIcon aria-hidden="true" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items.length > 1 && (
|
||||
<div className={styles.viewerFilmstrip} aria-label={copy.galleryLabel}>
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={`viewer-thumbnail-${item.image.src}`}
|
||||
type="button"
|
||||
className={styles.viewerThumbnail}
|
||||
data-selected={index === selectedIndex}
|
||||
onClick={() => selectImage(index)}
|
||||
aria-label={copy.selectGalleryImage(index + 1, item.caption)}
|
||||
aria-pressed={index === selectedIndex}
|
||||
>
|
||||
<Image src={item.image.src} width={item.image.width} height={item.image.height} alt="" sizes="6rem" quality={60} className={styles.thumbnailImage} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</dialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user