first commit
ci / build (push) Failing after 23s
deploy / deploy (push) Failing after 10m12s

This commit is contained in:
soroush.asadi
2026-05-31 12:47:02 +03:30
commit add78d8460
100 changed files with 15221 additions and 0 deletions
+407
View File
@@ -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>
);
}