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

This commit is contained in:
soroush.asadi
2026-05-31 12:47:02 +03:30
commit add78d8460
100 changed files with 15221 additions and 0 deletions
+91
View File
@@ -0,0 +1,91 @@
'use client';
import { Suspense, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
function LoginInner() {
const router = useRouter();
const params = useSearchParams();
const from = params.get('from') || '/admin';
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
try {
const res = await fetch('/api/admin/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ password }),
});
if (res.ok) {
router.replace(from);
router.refresh();
} else {
setError('Incorrect password.');
setBusy(false);
}
} catch {
setError('Something went wrong. Try again.');
setBusy(false);
}
}
return (
<div dir="ltr" className="flex min-h-screen items-center justify-center px-5">
<div
aria-hidden
className="pointer-events-none fixed inset-0 -z-10 bg-radial-aurora opacity-50"
/>
<form
onSubmit={submit}
className="w-full max-w-sm rounded-2xl border border-white/10 bg-base-900/70 p-8 backdrop-blur-xl"
>
<div className="mb-6 flex items-center gap-3">
<span className="grid h-10 w-10 place-items-center rounded-xl bg-electric/15 font-mono text-sm font-bold text-electric">
SA
</span>
<div>
<h1 className="text-base font-semibold text-white">Content CMS</h1>
<p className="font-mono text-[0.65rem] uppercase tracking-wider text-slate-500">
soroushasadi.ir
</p>
</div>
</div>
<label className="mb-1.5 block font-mono text-[0.68rem] uppercase tracking-wider text-slate-400">
Admin password
</label>
<input
type="password"
autoFocus
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-lg border border-white/10 bg-base-900/60 px-3 py-2.5 text-sm text-slate-100 outline-none focus:border-electric/60"
placeholder="••••••••"
/>
{error && <p className="mt-3 text-sm text-magenta">{error}</p>}
<button
type="submit"
disabled={busy || !password}
className="mt-5 w-full rounded-lg bg-electric px-4 py-2.5 text-sm font-semibold text-base-900 transition-opacity hover:opacity-90 disabled:opacity-50"
>
{busy ? 'Signing in…' : 'Sign in'}
</button>
</form>
</div>
);
}
export default function AdminLoginPage() {
return (
<Suspense fallback={null}>
<LoginInner />
</Suspense>
);
}
+95
View File
@@ -0,0 +1,95 @@
import Link from 'next/link';
import { AdminShell } from '@/components/admin/AdminShell';
import { EDITABLE_SECTIONS } from '@/lib/content/sections';
import { sectionStatus } from '@/lib/db/store';
import { passwordConfigured } from '@/lib/auth/session';
export const dynamic = 'force-dynamic';
function timeAgo(ts: number): string {
const s = Math.floor((Date.now() - ts) / 1000);
if (s < 60) return 'just now';
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
export default function AdminDashboard() {
const status = sectionStatus();
const usingDefaultPassword = !process.env.ADMIN_PASSWORD && passwordConfigured();
const edited = Object.keys(status).length;
return (
<AdminShell>
<div className="mx-auto max-w-4xl">
<div className="mb-8">
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
<p className="mt-1 text-sm text-slate-400">
Edit every section of the site. {edited > 0 ? `${edited} section${edited > 1 ? 's' : ''} customized.` : 'All sections are at their defaults.'}
</p>
</div>
{usingDefaultPassword && (
<div className="mb-6 rounded-xl border border-magenta/30 bg-magenta/5 p-4 text-sm text-magenta">
<strong>Heads up:</strong> no <code className="font-mono">ADMIN_PASSWORD</code> is set, so the dev default
(<code className="font-mono">admin</code>) is in use. Set one in your environment before going live.
</div>
)}
<div className="grid gap-3 sm:grid-cols-2">
{EDITABLE_SECTIONS.map((s) => {
const edited = status[s.key];
return (
<Link
key={s.key}
href={`/admin/sections/${s.key}`}
className="group rounded-xl border border-white/8 bg-white/[0.02] p-5 transition-colors hover:border-electric/30 hover:bg-electric/[0.03]"
>
<div className="flex items-center justify-between">
<h2 className="font-semibold text-white group-hover:text-electric">
{s.label.en}
<span className="ms-2 font-fa text-sm font-normal text-slate-500">
{s.label.fa}
</span>
</h2>
{edited ? (
<span className="rounded-full border border-emerald/30 bg-emerald/5 px-2 py-0.5 font-mono text-[0.6rem] uppercase tracking-wider text-emerald">
edited · {timeAgo(edited)}
</span>
) : (
<span className="rounded-full border border-white/10 px-2 py-0.5 font-mono text-[0.6rem] uppercase tracking-wider text-slate-500">
default
</span>
)}
</div>
<p className="mt-2 text-sm text-slate-400">{s.desc.en}</p>
</Link>
);
})}
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<Link
href="/admin/posts"
className="group rounded-xl border border-violet/20 bg-violet/[0.03] p-5 transition-colors hover:border-violet/40 hover:bg-violet/[0.06]"
>
<div className="flex items-center justify-between">
<h2 className="font-semibold text-white group-hover:text-violet">
Journal articles
<span className="ms-2 font-fa text-sm font-normal text-slate-500">مقالات</span>
</h2>
<span className="font-mono text-[0.65rem] uppercase tracking-wider text-violet">
bodies
</span>
</div>
<p className="mt-2 text-sm text-slate-400">
Edit the full bilingual body of each blog post (lead + content blocks).
</p>
</Link>
</div>
</div>
</AdminShell>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { AdminShell } from '@/components/admin/AdminShell';
import { PostEditor } from '@/components/admin/PostEditor';
import type { JsonValue } from '@/components/admin/JsonForm';
import { loadContent } from '@/lib/content/load';
import { loadPost, loadPostOverrides, isKnownSlug } from '@/lib/content/posts-store';
// Always render on demand so the editor mirrors current DB state.
export const dynamic = 'force-dynamic';
export default function AdminPostEditorPage({ params }: { params: { slug: string } }) {
const { slug } = params;
if (!isKnownSlug(slug)) notFound();
const post = loadPost(slug);
if (!post) notFound();
const overridden = slug in loadPostOverrides();
const { en } = loadContent();
const title = en.blog.items.find((p) => p.slug === slug)?.title ?? slug;
return (
<AdminShell>
<div className="mx-auto max-w-3xl">
<Link
href="/admin/posts"
className="font-mono text-[0.7rem] uppercase tracking-wider text-slate-500 transition-colors hover:text-electric"
>
Journal articles
</Link>
<h1 className="mb-1 mt-3 text-2xl font-bold text-white">{title}</h1>
<p className="mb-6 text-sm text-slate-400">
Edit the lead and body blocks for both languages, then save. Changes go live immediately.
</p>
<PostEditor
slug={slug}
title={title}
initial={{
date: post.date as JsonValue,
accent: post.accent as JsonValue,
fa: post.fa as unknown as JsonValue,
en: post.en as unknown as JsonValue,
}}
isOverridden={overridden}
/>
</div>
</AdminShell>
);
}
+74
View File
@@ -0,0 +1,74 @@
import Link from 'next/link';
import { AdminShell } from '@/components/admin/AdminShell';
import { loadContent } from '@/lib/content/load';
import { loadAllPosts, loadPostOverrides } from '@/lib/content/posts-store';
// Always reflect live DB state in the editor list.
export const dynamic = 'force-dynamic';
export default function AdminPostsPage() {
const posts = loadAllPosts();
const overrides = loadPostOverrides();
const { en } = loadContent();
const cardBySlug = new Map<string, (typeof en.blog.items)[number]>(
en.blog.items.map((p) => [p.slug, p]),
);
const slugs = Object.keys(posts);
const editedCount = Object.keys(overrides).length;
return (
<AdminShell>
<div className="mx-auto max-w-4xl">
<div className="mb-8">
<h1 className="text-2xl font-bold text-white">Journal articles</h1>
<p className="mt-1 text-sm text-slate-400">
Edit the full bilingual body of each post.{' '}
{editedCount > 0
? `${editedCount} article${editedCount > 1 ? 's' : ''} customized.`
: 'All articles are at their defaults.'}{' '}
Titles, excerpts and read time live under the{' '}
<Link href="/admin/sections/blog" className="text-electric hover:underline">
Journal
</Link>{' '}
section.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{slugs.map((slug) => {
const card = cardBySlug.get(slug);
const post = posts[slug];
const edited = slug in overrides;
return (
<Link
key={slug}
href={`/admin/posts/${slug}`}
className="group rounded-xl border border-white/8 bg-white/[0.02] p-5 transition-colors hover:border-electric/30 hover:bg-electric/[0.03]"
>
<div className="flex items-start justify-between gap-3">
<h2 className="font-semibold leading-snug text-white group-hover:text-electric">
{card?.title ?? slug}
</h2>
{edited ? (
<span className="shrink-0 rounded-full border border-emerald/30 bg-emerald/5 px-2 py-0.5 font-mono text-[0.6rem] uppercase tracking-wider text-emerald">
edited
</span>
) : (
<span className="shrink-0 rounded-full border border-white/10 px-2 py-0.5 font-mono text-[0.6rem] uppercase tracking-wider text-slate-500">
default
</span>
)}
</div>
<div className="mt-2 flex items-center gap-3 font-mono text-[0.65rem] uppercase tracking-wider text-slate-500">
<span>{card?.category ?? '—'}</span>
<span>·</span>
<span>{post.date}</span>
</div>
</Link>
);
})}
</div>
</div>
</AdminShell>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { AdminShell } from '@/components/admin/AdminShell';
import { SectionEditor } from '@/components/admin/SectionEditor';
import type { JsonValue } from '@/components/admin/JsonForm';
import { isEditableKey, sectionLabel } from '@/lib/content/sections';
import { loadSection } from '@/lib/content/load';
import { getSection } from '@/lib/db/store';
// Always render on demand: the editor must reflect the current DB state, and
// generateStaticParams would otherwise bake build-time defaults into the page.
export const dynamic = 'force-dynamic';
export default function SectionEditorPage({ params }: { params: { key: string } }) {
const { key } = params;
if (!isEditableKey(key)) notFound();
const data = loadSection(key);
const label = sectionLabel(key);
const isOverridden = getSection(key) !== null;
return (
<AdminShell>
<div className="mx-auto max-w-3xl">
<Link
href="/admin"
className="font-mono text-[0.7rem] uppercase tracking-wider text-slate-500 transition-colors hover:text-electric"
>
Dashboard
</Link>
<h1 className="mt-3 text-2xl font-bold text-white">
{label.en}
<span className="ms-2 font-fa text-lg font-normal text-slate-500">
{label.fa}
</span>
</h1>
<p className="mb-6 mt-1 text-sm text-slate-400">
Edit both languages with the FA / EN tabs, then save. Changes go live immediately.
</p>
<SectionEditor
sectionKey={key}
title={label.en}
initial={{ fa: data.fa as JsonValue, en: data.en as JsonValue }}
isOverridden={isOverridden}
/>
</div>
</AdminShell>
);
}
+16
View File
@@ -0,0 +1,16 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Admin · Content CMS',
robots: { index: false, follow: false },
};
// Admin chrome is independent of the public LocaleProvider/Navbar; the
// route-group split means these pages never inherit the marketing layout.
export default function AdminRootLayout({
children,
}: {
children: React.ReactNode;
}) {
return <div className="min-h-screen bg-base-900">{children}</div>;
}
+46
View File
@@ -0,0 +1,46 @@
import { notFound } from 'next/navigation';
import { loadContent } from '@/lib/content/load';
import { loadPost } from '@/lib/content/posts-store';
import { BlogArticle } from '@/components/blog/BlogArticle';
// Live content: bodies and card meta both come from the CMS-merged tree, so
// admin edits show immediately. (No generateStaticParams — render on demand.)
export const dynamic = 'force-dynamic';
type Params = { slug: string };
export function generateMetadata({ params }: { params: Params }) {
const { en } = loadContent();
const post = en.blog.items.find((p) => p.slug === params.slug);
if (!post) return {};
return {
title: post.title,
description: post.excerpt,
openGraph: { title: post.title, description: post.excerpt, type: 'article' },
alternates: {
canonical: `/blog/${post.slug}`,
languages: {
'fa-IR': `/blog/${post.slug}`,
'en-US': `/blog/${post.slug}`,
},
},
};
}
export default function BlogPostPage({ params }: { params: Params }) {
const content = loadPost(params.slug);
const { en: enContent, fa: faContent } = loadContent();
const en = enContent.blog.items.find((p) => p.slug === params.slug);
const fa = faContent.blog.items.find((p) => p.slug === params.slug);
if (!content || !en || !fa) notFound();
return (
<BlogArticle
content={content}
meta={{
en: { title: en.title, category: en.category, readTime: en.readTime },
fa: { title: fa.title, category: fa.category, readTime: fa.readTime },
}}
/>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { LocaleProvider } from '@/lib/i18n/locale-context';
import { loadContent } from '@/lib/content/load';
import { Navbar } from '@/components/nav/Navbar';
import { CustomCursor } from '@/components/ui/CustomCursor';
/**
* Public site shell. Reads the live content tree (dict defaults merged with
* any admin overrides) on every request so edits made in the panel appear
* immediately, then feeds it to the client-side LocaleProvider.
*/
export const dynamic = 'force-dynamic';
export default function SiteLayout({ children }: { children: React.ReactNode }) {
const content = loadContent();
return (
<LocaleProvider content={content}>
{/* Ambient backdrop */}
<div
aria-hidden
className="pointer-events-none fixed inset-0 -z-10 bg-radial-aurora"
/>
<div
aria-hidden
className="pointer-events-none fixed inset-0 -z-10 bg-grid-faint bg-grid opacity-40"
style={{
maskImage:
'radial-gradient(ellipse at center, black 30%, transparent 75%)',
WebkitMaskImage:
'radial-gradient(ellipse at center, black 30%, transparent 75%)',
}}
/>
<CustomCursor />
<Navbar />
<main>{children}</main>
</LocaleProvider>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { Hero } from '@/components/hero/Hero';
import { Services } from '@/components/sections/Services';
import { DataFlow } from '@/components/sections/DataFlow';
import { Stack } from '@/components/sections/Stack';
import { Expertise } from '@/components/sections/Expertise';
import { Portfolio } from '@/components/sections/Portfolio';
import { Blog } from '@/components/sections/Blog';
import { Contact } from '@/components/sections/Contact';
import { Footer } from '@/components/sections/Footer';
export default function HomePage() {
return (
<>
<Hero />
<Services />
<DataFlow />
<Stack />
<Expertise />
<Portfolio />
<Blog />
<Contact />
<Footer />
</>
);
}
+84
View File
@@ -0,0 +1,84 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { dict, SERVICE_IDS, type ServiceId } from '@/lib/i18n/dictionaries';
type Params = { slug: string };
export function generateStaticParams() {
return SERVICE_IDS.map((slug) => ({ slug }));
}
export function generateMetadata({ params }: { params: Params }) {
const id = params.slug as ServiceId;
const fa = dict.fa.services.items.find((s) => s.id === id);
const en = dict.en.services.items.find((s) => s.id === id);
if (!en) return {};
return {
title: en.title,
description: en.description,
openGraph: { title: en.title, description: en.description },
alternates: { canonical: `/services/${id}`, languages: { 'fa-IR': `/services/${id}`, 'en-US': `/services/${id}` } },
other: { 'fa-title': fa?.title ?? '' },
};
}
export default function ServiceDetailPage({ params }: { params: Params }) {
const id = params.slug as ServiceId;
if (!SERVICE_IDS.includes(id)) notFound();
const en = dict.en.services.items.find((s) => s.id === id)!;
const fa = dict.fa.services.items.find((s) => s.id === id)!;
return (
<article className="relative px-5 py-32 sm:px-8">
<div className="mx-auto max-w-3xl">
<Link
href="/#services"
className="label-mono inline-flex items-center gap-2 text-slate-400 hover:text-electric"
>
{dict.en.nav.services}
</Link>
<h1 className="mt-6 font-display text-[clamp(2rem,4.5vw,3.4rem)] font-extrabold leading-tight text-white">
{en.title}
</h1>
<p
dir="rtl"
className="mt-2 font-fa text-[clamp(1.1rem,2vw,1.5rem)] text-slate-400"
>
{fa.title}
</p>
<div className="mt-8 flex flex-wrap gap-2">
{en.tags.map((t) => (
<span
key={t}
className="rounded-full border border-electric/30 bg-electric/5 px-3 py-1 font-mono text-xs text-electric"
>
{t}
</span>
))}
</div>
<p className="mt-10 text-[1.05rem] leading-relaxed text-slate-300">
{en.description}
</p>
<p
dir="rtl"
className="mt-6 font-fa text-[1rem] leading-loose text-slate-400"
>
{fa.description}
</p>
<div className="mt-12 flex flex-wrap gap-3">
<Link href="/#contact" className="btn-primary">
Book a consultation
</Link>
<Link href="/#services" className="btn-ghost">
All services
</Link>
</div>
</div>
</article>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server';
import {
SESSION_COOKIE,
SESSION_MAX_AGE,
createSession,
verifyPassword,
} from '@/lib/auth/session';
export const runtime = 'nodejs';
export async function POST(req: Request) {
let password = '';
try {
const body = await req.json();
password = typeof body?.password === 'string' ? body.password : '';
} catch {
return NextResponse.json({ error: 'bad request' }, { status: 400 });
}
if (!(await verifyPassword(password))) {
// Small constant delay-ish guard; password compare is already constant-time.
return NextResponse.json({ error: 'invalid' }, { status: 401 });
}
const token = await createSession();
const res = NextResponse.json({ ok: true });
res.cookies.set(SESSION_COOKIE, token, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: SESSION_MAX_AGE,
});
return res;
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
import { SESSION_COOKIE } from '@/lib/auth/session';
export const runtime = 'nodejs';
export async function POST() {
const res = NextResponse.json({ ok: true });
res.cookies.set(SESSION_COOKIE, '', {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: 0,
});
return res;
}
+97
View File
@@ -0,0 +1,97 @@
import { NextResponse } from 'next/server';
import { revalidatePath } from 'next/cache';
import { setSection, resetSection } from '@/lib/db/store';
import {
POSTS_KEY,
loadPost,
loadPostOverrides,
isKnownSlug,
} from '@/lib/content/posts-store';
import type { PostContent } from '@/lib/content/posts';
export const runtime = 'nodejs';
const ACCENTS = ['electric', 'violet', 'magenta', 'emerald', 'cyan'];
/** Minimal structural validation for an incoming PostContent payload. */
function validPost(data: unknown): data is {
date: string;
accent: string;
en: { lead: string; blocks: unknown[] };
fa: { lead: string; blocks: unknown[] };
} {
if (!data || typeof data !== 'object') return false;
const d = data as Record<string, unknown>;
if (typeof d.date !== 'string') return false;
if (typeof d.accent !== 'string' || !ACCENTS.includes(d.accent)) return false;
for (const loc of ['en', 'fa'] as const) {
const art = d[loc] as Record<string, unknown> | undefined;
if (!art || typeof art !== 'object') return false;
if (typeof art.lead !== 'string') return false;
if (!Array.isArray(art.blocks)) return false;
}
return true;
}
// GET ?slug=rag-eval-framework -> live (merged) article + override flag
export async function GET(req: Request) {
const slug = new URL(req.url).searchParams.get('slug') ?? '';
if (!isKnownSlug(slug)) {
return NextResponse.json({ error: 'unknown post' }, { status: 400 });
}
return NextResponse.json({
slug,
post: loadPost(slug),
overridden: slug in loadPostOverrides(),
});
}
// POST { slug, data: PostContent } -> save the article override
export async function POST(req: Request) {
let body: { slug?: string; data?: unknown };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'bad json' }, { status: 400 });
}
const slug = body.slug ?? '';
if (!isKnownSlug(slug)) {
return NextResponse.json({ error: 'unknown post' }, { status: 400 });
}
if (!validPost(body.data)) {
return NextResponse.json(
{ error: 'expected a { date, accent, en:{lead,blocks}, fa:{lead,blocks} } payload' },
{ status: 400 },
);
}
const overrides = loadPostOverrides();
// validPost has confirmed the shape (incl. accent ∈ ACCENTS) above.
overrides[slug] = body.data as unknown as PostContent;
setSection(POSTS_KEY, overrides);
revalidatePath(`/blog/${slug}`);
revalidatePath('/', 'layout');
return NextResponse.json({ ok: true });
}
// DELETE ?slug=… -> revert one article to its in-code default
export async function DELETE(req: Request) {
const slug = new URL(req.url).searchParams.get('slug') ?? '';
if (!isKnownSlug(slug)) {
return NextResponse.json({ error: 'unknown post' }, { status: 400 });
}
const overrides = loadPostOverrides();
delete overrides[slug];
if (Object.keys(overrides).length === 0) {
resetSection(POSTS_KEY);
} else {
setSection(POSTS_KEY, overrides);
}
revalidatePath(`/blog/${slug}`);
revalidatePath('/', 'layout');
return NextResponse.json({ ok: true });
}
+49
View File
@@ -0,0 +1,49 @@
import { NextResponse } from 'next/server';
import { revalidatePath } from 'next/cache';
import { setSection, resetSection, getSection } from '@/lib/db/store';
import { isEditableKey } from '@/lib/content/sections';
export const runtime = 'nodejs';
// GET ?key=hero -> current stored override (or null)
export async function GET(req: Request) {
const key = new URL(req.url).searchParams.get('key') ?? '';
if (!isEditableKey(key)) {
return NextResponse.json({ error: 'unknown section' }, { status: 400 });
}
return NextResponse.json({ key, override: getSection(key) });
}
// POST { key, data: { fa, en } } -> save override
export async function POST(req: Request) {
let body: { key?: string; data?: { fa?: unknown; en?: unknown } };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'bad json' }, { status: 400 });
}
const key = body.key ?? '';
if (!isEditableKey(key)) {
return NextResponse.json({ error: 'unknown section' }, { status: 400 });
}
if (!body.data || typeof body.data !== 'object' || !('fa' in body.data) || !('en' in body.data)) {
return NextResponse.json({ error: 'expected { fa, en } payload' }, { status: 400 });
}
setSection(key, { fa: body.data.fa, en: body.data.en });
// (site) layout is force-dynamic, but revalidate keeps any cached routes fresh.
revalidatePath('/', 'layout');
return NextResponse.json({ ok: true });
}
// DELETE ?key=hero -> revert to in-code default
export async function DELETE(req: Request) {
const key = new URL(req.url).searchParams.get('key') ?? '';
if (!isEditableKey(key)) {
return NextResponse.json({ error: 'unknown section' }, { status: 400 });
}
resetSection(key);
revalidatePath('/', 'layout');
return NextResponse.json({ ok: true });
}
+44
View File
@@ -0,0 +1,44 @@
import { NextResponse } from 'next/server';
import { mkdir, writeFile } from 'node:fs/promises';
import { extname, join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { UPLOADS_DIR } from '@/lib/db/store';
export const runtime = 'nodejs';
const ALLOWED = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.svg', '.avif']);
const MAX_BYTES = 8 * 1024 * 1024; // 8 MB
export async function POST(req: Request) {
let form: FormData;
try {
form = await req.formData();
} catch {
return NextResponse.json({ error: 'expected multipart form' }, { status: 400 });
}
const file = form.get('file');
if (!(file instanceof File)) {
return NextResponse.json({ error: 'no file' }, { status: 400 });
}
if (file.size > MAX_BYTES) {
return NextResponse.json({ error: 'file too large (max 8MB)' }, { status: 413 });
}
const ext = extname(file.name).toLowerCase();
if (!ALLOWED.has(ext)) {
return NextResponse.json({ error: `unsupported type ${ext}` }, { status: 415 });
}
const name = `${Date.now()}-${randomUUID().slice(0, 8)}${ext}`;
const buffer = Buffer.from(await file.arrayBuffer());
try {
await mkdir(UPLOADS_DIR, { recursive: true });
await writeFile(join(UPLOADS_DIR, name), buffer);
} catch {
return NextResponse.json({ error: 'write failed' }, { status: 500 });
}
return NextResponse.json({ url: `/api/uploads/${name}`, name });
}
+99
View File
@@ -0,0 +1,99 @@
import { NextResponse } from 'next/server';
import { Resend } from 'resend';
export const runtime = 'edge';
type ContactPayload = {
name?: string;
company?: string;
service?: string;
budget?: string;
message?: string;
locale?: 'fa' | 'en';
};
const required = ['name', 'service', 'budget', 'message'] as const;
function escape(str: string) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
export async function POST(req: Request) {
let body: ContactPayload;
try {
body = (await req.json()) as ContactPayload;
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
for (const k of required) {
if (!body[k] || String(body[k]).trim().length < 2) {
return NextResponse.json(
{ error: `Missing field: ${k}` },
{ status: 422 },
);
}
}
const apiKey = process.env.RESEND_API_KEY;
const inbox = process.env.CONTACT_INBOX;
const from = process.env.CONTACT_FROM;
// Graceful no-op for local dev so the form UX can be validated
// without forcing a Resend key. Production should set these.
if (!apiKey || !inbox || !from) {
if (process.env.NODE_ENV === 'production') {
return NextResponse.json(
{ error: 'Email service is not configured' },
{ status: 500 },
);
}
console.info('[contact] received (no Resend key — logging only):', body);
return NextResponse.json({ ok: true, dev: true });
}
const resend = new Resend(apiKey);
const subject = `New consultation request — ${body.name}`;
const html = `
<div style="font-family: ui-sans-serif, system-ui, sans-serif; line-height: 1.55;">
<h2 style="margin: 0 0 12px;">New consultation request</h2>
<table style="border-collapse: collapse;">
<tr><td style="padding: 4px 12px 4px 0; color:#475569;">Name</td><td>${escape(body.name!)}</td></tr>
<tr><td style="padding: 4px 12px 4px 0; color:#475569;">Company</td><td>${escape(body.company ?? '')}</td></tr>
<tr><td style="padding: 4px 12px 4px 0; color:#475569;">Service</td><td>${escape(body.service!)}</td></tr>
<tr><td style="padding: 4px 12px 4px 0; color:#475569;">Budget</td><td>${escape(body.budget!)}</td></tr>
<tr><td style="padding: 4px 12px 4px 0; color:#475569;">Locale</td><td>${escape(body.locale ?? '')}</td></tr>
</table>
<h3 style="margin: 20px 0 6px;">Message</h3>
<p style="white-space: pre-wrap; background:#f8fafc; padding:12px; border-radius:8px;">${escape(body.message!)}</p>
</div>
`;
try {
const { error } = await resend.emails.send({
from,
to: inbox,
subject,
html,
replyTo: body.company ? `${body.name} <${body.company}>` : undefined,
});
if (error) {
console.error('[contact] resend error', error);
return NextResponse.json(
{ error: 'Email service rejected the request' },
{ status: 502 },
);
}
return NextResponse.json({ ok: true });
} catch (err) {
console.error('[contact] send failed', err);
return NextResponse.json(
{ error: 'Email service unreachable' },
{ status: 502 },
);
}
}
+45
View File
@@ -0,0 +1,45 @@
import { NextResponse } from 'next/server';
import { readFile, stat } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
import { UPLOADS_DIR } from '@/lib/db/store';
export const runtime = 'nodejs';
const MIME: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.avif': 'image/avif',
};
// Serves admin-uploaded media from the DATA_DIR volume. Public (not gated by
// middleware) so images render on the marketing site.
export async function GET(
_req: Request,
{ params }: { params: { path: string[] } },
) {
const rel = normalize(params.path.join('/'));
// Reject path traversal — the resolved file must stay inside UPLOADS_DIR.
if (rel.includes('..') || rel.startsWith('/') || rel.startsWith('\\')) {
return new NextResponse('bad path', { status: 400 });
}
const filePath = join(UPLOADS_DIR, rel);
try {
const info = await stat(filePath);
if (!info.isFile()) return new NextResponse('not found', { status: 404 });
const buf = await readFile(filePath);
const type = MIME[extname(filePath).toLowerCase()] ?? 'application/octet-stream';
return new NextResponse(buf, {
headers: {
'content-type': type,
'cache-control': 'public, max-age=31536000, immutable',
},
});
} catch {
return new NextResponse('not found', { status: 404 });
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+182
View File
@@ -0,0 +1,182 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ---------- Base ---------- */
:root {
--bg: #020510;
--fg: #e2e8f0;
--muted: #94a3b8;
--electric: #38bdf8;
--violet: #818cf8;
--magenta: #e879f9;
--emerald: #34d399;
--cyan: #22d3ee;
--radius: 14px;
color-scheme: dark;
}
html,
body {
background: var(--bg);
color: var(--fg);
font-feature-settings: 'ss01', 'cv11';
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html {
scroll-behavior: smooth;
}
html[dir='rtl'] body {
font-family: var(--font-vaz-ar), var(--font-vaz-lat), var(--font-syne),
system-ui, sans-serif;
}
html[dir='ltr'] body {
font-family: var(--font-syne), var(--font-vaz-ar), var(--font-vaz-lat),
system-ui, sans-serif;
}
/* ---------- Selection ---------- */
::selection {
background: rgba(56, 189, 248, 0.35);
color: #f8fafc;
}
/* ---------- Scrollbar ---------- */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: #050a1a;
}
::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #38bdf8, #818cf8);
border-radius: 999px;
border: 2px solid #050a1a;
}
/* ---------- Hide native cursor on desktop when custom cursor is active ---------- */
@media (min-width: 900px) {
html.has-cursor,
html.has-cursor * {
cursor: none !important;
}
}
/* ---------- Glass surface ---------- */
@layer components {
.glass {
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.04) 0%,
rgba(255, 255, 255, 0.015) 100%
);
border: 1px solid rgba(56, 189, 248, 0.14);
backdrop-filter: blur(14px);
box-shadow:
inset 0 1px 0 0 rgba(255, 255, 255, 0.06),
0 30px 60px -30px rgba(0, 0, 0, 0.6);
border-radius: var(--radius);
}
.hairline {
border: 1px solid rgba(255, 255, 255, 0.06);
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.75rem;
border-radius: 999px;
border: 1px solid rgba(52, 211, 153, 0.25);
background: rgba(52, 211, 153, 0.06);
color: #a7f3d0;
font-family: var(--font-space-mono), ui-monospace, monospace;
font-size: 0.72rem;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.label-mono {
font-family: var(--font-space-mono), ui-monospace, monospace;
font-size: 0.7rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: #94a3b8;
}
.gradient-text {
background: linear-gradient(135deg, #38bdf8 0%, #818cf8 45%, #e879f9 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
background-size: 200% 200%;
animation: gradient-pan 8s ease-in-out infinite;
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.6rem;
padding: 0.85rem 1.4rem;
border-radius: 999px;
font-weight: 600;
color: #020510;
background: linear-gradient(135deg, #38bdf8 0%, #818cf8 60%, #e879f9 100%);
background-size: 200% 200%;
transition:
transform 0.25s ease,
box-shadow 0.25s ease,
background-position 0.6s ease;
box-shadow: 0 12px 40px -12px rgba(56, 189, 248, 0.55);
}
.btn-primary:hover {
transform: translateY(-1px);
background-position: 100% 0;
box-shadow: 0 18px 50px -10px rgba(232, 121, 249, 0.55);
}
.btn-ghost {
display: inline-flex;
align-items: center;
gap: 0.6rem;
padding: 0.8rem 1.35rem;
border-radius: 999px;
font-weight: 500;
color: #e2e8f0;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.02);
transition:
border-color 0.25s ease,
background 0.25s ease,
transform 0.25s ease;
}
.btn-ghost:hover {
border-color: rgba(56, 189, 248, 0.6);
background: rgba(56, 189, 248, 0.06);
transform: translateY(-1px);
}
}
/* ---------- Persian numerals helper ---------- */
.fa-nums {
font-feature-settings: 'ss01';
}
/* ---------- Reduced motion ---------- */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
}
+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#38bdf8"/>
<stop offset="55%" stop-color="#818cf8"/>
<stop offset="100%" stop-color="#e879f9"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="#020510"/>
<path d="M32 6 L55 19 V45 L32 58 L9 45 V19 Z" stroke="url(#g)" stroke-width="2" fill="rgba(56,189,248,0.08)"/>
<path d="M22 23 Q22 19 26 19 H38 Q42 19 42 23 Q42 27 38 27 H26 Q22 27 22 32 Q22 37 26 37 H38 Q42 37 42 41 Q42 45 38 45 H22"
stroke="url(#g)" stroke-width="2.4" stroke-linecap="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 730 B

+96
View File
@@ -0,0 +1,96 @@
import type { Metadata, Viewport } from 'next';
import localFont from 'next/font/local';
import { dict } from '@/lib/i18n/dictionaries';
import './globals.css';
/**
* Fonts are self-hosted (woff2 in ./fonts) rather than fetched from Google
* at build time — Google Fonts is unreliable behind some networks, which is
* why the Persian face previously failed to load. All files ship in-repo.
*
* Vazirmatn is split into Arabic + Latin subsets. We expose each as its own
* CSS variable and chain them in the font stacks (Arabic first), so Persian
* glyphs resolve from the Arabic file and Latin characters fall through to
* the Latin file via normal font-family fallback.
*/
const vazirmatnArabic = localFont({
src: './fonts/Vazirmatn-Arabic.woff2',
weight: '100 900',
style: 'normal',
display: 'swap',
variable: '--font-vaz-ar',
// Cover the Arabic/Persian block so the browser knows to use this face.
declarations: [{ prop: 'unicode-range', value: 'U+0600-06FF, U+0750-077F, U+08A0-08FF, U+FB50-FDFF, U+FE70-FEFF, U+200C-200D' }],
});
const vazirmatnLatin = localFont({
src: './fonts/Vazirmatn-Latin.woff2',
weight: '100 900',
style: 'normal',
display: 'swap',
variable: '--font-vaz-lat',
});
const syne = localFont({
src: './fonts/Syne-Variable.woff2',
weight: '400 800',
style: 'normal',
display: 'swap',
variable: '--font-syne',
});
const spaceMono = localFont({
src: [
{ path: './fonts/SpaceMono-Regular.woff2', weight: '400', style: 'normal' },
{ path: './fonts/SpaceMono-Bold.woff2', weight: '700', style: 'normal' },
],
display: 'swap',
variable: '--font-space-mono',
});
export const viewport: Viewport = {
themeColor: '#020510',
width: 'device-width',
initialScale: 1,
};
export const metadata: Metadata = {
metadataBase: new URL('https://soroushasadi.ir'),
title: {
default: dict.fa.meta.title,
template: '%s — Soroush Asadi',
},
description: dict.fa.meta.description,
alternates: {
canonical: '/',
languages: {
'fa-IR': '/',
'en-US': '/',
},
},
openGraph: {
type: 'website',
title: dict.fa.meta.title,
description: dict.fa.meta.description,
siteName: 'Soroush Asadi',
images: ['/avatar.svg'],
},
twitter: {
card: 'summary_large_image',
title: dict.en.meta.title,
description: dict.en.meta.description,
images: ['/avatar.svg'],
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="fa" dir="rtl" suppressHydrationWarning>
<body
className={`${vazirmatnArabic.variable} ${vazirmatnLatin.variable} ${syne.variable} ${spaceMono.variable} min-h-screen bg-base text-slate-200 antialiased`}
>
{children}
</body>
</html>
);
}