feat: add route-backed project case studies
Some checks failed
Test and deploy / Verify production build (push) Has been cancelled
Test and deploy / Deploy production (push) Has been cancelled

This commit is contained in:
2026-09-09 22:00:57 +03:30
parent b3cfcfe057
commit 24cd86ede7
25 changed files with 1892 additions and 65 deletions

View File

@@ -0,0 +1,128 @@
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 styles from './CaseStudyDrawer.module.css';
export function CaseStudyContent({
project,
locale,
previous,
next,
}: {
project: Project;
locale: Locale;
previous: Project;
next: Project;
}) {
const copy = messagesByLocale[locale].projects;
const caseStudy = project.caseStudy;
const isRtl = locale === 'fa';
return (
<article className={styles.content}>
<header className={styles.hero}>
<p className={styles.date}>{project.date}</p>
<h1 id="case-study-title" className={styles.title} tabIndex={-1} data-case-study-heading>
{project.title}
</h1>
<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>
<div><dt>{copy.usersLabel}</dt><dd>{caseStudy.targetUsers}</dd></div>
<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 className={styles.storyGrid}>
<section className={styles.section} aria-labelledby="case-study-challenge">
<h2 id="case-study-challenge" className={styles.sectionTitle}>{copy.challengeLabel}</h2>
<p>{caseStudy.challenge}</p>
</section>
<section className={styles.section} aria-labelledby="case-study-ownership">
<h2 id="case-study-ownership" className={styles.sectionTitle}>{copy.ownershipLabel}</h2>
<ul className={styles.list}>{caseStudy.ownership.map((item) => <li key={item}>{item}</li>)}</ul>
</section>
</div>
{caseStudy.constraints && (
<section className={styles.section} aria-labelledby="case-study-constraints">
<h2 id="case-study-constraints" className={styles.sectionTitle}>{copy.constraintsLabel}</h2>
<ul className={styles.list}>{caseStudy.constraints.map((item) => <li key={item}>{item}</li>)}</ul>
</section>
)}
<section className={styles.section} aria-labelledby="case-study-decisions">
<h2 id="case-study-decisions" className={styles.sectionTitle}>{copy.decisionsLabel}</h2>
<div className={styles.decisions}>
{caseStudy.decisions.map((decision) => (
<article key={`${decision.discipline}-${decision.title}`} className={styles.decision} data-discipline={decision.discipline}>
<span>{copy.decisionLabels[decision.discipline]}</span>
<h3>{decision.title}</h3>
<p>{decision.description}</p>
</article>
))}
</div>
</section>
<section className={styles.section} aria-labelledby="case-study-outcomes">
<h2 id="case-study-outcomes" className={styles.sectionTitle}>{copy.outcomesLabel}</h2>
{caseStudy.metrics && <dl className={styles.metrics}>{caseStudy.metrics.map((metric) => <div key={metric.label}><dt>{metric.value}</dt><dd>{metric.label}</dd></div>)}</dl>}
<ul className={styles.list}>{caseStudy.outcomes.map((item) => <li key={item}>{item}</li>)}</ul>
</section>
<div className={styles.stackGrid}>
<section className={styles.section} aria-labelledby="case-study-skills">
<h2 id="case-study-skills" className={styles.sectionTitle}>{copy.skillsLabel}</h2>
<div className={styles.badges}>{caseStudy.skills.map((skill) => <span key={skill}>{skill}</span>)}</div>
</section>
<section className={styles.section} aria-labelledby="case-study-tools">
<h2 id="case-study-tools" className={styles.sectionTitle}>{copy.toolsLabel}</h2>
<div className={`${styles.badges} ltr-token`} lang="en" dir="ltr">{caseStudy.tools.map((tool) => <span key={tool}>{tool}</span>)}</div>
</section>
</div>
{caseStudy.lessons && (
<section className={styles.section} aria-labelledby="case-study-lessons">
<h2 id="case-study-lessons" className={styles.sectionTitle}>{copy.lessonsLabel}</h2>
<ul className={styles.list}>{caseStudy.lessons.map((item) => <li key={item}>{item}</li>)}</ul>
</section>
)}
<nav className={styles.projectNavigation} aria-label={copy.drawerLabel}>
<Link href={getProjectPath(locale, previous.id)} replace scroll={false} className={styles.projectNavLink}>
{isRtl ? <ArrowRight aria-hidden="true" /> : <ArrowLeft aria-hidden="true" />}
<span><small>{copy.previousCaseStudy}</small><strong>{previous.title}</strong></span>
</Link>
<Link href={getProjectPath(locale, next.id)} replace scroll={false} className={`${styles.projectNavLink} ${styles.nextProject}`}>
<span><small>{copy.nextCaseStudy}</small><strong>{next.title}</strong></span>
{isRtl ? <ArrowLeft aria-hidden="true" /> : <ArrowRight aria-hidden="true" />}
</Link>
</nav>
</article>
);
}

View File

@@ -0,0 +1,552 @@
.dialog {
position: fixed;
inset: 0;
width: 100vw;
max-width: none;
height: 100dvh;
max-height: none;
margin: 0;
padding: 0;
overflow: hidden;
border: 0;
background: transparent;
color: var(--text-primary);
}
.dialog::backdrop {
background: rgba(1, 13, 39, 0.52);
opacity: 0;
backdrop-filter: blur(3px);
transition: opacity 180ms var(--motion-ease-out);
}
.dialog[data-state='open']::backdrop {
opacity: 1;
}
.dialog[data-state='closing']::backdrop {
opacity: 0;
}
.sheet {
position: fixed;
right: 0;
bottom: 0;
left: 0;
display: flex;
flex-direction: column;
width: min(72rem, calc(100vw - 2rem));
height: min(86dvh, 56rem);
margin-inline: auto;
overflow: hidden;
border: 1px solid var(--border-strong);
border-bottom: 0;
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
background: var(--bg-raised);
box-shadow: 0 -1rem 4rem rgba(1, 13, 39, 0.2);
transform: translateY(100%);
transition: transform 240ms cubic-bezier(0.32, 0.72, 0, 1);
will-change: transform;
}
.dialog[data-state='open'] .sheet {
transform: translateY(0);
}
.dialog[data-motion='none'] .sheet {
transition-duration: 0ms;
}
.utilityHeader {
position: relative;
z-index: 2;
display: grid;
flex: 0 0 auto;
grid-template-columns: 1fr auto 1fr;
align-items: center;
min-height: 4.5rem;
padding: 1rem var(--space-5) var(--space-3);
border-bottom: 1px solid var(--border-default);
background: color-mix(in srgb, var(--bg-raised) 94%, transparent);
}
.dragTarget {
position: absolute;
top: 0;
left: 50%;
display: flex;
align-items: center;
justify-content: center;
width: 8rem;
height: 2rem;
cursor: grab;
touch-action: none;
transform: translateX(-50%);
}
.dragTarget:active {
cursor: grabbing;
}
.dragHandle {
width: 2.75rem;
height: 0.25rem;
border-radius: var(--radius-pill);
background: var(--border-strong);
}
.drawerLabel {
grid-column: 2;
color: var(--text-muted);
font-size: var(--font-size-label-small);
font-weight: 700;
letter-spacing: var(--letter-spacing-eyebrow);
text-transform: uppercase;
}
.actions {
display: flex;
grid-column: 3;
gap: var(--space-2);
justify-self: end;
}
.actionButton,
.iconButton {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.75rem;
height: 2.75rem;
border: 1px solid var(--border-default);
border-radius: var(--radius-pill);
background: var(--bg-surface);
color: var(--text-primary);
cursor: pointer;
font: inherit;
font-size: var(--font-size-body-small);
font-weight: 650;
transition:
color var(--motion-duration-fast) ease,
background-color var(--motion-duration-fast) ease,
border-color var(--motion-duration-fast) ease,
transform var(--motion-duration-fast) var(--motion-ease-out);
}
.actionButton {
gap: var(--space-2);
padding-inline: var(--space-4);
}
.iconButton {
width: 2.75rem;
padding: 0;
}
.actionButton:active,
.iconButton:active,
.projectNavLink:active {
transform: scale(0.97);
}
.liveRegion {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.scrollArea {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
scroll-padding-top: var(--space-5);
scrollbar-gutter: stable;
}
.content {
width: min(68rem, 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);
}
.date {
margin-bottom: var(--space-2);
color: var(--text-muted);
font-size: var(--font-size-label-small);
font-weight: 700;
letter-spacing: var(--letter-spacing-eyebrow);
text-transform: uppercase;
}
.title {
margin-bottom: var(--space-4);
color: var(--text-primary);
font-size: clamp(2rem, 5vw, 3.5rem);
font-weight: 780;
letter-spacing: -0.045em;
line-height: 1.05;
}
:global(html[dir='rtl']) .title,
:global(html[dir='rtl']) .date,
:global(html[dir='rtl']) .drawerLabel {
letter-spacing: 0;
}
.title:focus {
outline: none;
}
.title:focus-visible {
border-radius: var(--radius-sm);
outline: 3px solid var(--focus-ring);
outline-offset: var(--space-2);
}
.introduction {
max-width: 46rem;
color: var(--text-secondary);
font-size: var(--font-size-body-large);
line-height: var(--line-height-body-large);
}
.facts {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-3);
margin-top: var(--space-6);
}
.facts > div {
padding: var(--space-4);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
background: var(--bg-surface);
}
.facts dt {
margin-bottom: var(--space-1);
color: var(--text-muted);
font-size: var(--font-size-label-small);
font-weight: 700;
text-transform: uppercase;
}
.facts dd {
color: var(--text-primary);
font-size: var(--font-size-body-small);
font-weight: 650;
line-height: var(--line-height-body-small);
}
.section {
margin-top: var(--space-8);
}
.sectionTitle {
margin-bottom: var(--space-4);
color: var(--text-primary);
font-size: var(--font-size-h3);
font-weight: 750;
line-height: var(--line-height-h3);
}
.section p,
.list {
color: var(--text-secondary);
font-size: var(--font-size-body-default);
line-height: var(--line-height-body-default);
}
.gallery {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
}
.featuredMedia {
grid-column: 1 / -1;
}
.imageFrame {
display: flex;
min-height: 10rem;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
background: var(--color-blue-950);
}
.galleryImage {
display: block;
width: 100%;
height: auto;
object-fit: contain;
}
.gallery figcaption {
padding: var(--space-2) var(--space-1) 0;
color: var(--text-muted);
font-size: var(--font-size-body-small);
line-height: var(--line-height-body-small);
}
.storyGrid,
.stackGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-7);
}
.list {
display: grid;
gap: var(--space-3);
padding-inline-start: var(--space-5);
}
.list li::marker {
color: var(--accent-default);
}
.decisions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-3);
}
.decision {
padding: var(--space-5);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
background: var(--bg-surface);
}
.decision > span {
display: inline-flex;
margin-bottom: var(--space-3);
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-pill);
background: var(--bg-subtle);
color: var(--action-link);
font-size: var(--font-size-label-small);
font-weight: 700;
}
.decision h3 {
margin-bottom: var(--space-2);
color: var(--text-primary);
font-size: var(--font-size-body-default);
line-height: 1.4;
}
.decision p {
font-size: var(--font-size-body-small);
line-height: var(--line-height-body-small);
}
.metrics {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
margin-bottom: var(--space-5);
}
.metrics > div {
min-width: 9rem;
padding: var(--space-4);
border-radius: var(--radius-md);
background: var(--bg-subtle);
}
.metrics dt {
color: var(--text-primary);
font-size: var(--font-size-h3);
font-weight: 760;
}
.metrics dd {
color: var(--text-secondary);
font-size: var(--font-size-body-small);
}
.badges {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.badges span {
padding: var(--space-2) var(--space-4);
border: 1px solid var(--border-default);
border-radius: var(--radius-pill);
background: var(--bg-surface);
color: var(--text-primary);
font-size: var(--font-size-body-small);
font-weight: 600;
}
.projectNavigation {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
margin-top: var(--space-9);
padding-top: var(--space-6);
border-top: 1px solid var(--border-default);
}
.projectNavLink {
display: flex;
min-height: 5rem;
align-items: center;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
background: var(--bg-surface);
transition:
background-color var(--motion-duration-fast) ease,
border-color var(--motion-duration-fast) ease,
transform var(--motion-duration-fast) var(--motion-ease-out);
}
.projectNavLink svg {
flex: 0 0 auto;
width: var(--size-icon-md);
}
.projectNavLink span {
display: grid;
gap: var(--space-1);
}
.projectNavLink small {
color: var(--text-muted);
font-size: var(--font-size-label-small);
}
.projectNavLink strong {
color: var(--text-primary);
font-size: var(--font-size-body-default);
}
.nextProject {
justify-content: flex-end;
text-align: end;
}
@media (max-width: 1100px) {
.sheet {
width: calc(100vw - 1rem);
height: 90dvh;
}
.content {
padding-inline: var(--space-6);
}
.decisions {
grid-template-columns: 1fr;
}
}
@media (max-width: 650px) {
.sheet {
width: 100vw;
height: 92dvh;
}
.utilityHeader {
min-height: 4.25rem;
padding-inline: var(--space-4);
}
.drawerLabel {
justify-self: start;
}
.actionText {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
.actionButton {
width: 2.75rem;
padding: 0;
}
.content {
padding: var(--space-7) var(--space-4) calc(var(--space-7) + env(safe-area-inset-bottom, 0px));
}
.hero,
.section {
margin-bottom: 0;
}
.title {
font-size: 2rem;
line-height: 1.15;
}
.introduction {
font-size: var(--font-size-body-default);
line-height: var(--line-height-body-default);
}
.facts,
.gallery,
.storyGrid,
.stackGrid,
.projectNavigation {
grid-template-columns: 1fr;
}
.featuredMedia {
grid-column: auto;
}
}
@media (hover: hover) and (pointer: fine) {
.actionButton:hover,
.iconButton:hover,
.projectNavLink:hover {
border-color: var(--accent-default);
background: var(--bg-subtle);
}
.projectNavLink:hover {
transform: translateY(-1px);
}
}
@media (prefers-reduced-motion: reduce) {
.sheet {
transform: translateY(0);
transition: opacity 120ms ease;
}
.dialog[data-state='opening'] .sheet,
.dialog[data-state='closing'] .sheet {
opacity: 0;
}
.dialog::backdrop {
transition-duration: 120ms;
}
}

View File

@@ -0,0 +1,158 @@
'use client';
import { useCallback, useEffect, useRef, useState, type PointerEvent, type ReactNode } from 'react';
import { Check, Copy, X } from 'lucide-react';
import { usePathname } from 'next/navigation';
import { messagesByLocale, type Locale } from '@/i18n/content';
import { useDismissCaseStudy } from './PortfolioRouteShell';
import { useDrawerDrag } from './useDrawerDrag';
import styles from './CaseStudyDrawer.module.css';
type CopyState = 'idle' | 'copied' | 'error';
export function CaseStudyDrawer({ children, locale }: { children: ReactNode; locale: Locale }) {
const copy = messagesByLocale[locale].projects;
const pathname = usePathname();
const dismissRoute = useDismissCaseStudy();
const dialogRef = useRef<HTMLDialogElement>(null);
const sheetRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const backdropPressed = useRef(false);
const closing = useRef(false);
const returnFocusRef = useRef<HTMLElement | null>(null);
const backgroundScrollY = useRef(0);
const [copyState, setCopyState] = useState<CopyState>('idle');
const finishDismiss = useCallback(() => {
const dialog = dialogRef.current;
if (dialog?.open) dialog.close();
returnFocusRef.current?.focus({ preventScroll: true });
dismissRoute();
requestAnimationFrame(() => {
requestAnimationFrame(() => {
window.scrollTo({ top: backgroundScrollY.current, behavior: 'instant' });
returnFocusRef.current?.focus({ preventScroll: true });
});
});
}, [dismissRoute]);
const requestDismiss = useCallback((animate = true) => {
if (closing.current) return;
closing.current = true;
const dialog = dialogRef.current;
const sheet = sheetRef.current;
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!animate || reduceMotion || !dialog || !sheet) {
finishDismiss();
return;
}
dialog.dataset.state = 'closing';
sheet.style.transition = 'transform 180ms var(--motion-ease-out)';
sheet.style.transform = 'translateY(100%)';
window.setTimeout(finishDismiss, 180);
}, [finishDismiss]);
const dragHandlers = useDrawerDrag({ sheetRef, onDismiss: requestDismiss });
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
const root = document.documentElement;
const previousOverflow = root.style.overflow;
const skipMovement = root.dataset.caseStudyInput === 'keyboard';
const recordedScrollY = Number(root.dataset.caseStudyScrollY);
const activeElement = document.activeElement;
returnFocusRef.current = activeElement instanceof HTMLElement && activeElement !== document.body
? activeElement
: null;
backgroundScrollY.current = Number.isFinite(recordedScrollY) ? recordedScrollY : window.scrollY;
delete root.dataset.caseStudyInput;
dialog.dataset.motion = skipMovement ? 'none' : 'standard';
dialog.showModal();
root.style.overflow = 'hidden';
requestAnimationFrame(() => {
dialog.dataset.state = 'open';
dialog.querySelector<HTMLElement>('[data-case-study-heading]')?.focus();
});
return () => {
root.style.overflow = previousOverflow;
if (dialog.open) dialog.close();
if (returnFocusRef.current) {
const restoreScroll = () => window.scrollTo({
top: backgroundScrollY.current,
behavior: 'instant',
});
restoreScroll();
window.setTimeout(restoreScroll, 120);
window.setTimeout(restoreScroll, 600);
}
};
}, []);
useEffect(() => {
scrollRef.current?.scrollTo({ top: 0, behavior: 'auto' });
requestAnimationFrame(() => {
setCopyState('idle');
dialogRef.current?.querySelector<HTMLElement>('[data-case-study-heading]')?.focus();
});
}, [pathname]);
const copyCurrentLink = async () => {
try {
await navigator.clipboard.writeText(window.location.href);
setCopyState('copied');
} catch {
setCopyState('error');
}
};
const handleBackdropDown = (event: PointerEvent<HTMLDialogElement>) => {
backdropPressed.current = event.target === event.currentTarget;
};
const handleBackdropUp = (event: PointerEvent<HTMLDialogElement>) => {
if (backdropPressed.current && event.target === event.currentTarget) requestDismiss();
backdropPressed.current = false;
};
const feedback = copyState === 'copied' ? copy.linkCopied : copyState === 'error' ? copy.copyFailed : '';
return (
<dialog
ref={dialogRef}
className={styles.dialog}
aria-labelledby="case-study-title"
aria-describedby="case-study-introduction"
data-state="opening"
onCancel={(event) => {
event.preventDefault();
requestDismiss(false);
}}
onPointerDown={handleBackdropDown}
onPointerUp={handleBackdropUp}
>
<div ref={sheetRef} className={styles.sheet}>
<div className={styles.utilityHeader}>
<div className={styles.dragTarget} aria-hidden="true" {...dragHandlers}>
<span className={styles.dragHandle} />
</div>
<span className={styles.drawerLabel}>{copy.drawerLabel}</span>
<div className={styles.actions}>
<button type="button" className={styles.actionButton} onClick={copyCurrentLink} aria-label={copy.copyLink}>
{copyState === 'copied' ? <Check size={19} aria-hidden="true" /> : <Copy size={18} aria-hidden="true" />}
<span className={styles.actionText}>{copyState === 'copied' ? copy.linkCopied : copy.copyLink}</span>
</button>
<button type="button" className={styles.iconButton} onClick={(event) => requestDismiss(event.detail !== 0)} aria-label={copy.closeCaseStudy}>
<X size={21} aria-hidden="true" />
</button>
</div>
<span className={styles.liveRegion} aria-live="polite">{feedback}</span>
</div>
<div ref={scrollRef} className={styles.scrollArea}>{children}</div>
</div>
</dialog>
);
}

View File

@@ -0,0 +1,72 @@
'use client';
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
type ReactNode,
} from 'react';
import { usePathname, useRouter } from 'next/navigation';
const PortfolioRouteContext = createContext<(() => void) | null>(null);
export function PortfolioRouteShell({
children,
homePath,
}: {
children: ReactNode;
homePath: string;
}) {
const pathname = usePathname();
const router = useRouter();
const previousPath = useRef(pathname);
const canReturnWithBack = useRef(false);
const returnScrollY = useRef<number | null>(null);
const previousScrollRestoration = useRef<ScrollRestoration>('auto');
useEffect(() => {
if (pathname === homePath) {
canReturnWithBack.current = false;
if (previousPath.current !== homePath && returnScrollY.current !== null) {
const scrollY = returnScrollY.current;
requestAnimationFrame(() => window.scrollTo({ top: scrollY, behavior: 'instant' }));
window.setTimeout(() => window.scrollTo({ top: scrollY, behavior: 'instant' }), 120);
window.setTimeout(() => {
window.scrollTo({ top: scrollY, behavior: 'instant' });
history.scrollRestoration = previousScrollRestoration.current;
}, 600);
returnScrollY.current = null;
delete document.documentElement.dataset.caseStudyScrollY;
}
} else if (previousPath.current === homePath) {
canReturnWithBack.current = true;
const recordedScrollY = Number(document.documentElement.dataset.caseStudyScrollY);
returnScrollY.current = Number.isFinite(recordedScrollY) ? recordedScrollY : window.scrollY;
previousScrollRestoration.current = history.scrollRestoration;
history.scrollRestoration = 'manual';
}
previousPath.current = pathname;
}, [homePath, pathname]);
const dismissCaseStudy = useCallback(() => {
if (canReturnWithBack.current) {
router.back();
return;
}
router.replace(homePath, { scroll: false });
}, [homePath, router]);
return (
<PortfolioRouteContext.Provider value={dismissCaseStudy}>
{children}
</PortfolioRouteContext.Provider>
);
}
export function useDismissCaseStudy() {
const dismiss = useContext(PortfolioRouteContext);
if (!dismiss) throw new Error('useDismissCaseStudy must be used within PortfolioRouteShell');
return dismiss;
}

View File

@@ -157,7 +157,7 @@
.cardTitleRow {
display: flex;
align-items: center;
gap: 0.4375rem;
gap: var(--space-1);
margin-bottom: 0.375rem;
}
@@ -168,6 +168,17 @@
line-height: 1.25;
}
.titleLiveLink {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 2.75rem;
height: 2.75rem;
margin-block: -0.75rem;
border-radius: 50%;
}
.titleLinkIcon {
flex: 0 0 auto;
color: var(--color-blue-500);
@@ -506,7 +517,7 @@
transform: scale(1.035);
}
.card:hover .titleLinkIcon {
.titleLiveLink:hover .titleLinkIcon {
transform: translate(2px, -2px);
}

View File

@@ -1,8 +1,9 @@
'use client';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { ArrowLeft, ArrowRight, ArrowUpRight } from 'lucide-react';
import { getProjects } from '@/data/projects';
import { getProjectPath, getProjects } from '@/data/projects';
import { messagesByLocale, type Locale } from '@/i18n/content';
import { ProjectThumbnail } from './ProjectThumbnail';
import styles from './ProjectsSection.module.css';
@@ -369,13 +370,21 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
<div className={styles.cardBody} data-reveal="soft">
<div className={styles.cardTitleRow}>
{project.link && isRtl && (
<ArrowUpRight
size={19}
strokeWidth={2.5}
className={styles.titleLinkIcon}
aria-hidden="true"
/>
{project.liveUrl && isRtl && (
<a
href={project.liveUrl}
target="_blank"
rel="noopener noreferrer"
className={styles.titleLiveLink}
aria-label={copy.liveProjectLabel(project.title)}
>
<ArrowUpRight
size={19}
strokeWidth={2.5}
className={styles.titleLinkIcon}
aria-hidden="true"
/>
</a>
)}
<h3
className={`${styles.cardTitle} ${project.id === 'qlockify' ? 'ltr-token' : ''}`}
@@ -384,13 +393,21 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
>
{project.title}
</h3>
{project.link && !isRtl && (
<ArrowUpRight
size={19}
strokeWidth={2.5}
className={styles.titleLinkIcon}
aria-hidden="true"
/>
{project.liveUrl && !isRtl && (
<a
href={project.liveUrl}
target="_blank"
rel="noopener noreferrer"
className={styles.titleLiveLink}
aria-label={copy.liveProjectLabel(project.title)}
>
<ArrowUpRight
size={19}
strokeWidth={2.5}
className={styles.titleLinkIcon}
aria-hidden="true"
/>
</a>
)}
</div>
@@ -411,7 +428,16 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
</div>
<div className={styles.cardFooter}>
<a href={project.link || '#'} className={styles.caseStudyLink}>
<Link
href={getProjectPath(locale, project.id)}
scroll={false}
className={styles.caseStudyLink}
onClick={(event) => {
document.documentElement.dataset.caseStudyInput =
event.detail === 0 ? 'keyboard' : 'pointer';
document.documentElement.dataset.caseStudyScrollY = String(window.scrollY);
}}
>
{isRtl && (
<ArrowLeft size={18} strokeWidth={2.25} aria-hidden="true" />
)}
@@ -419,7 +445,7 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
{!isRtl && (
<ArrowRight size={18} strokeWidth={2.25} aria-hidden="true" />
)}
</a>
</Link>
</div>
</div>
</article>

View File

@@ -0,0 +1,72 @@
import { useRef, type PointerEvent as ReactPointerEvent, type RefObject } from 'react';
const DISMISS_VELOCITY = 0.11;
const DISMISS_DISTANCE_RATIO = 0.25;
type DragEvent = ReactPointerEvent<HTMLDivElement>;
export function useDrawerDrag({
sheetRef,
onDismiss,
}: {
sheetRef: RefObject<HTMLDivElement | null>;
onDismiss: () => void;
}) {
const drag = useRef({ active: false, pointerId: -1, startY: 0, startedAt: 0 });
const resetSheet = () => {
const sheet = sheetRef.current;
if (!sheet) return;
sheet.style.transition = 'transform 200ms var(--motion-ease-out)';
sheet.style.transform = 'translateY(0)';
sheet.addEventListener('transitionend', () => {
sheet.style.removeProperty('transition');
sheet.style.removeProperty('transform');
}, { once: true });
};
const onPointerDown = (event: DragEvent) => {
if (drag.current.active || (event.pointerType === 'mouse' && event.button !== 0)) return;
drag.current = {
active: true,
pointerId: event.pointerId,
startY: event.clientY,
startedAt: performance.now(),
};
event.currentTarget.setPointerCapture(event.pointerId);
sheetRef.current?.style.setProperty('transition', 'none');
};
const onPointerMove = (event: DragEvent) => {
if (!drag.current.active || drag.current.pointerId !== event.pointerId) return;
const rawDistance = event.clientY - drag.current.startY;
const distance = rawDistance < 0 ? rawDistance * 0.12 : rawDistance;
if (sheetRef.current) sheetRef.current.style.transform = `translateY(${distance}px)`;
event.preventDefault();
};
const finishDrag = (event: DragEvent, cancelled = false) => {
if (!drag.current.active || drag.current.pointerId !== event.pointerId) return;
const distance = Math.max(0, event.clientY - drag.current.startY);
const elapsed = Math.max(1, performance.now() - drag.current.startedAt);
const velocity = distance / elapsed;
const sheetHeight = sheetRef.current?.getBoundingClientRect().height ?? 1;
drag.current.active = false;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
if (!cancelled && (distance >= sheetHeight * DISMISS_DISTANCE_RATIO || velocity > DISMISS_VELOCITY)) {
onDismiss();
return;
}
resetSheet();
};
return {
onPointerDown,
onPointerMove,
onPointerUp: (event: DragEvent) => finishDrag(event),
onPointerCancel: (event: DragEvent) => finishDrag(event, true),
};
}