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 Link from 'next/link';
|
||||||
import { ArrowLeft, ArrowRight } from 'lucide-react';
|
import { ArrowLeft, ArrowRight } from 'lucide-react';
|
||||||
import { messagesByLocale, type Locale } from '@/i18n/content';
|
import { messagesByLocale, type Locale } from '@/i18n/content';
|
||||||
import { getProjectPath, type Project } from '@/data/projects';
|
import { getProjectPath, type Project } from '@/data/projects';
|
||||||
|
import { CaseStudyGallery } from './CaseStudyGallery';
|
||||||
import styles from './CaseStudyDrawer.module.css';
|
import styles from './CaseStudyDrawer.module.css';
|
||||||
|
|
||||||
export function CaseStudyContent({
|
export function CaseStudyContent({
|
||||||
@@ -27,6 +27,11 @@ export function CaseStudyContent({
|
|||||||
<h1 id="case-study-title" className={styles.title}>
|
<h1 id="case-study-title" className={styles.title}>
|
||||||
{project.title}
|
{project.title}
|
||||||
</h1>
|
</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>
|
<p id="case-study-introduction" className={styles.introduction}>{caseStudy.introduction}</p>
|
||||||
<dl className={styles.facts}>
|
<dl className={styles.facts}>
|
||||||
<div><dt>{copy.roleLabel}</dt><dd>{caseStudy.role}</dd></div>
|
<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>
|
<div><dt>{copy.statusLabel}</dt><dd>{caseStudy.status}</dd></div>
|
||||||
{caseStudy.team && <div><dt>{copy.teamLabel}</dt><dd>{caseStudy.team}</dd></div>}
|
{caseStudy.team && <div><dt>{copy.teamLabel}</dt><dd>{caseStudy.team}</dd></div>}
|
||||||
</dl>
|
</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>
|
</div>
|
||||||
<figcaption>{item.caption}</figcaption>
|
|
||||||
</figure>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className={styles.storyGrid}>
|
<div className={styles.storyGrid}>
|
||||||
<section className={styles.section} aria-labelledby="case-study-challenge">
|
<section className={styles.section} aria-labelledby="case-study-challenge">
|
||||||
|
|||||||
@@ -170,14 +170,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
width: min(68rem, 100%);
|
width: 100%;
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
padding: var(--space-8) var(--space-7) calc(var(--space-8) + env(safe-area-inset-bottom, 0px));
|
padding: var(--space-8) var(--space-7) calc(var(--space-8) + env(safe-area-inset-bottom, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
max-width: 52rem;
|
width: 100%;
|
||||||
margin-bottom: var(--space-8);
|
margin-bottom: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: var(--space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.date {
|
.date {
|
||||||
@@ -259,41 +264,277 @@
|
|||||||
line-height: var(--line-height-body-default);
|
line-height: var(--line-height-body-default);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery {
|
.visuallyHidden {
|
||||||
display: grid;
|
position: absolute;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
width: 1px;
|
||||||
gap: var(--space-4);
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.featuredMedia {
|
.gallerySection,
|
||||||
grid-column: 1 / -1;
|
.galleryFigure {
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.imageFrame {
|
.galleryStage {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 10rem;
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
max-height: 68dvh;
|
||||||
|
padding: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--border-default);
|
border: 1px solid var(--border-strong);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
background: var(--color-blue-950);
|
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;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery figcaption {
|
.expandBadge {
|
||||||
padding: var(--space-2) var(--space-1) 0;
|
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);
|
color: var(--text-muted);
|
||||||
font-size: var(--font-size-body-small);
|
font-size: var(--font-size-body-small);
|
||||||
line-height: var(--line-height-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,
|
.storyGrid,
|
||||||
.stackGrid {
|
.stackGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -500,26 +741,63 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.facts,
|
.facts,
|
||||||
.gallery,
|
|
||||||
.storyGrid,
|
.storyGrid,
|
||||||
.stackGrid,
|
.stackGrid,
|
||||||
.projectNavigation {
|
.projectNavigation {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.featuredMedia {
|
.galleryStage {
|
||||||
grid-column: auto;
|
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) {
|
@media (hover: hover) and (pointer: fine) {
|
||||||
.actionButton:hover,
|
.actionButton:hover,
|
||||||
.iconButton:hover,
|
.iconButton:hover,
|
||||||
.projectNavLink:hover {
|
.projectNavLink:hover,
|
||||||
|
.galleryStage:hover,
|
||||||
|
.viewerIconButton:hover,
|
||||||
|
.viewerNavButton:hover {
|
||||||
border-color: var(--accent-default);
|
border-color: var(--accent-default);
|
||||||
background: var(--bg-subtle);
|
background: var(--bg-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.thumbnailButton:hover,
|
||||||
|
.viewerThumbnail:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.projectNavLink:hover {
|
.projectNavLink:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
@@ -539,4 +817,12 @@
|
|||||||
.dialog::backdrop {
|
.dialog::backdrop {
|
||||||
transition-duration: 120ms;
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -63,6 +63,13 @@ export interface SiteMessages {
|
|||||||
statusLabel: string;
|
statusLabel: string;
|
||||||
teamLabel: string;
|
teamLabel: string;
|
||||||
galleryLabel: string;
|
galleryLabel: string;
|
||||||
|
openGalleryImage: (caption: string) => string;
|
||||||
|
selectGalleryImage: (index: number, caption: string) => string;
|
||||||
|
imageViewerLabel: string;
|
||||||
|
closeImageViewer: string;
|
||||||
|
previousImage: string;
|
||||||
|
nextImage: string;
|
||||||
|
imageCounter: (current: number, total: number) => string;
|
||||||
challengeLabel: string;
|
challengeLabel: string;
|
||||||
ownershipLabel: string;
|
ownershipLabel: string;
|
||||||
decisionsLabel: string;
|
decisionsLabel: string;
|
||||||
@@ -207,6 +214,13 @@ export const messagesByLocale = {
|
|||||||
statusLabel: 'Status',
|
statusLabel: 'Status',
|
||||||
teamLabel: 'Team',
|
teamLabel: 'Team',
|
||||||
galleryLabel: 'Product gallery',
|
galleryLabel: 'Product gallery',
|
||||||
|
openGalleryImage: (caption) => `Open full-screen image: ${caption}`,
|
||||||
|
selectGalleryImage: (index, caption) => `Show image ${index}: ${caption}`,
|
||||||
|
imageViewerLabel: 'Full-screen project gallery',
|
||||||
|
closeImageViewer: 'Close full-screen image',
|
||||||
|
previousImage: 'Previous image',
|
||||||
|
nextImage: 'Next image',
|
||||||
|
imageCounter: (current, total) => `${current} of ${total}`,
|
||||||
challengeLabel: 'The challenge',
|
challengeLabel: 'The challenge',
|
||||||
ownershipLabel: 'My ownership',
|
ownershipLabel: 'My ownership',
|
||||||
decisionsLabel: 'Key decisions',
|
decisionsLabel: 'Key decisions',
|
||||||
@@ -352,6 +366,13 @@ export const messagesByLocale = {
|
|||||||
statusLabel: 'وضعیت',
|
statusLabel: 'وضعیت',
|
||||||
teamLabel: 'تیم',
|
teamLabel: 'تیم',
|
||||||
galleryLabel: 'تصاویر محصول',
|
galleryLabel: 'تصاویر محصول',
|
||||||
|
openGalleryImage: (caption) => `باز کردن تصویر در نمای تمامصفحه: ${caption}`,
|
||||||
|
selectGalleryImage: (index, caption) => `نمایش تصویر ${formatPersianNumber(index)}: ${caption}`,
|
||||||
|
imageViewerLabel: 'گالری تمامصفحه پروژه',
|
||||||
|
closeImageViewer: 'بستن تصویر تمامصفحه',
|
||||||
|
previousImage: 'تصویر قبلی',
|
||||||
|
nextImage: 'تصویر بعدی',
|
||||||
|
imageCounter: (current, total) => `${formatPersianNumber(current)} از ${formatPersianNumber(total)}`,
|
||||||
challengeLabel: 'چالش',
|
challengeLabel: 'چالش',
|
||||||
ownershipLabel: 'مسئولیتهای من',
|
ownershipLabel: 'مسئولیتهای من',
|
||||||
decisionsLabel: 'تصمیمهای کلیدی',
|
decisionsLabel: 'تصمیمهای کلیدی',
|
||||||
|
|||||||
Reference in New Issue
Block a user