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