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
+156
View File
@@ -0,0 +1,156 @@
'use client';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { EDITABLE_SECTIONS } from '@/lib/content/sections';
import { cn } from '@/lib/utils';
export function AdminShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const router = useRouter();
async function logout() {
await fetch('/api/admin/logout', { method: 'POST' });
router.replace('/admin/login');
router.refresh();
}
return (
<div dir="ltr" className="flex min-h-screen bg-base-900 text-slate-200">
{/* Sidebar */}
<aside className="hidden w-64 shrink-0 flex-col border-e border-white/8 bg-base-900/60 p-4 md:flex">
<Link href="/admin" className="mb-6 flex items-center gap-2 px-2">
<span className="grid h-8 w-8 place-items-center rounded-lg bg-electric/15 font-mono text-sm font-bold text-electric">
SA
</span>
<span className="text-sm font-semibold text-white">Content CMS</span>
</Link>
<nav className="flex flex-col gap-0.5">
<SideLink href="/admin" active={pathname === '/admin'}>
Dashboard
</SideLink>
<div className="mt-3 px-3 pb-1 font-mono text-[0.6rem] uppercase tracking-wider text-slate-600">
Sections
</div>
{EDITABLE_SECTIONS.map((s) => {
const href = `/admin/sections/${s.key}`;
return (
<SideLink key={s.key} href={href} active={pathname === href}>
{s.label.en}
</SideLink>
);
})}
<div className="mt-3 px-3 pb-1 font-mono text-[0.6rem] uppercase tracking-wider text-slate-600">
Content
</div>
<SideLink href="/admin/posts" active={pathname.startsWith('/admin/posts')}>
Journal articles
</SideLink>
</nav>
<div className="mt-auto flex flex-col gap-1 pt-4">
<a
href="/"
target="_blank"
rel="noreferrer"
className="rounded-lg px-3 py-2 text-sm text-slate-400 transition-colors hover:bg-white/[0.04] hover:text-white"
>
View site
</a>
<button
type="button"
onClick={logout}
className="rounded-lg px-3 py-2 text-start text-sm text-slate-400 transition-colors hover:bg-white/[0.04] hover:text-white"
>
Log out
</button>
</div>
</aside>
{/* Mobile top bar */}
<div className="flex min-w-0 grow flex-col">
<header className="flex items-center justify-between border-b border-white/8 px-5 py-3 md:hidden">
<Link href="/admin" className="text-sm font-semibold text-white">
Content CMS
</Link>
<button
type="button"
onClick={logout}
className="text-sm text-slate-400 hover:text-white"
>
Log out
</button>
</header>
{/* Mobile section selector */}
<nav className="flex gap-2 overflow-x-auto border-b border-white/8 px-5 py-2 md:hidden">
<MobileChip href="/admin" active={pathname === '/admin'} label="Dashboard" />
{EDITABLE_SECTIONS.map((s) => (
<MobileChip
key={s.key}
href={`/admin/sections/${s.key}`}
active={pathname === `/admin/sections/${s.key}`}
label={s.label.en}
/>
))}
<MobileChip
href="/admin/posts"
active={pathname.startsWith('/admin/posts')}
label="Articles"
/>
</nav>
<main className="grow px-6 py-6 sm:px-8">{children}</main>
</div>
</div>
);
}
function SideLink({
href,
active,
children,
}: {
href: string;
active: boolean;
children: React.ReactNode;
}) {
return (
<Link
href={href}
className={cn(
'rounded-lg px-3 py-2 text-sm transition-colors',
active
? 'bg-electric/12 font-medium text-electric'
: 'text-slate-400 hover:bg-white/[0.04] hover:text-white',
)}
>
{children}
</Link>
);
}
function MobileChip({
href,
active,
label,
}: {
href: string;
active: boolean;
label: string;
}) {
return (
<Link
href={href}
className={cn(
'shrink-0 rounded-full border px-3 py-1 text-xs transition-colors',
active
? 'border-electric/40 bg-electric/10 text-electric'
: 'border-white/10 text-slate-400',
)}
>
{label}
</Link>
);
}
+308
View File
@@ -0,0 +1,308 @@
'use client';
import { useRef, useState } from 'react';
export type JsonValue =
| string
| number
| boolean
| null
| JsonValue[]
| { [k: string]: JsonValue };
const IMAGE_KEYS = new Set(['cover', 'image', 'avatar', 'gallery', 'logo', 'icon']);
const IMAGE_RE = /\.(png|jpe?g|svg|webp|gif|avif)$/i;
function looksLikeImage(key: string | undefined, value: string): boolean {
if (key && IMAGE_KEYS.has(key)) return true;
return IMAGE_RE.test(value) || value.startsWith('/api/uploads/') || value.startsWith('/portfolio/');
}
/** Produce an "empty" clone of a sample value, for new array entries. */
function emptyLike(sample: JsonValue | undefined): JsonValue {
if (sample === undefined || sample === null) return '';
if (typeof sample === 'string') return '';
if (typeof sample === 'number') return 0;
if (typeof sample === 'boolean') return false;
if (Array.isArray(sample)) return [];
const out: Record<string, JsonValue> = {};
for (const [k, v] of Object.entries(sample)) out[k] = emptyLike(v);
return out;
}
function humanize(key: string): string {
return key
.replace(/[_-]/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/^\w/, (c) => c.toUpperCase());
}
export function JsonForm({
value,
onChange,
fieldKey,
depth = 0,
}: {
value: JsonValue;
onChange: (v: JsonValue) => void;
fieldKey?: string;
depth?: number;
}) {
// ---- Primitive: string ----
if (typeof value === 'string') {
if (looksLikeImage(fieldKey, value)) {
return <ImageInput value={value} onChange={onChange} />;
}
const multiline = value.length > 64 || value.includes('\n');
return multiline ? (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
rows={Math.min(8, Math.max(2, Math.ceil(value.length / 60)))}
className="w-full resize-y rounded-lg border border-white/10 bg-base-900/60 px-3 py-2 text-sm text-slate-100 outline-none focus:border-electric/60"
/>
) : (
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full rounded-lg border border-white/10 bg-base-900/60 px-3 py-2 text-sm text-slate-100 outline-none focus:border-electric/60"
/>
);
}
// ---- Primitive: number ----
if (typeof value === 'number') {
return (
<input
type="number"
value={value}
onChange={(e) => onChange(e.target.value === '' ? 0 : Number(e.target.value))}
className="w-40 rounded-lg border border-white/10 bg-base-900/60 px-3 py-2 text-sm text-slate-100 outline-none focus:border-electric/60"
/>
);
}
// ---- Primitive: boolean ----
if (typeof value === 'boolean') {
return (
<label className="inline-flex cursor-pointer items-center gap-2 text-sm text-slate-300">
<input
type="checkbox"
checked={value}
onChange={(e) => onChange(e.target.checked)}
className="h-4 w-4 accent-electric"
/>
{value ? 'true' : 'false'}
</label>
);
}
// ---- null ----
if (value === null) {
return (
<input
type="text"
value=""
placeholder="(empty)"
onChange={(e) => onChange(e.target.value)}
className="w-full rounded-lg border border-white/10 bg-base-900/60 px-3 py-2 text-sm text-slate-100 outline-none focus:border-electric/60"
/>
);
}
// ---- Array ----
if (Array.isArray(value)) {
return (
<div className="flex flex-col gap-3">
{value.map((item, i) => (
<div
key={i}
className="rounded-xl border border-white/8 bg-white/[0.015] p-3"
>
<div className="mb-2 flex items-center justify-between">
<span className="font-mono text-[0.65rem] uppercase tracking-wider text-slate-500">
{fieldKey ? humanize(fieldKey).replace(/s$/, '') : 'Item'} #{i + 1}
</span>
<div className="flex gap-1">
{i > 0 && (
<MiniBtn label="↑" title="Move up" onClick={() => {
const cp = [...value];
[cp[i - 1], cp[i]] = [cp[i], cp[i - 1]];
onChange(cp);
}} />
)}
{i < value.length - 1 && (
<MiniBtn label="↓" title="Move down" onClick={() => {
const cp = [...value];
[cp[i + 1], cp[i]] = [cp[i], cp[i + 1]];
onChange(cp);
}} />
)}
<MiniBtn
label="✕"
title="Remove"
danger
onClick={() => onChange(value.filter((_, j) => j !== i))}
/>
</div>
</div>
<JsonForm
value={item}
fieldKey={fieldKey}
depth={depth + 1}
onChange={(nv) => {
const cp = [...value];
cp[i] = nv;
onChange(cp);
}}
/>
</div>
))}
<button
type="button"
onClick={() => onChange([...value, emptyLike(value[0])])}
className="self-start rounded-lg border border-electric/30 bg-electric/5 px-3 py-1.5 text-xs font-medium text-electric transition-colors hover:bg-electric/10"
>
+ Add {fieldKey ? humanize(fieldKey).replace(/s$/, '').toLowerCase() : 'item'}
</button>
</div>
);
}
// ---- Object ----
return (
<div
className={
depth === 0
? 'flex flex-col gap-5'
: 'flex flex-col gap-4 rounded-xl border-s-2 border-white/10 ps-4'
}
>
{Object.entries(value).map(([k, v]) => {
const primitive =
v === null || ['string', 'number', 'boolean'].includes(typeof v);
return (
<div
key={k}
className={primitive ? 'flex flex-col gap-1.5' : 'flex flex-col gap-2'}
>
<label className="font-mono text-[0.68rem] uppercase tracking-wider text-slate-400">
{humanize(k)}
</label>
<JsonForm
value={v}
fieldKey={k}
depth={depth + 1}
onChange={(nv) => onChange({ ...value, [k]: nv })}
/>
</div>
);
})}
</div>
);
}
function MiniBtn({
label,
title,
onClick,
danger,
}: {
label: string;
title: string;
onClick: () => void;
danger?: boolean;
}) {
return (
<button
type="button"
title={title}
onClick={onClick}
className={
'inline-flex h-6 w-6 items-center justify-center rounded-md border text-xs transition-colors ' +
(danger
? 'border-magenta/30 text-magenta hover:bg-magenta/10'
: 'border-white/10 text-slate-400 hover:bg-white/[0.06] hover:text-white')
}
>
{label}
</button>
);
}
function ImageInput({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [err, setErr] = useState<string | null>(null);
async function upload(file: File) {
setUploading(true);
setErr(null);
try {
const fd = new FormData();
fd.append('file', file);
const res = await fetch('/api/admin/upload', { method: 'POST', body: fd });
if (!res.ok) throw new Error(`upload failed (${res.status})`);
const json = await res.json();
if (json?.url) onChange(json.url as string);
else throw new Error('no url returned');
} catch (e) {
setErr(e instanceof Error ? e.message : 'upload error');
} finally {
setUploading(false);
}
}
return (
<div className="flex items-start gap-3">
<div className="relative h-16 w-24 shrink-0 overflow-hidden rounded-lg border border-white/10 bg-base-900/60">
{value ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={value} alt="" className="h-full w-full object-cover" />
) : (
<span className="flex h-full w-full items-center justify-center text-[0.6rem] text-slate-600">
no image
</span>
)}
</div>
<div className="flex grow flex-col gap-2">
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="/api/uploads/… or /portfolio/…"
className="w-full rounded-lg border border-white/10 bg-base-900/60 px-3 py-2 text-sm text-slate-100 outline-none focus:border-electric/60"
/>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs text-slate-200 transition-colors hover:bg-white/[0.08] disabled:opacity-50"
>
{uploading ? 'Uploading…' : 'Upload image'}
</button>
{err && <span className="text-xs text-magenta">{err}</span>}
</div>
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) upload(f);
e.target.value = '';
}}
/>
</div>
</div>
);
}
+165
View File
@@ -0,0 +1,165 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { JsonForm, type JsonValue } from './JsonForm';
type Status = 'idle' | 'saving' | 'saved' | 'error';
type Tab = 'meta' | 'fa' | 'en';
/**
* Edits a single blog article body. Top-level `date` / `accent` live in the
* "Meta" tab; the long-form FA and EN articles each get their own tab so the
* Persian body renders RTL. Saving stores the whole PostContent under the
* article's slug via /api/admin/posts.
*/
export function PostEditor({
slug,
title,
initial,
isOverridden,
}: {
slug: string;
title: string;
initial: { date: JsonValue; accent: JsonValue; fa: JsonValue; en: JsonValue };
isOverridden: boolean;
}) {
const router = useRouter();
const [data, setData] = useState(initial);
const [tab, setTab] = useState<Tab>('meta');
const [status, setStatus] = useState<Status>('idle');
const [overridden, setOverridden] = useState(isOverridden);
async function save() {
setStatus('saving');
try {
const payload = {
date: data.date,
accent: data.accent,
en: data.en,
fa: data.fa,
};
const res = await fetch('/api/admin/posts', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ slug, data: payload }),
});
if (!res.ok) throw new Error(String(res.status));
setStatus('saved');
setOverridden(true);
router.refresh();
setTimeout(() => setStatus('idle'), 2500);
} catch {
setStatus('error');
}
}
async function reset() {
if (!confirm('Revert this article to its built-in default? Your edits will be removed.')) return;
setStatus('saving');
try {
const res = await fetch(`/api/admin/posts?slug=${encodeURIComponent(slug)}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error(String(res.status));
router.refresh();
window.location.reload();
} catch {
setStatus('error');
}
}
return (
<div className="flex flex-col gap-6">
{/* Toolbar */}
<div className="sticky top-0 z-10 -mx-6 flex flex-wrap items-center justify-between gap-3 border-b border-white/8 bg-base-900/80 px-6 py-3 backdrop-blur sm:-mx-8 sm:px-8">
<div className="flex items-center gap-1 rounded-full border border-white/10 bg-white/[0.02] p-1">
<TabBtn active={tab === 'meta'} onClick={() => setTab('meta')}>Meta</TabBtn>
<TabBtn active={tab === 'fa'} onClick={() => setTab('fa')}>FA · فارسی</TabBtn>
<TabBtn active={tab === 'en'} onClick={() => setTab('en')}>EN · English</TabBtn>
</div>
<div className="flex items-center gap-2">
{status === 'saved' && <span className="text-sm text-emerald">Saved </span>}
{status === 'error' && <span className="text-sm text-magenta">Save failed</span>}
{overridden && (
<button
type="button"
onClick={reset}
className="rounded-lg border border-white/10 px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/[0.05]"
>
Reset to default
</button>
)}
<button
type="button"
onClick={save}
disabled={status === 'saving'}
className="rounded-lg bg-electric px-4 py-2 text-sm font-semibold text-base-900 transition-opacity hover:opacity-90 disabled:opacity-50"
>
{status === 'saving' ? 'Saving…' : 'Save changes'}
</button>
</div>
</div>
{tab === 'meta' && (
<div className="flex flex-col gap-5 pb-24">
<p className="rounded-lg border border-white/8 bg-white/[0.02] p-3 text-xs text-slate-400">
Editing <span className="font-mono text-electric">{slug}</span>. Accent must be one of:
electric, violet, magenta, emerald, cyan. The card title/excerpt live under the
<span className="font-mono"> Journal</span> section.
</p>
<JsonForm
value={{ date: data.date, accent: data.accent }}
onChange={(nv) => {
const o = nv as { date: JsonValue; accent: JsonValue };
setData((d) => ({ ...d, date: o.date, accent: o.accent }));
}}
/>
</div>
)}
{tab === 'fa' && (
<div dir="rtl" className="pb-24">
<JsonForm
key="fa"
value={data.fa}
onChange={(nv) => setData((d) => ({ ...d, fa: nv }))}
/>
</div>
)}
{tab === 'en' && (
<div dir="ltr" className="pb-24">
<JsonForm
key="en"
value={data.en}
onChange={(nv) => setData((d) => ({ ...d, en: nv }))}
/>
</div>
)}
</div>
);
}
function TabBtn({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={
'rounded-full px-4 py-1.5 text-sm font-medium transition-colors ' +
(active ? 'bg-electric text-base-900' : 'text-slate-300 hover:text-white')
}
>
{children}
</button>
);
}
+123
View File
@@ -0,0 +1,123 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { JsonForm, type JsonValue } from './JsonForm';
type Status = 'idle' | 'saving' | 'saved' | 'error';
export function SectionEditor({
sectionKey,
title,
initial,
isOverridden,
}: {
sectionKey: string;
title: string;
initial: { fa: JsonValue; en: JsonValue };
isOverridden: boolean;
}) {
const router = useRouter();
const [data, setData] = useState<{ fa: JsonValue; en: JsonValue }>(initial);
const [tab, setTab] = useState<'fa' | 'en'>('fa');
const [status, setStatus] = useState<Status>('idle');
const [overridden, setOverridden] = useState(isOverridden);
async function save() {
setStatus('saving');
try {
const res = await fetch('/api/admin/section', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ key: sectionKey, data }),
});
if (!res.ok) throw new Error(String(res.status));
setStatus('saved');
setOverridden(true);
router.refresh();
setTimeout(() => setStatus('idle'), 2500);
} catch {
setStatus('error');
}
}
async function reset() {
if (!confirm('Revert this section to its built-in default? Your edits will be removed.')) return;
setStatus('saving');
try {
const res = await fetch(`/api/admin/section?key=${encodeURIComponent(sectionKey)}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error(String(res.status));
router.refresh();
window.location.reload();
} catch {
setStatus('error');
}
}
return (
<div className="flex flex-col gap-6">
{/* Toolbar */}
<div className="sticky top-0 z-10 -mx-6 flex flex-wrap items-center justify-between gap-3 border-b border-white/8 bg-base-900/80 px-6 py-3 backdrop-blur sm:-mx-8 sm:px-8">
<div className="flex items-center gap-1 rounded-full border border-white/10 bg-white/[0.02] p-1">
<TabBtn active={tab === 'fa'} onClick={() => setTab('fa')}>FA · فارسی</TabBtn>
<TabBtn active={tab === 'en'} onClick={() => setTab('en')}>EN · English</TabBtn>
</div>
<div className="flex items-center gap-2">
{status === 'saved' && <span className="text-sm text-emerald">Saved </span>}
{status === 'error' && <span className="text-sm text-magenta">Save failed</span>}
{overridden && (
<button
type="button"
onClick={reset}
className="rounded-lg border border-white/10 px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/[0.05]"
>
Reset to default
</button>
)}
<button
type="button"
onClick={save}
disabled={status === 'saving'}
className="rounded-lg bg-electric px-4 py-2 text-sm font-semibold text-base-900 transition-opacity hover:opacity-90 disabled:opacity-50"
>
{status === 'saving' ? 'Saving…' : 'Save changes'}
</button>
</div>
</div>
{/* The form for the active locale. FA renders RTL. */}
<div dir={tab === 'fa' ? 'rtl' : 'ltr'} className="pb-24">
<JsonForm
key={tab}
value={data[tab]}
onChange={(nv) => setData((d) => ({ ...d, [tab]: nv }))}
/>
</div>
</div>
);
}
function TabBtn({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={
'rounded-full px-4 py-1.5 text-sm font-medium transition-colors ' +
(active ? 'bg-electric text-base-900' : 'text-slate-300 hover:text-white')
}
>
{children}
</button>
);
}
+178
View File
@@ -0,0 +1,178 @@
'use client';
import Link from 'next/link';
import { motion } from 'framer-motion';
import { useLocale } from '@/lib/i18n/locale-context';
import type { PostContent, Block } from '@/lib/content/posts';
import { cn } from '@/lib/utils';
const FA_DIGITS = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'] as const;
const toFa = (s: string | number) =>
s.toString().replace(/\d/g, (d) => FA_DIGITS[Number(d)]);
type Meta = { title: string; category: string; readTime: number };
const ACCENT_TEXT: Record<PostContent['accent'], string> = {
electric: 'text-electric',
violet: 'text-violet',
magenta: 'text-magenta',
emerald: 'text-emerald',
cyan: 'text-cyan',
};
const ACCENT_BORDER: Record<PostContent['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',
};
export function BlogArticle({
meta,
content,
}: {
meta: { fa: Meta; en: Meta };
content: PostContent;
}) {
const { t, locale } = useLocale();
const m = meta[locale];
const body = content[locale];
const dir = locale === 'fa' ? 'rtl' : 'ltr';
const dateLabel = new Intl.DateTimeFormat(
locale === 'fa' ? 'fa-IR' : 'en-US',
{ year: 'numeric', month: 'long', day: 'numeric' },
).format(new Date(content.date));
return (
<article dir={dir} className="relative px-5 py-32 sm:px-8">
{/* Cover glow */}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-80 bg-radial-aurora opacity-60"
/>
<div className="mx-auto max-w-3xl">
<Link
href="/#blog"
className="label-mono inline-flex items-center gap-2 text-slate-400 transition-colors hover:text-electric"
>
<span className={locale === 'fa' ? 'rotate-180' : ''}></span>
{t.nav.blog}
</Link>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
>
<div className="mt-7 flex flex-wrap items-center gap-3">
<span
className={cn(
'rounded-full border px-2.5 py-0.5 font-mono text-[0.65rem] uppercase tracking-wider',
ACCENT_BORDER[content.accent],
)}
>
{m.category}
</span>
<span className="font-mono text-[0.7rem] text-slate-500">
{dateLabel}
</span>
<span className="font-mono text-[0.7rem] text-slate-500">
{locale === 'fa' ? toFa(m.readTime) : m.readTime}{' '}
{t.blog.readTimeSuffix}
</span>
</div>
<h1
className={cn(
'mt-5 font-display text-[clamp(2rem,4.5vw,3.2rem)] font-extrabold leading-[1.08] tracking-tight text-white',
locale === 'fa' && 'font-fa',
)}
>
{m.title}
</h1>
<p className="mt-6 text-balance text-[clamp(1.05rem,1.8vw,1.3rem)] leading-relaxed text-slate-300">
{body.lead}
</p>
</motion.div>
{/* Body */}
<div className="mt-12 flex flex-col gap-6">
{body.blocks.map((block, i) => (
<BlockView key={i} block={block} accent={content.accent} />
))}
</div>
{/* CTA */}
<div className="mt-16 border-t border-white/5 pt-10">
<p className="text-slate-400">
{locale === 'fa'
? 'این موضوع به سیستم شما مربوط است؟ بیایید درباره‌اش صحبت کنیم.'
: 'Is this relevant to your system? Lets talk it through.'}
</p>
<div className="mt-5 flex flex-wrap gap-3">
<Link href="/#contact" className="btn-primary">
{t.hero.ctaPrimary}
</Link>
<Link href="/#blog" className="btn-ghost">
{t.nav.blog}
</Link>
</div>
</div>
</div>
</article>
);
}
function BlockView({
block,
accent,
}: {
block: Block;
accent: PostContent['accent'];
}) {
switch (block.k) {
case 'h2':
return (
<h2 className="mt-4 font-display text-[clamp(1.3rem,2.4vw,1.7rem)] font-semibold leading-snug text-white">
{block.t}
</h2>
);
case 'p':
return (
<p className="text-[1.02rem] leading-[1.85] text-slate-300">
{block.t}
</p>
);
case 'ul':
return (
<ul className="flex flex-col gap-2.5">
{block.items.map((it, i) => (
<li key={i} className="flex gap-3 text-[1.02rem] leading-relaxed text-slate-300">
<span className={cn('mt-2 h-1.5 w-1.5 shrink-0 rounded-full', `bg-current ${ACCENT_TEXT[accent]}`)} />
<span>{it}</span>
</li>
))}
</ul>
);
case 'quote':
return (
<blockquote
className={cn(
'my-2 border-s-2 ps-5 text-[1.1rem] font-medium italic leading-relaxed text-slate-200',
accent === 'magenta' ? 'border-magenta' : 'border-electric',
)}
>
{block.t}
</blockquote>
);
case 'code':
return (
<pre className="overflow-x-auto rounded-xl border border-white/10 bg-base-900/80 p-4 font-mono text-[0.85rem] leading-relaxed text-slate-200">
<code>{block.t}</code>
</pre>
);
}
}
+188
View File
@@ -0,0 +1,188 @@
'use client';
import { motion } from 'framer-motion';
import { useLocale } from '@/lib/i18n/locale-context';
import { cn } from '@/lib/utils';
import { ParticleCanvas } from './ParticleCanvas';
import { Typewriter } from './Typewriter';
import { Counter } from '@/components/ui/Counter';
const fadeUp = (delay = 0) => ({
initial: { opacity: 0, y: 28 },
animate: { opacity: 1, y: 0 },
transition: { duration: 0.7, ease: [0.22, 1, 0.36, 1], delay },
});
export function Hero() {
const { t, locale } = useLocale();
return (
<section
id="top"
className={cn(
'relative isolate overflow-hidden',
// Full-screen on desktop, generous on mobile — leaves room for hero
// metrics without forcing a scroll on first paint at 1080p.
'min-h-[100svh] pt-28 pb-20 sm:pt-32',
)}
>
{/* Particle network background */}
<div className="pointer-events-none absolute inset-0 -z-10">
<ParticleCanvas />
{/* Edge fade so particles don't fight section seams */}
<div
aria-hidden
className="absolute inset-x-0 bottom-0 h-40 bg-gradient-to-b from-transparent to-base"
/>
</div>
<div className="mx-auto flex max-w-7xl flex-col items-center px-5 text-center sm:px-8">
{/* Availability chip */}
<motion.div {...fadeUp(0)} className="mb-7">
<span className="chip">
<span className="relative inline-flex h-2 w-2">
<span className="absolute inset-0 animate-pulse-dot rounded-full bg-emerald" />
<span className="relative inline-block h-2 w-2 rounded-full bg-emerald" />
</span>
{t.hero.availability}
</span>
</motion.div>
{/* Eyebrow */}
<motion.p
{...fadeUp(0.08)}
className="label-mono mb-6 inline-flex items-center gap-3 text-[clamp(0.65rem,1vw,0.75rem)]"
>
<span className="h-px w-10 bg-electric/60" aria-hidden />
{t.hero.eyebrow}
<span className="h-px w-10 bg-electric/60" aria-hidden />
</motion.p>
{/* Name */}
<motion.h1
{...fadeUp(0.15)}
className={cn(
'font-display text-balance text-[clamp(2.4rem,7vw,5.4rem)] font-extrabold leading-[1.02] tracking-tight text-white',
locale === 'fa' && 'font-fa',
)}
>
{t.hero.name}
</motion.h1>
{/* Headline */}
<motion.p
{...fadeUp(0.25)}
className={cn(
'mt-5 max-w-4xl text-balance text-[clamp(1.15rem,2.2vw,1.75rem)] font-medium leading-[1.25] text-slate-200',
)}
>
{t.hero.headlineLead}{' '}
<span className="gradient-text font-semibold">
{t.hero.headlineAccent}
</span>{' '}
{t.hero.headlineTrail}
</motion.p>
{/* Role typewriter */}
<motion.div
{...fadeUp(0.35)}
className="mt-5 flex items-center gap-3 font-mono text-[clamp(0.9rem,1.4vw,1.05rem)] uppercase tracking-[0.15em] text-slate-400"
>
<span className="h-px w-6 bg-slate-700" aria-hidden />
<Typewriter words={t.hero.roles} />
<span className="h-px w-6 bg-slate-700" aria-hidden />
</motion.div>
{/* Sub */}
<motion.p
{...fadeUp(0.42)}
className="mt-7 max-w-2xl text-balance text-[clamp(0.95rem,1.4vw,1.08rem)] leading-relaxed text-slate-400"
>
{t.hero.sub}
</motion.p>
{/* CTAs */}
<motion.div
{...fadeUp(0.5)}
className="mt-9 flex flex-wrap items-center justify-center gap-3"
>
<a href="#contact" className="btn-primary">
{t.hero.ctaPrimary}
<Arrow locale={locale} />
</a>
<a href="#services" className="btn-ghost">
{t.hero.ctaSecondary}
</a>
</motion.div>
{/* Metrics */}
<motion.div
{...fadeUp(0.6)}
className="mt-16 grid w-full max-w-4xl grid-cols-2 gap-4 sm:grid-cols-4"
>
{t.hero.metrics.map((m, i) => (
<div
key={m.label}
className="glass relative overflow-hidden px-5 py-5 text-start"
>
<span
aria-hidden
className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-electric/50 to-transparent"
/>
<div
className={cn(
'font-display text-[clamp(1.6rem,3vw,2.25rem)] font-bold leading-none',
// Cycle the accent colors across the 4 tiles
[
'text-electric',
'text-violet',
'text-magenta',
'text-emerald',
][i % 4],
)}
>
<Counter value={m.value} locale={locale} />
</div>
<div className="mt-2 text-[0.78rem] leading-snug text-slate-400">
{m.label}
</div>
</div>
))}
</motion.div>
{/* Scroll cue */}
<motion.a
href="#services"
{...fadeUp(0.75)}
aria-label={t.hero.scroll}
className="mt-14 inline-flex flex-col items-center gap-2 text-slate-500 transition-colors hover:text-slate-200"
>
<span className="label-mono">{t.hero.scroll}</span>
<span className="relative block h-9 w-5 rounded-full border border-slate-700">
<span className="absolute left-1/2 top-1.5 inline-block h-1.5 w-0.5 -translate-x-1/2 animate-float-y rounded-full bg-electric" />
</span>
</motion.a>
</div>
</section>
);
}
function Arrow({ locale }: { locale: 'fa' | 'en' }) {
return (
<svg
viewBox="0 0 24 24"
width="16"
height="16"
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>
);
}
+175
View File
@@ -0,0 +1,175 @@
'use client';
import { useEffect, useRef } from 'react';
/**
* Lightweight 2D hex-grid particle network.
* - Nodes drift slowly, repelled by the cursor.
* - Edges drawn between nearby nodes form a connection mesh.
* - Pauses when the tab is hidden or the section scrolls offscreen.
*/
export function ParticleCanvas() {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = ref.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const DPR = Math.min(window.devicePixelRatio || 1, 2);
let width = 0;
let height = 0;
let raf = 0;
let running = true;
const mouse = { x: -9999, y: -9999, active: false };
type Node = { x: number; y: number; vx: number; vy: number; r: number; hue: number };
let nodes: Node[] = [];
const COLORS = [
{ r: 56, g: 189, b: 248 }, // electric
{ r: 129, g: 140, b: 248 }, // violet
{ r: 232, g: 121, b: 249 }, // magenta
{ r: 34, g: 211, b: 238 }, // cyan
];
const seed = () => {
const area = width * height;
const density = window.matchMedia('(max-width: 640px)').matches ? 14000 : 9000;
const count = Math.min(140, Math.max(40, Math.floor(area / density)));
nodes = Array.from({ length: count }, () => ({
x: Math.random() * width,
y: Math.random() * height,
vx: (Math.random() - 0.5) * 0.18,
vy: (Math.random() - 0.5) * 0.18,
r: 0.8 + Math.random() * 1.6,
hue: Math.floor(Math.random() * COLORS.length),
}));
};
const resize = () => {
const rect = canvas.getBoundingClientRect();
width = rect.width;
height = rect.height;
canvas.width = Math.floor(width * DPR);
canvas.height = Math.floor(height * DPR);
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
seed();
};
const onMove = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
mouse.x = e.clientX - rect.left;
mouse.y = e.clientY - rect.top;
mouse.active = true;
};
const onLeave = () => {
mouse.active = false;
mouse.x = -9999;
mouse.y = -9999;
};
const onVisibility = () => {
running = !document.hidden;
if (running && !raf) raf = requestAnimationFrame(tick);
};
const tick = () => {
raf = 0;
if (!running) return;
ctx.clearRect(0, 0, width, height);
// Drift + cursor repel
for (const n of nodes) {
n.x += n.vx;
n.y += n.vy;
// Wrap around edges
if (n.x < -10) n.x = width + 10;
else if (n.x > width + 10) n.x = -10;
if (n.y < -10) n.y = height + 10;
else if (n.y > height + 10) n.y = -10;
if (mouse.active) {
const dx = n.x - mouse.x;
const dy = n.y - mouse.y;
const d2 = dx * dx + dy * dy;
const R = 140;
if (d2 < R * R && d2 > 0.01) {
const d = Math.sqrt(d2);
const force = (R - d) / R;
n.x += (dx / d) * force * 2.4;
n.y += (dy / d) * force * 2.4;
}
}
}
// Edges
const LINK_DIST = 130;
for (let i = 0; i < nodes.length; i++) {
const a = nodes[i];
for (let j = i + 1; j < nodes.length; j++) {
const b = nodes[j];
const dx = a.x - b.x;
const dy = a.y - b.y;
const d2 = dx * dx + dy * dy;
if (d2 < LINK_DIST * LINK_DIST) {
const d = Math.sqrt(d2);
const alpha = (1 - d / LINK_DIST) * 0.35;
const ca = COLORS[a.hue];
const cb = COLORS[b.hue];
const grad = ctx.createLinearGradient(a.x, a.y, b.x, b.y);
grad.addColorStop(0, `rgba(${ca.r},${ca.g},${ca.b},${alpha})`);
grad.addColorStop(1, `rgba(${cb.r},${cb.g},${cb.b},${alpha})`);
ctx.strokeStyle = grad;
ctx.lineWidth = 0.7;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
}
}
// Nodes
for (const n of nodes) {
const c = COLORS[n.hue];
ctx.beginPath();
ctx.fillStyle = `rgba(${c.r},${c.g},${c.b},0.85)`;
ctx.shadowBlur = 8;
ctx.shadowColor = `rgba(${c.r},${c.g},${c.b},0.55)`;
ctx.arc(n.x, n.y, n.r, 0, Math.PI * 2);
ctx.fill();
}
ctx.shadowBlur = 0;
raf = requestAnimationFrame(tick);
};
resize();
raf = requestAnimationFrame(tick);
window.addEventListener('resize', resize);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseleave', onLeave);
document.addEventListener('visibilitychange', onVisibility);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener('resize', resize);
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseleave', onLeave);
document.removeEventListener('visibilitychange', onVisibility);
};
}, []);
return (
<canvas
ref={ref}
aria-hidden
className="absolute inset-0 h-full w-full"
style={{ background: 'transparent' }}
/>
);
}
+69
View File
@@ -0,0 +1,69 @@
'use client';
import { useEffect, useState } from 'react';
type Props = {
words: readonly string[];
typeSpeed?: number;
eraseSpeed?: number;
holdMs?: number;
};
/**
* Cycles through a list of phrases — types one in, holds, erases, advances.
* Resets cleanly when `words` reference changes (e.g. locale switch).
*/
export function Typewriter({
words,
typeSpeed = 70,
eraseSpeed = 40,
holdMs = 1600,
}: Props) {
const [index, setIndex] = useState(0);
const [text, setText] = useState('');
const [phase, setPhase] = useState<'typing' | 'holding' | 'erasing'>('typing');
// Reset state when the words array identity changes (locale switch).
useEffect(() => {
setIndex(0);
setText('');
setPhase('typing');
}, [words]);
useEffect(() => {
if (!words.length) return;
const target = words[index % words.length];
if (phase === 'typing') {
if (text === target) {
const t = setTimeout(() => setPhase('erasing'), holdMs);
return () => clearTimeout(t);
}
const t = setTimeout(
() => setText(target.slice(0, text.length + 1)),
typeSpeed,
);
return () => clearTimeout(t);
}
if (phase === 'erasing') {
if (text === '') {
setIndex((i) => (i + 1) % words.length);
setPhase('typing');
return;
}
const t = setTimeout(() => setText(text.slice(0, -1)), eraseSpeed);
return () => clearTimeout(t);
}
}, [text, phase, index, words, typeSpeed, eraseSpeed, holdMs]);
return (
<span className="inline-flex items-baseline gap-0.5" aria-live="polite">
<span className="gradient-text">{text}</span>
<span
aria-hidden
className="inline-block w-[2px] self-stretch translate-y-[2px] bg-electric animate-caret-blink"
/>
</span>
);
}
+51
View File
@@ -0,0 +1,51 @@
'use client';
import { useLocale } from '@/lib/i18n/locale-context';
import { cn } from '@/lib/utils';
export function LanguageToggle({ compact = false }: { compact?: boolean }) {
const { locale, setLocale } = useLocale();
return (
<div
className={cn(
'relative flex items-center rounded-full border border-white/10 bg-white/[0.02] p-0.5',
compact ? 'text-[0.65rem]' : 'text-xs',
)}
role="group"
aria-label="Language"
>
<button
type="button"
onClick={() => setLocale('fa')}
className={cn(
'relative z-10 rounded-full px-3 py-1 font-mono uppercase tracking-widest transition-colors',
locale === 'fa' ? 'text-base-900' : 'text-slate-400 hover:text-slate-200',
)}
aria-pressed={locale === 'fa'}
>
FA
</button>
<button
type="button"
onClick={() => setLocale('en')}
className={cn(
'relative z-10 rounded-full px-3 py-1 font-mono uppercase tracking-widest transition-colors',
locale === 'en' ? 'text-base-900' : 'text-slate-400 hover:text-slate-200',
)}
aria-pressed={locale === 'en'}
>
EN
</button>
<span
aria-hidden
className={cn(
'absolute top-0.5 bottom-0.5 w-[calc(50%-2px)] rounded-full bg-brand-gradient transition-[inset-inline-start] duration-300 ease-out',
)}
style={{
insetInlineStart: locale === 'fa' ? '2px' : 'calc(50% + 0px)',
}}
/>
</div>
);
}
+164
View File
@@ -0,0 +1,164 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { motion } from 'framer-motion';
import { useLocale } from '@/lib/i18n/locale-context';
import { LanguageToggle } from './LanguageToggle';
import { cn } from '@/lib/utils';
export function Navbar() {
const { t, locale } = useLocale();
const [scrolled, setScrolled] = useState(false);
const [open, setOpen] = useState(false);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 12);
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
const links = [
{ href: '#services', label: t.nav.services },
{ href: '#stack', label: t.nav.stack },
{ href: '#expertise', label: t.nav.expertise },
{ href: '#portfolio', label: t.nav.portfolio },
{ href: '#blog', label: t.nav.blog },
{ href: '#contact', label: t.nav.contact },
];
return (
<motion.header
initial={{ y: -24, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
className={cn(
'fixed inset-x-0 top-0 z-40 transition-colors duration-300',
scrolled
? 'border-b border-white/5 bg-base-900/70 backdrop-blur-xl'
: 'border-b border-transparent',
)}
>
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-5 sm:px-8">
{/* Logo */}
<Link href="/" aria-label="Soroush Asadi" className="group flex items-center gap-2.5">
<Image
src="/logo-mark.svg"
alt=""
width={32}
height={32}
priority
className="transition-transform duration-300 group-hover:rotate-[8deg]"
/>
<span className="hidden sm:inline-flex flex-col leading-tight">
<span
className={cn(
'text-[0.95rem] font-semibold tracking-wide text-slate-100',
locale === 'fa' ? 'font-fa' : 'font-en',
)}
>
{locale === 'fa' ? 'سروش اسعدی' : 'Soroush Asadi'}
</span>
<span className="font-mono text-[0.6rem] uppercase tracking-[0.22em] text-slate-500">
AI · Architecture
</span>
</span>
</Link>
{/* Center nav */}
<nav
className="hidden items-center gap-1 rounded-full border border-white/5 bg-white/[0.02] px-2 py-1.5 md:flex"
aria-label="primary"
>
{links.map((l) => (
<a
key={l.href}
href={l.href}
className="rounded-full px-3 py-1.5 text-[0.82rem] text-slate-300 transition-colors hover:bg-white/[0.04] hover:text-white"
>
{l.label}
</a>
))}
</nav>
{/* Right cluster */}
<div className="flex items-center gap-3">
<LanguageToggle />
<a href="#contact" className="hidden sm:inline-flex btn-primary text-[0.82rem] !px-4 !py-2">
{t.nav.book}
<ArrowIcon locale={locale} />
</a>
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="md:hidden inline-flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-white/[0.02] text-slate-200"
aria-label="Toggle menu"
aria-expanded={open}
>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
{open ? (
<>
<path d="M6 6 L18 18" />
<path d="M18 6 L6 18" />
</>
) : (
<>
<path d="M4 7 H20" />
<path d="M4 12 H20" />
<path d="M4 17 H20" />
</>
)}
</svg>
</button>
</div>
</div>
{/* Mobile dropdown */}
{open && (
<div className="md:hidden border-t border-white/5 bg-base-900/95 px-5 py-4 backdrop-blur-xl">
<nav className="grid gap-1" aria-label="mobile">
{links.map((l) => (
<a
key={l.href}
href={l.href}
onClick={() => setOpen(false)}
className="rounded-lg px-3 py-2 text-sm text-slate-300 hover:bg-white/[0.04] hover:text-white"
>
{l.label}
</a>
))}
<a
href="#contact"
onClick={() => setOpen(false)}
className="mt-2 btn-primary justify-center"
>
{t.nav.book}
</a>
</nav>
</div>
)}
</motion.header>
);
}
function ArrowIcon({ locale }: { locale: 'fa' | 'en' }) {
return (
<svg
viewBox="0 0 24 24"
width="14"
height="14"
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>
);
}
+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>
);
}
+99
View File
@@ -0,0 +1,99 @@
'use client';
import { useEffect, useRef, useState } from 'react';
const FA_DIGITS = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'] as const;
function toAscii(str: string) {
return str.replace(/[۰-۹]/g, (d) =>
String(FA_DIGITS.indexOf(d as (typeof FA_DIGITS)[number])),
);
}
function toFa(n: number) {
return n.toString().replace(/\d/g, (d) => FA_DIGITS[Number(d)]);
}
/**
* Parses a metric string like "18+", "۱۲ms", "99%", "۹۹٪" into a numeric
* target plus a trailing suffix that survives the count animation.
*/
function parse(value: string) {
const ascii = toAscii(value);
const match = ascii.match(/^(\d+(?:\.\d+)?)(.*)$/);
if (!match) return { target: 0, suffix: value, decimals: 0 };
const target = parseFloat(match[1]);
const decimals = match[1].includes('.') ? match[1].split('.')[1].length : 0;
return { target, suffix: match[2], decimals };
}
const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
type Props = {
/** Final string, e.g. "18+", "۱۲ms", "99%" */
value: string;
/** Locale controls digit script in the rendered output */
locale: 'fa' | 'en';
/** Animation duration in ms */
duration?: number;
className?: string;
};
export function Counter({ value, locale, duration = 1600, className }: Props) {
const { target, suffix, decimals } = parse(value);
const [display, setDisplay] = useState(0);
const elRef = useRef<HTMLSpanElement>(null);
const started = useRef(false);
useEffect(() => {
const el = elRef.current;
if (!el) return;
const start = () => {
if (started.current) return;
started.current = true;
const t0 = performance.now();
const tick = (now: number) => {
const p = Math.min(1, (now - t0) / duration);
const eased = easeOutCubic(p);
setDisplay(target * eased);
if (p < 1) requestAnimationFrame(tick);
else setDisplay(target);
};
requestAnimationFrame(tick);
};
if (typeof IntersectionObserver === 'undefined') {
start();
return;
}
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting) {
start();
io.disconnect();
break;
}
}
},
{ threshold: 0.4 },
);
io.observe(el);
return () => io.disconnect();
}, [target, duration]);
const formatted = decimals
? display.toFixed(decimals)
: Math.round(display).toString();
const rendered = locale === 'fa' ? toFa(Number(formatted)) : formatted;
const sfx = locale === 'fa' ? suffix : toAscii(suffix);
return (
<span ref={elRef} className={className}>
{rendered}
{sfx}
</span>
);
}
+147
View File
@@ -0,0 +1,147 @@
'use client';
import { useEffect, useRef, useState } from 'react';
const HOVER_SELECTOR =
'a, button, [role="button"], input, textarea, select, summary, [data-cursor-hover]';
export function CustomCursor() {
const dotRef = useRef<HTMLDivElement>(null);
const ringRef = useRef<HTMLDivElement>(null);
const [enabled, setEnabled] = useState(false);
useEffect(() => {
// Only enable on desktop pointers (>= 900px and fine pointer)
const mq = window.matchMedia('(min-width: 900px) and (pointer: fine)');
const apply = () => {
const on = mq.matches;
setEnabled(on);
document.documentElement.classList.toggle('has-cursor', on);
};
apply();
mq.addEventListener('change', apply);
return () => mq.removeEventListener('change', apply);
}, []);
useEffect(() => {
if (!enabled) return;
let dotX = window.innerWidth / 2;
let dotY = window.innerHeight / 2;
let ringX = dotX;
let ringY = dotY;
let raf = 0;
const onMove = (e: MouseEvent) => {
dotX = e.clientX;
dotY = e.clientY;
};
const tick = () => {
// Ring lags the dot — trailing effect.
ringX += (dotX - ringX) * 0.18;
ringY += (dotY - ringY) * 0.18;
if (dotRef.current) {
dotRef.current.style.transform = `translate3d(${dotX}px, ${dotY}px, 0) translate(-50%, -50%)`;
}
if (ringRef.current) {
ringRef.current.style.transform = `translate3d(${ringX}px, ${ringY}px, 0) translate(-50%, -50%)`;
}
raf = requestAnimationFrame(tick);
};
const onOver = (e: MouseEvent) => {
const target = e.target as HTMLElement | null;
const isHover = !!target?.closest(HOVER_SELECTOR);
ringRef.current?.classList.toggle('cursor-ring--hover', isHover);
dotRef.current?.classList.toggle('cursor-dot--hover', isHover);
};
const onDown = () => ringRef.current?.classList.add('cursor-ring--down');
const onUp = () => ringRef.current?.classList.remove('cursor-ring--down');
const onLeave = () => {
ringRef.current?.classList.add('cursor--hidden');
dotRef.current?.classList.add('cursor--hidden');
};
const onEnter = () => {
ringRef.current?.classList.remove('cursor--hidden');
dotRef.current?.classList.remove('cursor--hidden');
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseover', onOver);
window.addEventListener('mousedown', onDown);
window.addEventListener('mouseup', onUp);
document.addEventListener('mouseleave', onLeave);
document.addEventListener('mouseenter', onEnter);
raf = requestAnimationFrame(tick);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseover', onOver);
window.removeEventListener('mousedown', onDown);
window.removeEventListener('mouseup', onUp);
document.removeEventListener('mouseleave', onLeave);
document.removeEventListener('mouseenter', onEnter);
cancelAnimationFrame(raf);
};
}, [enabled]);
if (!enabled) return null;
return (
<>
<style jsx global>{`
.cursor-dot,
.cursor-ring {
position: fixed;
left: 0;
top: 0;
pointer-events: none;
z-index: 9999;
will-change: transform;
transition:
width 0.25s ease,
height 0.25s ease,
background 0.25s ease,
border-color 0.25s ease,
opacity 0.25s ease;
}
.cursor-dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: #38bdf8;
box-shadow: 0 0 14px rgba(56, 189, 248, 0.8);
mix-blend-mode: screen;
}
.cursor-ring {
width: 36px;
height: 36px;
border-radius: 999px;
border: 1.5px solid rgba(56, 189, 248, 0.55);
}
.cursor-dot--hover {
background: #e879f9;
box-shadow: 0 0 18px rgba(232, 121, 249, 0.85);
}
.cursor-ring--hover {
width: 56px;
height: 56px;
border-color: rgba(232, 121, 249, 0.7);
background: rgba(232, 121, 249, 0.05);
}
.cursor-ring--down {
width: 30px;
height: 30px;
border-color: rgba(255, 255, 255, 0.8);
}
.cursor--hidden {
opacity: 0;
}
`}</style>
<div ref={ringRef} className="cursor-ring" aria-hidden />
<div ref={dotRef} className="cursor-dot" aria-hidden />
</>
);
}
+50
View File
@@ -0,0 +1,50 @@
'use client';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
type Props = {
eyebrow: string;
title: string;
sub?: string;
align?: 'center' | 'start';
className?: string;
};
export function SectionHeader({
eyebrow,
title,
sub,
align = 'start',
className,
}: Props) {
const isCenter = align === 'center';
return (
<motion.header
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
className={cn(
'flex flex-col gap-4',
isCenter ? 'items-center text-center' : 'items-start',
'max-w-3xl',
isCenter && 'mx-auto',
className,
)}
>
<span className="label-mono inline-flex items-center gap-2">
<span className="h-px w-8 bg-electric/60" aria-hidden />
{eyebrow}
</span>
<h2 className="font-display text-balance text-[clamp(1.85rem,3.6vw,2.9rem)] font-semibold leading-[1.1] tracking-tight text-white">
{title}
</h2>
{sub && (
<p className="text-balance text-[clamp(0.98rem,1.4vw,1.1rem)] leading-relaxed text-slate-400">
{sub}
</p>
)}
</motion.header>
);
}