feat: add route-backed project case studies
This commit is contained in:
128
src/components/CaseStudyContent.tsx
Normal file
128
src/components/CaseStudyContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
552
src/components/CaseStudyDrawer.module.css
Normal file
552
src/components/CaseStudyDrawer.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
158
src/components/CaseStudyDrawer.tsx
Normal file
158
src/components/CaseStudyDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
src/components/PortfolioRouteShell.tsx
Normal file
72
src/components/PortfolioRouteShell.tsx
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
72
src/components/useDrawerDrag.ts
Normal file
72
src/components/useDrawerDrag.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user