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
+108
View File
@@ -0,0 +1,108 @@
'use client';
import Link from 'next/link';
import { motion } from 'framer-motion';
import { useLocale } from '@/lib/i18n/locale-context';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { cn } from '@/lib/utils';
const FA_DIGITS = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'] as const;
const toFa = (n: number) =>
n.toString().replace(/\d/g, (d) => FA_DIGITS[Number(d)]);
const CATEGORY_COLOR: Record<string, string> = {
LLM: 'text-magenta border-magenta/30 bg-magenta/5',
Automation: 'text-violet border-violet/30 bg-violet/5',
'Google Stack': 'text-cyan border-cyan/30 bg-cyan/5',
Infra: 'text-emerald border-emerald/30 bg-emerald/5',
Mobile: 'text-electric border-electric/30 bg-electric/5',
Strategy: 'text-electric border-electric/30 bg-electric/5',
};
export function Blog() {
const { t, locale } = useLocale();
return (
<section id="blog" className="relative px-5 py-28 sm:px-8">
<div className="mx-auto max-w-7xl">
<SectionHeader
eyebrow={t.blog.eyebrow}
title={t.blog.title}
sub={t.blog.sub}
/>
<div className="mt-14 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{t.blog.items.map((post, i) => (
<motion.article
key={post.slug}
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="glass group relative flex flex-col p-6 transition-shadow duration-300 hover:shadow-glow-electric"
>
<div className="flex items-center justify-between">
<span
className={cn(
'rounded-full border px-2.5 py-0.5 font-mono text-[0.65rem] uppercase tracking-wider',
CATEGORY_COLOR[post.category] ?? 'text-slate-300 border-white/10 bg-white/[0.03]',
)}
>
{post.category}
</span>
<span className="font-mono text-[0.7rem] text-slate-500">
{locale === 'fa' ? toFa(post.readTime) : post.readTime}{' '}
{t.blog.readTimeSuffix}
</span>
</div>
<h3
className={cn(
'mt-5 font-display text-[1.05rem] font-semibold leading-snug text-white transition-colors group-hover:text-electric',
locale === 'fa' && 'font-fa',
)}
>
<Link href={`/blog/${post.slug}`} className="after:absolute after:inset-0">
{post.title}
</Link>
</h3>
<p className="mt-3 grow text-[0.92rem] leading-relaxed text-slate-400">
{post.excerpt}
</p>
<span className="mt-5 inline-flex items-center gap-1.5 font-mono text-[0.72rem] uppercase tracking-wider text-electric">
{t.blog.readMore}
<Arrow locale={locale} />
</span>
</motion.article>
))}
</div>
</div>
</section>
);
}
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>
);
}
+171
View File
@@ -0,0 +1,171 @@
'use client';
import { useState } from 'react';
import { motion } from 'framer-motion';
import { useLocale } from '@/lib/i18n/locale-context';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { SERVICE_IDS } from '@/lib/i18n/dictionaries';
import { cn } from '@/lib/utils';
type Status = 'idle' | 'sending' | 'sent' | 'error';
export function Contact() {
const { t, locale } = useLocale();
const [status, setStatus] = useState<Status>('idle');
const [error, setError] = useState<string | null>(null);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setStatus('sending');
setError(null);
const form = e.currentTarget;
const data = Object.fromEntries(new FormData(form).entries());
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ ...data, locale }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body?.error ?? `HTTP ${res.status}`);
}
setStatus('sent');
form.reset();
} catch (err) {
setStatus('error');
setError(err instanceof Error ? err.message : 'Unknown error');
}
}
return (
<section id="contact" className="relative px-5 py-28 sm:px-8">
<div className="mx-auto max-w-5xl">
<SectionHeader
align="center"
eyebrow={t.contact.eyebrow}
title={t.contact.title}
sub={t.contact.sub}
/>
<motion.form
onSubmit={onSubmit}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
className="glass mt-14 grid grid-cols-1 gap-5 p-7 sm:grid-cols-2 sm:p-9"
noValidate
>
<Field label={t.contact.fields.name} htmlFor="name">
<input
id="name"
name="name"
type="text"
required
placeholder={t.contact.placeholders.name}
className={inputCls}
/>
</Field>
<Field label={t.contact.fields.company} htmlFor="company">
<input
id="company"
name="company"
type="text"
placeholder={t.contact.placeholders.company}
className={inputCls}
/>
</Field>
<Field label={t.contact.fields.service} htmlFor="service">
<select id="service" name="service" defaultValue="" className={inputCls} required>
<option value="" disabled>
</option>
{t.services.items.map((s, i) => (
<option key={SERVICE_IDS[i]} value={SERVICE_IDS[i]}>
{s.title}
</option>
))}
</select>
</Field>
<Field label={t.contact.fields.budget} htmlFor="budget">
<select id="budget" name="budget" defaultValue="" className={inputCls} required>
<option value="" disabled>
</option>
{t.contact.budgets.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
</Field>
<Field
label={t.contact.fields.message}
htmlFor="message"
className="sm:col-span-2"
>
<textarea
id="message"
name="message"
rows={5}
required
placeholder={t.contact.placeholders.message}
className={cn(inputCls, 'resize-y')}
/>
</Field>
<div className="sm:col-span-2 flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="font-mono text-[0.72rem] uppercase tracking-wider text-slate-500">
{t.contact.note}
</p>
<button
type="submit"
disabled={status === 'sending'}
className="btn-primary disabled:cursor-not-allowed disabled:opacity-60"
>
{status === 'sending' ? '…' : t.contact.submit}
</button>
</div>
{status === 'sent' && (
<p className="sm:col-span-2 rounded-lg border border-emerald/30 bg-emerald/5 px-4 py-3 text-sm text-emerald">
{locale === 'fa' ? 'پیام شما ارسال شد.' : 'Your message was sent.'}
</p>
)}
{status === 'error' && (
<p className="sm:col-span-2 rounded-lg border border-magenta/30 bg-magenta/5 px-4 py-3 text-sm text-magenta">
{locale === 'fa' ? 'خطا در ارسال:' : 'Send failed:'} {error}
</p>
)}
</motion.form>
</div>
</section>
);
}
const inputCls =
'w-full rounded-xl border border-white/10 bg-base-800/60 px-4 py-3 text-sm text-slate-100 placeholder:text-slate-500 outline-none transition-colors focus:border-electric/60 focus:bg-base-800';
function Field({
label,
htmlFor,
className,
children,
}: {
label: string;
htmlFor: string;
className?: string;
children: React.ReactNode;
}) {
return (
<label htmlFor={htmlFor} className={cn('flex flex-col gap-2', className)}>
<span className="label-mono">{label}</span>
{children}
</label>
);
}
+194
View File
@@ -0,0 +1,194 @@
'use client';
import { motion } from 'framer-motion';
import { useLocale } from '@/lib/i18n/locale-context';
import { SectionHeader } from '@/components/ui/SectionHeader';
/**
* Animated RAG pipeline: ingest → embed → retrieve → rerank → generate.
*
* The diagram itself is always laid out left-to-right (dir="ltr") regardless of
* page locale — a data pipeline reads forward in both languages — while the
* labels/descriptions come from the localized dictionary. The flowing dashes
* are pure SVG (animated stroke-dashoffset), so there is no per-frame JS.
*/
type Accent = 'electric' | 'violet' | 'cyan' | 'magenta' | 'emerald';
const ACCENT_HEX: Record<Accent, string> = {
electric: '#38bdf8',
violet: '#818cf8',
cyan: '#22d3ee',
magenta: '#e879f9',
emerald: '#34d399',
};
// Literal class maps so Tailwind's JIT scanner can see every variant.
const ACCENT_TEXT: Record<Accent, string> = {
electric: 'text-electric',
violet: 'text-violet',
cyan: 'text-cyan',
magenta: 'text-magenta',
emerald: 'text-emerald',
};
const ACCENT_BORDER: Record<Accent, string> = {
electric: 'border-electric/40',
violet: 'border-violet/40',
cyan: 'border-cyan/40',
magenta: 'border-magenta/40',
emerald: 'border-emerald/40',
};
const ACCENT_HOVER_SHADOW: Record<Accent, string> = {
electric: 'hover:shadow-[0_0_30px_-12px_#38bdf8]',
violet: 'hover:shadow-[0_0_30px_-12px_#818cf8]',
cyan: 'hover:shadow-[0_0_30px_-12px_#22d3ee]',
magenta: 'hover:shadow-[0_0_30px_-12px_#e879f9]',
emerald: 'hover:shadow-[0_0_30px_-12px_#34d399]',
};
function asAccent(value: string | undefined): Accent {
return value === 'violet' ||
value === 'cyan' ||
value === 'magenta' ||
value === 'emerald' ||
value === 'electric'
? value
: 'electric';
}
export function DataFlow() {
const { t } = useLocale();
const data = t.dataflow;
const nodes = data.nodes;
return (
<section id="dataflow" className="relative px-5 py-28 sm:px-8">
<div className="mx-auto max-w-7xl">
<SectionHeader eyebrow={data.eyebrow} title={data.title} sub={data.sub} />
{/* Diagram canvas — fixed LTR reading order. */}
<div dir="ltr" className="relative mt-14">
{/* SVG connectors sit behind the cards on md+ (horizontal flow). */}
<svg
aria-hidden
viewBox="0 0 1000 120"
preserveAspectRatio="none"
className="pointer-events-none absolute inset-x-0 top-1/2 hidden h-28 -translate-y-1/2 md:block"
>
<defs>
<linearGradient id="flow-line" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="#38bdf8" />
<stop offset="25%" stopColor="#818cf8" />
<stop offset="50%" stopColor="#22d3ee" />
<stop offset="75%" stopColor="#e879f9" />
<stop offset="100%" stopColor="#34d399" />
</linearGradient>
</defs>
{/* Static base rail */}
<line
x1="40"
y1="60"
x2="960"
y2="60"
stroke="url(#flow-line)"
strokeWidth="1.5"
strokeOpacity="0.28"
/>
{/* Animated travelling packets */}
<line
x1="40"
y1="60"
x2="960"
y2="60"
stroke="url(#flow-line)"
strokeWidth="2.5"
strokeLinecap="round"
strokeDasharray="6 60"
className="animate-flow-dash"
/>
</svg>
<ol className="relative grid grid-cols-1 gap-5 sm:grid-cols-2 md:grid-cols-5 md:gap-3">
{nodes.map((node, i) => {
const accent = asAccent(node.accent);
return (
<motion.li
key={node.id}
initial={{ opacity: 0, y: 22 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{
duration: 0.5,
ease: [0.22, 1, 0.36, 1],
delay: 0.08 * i,
}}
className="relative"
>
<div
className={`glass group relative flex h-full flex-col gap-3 rounded-2xl border ${ACCENT_BORDER[accent]} bg-white/[0.02] p-5 transition-shadow duration-500 ${ACCENT_HOVER_SHADOW[accent]}`}
>
{/* Step index + pulsing node dot */}
<div className="flex items-center justify-between">
<span className="font-mono text-[0.7rem] text-slate-500">
{String(i + 1).padStart(2, '0')}
</span>
<span className="relative flex h-2.5 w-2.5">
<span
className="absolute inline-flex h-full w-full animate-ping rounded-full opacity-60"
style={{ backgroundColor: ACCENT_HEX[accent] }}
/>
<span
className="relative inline-flex h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: ACCENT_HEX[accent] }}
/>
</span>
</div>
<h3
className={`font-display text-lg font-semibold ${ACCENT_TEXT[accent]}`}
>
{node.label}
</h3>
<p className="text-sm leading-relaxed text-slate-400">
{node.desc}
</p>
</div>
{/* Arrow connector for stacked (mobile / sm) layouts */}
{i < nodes.length - 1 && (
<span
aria-hidden
className="absolute left-1/2 top-full z-10 -translate-x-1/2 text-slate-600 sm:hidden"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path
d="M12 4v16m0 0l6-6m-6 6l-6-6"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
)}
</motion.li>
);
})}
</ol>
{data.caption && (
<motion.p
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.4 }}
className="mt-10 text-center font-mono text-[0.72rem] uppercase tracking-[0.18em] text-slate-500"
>
{data.caption}
</motion.p>
)}
</div>
</div>
</section>
);
}
+102
View File
@@ -0,0 +1,102 @@
'use client';
import { motion, useInView } from 'framer-motion';
import { useRef } from 'react';
import { useLocale } from '@/lib/i18n/locale-context';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { Counter } from '@/components/ui/Counter';
import { cn } from '@/lib/utils';
const FA_DIGITS = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'] as const;
const toFa = (s: string) =>
s.replace(/\d/g, (d) => FA_DIGITS[Number(d)]);
export function Expertise() {
const { t, locale } = useLocale();
const barsRef = useRef<HTMLDivElement>(null);
const inView = useInView(barsRef, { once: true, margin: '-80px' });
return (
<section id="expertise" className="relative px-5 py-28 sm:px-8">
<div className="mx-auto max-w-7xl">
<SectionHeader
eyebrow={t.expertise.eyebrow}
title={t.expertise.title}
sub={t.expertise.sub}
/>
<div className="mt-14 grid grid-cols-1 gap-10 lg:grid-cols-2">
{/* Metric tiles */}
<div className="grid grid-cols-2 gap-4 self-start">
{t.hero.metrics.map((m, i) => (
<motion.div
key={m.label}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{
duration: 0.5,
ease: [0.22, 1, 0.36, 1],
delay: 0.05 * i,
}}
className="glass relative overflow-hidden p-6"
>
<span
aria-hidden
className={cn(
'absolute inset-x-0 top-0 h-px',
'bg-gradient-to-r from-transparent via-electric/60 to-transparent',
)}
/>
<div
className={cn(
'font-display text-[clamp(1.8rem,3.5vw,2.6rem)] font-bold leading-none',
['text-electric', 'text-violet', 'text-magenta', 'text-emerald'][i % 4],
)}
>
<Counter value={m.value} locale={locale} />
</div>
<div className="mt-3 text-sm leading-snug text-slate-400">
{m.label}
</div>
</motion.div>
))}
</div>
{/* Skill bars */}
<div ref={barsRef} className="glass relative p-7 sm:p-8">
<span
aria-hidden
className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-magenta/60 to-transparent"
/>
<ul className="flex flex-col gap-6">
{t.expertise.bars.map((b, i) => (
<li key={b.label}>
<div className="mb-2 flex items-baseline justify-between text-sm">
<span className="text-slate-200">{b.label}</span>
<span className="font-mono text-xs text-slate-400">
{locale === 'fa' ? toFa(b.value.toString()) + '٪' : `${b.value}%`}
</span>
</div>
<div className="relative h-1.5 overflow-hidden rounded-full bg-white/[0.05]">
<motion.div
initial={{ width: 0 }}
animate={inView ? { width: `${b.value}%` } : { width: 0 }}
transition={{
duration: 1.2,
ease: [0.22, 1, 0.36, 1],
delay: 0.08 * i,
}}
className="absolute inset-y-0 start-0 rounded-full bg-brand-gradient"
style={{ backgroundSize: '200% 200%' }}
/>
</div>
</li>
))}
</ul>
</div>
</div>
</div>
</section>
);
}
+29
View File
@@ -0,0 +1,29 @@
'use client';
import Image from 'next/image';
import { useLocale } from '@/lib/i18n/locale-context';
export function Footer() {
const { t, locale } = useLocale();
return (
<footer className="relative border-t border-white/5 bg-base-900/40 px-5 py-12 sm:px-8">
<div className="mx-auto flex max-w-7xl flex-col gap-6 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Image src="/logo-mark.svg" alt="" width={28} height={28} />
<div className="flex flex-col leading-tight">
<span className="text-sm font-semibold text-slate-100">
{locale === 'fa' ? 'سروش اسعدی' : 'Soroush Asadi'}
</span>
<span className="font-mono text-[0.65rem] uppercase tracking-[0.2em] text-slate-500">
{t.footer.tagline}
</span>
</div>
</div>
<span className="font-mono text-[0.7rem] text-slate-500">
{t.footer.rights}
</span>
</div>
</footer>
);
}
+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>
);
}
+88
View File
@@ -0,0 +1,88 @@
import { cn } from '@/lib/utils';
export type ServiceIconKind =
| 'strategy'
| 'automation'
| 'llm-rag'
| 'architecture'
| 'mobile'
| 'google-stack';
type Props = {
kind: ServiceIconKind;
className?: string;
};
/**
* Custom line icons — one per service. Stroke uses currentColor so the
* parent's text color drives the accent.
*/
export function ServiceIcon({ kind, className }: Props) {
const base = cn('shrink-0', className);
switch (kind) {
case 'strategy':
return (
<svg viewBox="0 0 32 32" className={base} fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="16" cy="16" r="3" />
<circle cx="16" cy="16" r="9" />
<circle cx="16" cy="16" r="13.5" strokeOpacity="0.4" />
<path d="M16 3 V7" />
<path d="M16 25 V29" />
<path d="M3 16 H7" />
<path d="M25 16 H29" />
<path d="M16 16 L23.5 8.5" strokeWidth="2" />
</svg>
);
case 'automation':
return (
<svg viewBox="0 0 32 32" className={base} fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<rect x="5" y="6" width="9" height="6" rx="1.5" />
<rect x="18" y="6" width="9" height="6" rx="1.5" />
<rect x="5" y="20" width="9" height="6" rx="1.5" />
<rect x="18" y="20" width="9" height="6" rx="1.5" />
<path d="M14 9 H18" />
<path d="M9.5 12 V20" />
<path d="M22.5 12 V20" />
<path d="M14 23 H18" />
</svg>
);
case 'llm-rag':
return (
<svg viewBox="0 0 32 32" className={base} fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M16 4 C9 4 5 9 5 14 c0 3 1.4 5.4 3.5 7 V25 l3-2 a13 13 0 0 0 4.5 1 c7 0 11-5 11-10 S23 4 16 4 Z" />
<circle cx="11.5" cy="14" r="1.2" fill="currentColor" />
<circle cx="16" cy="14" r="1.2" fill="currentColor" />
<circle cx="20.5" cy="14" r="1.2" fill="currentColor" />
</svg>
);
case 'architecture':
return (
<svg viewBox="0 0 32 32" className={base} fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M16 4 L27 9.5 L16 15 L5 9.5 Z" />
<path d="M5 16 L16 21.5 L27 16" />
<path d="M5 22.5 L16 28 L27 22.5" strokeOpacity="0.6" />
</svg>
);
case 'mobile':
return (
<svg viewBox="0 0 32 32" className={base} fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<rect x="9" y="3" width="14" height="26" rx="3" />
<path d="M14 7 H18" />
<circle cx="16" cy="24.5" r="1" fill="currentColor" />
<path d="M12 13 L20 13" />
<path d="M12 17 L17 17" />
<path d="M12 21 L19 21" strokeOpacity="0.6" />
</svg>
);
case 'google-stack':
return (
<svg viewBox="0 0 32 32" className={base} fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M16 4 L28 11 V21 L16 28 L4 21 V11 Z" />
<path d="M16 4 V28" strokeOpacity="0.5" />
<path d="M4 11 L28 11" strokeOpacity="0.5" />
<path d="M4 21 L28 21" strokeOpacity="0.5" />
<circle cx="16" cy="16" r="2.5" fill="currentColor" />
</svg>
);
}
}
+221
View File
@@ -0,0 +1,221 @@
'use client';
import { useRef } from 'react';
import { motion, useMotionTemplate, useMotionValue } from 'framer-motion';
import { useLocale } from '@/lib/i18n/locale-context';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { cn } from '@/lib/utils';
import { ServiceIcon, type ServiceIconKind } from './ServiceIcon';
const FA_DIGITS = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'] as const;
function num(n: number, locale: 'fa' | 'en') {
const str = n.toString().padStart(2, '0');
return locale === 'fa'
? str.replace(/\d/g, (d) => FA_DIGITS[Number(d)])
: str;
}
const COLOR_MAP: Record<
string,
{ text: string; ring: string; glow: string; chip: string }
> = {
electric: {
text: 'text-electric',
ring: 'group-hover:border-electric/50',
glow: 'group-hover:shadow-glow-electric',
chip: 'border-electric/30 bg-electric/5 text-electric/90',
},
violet: {
text: 'text-violet',
ring: 'group-hover:border-violet/50',
glow: 'group-hover:shadow-glow-violet',
chip: 'border-violet/30 bg-violet/5 text-violet/90',
},
magenta: {
text: 'text-magenta',
ring: 'group-hover:border-magenta/50',
glow: 'group-hover:shadow-glow-magenta',
chip: 'border-magenta/30 bg-magenta/5 text-magenta/90',
},
emerald: {
text: 'text-emerald',
ring: 'group-hover:border-emerald/50',
glow: 'group-hover:shadow-glow-emerald',
chip: 'border-emerald/30 bg-emerald/5 text-emerald/90',
},
cyan: {
text: 'text-cyan',
ring: 'group-hover:border-cyan/50',
glow: 'group-hover:shadow-glow-electric',
chip: 'border-cyan/30 bg-cyan/5 text-cyan/90',
},
};
export function Services() {
const { t, locale } = useLocale();
return (
<section id="services" className="relative px-5 py-28 sm:px-8">
<div className="mx-auto max-w-7xl">
<SectionHeader
eyebrow={t.services.eyebrow}
title={t.services.title}
sub={t.services.sub}
/>
<div className="mt-14 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{t.services.items.map((item, i) => (
<ServiceCard
key={item.id}
index={i}
numLabel={num(i + 1, locale)}
title={item.title}
description={item.description}
tags={item.tags}
color={item.color}
iconKind={item.id as ServiceIconKind}
href={`/services/${item.id}`}
locale={locale}
/>
))}
</div>
</div>
</section>
);
}
function ServiceCard({
index,
numLabel,
title,
description,
tags,
color,
iconKind,
href,
locale,
}: {
index: number;
numLabel: string;
title: string;
description: string;
tags: readonly string[];
color: string;
iconKind: ServiceIconKind;
href: string;
locale: 'fa' | 'en';
}) {
const ref = useRef<HTMLDivElement>(null);
const mx = useMotionValue(50);
const my = useMotionValue(50);
const rotateX = useMotionValue(0);
const rotateY = useMotionValue(0);
// Subtle 3D tilt on pointer move — keeps the card "alive" without
// forcing GPU work when the cursor isn't over it.
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width;
const y = (e.clientY - r.top) / r.height;
mx.set(x * 100);
my.set(y * 100);
rotateY.set((x - 0.5) * 8);
rotateX.set((0.5 - y) * 8);
};
const onPointerLeave = () => {
rotateX.set(0);
rotateY.set(0);
};
const spotlight = useMotionTemplate`radial-gradient(220px circle at ${mx}% ${my}%, rgba(255,255,255,0.08), transparent 60%)`;
const c = COLOR_MAP[color] ?? COLOR_MAP.electric;
return (
<motion.article
ref={ref}
onPointerMove={onPointerMove}
onPointerLeave={onPointerLeave}
style={{
rotateX,
rotateY,
transformStyle: 'preserve-3d',
transformPerspective: 1000,
}}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{
duration: 0.6,
ease: [0.22, 1, 0.36, 1],
delay: 0.05 * index,
}}
className={cn(
'group relative isolate overflow-hidden p-6 sm:p-7',
'glass transition-all duration-300',
c.ring,
c.glow,
)}
>
{/* Spotlight */}
<motion.div
aria-hidden
style={{ background: spotlight }}
className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
/>
{/* Number + icon row */}
<div className="relative flex items-start justify-between">
<span
className={cn(
'font-mono text-[0.78rem] tracking-[0.18em] text-slate-500',
locale === 'fa' && 'fa-nums',
)}
>
{numLabel}
</span>
<span className={cn('transition-colors duration-300', c.text)}>
<ServiceIcon kind={iconKind} className="h-7 w-7" />
</span>
</div>
{/* Title */}
<h3
className={cn(
'relative mt-6 font-display text-[clamp(1.15rem,1.8vw,1.4rem)] font-semibold leading-snug text-white',
locale === 'fa' && 'font-fa',
)}
>
{title}
</h3>
{/* Description */}
<p className="relative mt-3 text-[0.94rem] leading-relaxed text-slate-400">
{description}
</p>
{/* Tags */}
<div className="relative mt-5 flex flex-wrap gap-1.5">
{tags.map((tag) => (
<span
key={tag}
className={cn(
'rounded-full border px-2.5 py-0.5 font-mono text-[0.65rem] uppercase tracking-wider',
c.chip,
)}
>
{tag}
</span>
))}
</div>
{/* Hairline */}
<span
aria-hidden
className="absolute inset-x-6 bottom-0 h-px bg-gradient-to-r from-transparent via-white/10 to-transparent"
/>
</motion.article>
);
}
+124
View File
@@ -0,0 +1,124 @@
'use client';
import dynamic from 'next/dynamic';
import { motion } from 'framer-motion';
import { useLocale } from '@/lib/i18n/locale-context';
import { SectionHeader } from '@/components/ui/SectionHeader';
import type { StackNode } from './StackCanvas';
// Category accent palette (index-aligned). Hex feeds the WebGL sprites; the
// literal Tailwind maps below keep the JIT scanner happy for the legend.
const ACCENTS = ['electric', 'violet', 'magenta', 'cyan'] as const;
type Accent = (typeof ACCENTS)[number];
const ACCENT_HEX: Record<Accent, string> = {
electric: '#38bdf8',
violet: '#818cf8',
magenta: '#e879f9',
cyan: '#22d3ee',
};
const ACCENT_TEXT: Record<Accent, string> = {
electric: 'text-electric',
violet: 'text-violet',
magenta: 'text-magenta',
cyan: 'text-cyan',
};
const ACCENT_BORDER: Record<Accent, string> = {
electric: 'border-electric/30',
violet: 'border-violet/30',
magenta: 'border-magenta/30',
cyan: 'border-cyan/30',
};
// The globe is client-only WebGL: never SSR it. While the chunk loads we show
// a calm placeholder so layout doesn't jump.
const StackCanvas = dynamic(
() => import('./StackCanvas').then((m) => m.StackCanvas),
{
ssr: false,
loading: () => (
<div className="flex h-[400px] w-full items-center justify-center sm:h-[460px] lg:h-[520px]">
<span className="h-24 w-24 animate-pulse rounded-full bg-gradient-to-br from-electric/20 to-violet/20 blur-xl" />
</div>
),
},
);
export function Stack() {
const { t, locale } = useLocale();
// Flatten every tool into a colored node for the constellation.
const nodes: StackNode[] = t.stack.categories.flatMap((cat, i) => {
const hex = ACCENT_HEX[ACCENTS[i % ACCENTS.length]];
return cat.items.map((label) => ({ label, color: hex }));
});
return (
<section id="stack" className="relative overflow-hidden px-5 py-28 sm:px-8">
<div className="mx-auto max-w-7xl">
<SectionHeader
eyebrow={t.stack.eyebrow}
title={t.stack.title}
sub={t.stack.sub}
/>
<div className="mt-10 grid grid-cols-1 items-center gap-8 lg:grid-cols-2">
{/* 3D constellation */}
<motion.div
initial={{ opacity: 0, scale: 0.94 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true, margin: '-60px' }}
transition={{ duration: 0.7, ease: [0.22, 1, 0.36, 1] }}
className="relative order-1 lg:order-none"
>
<StackCanvas nodes={nodes} />
<p className="pointer-events-none mt-2 text-center font-mono text-[0.66rem] uppercase tracking-[0.18em] text-slate-600">
{locale === 'fa' ? 'بکشید برای چرخش · نشانگر برای نام' : 'Drag to spin · hover for name'}
</p>
</motion.div>
{/* Category legend */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{t.stack.categories.map((cat, i) => {
const accent = ACCENTS[i % ACCENTS.length];
return (
<motion.div
key={cat.id}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{
duration: 0.5,
ease: [0.22, 1, 0.36, 1],
delay: 0.06 * i,
}}
className={`glass relative border ${ACCENT_BORDER[accent]} p-5`}
>
<div className="flex items-center gap-2">
<span
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: ACCENT_HEX[accent] }}
/>
<span className={`label-mono ${ACCENT_TEXT[accent]}`}>
{cat.label}
</span>
</div>
<ul className="mt-4 flex flex-wrap gap-2">
{cat.items.map((item) => (
<li
key={item}
className="rounded-full border border-white/10 px-2.5 py-1 font-mono text-[0.7rem] tracking-wide text-slate-300"
>
{item}
</li>
))}
</ul>
</motion.div>
);
})}
</div>
</div>
</div>
</section>
);
}
+259
View File
@@ -0,0 +1,259 @@
'use client';
import { useEffect, useRef } from 'react';
import * as THREE from 'three';
export type StackNode = { label: string; color: string };
/**
* An interactive 3D constellation of the tech stack. Every tool is a glowing
* dot positioned on a Fibonacci sphere and tinted by its category color. The
* globe auto-rotates, can be dragged to spin, and reveals a tooltip with the
* tool name when a dot is hovered (raycast). Everything is torn down on unmount
* — RAF, GL context, geometries, materials, textures, and listeners.
*/
export function StackCanvas({ nodes }: { nodes: StackNode[] }) {
const mountRef = useRef<HTMLDivElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const mount = mountRef.current;
const tooltip = tooltipRef.current;
if (!mount || !tooltip || nodes.length === 0) return;
const prefersReduced = window.matchMedia(
'(prefers-reduced-motion: reduce)',
).matches;
// --- Sizing -------------------------------------------------------------
let width = mount.clientWidth || 600;
let height = mount.clientHeight || 460;
// --- Renderer -----------------------------------------------------------
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(width, height);
renderer.setClearColor(0x000000, 0);
mount.appendChild(renderer.domElement);
renderer.domElement.style.touchAction = 'pan-y';
renderer.domElement.style.cursor = 'grab';
// --- Scene / camera -----------------------------------------------------
const scene = new THREE.Scene();
const R = 2.6;
const dist = 6.6;
const camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 100);
camera.position.set(0, 0, dist);
const group = new THREE.Group();
scene.add(group);
// --- Wireframe backdrop globe ------------------------------------------
const wireGeo = new THREE.IcosahedronGeometry(R, 2);
const wire = new THREE.LineSegments(
new THREE.WireframeGeometry(wireGeo),
new THREE.LineBasicMaterial({
color: 0x38bdf8,
transparent: true,
opacity: 0.08,
}),
);
wireGeo.dispose();
group.add(wire);
// --- Glow sprite texture (shared) --------------------------------------
const glowCanvas = document.createElement('canvas');
glowCanvas.width = glowCanvas.height = 64;
const gctx = glowCanvas.getContext('2d')!;
const grad = gctx.createRadialGradient(32, 32, 0, 32, 32, 32);
grad.addColorStop(0, 'rgba(255,255,255,1)');
grad.addColorStop(0.25, 'rgba(255,255,255,0.85)');
grad.addColorStop(1, 'rgba(255,255,255,0)');
gctx.fillStyle = grad;
gctx.fillRect(0, 0, 64, 64);
const glowTex = new THREE.CanvasTexture(glowCanvas);
// --- Nodes as sprites on a Fibonacci sphere ----------------------------
const golden = Math.PI * (3 - Math.sqrt(5));
const sprites: THREE.Sprite[] = [];
const materials: THREE.SpriteMaterial[] = [];
const n = nodes.length;
nodes.forEach((node, i) => {
const y = 1 - (i / Math.max(1, n - 1)) * 2;
const r = Math.sqrt(Math.max(0, 1 - y * y));
const theta = i * golden;
const pos = new THREE.Vector3(
Math.cos(theta) * r,
y,
Math.sin(theta) * r,
).multiplyScalar(R);
const mat = new THREE.SpriteMaterial({
map: glowTex,
color: new THREE.Color(node.color),
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
const sprite = new THREE.Sprite(mat);
sprite.position.copy(pos);
sprite.scale.setScalar(0.5);
sprite.userData = { label: node.label, color: node.color, base: 0.5 };
group.add(sprite);
sprites.push(sprite);
materials.push(mat);
});
// --- Interaction state --------------------------------------------------
let dragging = false;
let lastX = 0;
let lastY = 0;
let velX = 0;
let velY = 0;
const auto = prefersReduced ? 0 : 0.0018;
let hovered: THREE.Sprite | null = null;
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
let pointerInside = false;
const onPointerDown = (e: PointerEvent) => {
dragging = true;
lastX = e.clientX;
lastY = e.clientY;
renderer.domElement.setPointerCapture(e.pointerId);
renderer.domElement.style.cursor = 'grabbing';
};
const onPointerMove = (e: PointerEvent) => {
const rect = renderer.domElement.getBoundingClientRect();
pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
pointerInside = true;
if (dragging) {
const dx = e.clientX - lastX;
const dy = e.clientY - lastY;
lastX = e.clientX;
lastY = e.clientY;
velY = dx * 0.005;
velX = dy * 0.005;
}
};
const onPointerUp = (e: PointerEvent) => {
dragging = false;
try {
renderer.domElement.releasePointerCapture(e.pointerId);
} catch {
/* noop */
}
renderer.domElement.style.cursor = 'grab';
};
const onPointerLeave = () => {
pointerInside = false;
};
renderer.domElement.addEventListener('pointerdown', onPointerDown);
renderer.domElement.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
renderer.domElement.addEventListener('pointerleave', onPointerLeave);
// --- Resize -------------------------------------------------------------
const ro = new ResizeObserver(() => {
width = mount.clientWidth || width;
height = mount.clientHeight || height;
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height);
});
ro.observe(mount);
// --- Render loop --------------------------------------------------------
let raf = 0;
const tmp = new THREE.Vector3();
const tick = () => {
raf = requestAnimationFrame(tick);
// Rotation: apply velocity + gentle auto-spin, with decay when idle.
if (!dragging) {
velY *= 0.94;
velX *= 0.94;
}
group.rotation.y += velY + auto;
group.rotation.x += velX;
group.rotation.x = Math.max(-0.6, Math.min(0.6, group.rotation.x));
group.updateMatrixWorld();
// Hover raycast (only when not dragging and pointer is inside).
if (pointerInside && !dragging) {
raycaster.setFromCamera(pointer, camera);
const hits = raycaster.intersectObjects(sprites, false);
const next = (hits[0]?.object as THREE.Sprite) ?? null;
if (next !== hovered) {
hovered = next;
}
} else if (!pointerInside) {
hovered = null;
}
// Scale + tooltip for the hovered sprite.
for (const s of sprites) {
const target = s === hovered ? 0.85 : 0.5;
const cur = s.scale.x;
s.scale.setScalar(cur + (target - cur) * 0.2);
}
if (hovered) {
hovered.getWorldPosition(tmp);
tmp.project(camera);
const sx = (tmp.x * 0.5 + 0.5) * width;
const sy = (-tmp.y * 0.5 + 0.5) * height;
const data = hovered.userData as { label: string; color: string };
tooltip.textContent = data.label;
tooltip.style.transform = `translate(${sx}px, ${sy}px) translate(-50%, -160%)`;
tooltip.style.borderColor = data.color;
tooltip.style.color = data.color;
tooltip.style.opacity = '1';
renderer.domElement.style.cursor = 'pointer';
} else {
tooltip.style.opacity = '0';
if (!dragging) renderer.domElement.style.cursor = 'grab';
}
renderer.render(scene, camera);
};
tick();
// --- Teardown -----------------------------------------------------------
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
renderer.domElement.removeEventListener('pointerdown', onPointerDown);
renderer.domElement.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
renderer.domElement.removeEventListener('pointerleave', onPointerLeave);
materials.forEach((m) => m.dispose());
glowTex.dispose();
wire.geometry.dispose();
(wire.material as THREE.Material).dispose();
renderer.dispose();
if (renderer.domElement.parentNode === mount) {
mount.removeChild(renderer.domElement);
}
};
}, [nodes]);
return (
<div
ref={mountRef}
className="relative h-[400px] w-full select-none sm:h-[460px] lg:h-[520px]"
>
<div
ref={tooltipRef}
className="pointer-events-none absolute left-0 top-0 z-10 whitespace-nowrap rounded-full border bg-base-900/80 px-3 py-1 font-mono text-[0.72rem] tracking-wide backdrop-blur transition-opacity duration-150"
style={{ opacity: 0 }}
/>
</div>
);
}