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