diff --git a/src/components/CaseStudyContent.tsx b/src/components/CaseStudyContent.tsx index eb9f13a..412498b 100644 --- a/src/components/CaseStudyContent.tsx +++ b/src/components/CaseStudyContent.tsx @@ -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({

{project.title}

+ + + + +

{caseStudy.introduction}

{copy.roleLabel}
{caseStudy.role}
@@ -34,29 +39,7 @@ export function CaseStudyContent({
{copy.statusLabel}
{caseStudy.status}
{caseStudy.team &&
{copy.teamLabel}
{caseStudy.team}
}
- - -
- -
- {caseStudy.gallery.map((item, index) => ( -
-
- {item.alt} -
-
{item.caption}
-
- ))} -
-
+
diff --git a/src/components/CaseStudyDrawer.module.css b/src/components/CaseStudyDrawer.module.css index 6a96433..607fe7e 100644 --- a/src/components/CaseStudyDrawer.module.css +++ b/src/components/CaseStudyDrawer.module.css @@ -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; + } } diff --git a/src/components/CaseStudyGallery.tsx b/src/components/CaseStudyGallery.tsx new file mode 100644 index 0000000..8905162 --- /dev/null +++ b/src/components/CaseStudyGallery.tsx @@ -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(null); + const closeRef = useRef(null); + const stageRef = useRef(null); + const thumbnailRefs = useRef>([]); + 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) => { + 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) => { + 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 ( +
+ +
+ +
+ {selected.caption} + {copy.imageCounter(selectedIndex + 1, items.length)} +
+
+ + {items.length > 1 && ( +
+ {items.map((item, index) => ( + + ))} +
+ )} + + { + 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(); + } + }} + > +
event.stopPropagation()}> +
+
+ {selected.caption} + {copy.imageCounter(selectedIndex + 1, items.length)} +
+ +
+ +
{ swipeStart.current = null; }} + > + {selected.alt} + {items.length > 1 && ( + <> + + + + )} +
+ + {items.length > 1 && ( +
+ {items.map((item, index) => ( + + ))} +
+ )} +
+
+
+ ); +} diff --git a/src/i18n/content.ts b/src/i18n/content.ts index 407c590..b57d46a 100644 --- a/src/i18n/content.ts +++ b/src/i18n/content.ts @@ -63,6 +63,13 @@ export interface SiteMessages { statusLabel: string; teamLabel: 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; ownershipLabel: string; decisionsLabel: string; @@ -207,6 +214,13 @@ export const messagesByLocale = { statusLabel: 'Status', teamLabel: 'Team', 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', ownershipLabel: 'My ownership', decisionsLabel: 'Key decisions', @@ -352,6 +366,13 @@ export const messagesByLocale = { statusLabel: 'وضعیت', teamLabel: 'تیم', galleryLabel: 'تصاویر محصول', + openGalleryImage: (caption) => `باز کردن تصویر در نمای تمام‌صفحه: ${caption}`, + selectGalleryImage: (index, caption) => `نمایش تصویر ${formatPersianNumber(index)}: ${caption}`, + imageViewerLabel: 'گالری تمام‌صفحه پروژه', + closeImageViewer: 'بستن تصویر تمام‌صفحه', + previousImage: 'تصویر قبلی', + nextImage: 'تصویر بعدی', + imageCounter: (current, total) => `${formatPersianNumber(current)} از ${formatPersianNumber(total)}`, challengeLabel: 'چالش', ownershipLabel: 'مسئولیت‌های من', decisionsLabel: 'تصمیم‌های کلیدی',