feat: add immersive case study gallery

This commit is contained in:
2026-09-09 23:26:30 +03:30
parent 0ddfa583cb
commit 4b9d4ae893
4 changed files with 608 additions and 44 deletions

View File

@@ -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">

View File

@@ -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;
}
} }

View 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>
);
}

View File

@@ -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: 'تصمیم‌های کلیدی',