first commit
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user