408 lines
14 KiB
TypeScript
408 lines
14 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { AnimatePresence, motion } from 'framer-motion';
|
|
import { useLocale } from '@/lib/i18n/locale-context';
|
|
import { SectionHeader } from '@/components/ui/SectionHeader';
|
|
import type { Dict } from '@/lib/i18n/dictionaries';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
type Item = Dict['portfolio']['items'][number];
|
|
type Accent = 'electric' | 'violet' | 'magenta' | 'emerald' | 'cyan';
|
|
|
|
const ACCENT_TEXT: Record<Accent, string> = {
|
|
electric: 'text-electric',
|
|
violet: 'text-violet',
|
|
magenta: 'text-magenta',
|
|
emerald: 'text-emerald',
|
|
cyan: 'text-cyan',
|
|
};
|
|
const ACCENT_BORDER: Record<Accent, string> = {
|
|
electric: 'border-electric/30 bg-electric/5 text-electric',
|
|
violet: 'border-violet/30 bg-violet/5 text-violet',
|
|
magenta: 'border-magenta/30 bg-magenta/5 text-magenta',
|
|
emerald: 'border-emerald/30 bg-emerald/5 text-emerald',
|
|
cyan: 'border-cyan/30 bg-cyan/5 text-cyan',
|
|
};
|
|
const ACCENT_RING: Record<Accent, string> = {
|
|
electric: 'hover:ring-electric/40',
|
|
violet: 'hover:ring-violet/40',
|
|
magenta: 'hover:ring-magenta/40',
|
|
emerald: 'hover:ring-emerald/40',
|
|
cyan: 'hover:ring-cyan/40',
|
|
};
|
|
// Full literal classes so Tailwind's JIT scanner picks them up — runtime
|
|
// string concatenation (`group-hover:${...}`) would never be detected.
|
|
const ACCENT_GROUP_HOVER: Record<Accent, string> = {
|
|
electric: 'group-hover:text-electric',
|
|
violet: 'group-hover:text-violet',
|
|
magenta: 'group-hover:text-magenta',
|
|
emerald: 'group-hover:text-emerald',
|
|
cyan: 'group-hover:text-cyan',
|
|
};
|
|
|
|
export function Portfolio() {
|
|
const { t, locale } = useLocale();
|
|
const items = t.portfolio.items as readonly Item[];
|
|
const [openId, setOpenId] = useState<string | null>(null);
|
|
|
|
const active = useMemo(
|
|
() => items.find((p) => p.id === openId) ?? null,
|
|
[items, openId],
|
|
);
|
|
|
|
return (
|
|
<section id="portfolio" className="relative px-5 py-28 sm:px-8">
|
|
<div className="mx-auto max-w-7xl">
|
|
<SectionHeader
|
|
eyebrow={t.portfolio.eyebrow}
|
|
title={t.portfolio.title}
|
|
sub={t.portfolio.sub}
|
|
/>
|
|
|
|
<div className="mt-14 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
|
{items.map((item, i) => {
|
|
const accent = item.accent as Accent;
|
|
return (
|
|
<motion.button
|
|
key={item.id}
|
|
type="button"
|
|
onClick={() => setOpenId(item.id)}
|
|
initial={{ opacity: 0, y: 24 }}
|
|
whileInView={{ opacity: 1, y: 0 }}
|
|
viewport={{ once: true, margin: '-60px' }}
|
|
transition={{
|
|
duration: 0.55,
|
|
ease: [0.22, 1, 0.36, 1],
|
|
delay: 0.04 * i,
|
|
}}
|
|
className={cn(
|
|
'group relative flex flex-col overflow-hidden rounded-2xl border border-white/8 bg-white/[0.02] text-start ring-1 ring-transparent transition-all duration-300 hover:-translate-y-1',
|
|
ACCENT_RING[accent],
|
|
)}
|
|
>
|
|
{/* Cover */}
|
|
<div className="relative aspect-[16/10] overflow-hidden">
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={item.cover}
|
|
alt={item.title}
|
|
loading="lazy"
|
|
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
|
/>
|
|
<div className="absolute inset-0 bg-gradient-to-t from-base-900/90 via-base-900/10 to-transparent" />
|
|
<div className="absolute inset-x-0 bottom-0 flex items-end justify-between gap-3 p-4">
|
|
<span
|
|
className={cn(
|
|
'rounded-full border px-2.5 py-0.5 font-mono text-[0.62rem] uppercase tracking-wider backdrop-blur-sm',
|
|
ACCENT_BORDER[accent],
|
|
)}
|
|
>
|
|
{item.role}
|
|
</span>
|
|
<span className="font-mono text-[0.7rem] text-slate-300">
|
|
{item.year}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="flex grow flex-col p-5">
|
|
<h3
|
|
className={cn(
|
|
'font-display text-[1.05rem] font-semibold leading-snug text-white transition-colors',
|
|
ACCENT_GROUP_HOVER[accent],
|
|
locale === 'fa' && 'font-fa',
|
|
)}
|
|
>
|
|
{item.title}
|
|
</h3>
|
|
<p className="mt-2 line-clamp-2 grow text-[0.9rem] leading-relaxed text-slate-400">
|
|
{item.summary}
|
|
</p>
|
|
<div className="mt-4 flex flex-wrap gap-1.5">
|
|
{item.tags.slice(0, 4).map((tag) => (
|
|
<span
|
|
key={tag}
|
|
className="rounded-md border border-white/8 bg-white/[0.03] px-2 py-0.5 font-mono text-[0.62rem] text-slate-400"
|
|
>
|
|
{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<span
|
|
className={cn(
|
|
'mt-5 inline-flex items-center gap-1.5 font-mono text-[0.7rem] uppercase tracking-wider',
|
|
ACCENT_TEXT[accent],
|
|
)}
|
|
>
|
|
{t.portfolio.labels.view}
|
|
<Arrow locale={locale} />
|
|
</span>
|
|
</div>
|
|
</motion.button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<AnimatePresence>
|
|
{active && (
|
|
<Lightbox
|
|
key={active.id}
|
|
item={active}
|
|
labels={t.portfolio.labels}
|
|
locale={locale}
|
|
onClose={() => setOpenId(null)}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function Lightbox({
|
|
item,
|
|
labels,
|
|
locale,
|
|
onClose,
|
|
}: {
|
|
item: Item;
|
|
labels: Dict['portfolio']['labels'];
|
|
locale: 'fa' | 'en';
|
|
onClose: () => void;
|
|
}) {
|
|
const accent = item.accent as Accent;
|
|
const images = useMemo(() => [item.cover, ...item.gallery], [item]);
|
|
const [idx, setIdx] = useState(0);
|
|
|
|
const go = useCallback(
|
|
(dir: number) => setIdx((i) => (i + dir + images.length) % images.length),
|
|
[images.length],
|
|
);
|
|
|
|
// Keyboard navigation + scroll lock while the lightbox is open.
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') onClose();
|
|
else if (e.key === 'ArrowRight') go(locale === 'fa' ? -1 : 1);
|
|
else if (e.key === 'ArrowLeft') go(locale === 'fa' ? 1 : -1);
|
|
};
|
|
document.addEventListener('keydown', onKey);
|
|
const prevOverflow = document.body.style.overflow;
|
|
document.body.style.overflow = 'hidden';
|
|
return () => {
|
|
document.removeEventListener('keydown', onKey);
|
|
document.body.style.overflow = prevOverflow;
|
|
};
|
|
}, [go, locale, onClose]);
|
|
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.25 }}
|
|
onClick={onClose}
|
|
className="fixed inset-0 z-[60] flex items-center justify-center bg-base-900/85 p-4 backdrop-blur-md sm:p-8"
|
|
dir={locale === 'fa' ? 'rtl' : 'ltr'}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={item.title}
|
|
>
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.96, y: 16 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.97, y: 10 }}
|
|
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="grid max-h-full w-full max-w-5xl grid-rows-[auto] overflow-hidden rounded-3xl border border-white/10 bg-base-900/95 shadow-2xl md:grid-cols-[1.4fr_1fr]"
|
|
>
|
|
{/* Gallery viewer */}
|
|
<div className="relative flex flex-col bg-black/30">
|
|
<div className="relative aspect-[16/10] w-full overflow-hidden">
|
|
<AnimatePresence mode="wait">
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<motion.img
|
|
key={images[idx]}
|
|
src={images[idx]}
|
|
alt={`${item.title} — ${idx + 1}`}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="h-full w-full object-cover"
|
|
/>
|
|
</AnimatePresence>
|
|
|
|
{images.length > 1 && (
|
|
<>
|
|
<NavButton side="prev" locale={locale} onClick={() => go(locale === 'fa' ? 1 : -1)} label={labels.prev} />
|
|
<NavButton side="next" locale={locale} onClick={() => go(locale === 'fa' ? -1 : 1)} label={labels.next} />
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Thumbnails */}
|
|
<div className="flex gap-2 overflow-x-auto p-3">
|
|
{images.map((src, i) => (
|
|
<button
|
|
key={src}
|
|
type="button"
|
|
onClick={() => setIdx(i)}
|
|
aria-label={`${labels.gallery} ${i + 1}`}
|
|
className={cn(
|
|
'relative h-12 w-20 shrink-0 overflow-hidden rounded-lg border transition-all',
|
|
i === idx
|
|
? cn('border-2', ACCENT_BORDER[accent])
|
|
: 'border-white/10 opacity-60 hover:opacity-100',
|
|
)}
|
|
>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img src={src} alt="" className="h-full w-full object-cover" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Meta panel */}
|
|
<div className="flex flex-col gap-5 overflow-y-auto p-6 sm:p-7">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<span
|
|
className={cn(
|
|
'rounded-full border px-2.5 py-0.5 font-mono text-[0.62rem] uppercase tracking-wider',
|
|
ACCENT_BORDER[accent],
|
|
)}
|
|
>
|
|
{item.client}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
aria-label={labels.close}
|
|
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/10 bg-white/[0.03] text-slate-300 transition-colors hover:bg-white/[0.07] hover:text-white"
|
|
>
|
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
<path d="M6 6 L18 18" />
|
|
<path d="M18 6 L6 18" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<h3
|
|
className={cn(
|
|
'font-display text-[1.45rem] font-bold leading-tight text-white',
|
|
locale === 'fa' && 'font-fa',
|
|
)}
|
|
>
|
|
{item.title}
|
|
</h3>
|
|
|
|
<p className="text-[0.95rem] leading-relaxed text-slate-300">
|
|
{item.summary}
|
|
</p>
|
|
|
|
{/* Metrics */}
|
|
<div className="grid grid-cols-3 gap-3">
|
|
{item.metrics.map((mt) => (
|
|
<div
|
|
key={mt.label}
|
|
className="rounded-xl border border-white/8 bg-white/[0.02] p-3 text-center"
|
|
>
|
|
<div className={cn('font-display text-lg font-bold', ACCENT_TEXT[accent])}>
|
|
{mt.value}
|
|
</div>
|
|
<div className="mt-0.5 text-[0.65rem] leading-tight text-slate-500">
|
|
{mt.label}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<dl className="grid grid-cols-2 gap-x-4 gap-y-3 border-t border-white/5 pt-5 text-sm">
|
|
<Field label={labels.role} value={item.role} />
|
|
<Field label={labels.year} value={item.year} />
|
|
<Field label={labels.client} value={item.client} />
|
|
</dl>
|
|
|
|
<div>
|
|
<span className="label-mono text-slate-500">{labels.stack}</span>
|
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
|
{item.tags.map((tag) => (
|
|
<span
|
|
key={tag}
|
|
className="rounded-md border border-white/8 bg-white/[0.03] px-2 py-0.5 font-mono text-[0.66rem] text-slate-300"
|
|
>
|
|
{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
function Field({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div>
|
|
<dt className="font-mono text-[0.6rem] uppercase tracking-wider text-slate-500">
|
|
{label}
|
|
</dt>
|
|
<dd className="mt-1 text-slate-200">{value}</dd>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function NavButton({
|
|
side,
|
|
locale,
|
|
onClick,
|
|
label,
|
|
}: {
|
|
side: 'prev' | 'next';
|
|
locale: 'fa' | 'en';
|
|
onClick: () => void;
|
|
label: string;
|
|
}) {
|
|
// Visually pin to the left/right edge regardless of text direction.
|
|
const edge = side === 'prev' ? 'left-3' : 'right-3';
|
|
const pointLeft = side === 'prev';
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
aria-label={label}
|
|
className={cn(
|
|
'absolute top-1/2 -translate-y-1/2 inline-flex h-9 w-9 items-center justify-center rounded-full border border-white/15 bg-base-900/70 text-slate-200 backdrop-blur transition-colors hover:bg-base-900/90 hover:text-white',
|
|
edge,
|
|
)}
|
|
>
|
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" className={pointLeft ? '' : 'rotate-180'}>
|
|
<path d="M15 6 L9 12 L15 18" />
|
|
</svg>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function Arrow({ locale }: { locale: 'fa' | 'en' }) {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
width="12"
|
|
height="12"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2.4"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={locale === 'fa' ? 'rotate-180' : ''}
|
|
aria-hidden
|
|
>
|
|
<path d="M5 12 H19" />
|
|
<path d="M13 6 L19 12 L13 18" />
|
|
</svg>
|
|
);
|
|
}
|