first commit
@@ -0,0 +1,14 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
data
|
||||||
|
npm-debug.log*
|
||||||
|
.env*.local
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
*.tsbuildinfo
|
||||||
|
README.md
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
docker-compose.yml
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Resend API key — get from https://resend.com
|
||||||
|
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
|
||||||
|
# Inbox that receives contact-form submissions
|
||||||
|
CONTACT_INBOX=hello@soroushasadi.ir
|
||||||
|
|
||||||
|
# Verified sender domain on Resend
|
||||||
|
CONTACT_FROM="Soroush Asadi <noreply@soroushasadi.ir>"
|
||||||
|
|
||||||
|
# --- Admin CMS ---------------------------------------------------------------
|
||||||
|
# Single password that unlocks the /admin content panel.
|
||||||
|
# REQUIRED in production (no default is used when NODE_ENV=production).
|
||||||
|
# In development it falls back to "admin" if unset.
|
||||||
|
ADMIN_PASSWORD=change-me-to-a-long-random-string
|
||||||
|
|
||||||
|
# Secret used to sign the admin session cookie (HMAC-SHA256).
|
||||||
|
# Set to a long random value; if unset it derives from ADMIN_PASSWORD.
|
||||||
|
ADMIN_SESSION_SECRET=another-long-random-string
|
||||||
|
|
||||||
|
# Where the SQLite content DB and uploaded images live. On the Docker
|
||||||
|
# deployment this is a mounted volume so content survives rebuilds.
|
||||||
|
# Defaults to ./data when unset.
|
||||||
|
DATA_DIR=./data
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
name: ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches-ignore:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: self-hosted
|
||||||
|
|
||||||
|
env:
|
||||||
|
DOCKER_BUILDKIT: 1
|
||||||
|
|
||||||
|
steps:
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ github.token }}
|
||||||
|
REF: ${{ github.ref }}
|
||||||
|
run: |
|
||||||
|
git init
|
||||||
|
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
|
||||||
|
git config http.extraheader "Authorization: Bearer ${TOKEN}"
|
||||||
|
git fetch --depth=1 origin "${REF}"
|
||||||
|
git checkout FETCH_HEAD
|
||||||
|
|
||||||
|
- name: Docker Build Test
|
||||||
|
env:
|
||||||
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
run: |
|
||||||
|
docker build \
|
||||||
|
--build-arg NPM_TOKEN="$NPM_TOKEN" \
|
||||||
|
-t soroushasadi-site:test .
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
name: deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: self-hosted
|
||||||
|
|
||||||
|
env:
|
||||||
|
DOCKER_BUILDKIT: 1
|
||||||
|
COMPOSE_DOCKER_CLI_BUILD: 1
|
||||||
|
|
||||||
|
steps:
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ github.token }}
|
||||||
|
REF: ${{ github.ref }}
|
||||||
|
run: |
|
||||||
|
git init
|
||||||
|
git remote remove origin 2>/dev/null || true
|
||||||
|
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
|
||||||
|
git config http.extraheader "Authorization: Bearer ${TOKEN}"
|
||||||
|
git fetch --depth=1 origin "${REF}"
|
||||||
|
git checkout FETCH_HEAD
|
||||||
|
|
||||||
|
- name: Create Environment File
|
||||||
|
run: |
|
||||||
|
cat > .env << EOF
|
||||||
|
ADMIN_PASSWORD=${{ secrets.ADMIN_PASSWORD }}
|
||||||
|
ADMIN_SESSION_SECRET=${{ secrets.ADMIN_SESSION_SECRET }}
|
||||||
|
RESEND_API_KEY=${{ secrets.RESEND_API_KEY }}
|
||||||
|
CONTACT_INBOX=${{ secrets.CONTACT_INBOX }}
|
||||||
|
CONTACT_FROM=${{ secrets.CONTACT_FROM }}
|
||||||
|
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Build Container
|
||||||
|
run: |
|
||||||
|
docker compose build
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
run: |
|
||||||
|
docker compose up -d --remove-orphans
|
||||||
|
|
||||||
|
- name: Wait For Health Check
|
||||||
|
run: |
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
|
||||||
|
STATUS=$(docker inspect \
|
||||||
|
--format='{{.State.Health.Status}}' \
|
||||||
|
soroushasadi-site 2>/dev/null)
|
||||||
|
|
||||||
|
echo "Status: $STATUS"
|
||||||
|
|
||||||
|
if [ "$STATUS" = "healthy" ]; then
|
||||||
|
echo "Deployment successful"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|
||||||
|
docker logs soroushasadi-site --tail 100
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Cleanup
|
||||||
|
if: success()
|
||||||
|
run: |
|
||||||
|
docker image prune -f
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# dependencies
|
||||||
|
node_modules
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# production
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# env
|
||||||
|
.env*.local
|
||||||
|
.env
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# CMS data (SQLite DB + uploaded media live in the mounted volume)
|
||||||
|
/data
|
||||||
|
|
||||||
|
# local tooling / agent state
|
||||||
|
.claude/
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# All npm traffic is proxied through the Nexus npm-group repository. npm rewrites
|
||||||
|
# the registry.npmjs.org hosts found in package-lock.json to this mirror at
|
||||||
|
# install time (default replace-registry-host=npmjs), so the committed lockfile
|
||||||
|
# is reused as-is — no regeneration needed.
|
||||||
|
registry=https://mirror.soroushasadi.com/repository/npm-group/
|
||||||
|
|
||||||
|
# Auth is never committed. CI and the Docker build append an `_authToken` line
|
||||||
|
# from the NPM_TOKEN secret at install time; for local installs put the token in
|
||||||
|
# your personal ~/.npmrc. See .gitea/workflows/*.yml.
|
||||||
|
|
||||||
|
# Trim install noise and avoid extra round-trips to the public registry.
|
||||||
|
audit=false
|
||||||
|
fund=false
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# All base images flow through the Nexus docker-group proxy, which aggregates
|
||||||
|
# Docker Hub, Microsoft Container Registry (mcr.microsoft.com) and GitHub
|
||||||
|
# Container Registry (ghcr.io) behind one path — any upstream image is reachable
|
||||||
|
# as mirror.soroushasadi.com/repository/docker-group/<image>.
|
||||||
|
# Build directly against Docker Hub instead with:
|
||||||
|
# --build-arg NODE_IMAGE=node:20-slim
|
||||||
|
ARG NODE_IMAGE=node:20-slim
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Dependencies — installs node_modules and compiles the better-sqlite3
|
||||||
|
# native addon (needs python3 + a C++ toolchain).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
FROM ${NODE_IMAGE} AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends python3 make g++ ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
# .npmrc points npm at the Nexus npm-group; NPM_TOKEN (optional) authenticates.
|
||||||
|
# The token is written only into this build stage and never reaches the runner
|
||||||
|
# image, which copies node_modules — not .npmrc.
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN if [ -n "$NPM_TOKEN" ]; then \
|
||||||
|
echo "//mirror.soroushasadi.com/repository/npm-group/:_authToken=${NPM_TOKEN}" >> .npmrc ; \
|
||||||
|
fi \
|
||||||
|
&& npm ci
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Builder — produces the standalone Next.js server bundle.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
FROM ${NODE_IMAGE} AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. Runner — minimal runtime image. Content DB + uploads live in /data,
|
||||||
|
# which is a mounted volume so they survive image rebuilds.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
FROM ${NODE_IMAGE} AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
NEXT_TELEMETRY_DISABLED=1 \
|
||||||
|
PORT=3000 \
|
||||||
|
HOSTNAME=0.0.0.0 \
|
||||||
|
DATA_DIR=/data
|
||||||
|
|
||||||
|
RUN groupadd -g 1001 nodejs && useradd -u 1001 -g nodejs -m nextjs
|
||||||
|
|
||||||
|
# Standalone server, static assets, and the public/ tree (portfolio art etc.).
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
|
# Native module + its loaders. Next's file tracing usually copies these, but
|
||||||
|
# we copy the compiled .node and bindings explicitly as a safety net.
|
||||||
|
COPY --from=builder /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3
|
||||||
|
COPY --from=builder /app/node_modules/bindings ./node_modules/bindings
|
||||||
|
COPY --from=builder /app/node_modules/file-uri-to-path ./node_modules/file-uri-to-path
|
||||||
|
|
||||||
|
RUN mkdir -p /data/uploads && chown -R nextjs:nodejs /data /app
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
VOLUME ["/data"]
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
services:
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
# Pull the base image through the Nexus docker-group proxy. Override per
|
||||||
|
# environment, e.g. NODE_IMAGE=node:20-slim docker compose build.
|
||||||
|
NODE_IMAGE: node:20-slim
|
||||||
|
# Optional Nexus npm-group token, consumed only by the deps stage.
|
||||||
|
NPM_TOKEN: ${NPM_TOKEN:-}
|
||||||
|
image: soroushasadi-site:latest
|
||||||
|
container_name: soroushasadi-site
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
DATA_DIR: /data
|
||||||
|
# Set these in a sibling .env file (NOT committed) or your host env.
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD}
|
||||||
|
ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET:?set ADMIN_SESSION_SECRET}
|
||||||
|
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||||
|
CONTACT_INBOX: ${CONTACT_INBOX:-}
|
||||||
|
CONTACT_FROM: ${CONTACT_FROM:-}
|
||||||
|
volumes:
|
||||||
|
# SQLite content DB + uploaded media persist across rebuilds.
|
||||||
|
- cms-data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
cms-data:
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Stateless admin session — a single password gates the whole CMS.
|
||||||
|
*
|
||||||
|
* On login we mint an HMAC-signed token (payload + signature) and store it in
|
||||||
|
* an httpOnly cookie. Verification re-computes the HMAC and checks expiry.
|
||||||
|
* Everything here uses the Web Crypto API only (no `node:crypto`) so the same
|
||||||
|
* code runs in Edge middleware AND in Node route handlers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SESSION_COOKIE = 'sa_admin';
|
||||||
|
export const SESSION_MAX_AGE = 60 * 60 * 24 * 7; // 7 days, in seconds
|
||||||
|
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const dec = new TextDecoder();
|
||||||
|
|
||||||
|
/** Signing secret. In production set ADMIN_SESSION_SECRET (or it derives from
|
||||||
|
* ADMIN_PASSWORD). A loud, obviously-insecure default keeps dev frictionless. */
|
||||||
|
function getSecret(): string {
|
||||||
|
return (
|
||||||
|
process.env.ADMIN_SESSION_SECRET ||
|
||||||
|
process.env.ADMIN_PASSWORD ||
|
||||||
|
'dev-insecure-secret-change-me'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The single admin password. Falls back to "admin" in non-production only. */
|
||||||
|
function getPassword(): string | undefined {
|
||||||
|
if (process.env.ADMIN_PASSWORD) return process.env.ADMIN_PASSWORD;
|
||||||
|
if (process.env.NODE_ENV !== 'production') return 'admin';
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toB64url(bytes: ArrayBuffer | Uint8Array): string {
|
||||||
|
const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
||||||
|
let bin = '';
|
||||||
|
for (let i = 0; i < arr.length; i++) bin += String.fromCharCode(arr[i]);
|
||||||
|
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromB64url(str: string): Uint8Array {
|
||||||
|
const b64 = str.replace(/-/g, '+').replace(/_/g, '/') + '=='.slice((str.length + 3) % 4);
|
||||||
|
const bin = atob(b64);
|
||||||
|
const out = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hmacKey(): Promise<CryptoKey> {
|
||||||
|
return crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
enc.encode(getSecret()),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['sign', 'verify'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256Hex(input: string): Promise<string> {
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', enc.encode(input));
|
||||||
|
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Constant-time comparison of two equal-length hex strings. */
|
||||||
|
function timingSafeEqual(a: string, b: string): boolean {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
let diff = 0;
|
||||||
|
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||||
|
return diff === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mint a signed session token. */
|
||||||
|
export async function createSession(): Promise<string> {
|
||||||
|
const payload = { iat: Date.now(), exp: Date.now() + SESSION_MAX_AGE * 1000 };
|
||||||
|
const data = toB64url(enc.encode(JSON.stringify(payload)));
|
||||||
|
const sig = await crypto.subtle.sign('HMAC', await hmacKey(), enc.encode(data));
|
||||||
|
return `${data}.${toB64url(sig)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the token's signature is valid and it has not expired. */
|
||||||
|
export async function verifySession(token?: string | null): Promise<boolean> {
|
||||||
|
if (!token) return false;
|
||||||
|
const dot = token.indexOf('.');
|
||||||
|
if (dot <= 0) return false;
|
||||||
|
const data = token.slice(0, dot);
|
||||||
|
const sig = token.slice(dot + 1);
|
||||||
|
try {
|
||||||
|
const valid = await crypto.subtle.verify(
|
||||||
|
'HMAC',
|
||||||
|
await hmacKey(),
|
||||||
|
fromB64url(sig),
|
||||||
|
enc.encode(data),
|
||||||
|
);
|
||||||
|
if (!valid) return false;
|
||||||
|
const payload = JSON.parse(dec.decode(fromB64url(data))) as { exp?: number };
|
||||||
|
return typeof payload.exp === 'number' && payload.exp > Date.now();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check a submitted password against the configured one (constant-time). */
|
||||||
|
export async function verifyPassword(input: string): Promise<boolean> {
|
||||||
|
const expected = getPassword();
|
||||||
|
if (!expected) return false; // No password set in production => locked out.
|
||||||
|
const [a, b] = await Promise.all([sha256Hex(input), sha256Hex(expected)]);
|
||||||
|
return timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether an admin password is configured (used to warn in the UI). */
|
||||||
|
export function passwordConfigured(): boolean {
|
||||||
|
return Boolean(getPassword());
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import 'server-only';
|
||||||
|
import { dict, type Dict } from '@/lib/i18n/dictionaries';
|
||||||
|
import { getAllSections } from '@/lib/db/store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shape handed to the client: a fully-resolved bilingual content tree.
|
||||||
|
* It is structurally identical to `dict` so `LocaleProvider` can drop it in
|
||||||
|
* without any component being aware the data now comes from a database.
|
||||||
|
*/
|
||||||
|
export type SiteContent = { fa: Dict; en: Dict };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the live content tree: start from the in-code `dict` defaults, then
|
||||||
|
* overlay any per-section overrides saved through the admin panel. Each stored
|
||||||
|
* override is `{ fa, en }` for one top-level section key and replaces that
|
||||||
|
* subtree wholesale (the admin always edits and saves a complete section).
|
||||||
|
*/
|
||||||
|
export function loadContent(): SiteContent {
|
||||||
|
// Shallow clone the locale roots so we can swap section subtrees safely.
|
||||||
|
// `as const` gives fa/en distinct literal types, so cast through unknown.
|
||||||
|
const fa = { ...dict.fa } as unknown as Dict;
|
||||||
|
const en = { ...dict.en } as unknown as Dict;
|
||||||
|
|
||||||
|
for (const row of getAllSections()) {
|
||||||
|
let payload: { fa?: unknown; en?: unknown };
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(row.data);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = row.key as keyof Dict;
|
||||||
|
if (payload.fa !== undefined) (fa as Record<string, unknown>)[key] = payload.fa;
|
||||||
|
if (payload.en !== undefined) (en as Record<string, unknown>)[key] = payload.en;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { fa, en };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a single section for the admin editor: the saved override if one
|
||||||
|
* exists, otherwise the in-code default for both locales.
|
||||||
|
*/
|
||||||
|
export function loadSection(key: keyof Dict): { fa: unknown; en: unknown } {
|
||||||
|
for (const row of getAllSections()) {
|
||||||
|
if (row.key !== key) continue;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(row.data);
|
||||||
|
return {
|
||||||
|
fa: payload.fa ?? dict.fa[key],
|
||||||
|
en: payload.en ?? dict.en[key],
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { fa: dict.fa[key], en: dict.en[key] };
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import 'server-only';
|
||||||
|
import { POSTS, POST_SLUGS, type PostContent } from '@/lib/content/posts';
|
||||||
|
import { getSection } from '@/lib/db/store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live blog bodies = in-code `POSTS` defaults overlaid with admin edits.
|
||||||
|
*
|
||||||
|
* Unlike the `{ fa, en }` section overrides, the blog override stored under the
|
||||||
|
* `posts` key is a *partial* map of `slug -> PostContent` holding only the
|
||||||
|
* articles that have been edited. Reverting a single article just drops its key
|
||||||
|
* from that map, so the in-code default shows through again.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const POSTS_KEY = 'posts';
|
||||||
|
|
||||||
|
/** Only the edited articles (empty when nothing has been customized). */
|
||||||
|
export function loadPostOverrides(): Record<string, PostContent> {
|
||||||
|
try {
|
||||||
|
const row = getSection(POSTS_KEY);
|
||||||
|
if (row && row.data && typeof row.data === 'object' && !Array.isArray(row.data)) {
|
||||||
|
return row.data as Record<string, PostContent>;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// A missing or locked DB must never crash a public render — defaults only.
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Defaults merged with overrides — the full, live article set. */
|
||||||
|
export function loadAllPosts(): Record<string, PostContent> {
|
||||||
|
return { ...POSTS, ...loadPostOverrides() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadPost(slug: string): PostContent | undefined {
|
||||||
|
return loadAllPosts()[slug];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPostSlugs(): string[] {
|
||||||
|
return Object.keys(loadAllPosts());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A slug is editable only if it ships with a default (and thus a blog card). */
|
||||||
|
export function isKnownSlug(slug: string): boolean {
|
||||||
|
return (POST_SLUGS as string[]).includes(slug);
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
/**
|
||||||
|
* Full article bodies for the blog, seeded for production.
|
||||||
|
* Metadata (title, excerpt, category, readTime) lives in the i18n dict;
|
||||||
|
* this module holds the long-form body in both locales.
|
||||||
|
*
|
||||||
|
* When the admin panel / CMS lands, this file becomes the seed source —
|
||||||
|
* the shape maps 1:1 to a `posts` table.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Block =
|
||||||
|
| { k: 'p'; t: string }
|
||||||
|
| { k: 'h2'; t: string }
|
||||||
|
| { k: 'ul'; items: string[] }
|
||||||
|
| { k: 'quote'; t: string }
|
||||||
|
| { k: 'code'; lang?: string; t: string };
|
||||||
|
|
||||||
|
export type Article = { lead: string; blocks: Block[] };
|
||||||
|
|
||||||
|
export type PostContent = {
|
||||||
|
/** ISO date */
|
||||||
|
date: string;
|
||||||
|
/** accent key used for the cover gradient */
|
||||||
|
accent: 'electric' | 'violet' | 'magenta' | 'emerald' | 'cyan';
|
||||||
|
en: Article;
|
||||||
|
fa: Article;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POSTS: Record<string, PostContent> = {
|
||||||
|
'rag-eval-framework': {
|
||||||
|
date: '2026-04-22',
|
||||||
|
accent: 'magenta',
|
||||||
|
en: {
|
||||||
|
lead: 'Most RAG systems are shipped on a vibe. A demo answers three questions well, everyone nods, and it goes to production untested. Here is the evaluation framework I install before a single user touches it.',
|
||||||
|
blocks: [
|
||||||
|
{ k: 'h2', t: 'Why BLEU and ROUGE fail you' },
|
||||||
|
{ k: 'p', t: 'BLEU and ROUGE measure n-gram overlap with a reference answer. For translation that is fine. For retrieval-augmented generation it is misleading: a correct answer phrased differently scores low, and a fluent hallucination that happens to reuse words scores high. You end up optimizing for surface similarity instead of truth.' },
|
||||||
|
{ k: 'p', t: 'The fix is to split evaluation into two independent layers — retrieval quality and answer quality — and never average them into a single vanity number.' },
|
||||||
|
{ k: 'h2', t: 'Layer one: retrieval' },
|
||||||
|
{ k: 'p', t: 'Before the model writes anything, ask whether the right context was even fetched. Build a labelled set of question → gold-chunk pairs and track these:' },
|
||||||
|
{ k: 'ul', items: [
|
||||||
|
'Recall@k — did the gold chunk appear in the top k results?',
|
||||||
|
'MRR — how high did it rank when it did appear?',
|
||||||
|
'Context precision — what fraction of retrieved chunks were actually relevant?',
|
||||||
|
] },
|
||||||
|
{ k: 'p', t: 'If recall@5 is below 0.9, no amount of prompt engineering will save the answer. Fix retrieval first — chunking, embeddings, hybrid search — before you touch the generation prompt.' },
|
||||||
|
{ k: 'h2', t: 'Layer two: answer faithfulness' },
|
||||||
|
{ k: 'p', t: 'For generation, the metric that actually moves the needle is groundedness: is every claim in the answer supported by the retrieved context? I use an LLM-as-judge with a strict rubric and a small human-graded calibration set to keep the judge honest.' },
|
||||||
|
{ k: 'quote', t: 'A RAG system you cannot measure is a RAG system you cannot improve. Eval is not a phase — it is the control loop.' },
|
||||||
|
{ k: 'h2', t: 'Wire it into CI' },
|
||||||
|
{ k: 'p', t: 'The framework only pays off when it runs on every change. I gate deploys on a regression suite: if faithfulness drops more than two points or recall@5 falls below threshold, the pipeline blocks. That single gate has caught more silent regressions than any manual QA pass.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
fa: {
|
||||||
|
lead: 'بیشتر سامانههای RAG بر اساس حسوحال منتشر میشوند. یک دموی سهسؤالی خوب جواب میدهد، همه سر تکان میدهند و بدون آزمون به تولید میرود. این چارچوب ارزیابیای است که پیش از آنکه حتی یک کاربر آن را لمس کند، نصب میکنم.',
|
||||||
|
blocks: [
|
||||||
|
{ k: 'h2', t: 'چرا BLEU و ROUGE ناکافیاند' },
|
||||||
|
{ k: 'p', t: 'BLEU و ROUGE همپوشانی n-gram با پاسخ مرجع را میسنجند. برای ترجمه قابل قبول است، اما برای RAG گمراهکننده: پاسخ درستی که با عبارت متفاوت بیان شود امتیاز پایین میگیرد و توهمی روان که اتفاقاً واژهها را تکرار کند امتیاز بالا. در نهایت به جای حقیقت، شباهت سطحی را بهینه میکنید.' },
|
||||||
|
{ k: 'p', t: 'راهحل، تفکیک ارزیابی به دو لایهی مستقل است — کیفیت بازیابی و کیفیت پاسخ — و هرگز میانگینگرفتن آنها در یک عدد تزئینی.' },
|
||||||
|
{ k: 'h2', t: 'لایهی اول: بازیابی' },
|
||||||
|
{ k: 'p', t: 'پیش از آنکه مدل چیزی بنویسد، بپرسید آیا اصلاً متن درست بازیابی شده است؟ یک مجموعهی برچسبخورده از جفتهای پرسش ← قطعهی طلایی بسازید و اینها را پایش کنید:' },
|
||||||
|
{ k: 'ul', items: [
|
||||||
|
'Recall@k — آیا قطعهی طلایی در k نتیجهی برتر ظاهر شد؟',
|
||||||
|
'MRR — وقتی ظاهر شد، چه رتبهای داشت؟',
|
||||||
|
'دقت متن — چه کسری از قطعات بازیابیشده واقعاً مرتبط بودند؟',
|
||||||
|
] },
|
||||||
|
{ k: 'p', t: 'اگر recall@5 زیر ۰٫۹ باشد، هیچ مقدار مهندسی پرامپت پاسخ را نجات نمیدهد. اول بازیابی را درست کنید — قطعهبندی، embedding، جستوجوی ترکیبی — بعد سراغ پرامپت تولید بروید.' },
|
||||||
|
{ k: 'h2', t: 'لایهی دوم: وفاداری پاسخ' },
|
||||||
|
{ k: 'p', t: 'برای تولید، معیاری که واقعاً تأثیر دارد groundedness است: آیا هر ادعای پاسخ توسط متن بازیابیشده پشتیبانی میشود؟ من از LLM بهعنوان داور با یک rubric سختگیرانه و یک مجموعهی کالیبراسیون انسانی کوچک استفاده میکنم تا داور صادق بماند.' },
|
||||||
|
{ k: 'quote', t: 'سامانهی RAGای که نتوانید اندازه بگیرید، سامانهای است که نمیتوانید بهبودش دهید. ارزیابی یک فاز نیست — حلقهی کنترل است.' },
|
||||||
|
{ k: 'h2', t: 'آن را در CI ببندید' },
|
||||||
|
{ k: 'p', t: 'این چارچوب تنها وقتی ارزش دارد که روی هر تغییر اجرا شود. من استقرارها را به یک مجموعهی regression مشروط میکنم: اگر وفاداری بیش از دو واحد افت کند یا recall@5 از آستانه پایینتر بیاید، خط لوله مسدود میشود. همین یک دروازه بیش از هر QA دستی، افتهای خاموش را گرفته است.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
'agentic-n8n-patterns': {
|
||||||
|
date: '2026-04-09',
|
||||||
|
accent: 'violet',
|
||||||
|
en: {
|
||||||
|
lead: 'n8n is the most underrated tool in the enterprise AI stack. Not because it is clever, but because it is boring in exactly the right places — and boring is what you want around an LLM agent.',
|
||||||
|
blocks: [
|
||||||
|
{ k: 'h2', t: 'Agents need a substrate, not a framework' },
|
||||||
|
{ k: 'p', t: 'The mistake teams make is reaching for an agent framework first. Frameworks hide the control flow inside the model. In production you want the opposite: deterministic orchestration around a non-deterministic core. n8n gives you that substrate — visible nodes, retries, error branches, and a durable execution log.' },
|
||||||
|
{ k: 'h2', t: 'The pattern: LLM as a node, not the conductor' },
|
||||||
|
{ k: 'p', t: 'Treat the model as one step that proposes an action, then let n8n decide whether to execute it. The agent suggests; the workflow disposes. This keeps every side effect — an API call, a database write, an email — gated behind a node you can inspect, rate-limit, and roll back.' },
|
||||||
|
{ k: 'ul', items: [
|
||||||
|
'Planner node — the LLM returns a structured action, never raw text.',
|
||||||
|
'Router node — n8n validates the action against an allow-list.',
|
||||||
|
'Tool nodes — real integrations, each with their own retry policy.',
|
||||||
|
'Audit node — every step is appended to an execution store.',
|
||||||
|
] },
|
||||||
|
{ k: 'h2', t: 'Where LangGraph fits' },
|
||||||
|
{ k: 'p', t: 'For loops that need real state — multi-turn reasoning, reflection, tool retries with memory — I drop LangGraph inside a single n8n node. n8n owns the macro workflow and the durability; LangGraph owns the micro reasoning loop. The boundary is clean and each tool does what it is good at.' },
|
||||||
|
{ k: 'quote', t: 'Make the deterministic parts boring and the boring parts auditable. The intelligence belongs in exactly one node.' },
|
||||||
|
{ k: 'h2', t: 'Observability is the whole game' },
|
||||||
|
{ k: 'p', t: 'Because every execution is a record, you can replay a failed run, diff two runs, and answer the question every stakeholder eventually asks: "why did it do that?" An agent you can explain is an agent you can ship.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
fa: {
|
||||||
|
lead: 'n8n کمارزشگذاریشدهترین ابزار استک هوش مصنوعی سازمانی است. نه به این دلیل که باهوش است، بلکه چون دقیقاً در جای درست «خستهکننده» است — و خستهکننده دقیقاً همان چیزی است که گرداگرد یک عامل LLM میخواهید.',
|
||||||
|
blocks: [
|
||||||
|
{ k: 'h2', t: 'عاملها به بستر نیاز دارند، نه فریمورک' },
|
||||||
|
{ k: 'p', t: 'اشتباه تیمها این است که اول سراغ فریمورک عامل میروند. فریمورکها جریان کنترل را داخل مدل پنهان میکنند. در تولید عکسش را میخواهید: ارکستراسیون قطعی گرداگرد یک هستهی نامعین. n8n همان بستر را میدهد — گرههای قابلمشاهده، تلاش مجدد، شاخههای خطا و یک گزارش اجرای پایدار.' },
|
||||||
|
{ k: 'h2', t: 'الگو: LLM بهعنوان یک گره، نه رهبر ارکستر' },
|
||||||
|
{ k: 'p', t: 'مدل را یک گام بدانید که کنشی را پیشنهاد میدهد، سپس بگذارید n8n تصمیم بگیرد آن را اجرا کند یا نه. عامل پیشنهاد میدهد؛ گردشکار تصمیم میگیرد. این کار هر اثر جانبی — فراخوان API، نوشتن در پایگاهداده، ایمیل — را پشت گرهای نگه میدارد که میتوانید بازرسی، محدود و بازگردانیاش کنید.' },
|
||||||
|
{ k: 'ul', items: [
|
||||||
|
'گره برنامهریز — LLM یک کنش ساختارمند برمیگرداند، نه متن خام.',
|
||||||
|
'گره مسیریاب — n8n کنش را در برابر فهرست مجاز اعتبارسنجی میکند.',
|
||||||
|
'گرههای ابزار — یکپارچهسازیهای واقعی، هرکدام با سیاست تلاش مجدد خود.',
|
||||||
|
'گره ممیزی — هر گام به یک انبار اجرا افزوده میشود.',
|
||||||
|
] },
|
||||||
|
{ k: 'h2', t: 'جای LangGraph کجاست' },
|
||||||
|
{ k: 'p', t: 'برای حلقههایی که به حالت واقعی نیاز دارند — استدلال چندمرحلهای، بازتاب، تلاش مجدد ابزار با حافظه — LangGraph را داخل یک گرهی n8n میگذارم. n8n مالک گردشکار کلان و پایداری است؛ LangGraph مالک حلقهی استدلال خرد. مرز تمیز است و هر ابزار کاری را میکند که در آن خوب است.' },
|
||||||
|
{ k: 'quote', t: 'بخشهای قطعی را خستهکننده کنید و بخشهای خستهکننده را قابلممیزی. هوش دقیقاً به یک گره تعلق دارد.' },
|
||||||
|
{ k: 'h2', t: 'مشاهدهپذیری همهی بازی است' },
|
||||||
|
{ k: 'p', t: 'چون هر اجرا یک رکورد است، میتوانید اجرای ناموفق را بازپخش کنید، دو اجرا را مقایسه کنید و به پرسشی پاسخ دهید که هر ذینفعی سرانجام میپرسد: «چرا این کار را کرد؟» عاملی که بتوانید توضیحش دهید، عاملی است که میتوانید منتشرش کنید.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
'vertex-cost-control': {
|
||||||
|
date: '2026-03-28',
|
||||||
|
accent: 'cyan',
|
||||||
|
en: {
|
||||||
|
lead: 'I have reviewed dozens of Vertex AI bills. The same three anti-patterns show up in roughly 80% of them — and removing them routinely cuts monthly spend by half without touching quality.',
|
||||||
|
blocks: [
|
||||||
|
{ k: 'h2', t: 'Anti-pattern 1: the always-on endpoint' },
|
||||||
|
{ k: 'p', t: 'Teams deploy a model to a dedicated endpoint with a minimum replica count of one and then forget about it. For bursty internal traffic that is a machine billing 24/7 to serve a few hundred requests a day. Set min replicas to zero where the latency budget allows, or batch the workload.' },
|
||||||
|
{ k: 'h2', t: 'Anti-pattern 2: the wrong model for the job' },
|
||||||
|
{ k: 'p', t: 'Not every call needs the frontier model. A cascade — cheap model first, escalate to the expensive one only when confidence is low — keeps quality high where it matters and spend low everywhere else.' },
|
||||||
|
{ k: 'ul', items: [
|
||||||
|
'Route by task complexity, not by habit.',
|
||||||
|
'Cache embeddings aggressively — they rarely change.',
|
||||||
|
'Use context caching for stable system prompts and long shared documents.',
|
||||||
|
] },
|
||||||
|
{ k: 'h2', t: 'Anti-pattern 3: no unit economics' },
|
||||||
|
{ k: 'p', t: 'If you cannot state the cost per request, you cannot control it. I instrument every call with token counts and model id, then roll it up to cost-per-feature. The moment a feature has a dollar figure attached, the optimization conversation changes from abstract to obvious.' },
|
||||||
|
{ k: 'quote', t: 'You do not cut cloud cost with a spreadsheet at month-end. You cut it with a label on every request.' },
|
||||||
|
{ k: 'h2', t: 'The result' },
|
||||||
|
{ k: 'p', t: 'On the last engagement, those three fixes plus context caching took a $40k/month Vertex bill to under $16k — and p95 latency improved, because the cascade kept most traffic on a faster, smaller model.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
fa: {
|
||||||
|
lead: 'دهها صورتحساب Vertex AI را بررسی کردهام. همان سه ضدالگو در حدود ۸۰٪ آنها دیده میشود — و حذفشان معمولاً هزینهی ماهانه را بدون دستزدن به کیفیت نصف میکند.',
|
||||||
|
blocks: [
|
||||||
|
{ k: 'h2', t: 'ضدالگوی ۱: endpoint همیشهروشن' },
|
||||||
|
{ k: 'p', t: 'تیمها مدلی را روی یک endpoint اختصاصی با حداقل یک replica مستقر میکنند و فراموشش میکنند. برای ترافیک داخلی پرنوسان، این یعنی ماشینی که ۲۴ ساعته صورتحساب میدهد تا چند صد درخواست در روز را پاسخ دهد. جایی که بودجهی تأخیر اجازه میدهد حداقل replica را صفر کنید، یا بار کاری را batch کنید.' },
|
||||||
|
{ k: 'h2', t: 'ضدالگوی ۲: مدل نامناسب برای کار' },
|
||||||
|
{ k: 'p', t: 'هر فراخوان به مدل مرزی نیاز ندارد. یک cascade — اول مدل ارزان، فقط وقتی اطمینان پایین است به مدل گران ارتقا — کیفیت را جایی که مهم است بالا و هزینه را همهجا پایین نگه میدارد.' },
|
||||||
|
{ k: 'ul', items: [
|
||||||
|
'مسیریابی بر اساس پیچیدگی کار، نه عادت.',
|
||||||
|
'embeddingها را پرحجم cache کنید — بهندرت تغییر میکنند.',
|
||||||
|
'برای پرامپتهای سیستمی پایدار و اسناد مشترک طولانی از context caching استفاده کنید.',
|
||||||
|
] },
|
||||||
|
{ k: 'h2', t: 'ضدالگوی ۳: نبود اقتصاد واحد' },
|
||||||
|
{ k: 'p', t: 'اگر نتوانید هزینهی هر درخواست را بگویید، نمیتوانید کنترلش کنید. من هر فراخوان را با شمار توکن و شناسهی مدل ابزارگذاری میکنم و سپس به هزینهبهازایقابلیت تجمیع میکنم. لحظهای که یک قابلیت رقم دلاری پیدا کند، گفتوگوی بهینهسازی از انتزاعی به بدیهی تبدیل میشود.' },
|
||||||
|
{ k: 'quote', t: 'هزینهی ابر را با یک صفحهگسترده در پایان ماه کم نمیکنید. با یک برچسب روی هر درخواست کم میکنید.' },
|
||||||
|
{ k: 'h2', t: 'نتیجه' },
|
||||||
|
{ k: 'p', t: 'در آخرین پروژه، همین سه اصلاح بهعلاوهی context caching صورتحساب ۴۰هزاردلاری ماهانهی Vertex را به زیر ۱۶هزار دلار رساند — و تأخیر p95 هم بهتر شد، چون cascade بیشتر ترافیک را روی مدلی کوچکتر و سریعتر نگه داشت.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
'k8s-llm-inference': {
|
||||||
|
date: '2026-03-11',
|
||||||
|
accent: 'emerald',
|
||||||
|
en: {
|
||||||
|
lead: 'Sub-50ms LLM inference on commodity Kubernetes is achievable — but not by throwing GPUs at the problem. It comes from removing the three places latency actually hides.',
|
||||||
|
blocks: [
|
||||||
|
{ k: 'h2', t: 'Latency hides in cold starts' },
|
||||||
|
{ k: 'p', t: 'A pod that scales from zero pays a model-load tax of tens of seconds. The answer is KEDA scaling on a queue depth signal, with a warm pool sized to your p50 traffic. You autoscale for the spikes, but you never serve a request from a cold replica.' },
|
||||||
|
{ k: 'h2', t: 'Latency hides in GPU contention' },
|
||||||
|
{ k: 'p', t: 'One model per GPU is wasteful; ten models fighting for one GPU is slow. The middle path is time-slicing or MIG partitions with explicit memory budgets, plus a scheduler that is GPU-topology aware so chatty replicas land on the same node.' },
|
||||||
|
{ k: 'ul', items: [
|
||||||
|
'Pin the model in GPU memory — never reload per request.',
|
||||||
|
'Use continuous batching so concurrent requests share a forward pass.',
|
||||||
|
'Hedge slow requests: fire a second attempt at p95 and take the first to finish.',
|
||||||
|
] },
|
||||||
|
{ k: 'h2', t: 'Latency hides in the network' },
|
||||||
|
{ k: 'p', t: 'Cross-AZ hops, TLS renegotiation, and an over-eager service mesh quietly add milliseconds. Keep inference traffic in-zone, reuse connections, and measure the mesh overhead before you assume it is free.' },
|
||||||
|
{ k: 'quote', t: 'You do not buy latency with bigger GPUs. You earn it by deleting the waits nobody is looking at.' },
|
||||||
|
{ k: 'h2', t: 'Prove it with a budget' },
|
||||||
|
{ k: 'p', t: 'I define an explicit latency budget per stage — queue, batch, forward pass, serialization, network — and alert when any stage drifts. When p95 regresses, the budget tells you exactly which stage to open, instead of guessing.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
fa: {
|
||||||
|
lead: 'استنتاج LLM با تأخیر زیر ۵۰ میلیثانیه روی Kubernetes معمولی دستیافتنی است — اما نه با ریختن GPU روی مسئله. از حذف سه جایی میآید که تأخیر واقعاً پنهان میشود.',
|
||||||
|
blocks: [
|
||||||
|
{ k: 'h2', t: 'تأخیر در cold start پنهان است' },
|
||||||
|
{ k: 'p', t: 'پادی که از صفر مقیاس میگیرد، مالیات بارگذاری مدل بهاندازهی دهها ثانیه میپردازد. پاسخ، مقیاسدهی KEDA بر اساس عمق صف است، با یک استخر گرم بهاندازهی ترافیک p50. برای جهشها autoscale میکنید، اما هرگز درخواستی را از replica سرد پاسخ نمیدهید.' },
|
||||||
|
{ k: 'h2', t: 'تأخیر در رقابت GPU پنهان است' },
|
||||||
|
{ k: 'p', t: 'یک مدل بهازای هر GPU اسراف است؛ ده مدل در رقابت بر سر یک GPU کند است. راه میانه، time-slicing یا پارتیشنهای MIG با بودجهی حافظهی صریح است، بهعلاوهی زمانبندیای که از توپولوژی GPU آگاه باشد تا replicaهای پرگفتوگو روی یک گره بنشینند.' },
|
||||||
|
{ k: 'ul', items: [
|
||||||
|
'مدل را در حافظهی GPU پین کنید — هرگز بهازای هر درخواست بارگذاری نکنید.',
|
||||||
|
'از continuous batching استفاده کنید تا درخواستهای همزمان یک forward pass را به اشتراک بگذارند.',
|
||||||
|
'درخواستهای کند را hedge کنید: در p95 تلاش دوم را بفرستید و اولی که تمام شد را بردارید.',
|
||||||
|
] },
|
||||||
|
{ k: 'h2', t: 'تأخیر در شبکه پنهان است' },
|
||||||
|
{ k: 'p', t: 'پرشهای بینAZ، مذاکرهی مجدد TLS و یک service mesh بیشازحد مشتاق بیسروصدا میلیثانیه اضافه میکنند. ترافیک استنتاج را درونناحیه نگه دارید، اتصالها را بازاستفاده کنید و پیش از آنکه فرض کنید mesh رایگان است، سربارش را اندازه بگیرید.' },
|
||||||
|
{ k: 'quote', t: 'تأخیر را با GPUهای بزرگتر نمیخرید. با حذف انتظارهایی که کسی نگاهشان نمیکند به دستش میآورید.' },
|
||||||
|
{ k: 'h2', t: 'با یک بودجه اثباتش کنید' },
|
||||||
|
{ k: 'p', t: 'برای هر مرحله بودجهی تأخیر صریح تعریف میکنم — صف، batch، forward pass، سریالسازی، شبکه — و وقتی هر مرحله منحرف شد هشدار میدهم. وقتی p95 پسرفت میکند، بودجه دقیقاً میگوید کدام مرحله را باز کنید، بهجای حدسزدن.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
'flutter-on-device-ai': {
|
||||||
|
date: '2026-02-19',
|
||||||
|
accent: 'electric',
|
||||||
|
en: {
|
||||||
|
lead: 'On-device AI is not a smaller version of cloud AI. It is a different engineering problem with a different reward: privacy, offline capability, and zero per-inference cost.',
|
||||||
|
blocks: [
|
||||||
|
{ k: 'h2', t: 'Pick the right tier' },
|
||||||
|
{ k: 'p', t: 'Not everything belongs on the device. The decision tree is simple: if the task is latency-critical, privacy-sensitive, or must work offline, it runs on-device. Everything else can call the cloud. Most real apps end up hybrid — a small local model for the common case, a cloud fallback for the hard one.' },
|
||||||
|
{ k: 'h2', t: 'Gemini Nano and LiteRT in Flutter' },
|
||||||
|
{ k: 'p', t: 'On Android, Gemini Nano gives you a capable on-device model through AICore. For custom models, LiteRT (formerly TFLite) runs quantized weights with hardware delegation. From Flutter you bridge to both through a thin platform channel — keep the inference on the native side and pass only structured results across.' },
|
||||||
|
{ k: 'ul', items: [
|
||||||
|
'Quantize to int8 — the quality loss is usually negligible, the speedup is not.',
|
||||||
|
'Warm the interpreter at app start, not on first use.',
|
||||||
|
'Stream tokens to the UI so perceived latency stays low even when total latency is not.',
|
||||||
|
] },
|
||||||
|
{ k: 'h2', t: 'The UX is the hard part' },
|
||||||
|
{ k: 'p', t: 'On-device models are smaller, so the product has to be honest about their limits. Constrain the task, give the model structure, and design graceful fallbacks. A focused local model that does one thing reliably beats a general one that occasionally embarrasses you.' },
|
||||||
|
{ k: 'quote', t: 'On-device AI rewards narrow scope. Ship the model that nails one job, not the one that attempts ten.' },
|
||||||
|
{ k: 'h2', t: 'Battery and binary size are product decisions' },
|
||||||
|
{ k: 'p', t: 'A 200MB model and a hot CPU are features your users feel. Measure energy per inference and ship the model on demand rather than in the initial bundle. The right size is the smallest one that clears your quality bar.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
fa: {
|
||||||
|
lead: 'هوش مصنوعی on-device نسخهی کوچکتر هوش مصنوعی ابری نیست. مسئلهی مهندسی متفاوتی با پاداش متفاوت است: حریم خصوصی، توان آفلاین و هزینهی صفر بهازای هر استنتاج.',
|
||||||
|
blocks: [
|
||||||
|
{ k: 'h2', t: 'لایهی درست را انتخاب کنید' },
|
||||||
|
{ k: 'p', t: 'همهچیز به دستگاه تعلق ندارد. درخت تصمیم ساده است: اگر کار حساسبهتأخیر، حساسبهحریمخصوصی یا نیازمند کار آفلاین است، روی دستگاه اجرا میشود. بقیه میتوانند ابر را فرابخوانند. بیشتر اپهای واقعی ترکیبی میشوند — یک مدل محلی کوچک برای حالت رایج، یک fallback ابری برای حالت سخت.' },
|
||||||
|
{ k: 'h2', t: 'Gemini Nano و LiteRT در Flutter' },
|
||||||
|
{ k: 'p', t: 'در اندروید، Gemini Nano از طریق AICore یک مدل on-device توانمند میدهد. برای مدلهای سفارشی، LiteRT (همان TFLite سابق) وزنهای کوانتیزه را با واگذاری سختافزاری اجرا میکند. از Flutter از طریق یک platform channel نازک به هردو پل میزنید — استنتاج را سمت native نگه دارید و فقط نتایج ساختارمند را عبور دهید.' },
|
||||||
|
{ k: 'ul', items: [
|
||||||
|
'به int8 کوانتیزه کنید — افت کیفیت معمولاً ناچیز است، شتاب نه.',
|
||||||
|
'مفسر را در شروع اپ گرم کنید، نه در اولین استفاده.',
|
||||||
|
'توکنها را به UI استریم کنید تا تأخیر ادراکشده پایین بماند حتی اگر تأخیر کل نباشد.',
|
||||||
|
] },
|
||||||
|
{ k: 'h2', t: 'بخش سخت، UX است' },
|
||||||
|
{ k: 'p', t: 'مدلهای on-device کوچکترند، پس محصول باید دربارهی محدودیتهایشان صادق باشد. کار را محدود کنید، به مدل ساختار بدهید و fallbackهای مودبانه طراحی کنید. یک مدل محلی متمرکز که یک کار را قابلاتکا انجام دهد، از مدلی عمومی که گاهی شرمندهتان میکند بهتر است.' },
|
||||||
|
{ k: 'quote', t: 'هوش مصنوعی on-device به دامنهی باریک پاداش میدهد. مدلی را منتشر کنید که یک کار را بینقص انجام دهد، نه آنکه ده کار را امتحان کند.' },
|
||||||
|
{ k: 'h2', t: 'باتری و حجم باینری تصمیمهای محصولاند' },
|
||||||
|
{ k: 'p', t: 'یک مدل ۲۰۰مگابایتی و CPU داغ، قابلیتهاییاند که کاربرانتان حس میکنند. انرژی بهازای هر استنتاج را اندازه بگیرید و مدل را بهصورت on-demand منتشر کنید نه در بستهی اولیه. اندازهی درست، کوچکترین اندازهای است که از خط کیفیت شما رد شود.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
'enterprise-ai-roadmap': {
|
||||||
|
date: '2026-01-30',
|
||||||
|
accent: 'electric',
|
||||||
|
en: {
|
||||||
|
lead: 'Most enterprise AI initiatives die in the gap between a board mandate and a shipped feature. This is the 90-day roadmap I build to cross it — discovery to first production deployment.',
|
||||||
|
blocks: [
|
||||||
|
{ k: 'h2', t: 'Days 0–30: discovery, not deck-building' },
|
||||||
|
{ k: 'p', t: 'The first month is spent finding the use cases that are both valuable and feasible. I interview the people doing the work, map the data that actually exists (not the data the org wishes it had), and score candidates on impact versus effort. The output is a shortlist of three, not a 40-slide strategy.' },
|
||||||
|
{ k: 'h2', t: 'Days 30–60: one thin slice to production' },
|
||||||
|
{ k: 'p', t: 'We pick the single highest-leverage use case and ship it end-to-end for a small group of real users. Not a pilot in a sandbox — a thin slice in production, with monitoring, evaluation, and a rollback path. The goal is to learn what breaks when reality arrives.' },
|
||||||
|
{ k: 'ul', items: [
|
||||||
|
'Define success metrics before writing code.',
|
||||||
|
'Instrument cost and quality from request one.',
|
||||||
|
'Ship behind a flag to a controlled cohort.',
|
||||||
|
] },
|
||||||
|
{ k: 'h2', t: 'Days 60–90: harden and templatize' },
|
||||||
|
{ k: 'p', t: 'With one real workload live, the last month turns the bespoke build into a repeatable pattern: shared eval harness, a reference architecture, and the platform pieces the next three use cases will reuse. The second project should take half the time of the first.' },
|
||||||
|
{ k: 'quote', t: 'A roadmap is not a list of features. It is the order in which you reduce uncertainty.' },
|
||||||
|
{ k: 'h2', t: 'What kills roadmaps' },
|
||||||
|
{ k: 'p', t: 'Boiling the ocean, optimizing a model nobody uses, and treating AI as a research project instead of a product. The antidote to all three is the same: get one real thing in front of real users fast, then let what you learn redraw the map.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
fa: {
|
||||||
|
lead: 'بیشتر ابتکارهای هوش مصنوعی سازمانی در شکاف میان دستور هیئتمدیره و یک قابلیت منتشرشده میمیرند. این نقشهی راه ۹۰روزهای است که برای عبور از آن میسازم — از کشف تا اولین استقرار تولید.',
|
||||||
|
blocks: [
|
||||||
|
{ k: 'h2', t: 'روز ۰ تا ۳۰: کشف، نه ساختن اسلاید' },
|
||||||
|
{ k: 'p', t: 'ماه اول صرف یافتن موارد کاربریای میشود که هم ارزشمند و هم شدنیاند. با کسانی که کار را انجام میدهند مصاحبه میکنم، دادهای را که واقعاً وجود دارد نگاشت میکنم (نه دادهای که سازمان آرزویش را دارد) و گزینهها را بر اساس اثر در برابر تلاش امتیاز میدهم. خروجی، فهرست کوتاهی از سه مورد است، نه یک راهبرد ۴۰اسلایدی.' },
|
||||||
|
{ k: 'h2', t: 'روز ۳۰ تا ۶۰: یک برش نازک تا تولید' },
|
||||||
|
{ k: 'p', t: 'تکمورد با بیشترین اهرم را برمیگزینیم و آن را سرتاسری برای گروه کوچکی از کاربران واقعی منتشر میکنیم. نه یک pilot در sandbox — یک برش نازک در تولید، با پایش، ارزیابی و مسیر بازگشت. هدف، یادگرفتن چیزی است که وقتی واقعیت میرسد میشکند.' },
|
||||||
|
{ k: 'ul', items: [
|
||||||
|
'معیارهای موفقیت را پیش از نوشتن کد تعریف کنید.',
|
||||||
|
'هزینه و کیفیت را از همان درخواست اول ابزارگذاری کنید.',
|
||||||
|
'پشت یک flag برای یک گروه کنترلشده منتشر کنید.',
|
||||||
|
] },
|
||||||
|
{ k: 'h2', t: 'روز ۶۰ تا ۹۰: تثبیت و قالبسازی' },
|
||||||
|
{ k: 'p', t: 'با یک بار کاری واقعی در حال اجرا، ماه آخر ساخت سفارشی را به الگویی تکرارپذیر تبدیل میکند: harness ارزیابی مشترک، یک معماری مرجع و قطعات پلتفرمی که سه مورد بعدی بازاستفاده خواهند کرد. پروژهی دوم باید نصف زمان اولی را ببرد.' },
|
||||||
|
{ k: 'quote', t: 'نقشهی راه فهرستی از قابلیتها نیست. ترتیبی است که در آن عدمقطعیت را کاهش میدهید.' },
|
||||||
|
{ k: 'h2', t: 'چه چیزی نقشهی راه را میکشد' },
|
||||||
|
{ k: 'p', t: 'جوشاندن اقیانوس، بهینهسازی مدلی که کسی استفاده نمیکند و رفتار با هوش مصنوعی بهمثابهی پروژهی پژوهشی بهجای محصول. پادزهر هر سه یکی است: یک چیز واقعی را سریع جلوی کاربران واقعی بگذارید، سپس بگذارید آنچه میآموزید نقشه را دوباره بکشد.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST_SLUGS = Object.keys(POSTS);
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* The set of content sections exposed in the admin panel. Each key maps to a
|
||||||
|
* top-level key inside `dict`; editing one stores a `{ fa, en }` override that
|
||||||
|
* the content loader merges over the in-code default. Kept dependency-free so
|
||||||
|
* both client (sidebar, editor) and server (dashboard, API) can import it.
|
||||||
|
*/
|
||||||
|
export const EDITABLE_SECTIONS = [
|
||||||
|
{ key: 'hero', label: { en: 'Hero', fa: 'هیرو' }, desc: { en: 'Headline, roles, metrics, CTAs', fa: 'تیتر، نقشها، اعداد، دکمهها' } },
|
||||||
|
{ key: 'services', label: { en: 'Services', fa: 'خدمات' }, desc: { en: 'The six practice cards', fa: 'شش کارت خدمات' } },
|
||||||
|
{ key: 'dataflow', label: { en: 'Data Flow', fa: 'پایپلاین داده' }, desc: { en: 'RAG pipeline diagram stages', fa: 'مراحل نمودار پایپلاین RAG' } },
|
||||||
|
{ key: 'stack', label: { en: 'Stack', fa: 'استک' }, desc: { en: 'Tooling categories', fa: 'دستههای ابزار' } },
|
||||||
|
{ key: 'expertise', label: { en: 'Expertise', fa: 'تخصص' }, desc: { en: 'Skill bars', fa: 'نوارهای مهارت' } },
|
||||||
|
{ key: 'portfolio', label: { en: 'Portfolio', fa: 'نمونهکارها' }, desc: { en: 'Projects + galleries', fa: 'پروژهها و گالری' } },
|
||||||
|
{ key: 'blog', label: { en: 'Journal', fa: 'بلاگ' }, desc: { en: 'Post cards (titles, excerpts)', fa: 'کارتهای مقاله' } },
|
||||||
|
{ key: 'contact', label: { en: 'Contact', fa: 'تماس' }, desc: { en: 'Form copy + budgets', fa: 'متن فرم و بودجهها' } },
|
||||||
|
{ key: 'nav', label: { en: 'Navigation', fa: 'ناوبری' }, desc: { en: 'Menu labels', fa: 'برچسبهای منو' } },
|
||||||
|
{ key: 'footer', label: { en: 'Footer', fa: 'فوتر' }, desc: { en: 'Tagline + rights', fa: 'شعار و حقوق' } },
|
||||||
|
{ key: 'meta', label: { en: 'SEO / Meta', fa: 'سئو' }, desc: { en: 'Title + description', fa: 'عنوان و توضیحات' } },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type EditableSectionKey = (typeof EDITABLE_SECTIONS)[number]['key'];
|
||||||
|
|
||||||
|
export const EDITABLE_KEYS = EDITABLE_SECTIONS.map((s) => s.key) as EditableSectionKey[];
|
||||||
|
|
||||||
|
export function isEditableKey(key: string): key is EditableSectionKey {
|
||||||
|
return (EDITABLE_KEYS as string[]).includes(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sectionLabel(key: string): { en: string; fa: string } {
|
||||||
|
return EDITABLE_SECTIONS.find((s) => s.key === key)?.label ?? { en: key, fa: key };
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import 'server-only';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { existsSync, mkdirSync } from 'node:fs';
|
||||||
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistent content store for the CMS.
|
||||||
|
*
|
||||||
|
* A single `sections` table holds JSON overrides keyed by section name
|
||||||
|
* (e.g. "hero", "services", "portfolio"). The stored JSON is a bilingual
|
||||||
|
* payload — `{ fa: <sectionObject>, en: <sectionObject> }` — that mirrors the
|
||||||
|
* shape of the matching key inside `dict`. At request time the content loader
|
||||||
|
* merges these overrides on top of the in-code `dict` defaults, so editing a
|
||||||
|
* section in the admin panel transparently rewrites what every public section
|
||||||
|
* renders without touching any component.
|
||||||
|
*
|
||||||
|
* The database file lives under DATA_DIR (default ./data) which on the
|
||||||
|
* self-hosted deployment is a mounted Docker volume, so content survives
|
||||||
|
* container rebuilds.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const DATA_DIR = resolve(process.env.DATA_DIR ?? join(process.cwd(), 'data'));
|
||||||
|
export const UPLOADS_DIR = join(DATA_DIR, 'uploads');
|
||||||
|
const DB_PATH = join(DATA_DIR, 'cms.db');
|
||||||
|
|
||||||
|
let _db: Database.Database | null = null;
|
||||||
|
|
||||||
|
function db(): Database.Database {
|
||||||
|
if (_db) return _db;
|
||||||
|
|
||||||
|
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
if (!existsSync(UPLOADS_DIR)) mkdirSync(UPLOADS_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const handle = new Database(DB_PATH);
|
||||||
|
handle.pragma('journal_mode = WAL');
|
||||||
|
handle.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS sections (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
_db = handle;
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SectionRow = {
|
||||||
|
key: string;
|
||||||
|
/** JSON-encoded `{ fa, en }` payload. */
|
||||||
|
data: string;
|
||||||
|
updated_at: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SectionOverride = {
|
||||||
|
key: string;
|
||||||
|
data: unknown;
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Every stored override, used by the content loader to merge onto defaults. */
|
||||||
|
export function getAllSections(): SectionRow[] {
|
||||||
|
try {
|
||||||
|
return db()
|
||||||
|
.prepare('SELECT key, data, updated_at FROM sections')
|
||||||
|
.all() as SectionRow[];
|
||||||
|
} catch {
|
||||||
|
// A missing/locked DB must never crash a public render — fall back to dict.
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single override, or null when the section has never been edited. */
|
||||||
|
export function getSection(key: string): SectionOverride | null {
|
||||||
|
const row = db()
|
||||||
|
.prepare('SELECT key, data, updated_at FROM sections WHERE key = ?')
|
||||||
|
.get(key) as SectionRow | undefined;
|
||||||
|
if (!row) return null;
|
||||||
|
return { key: row.key, data: JSON.parse(row.data), updatedAt: row.updated_at };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert or replace a section override (admin only). */
|
||||||
|
export function setSection(key: string, data: unknown): void {
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO sections (key, data, updated_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`,
|
||||||
|
)
|
||||||
|
.run(key, JSON.stringify(data), Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop an override so the section reverts to its in-code default. */
|
||||||
|
export function resetSection(key: string): void {
|
||||||
|
db().prepare('DELETE FROM sections WHERE key = ?').run(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map of key → updatedAt for showing edit status in the dashboard. */
|
||||||
|
export function sectionStatus(): Record<string, number> {
|
||||||
|
const out: Record<string, number> = {};
|
||||||
|
for (const row of getAllSections()) out[row.key] = row.updated_at;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { dirname };
|
||||||
@@ -0,0 +1,855 @@
|
|||||||
|
export type Locale = 'fa' | 'en';
|
||||||
|
|
||||||
|
export const LOCALES: Locale[] = ['fa', 'en'];
|
||||||
|
export const DEFAULT_LOCALE: Locale = 'fa';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service identifiers stay locale-independent so they can key into
|
||||||
|
* routes (`/services/[slug]`), analytics events, and form values.
|
||||||
|
*/
|
||||||
|
export const SERVICE_IDS = [
|
||||||
|
'strategy',
|
||||||
|
'automation',
|
||||||
|
'llm-rag',
|
||||||
|
'architecture',
|
||||||
|
'mobile',
|
||||||
|
'google-stack',
|
||||||
|
] as const;
|
||||||
|
export type ServiceId = (typeof SERVICE_IDS)[number];
|
||||||
|
|
||||||
|
export const dict = {
|
||||||
|
fa: {
|
||||||
|
meta: {
|
||||||
|
title: 'سروش اسعدی — مهندس هوش مصنوعی، مشاور، معمار راهکار',
|
||||||
|
description:
|
||||||
|
'طراحی و پیادهسازی سامانههای هوش مصنوعی در مقیاس سازمانی — راهبرد، LLM و RAG، اتوماسیون عاملمحور، زیرساخت ابری و استک گوگل.',
|
||||||
|
},
|
||||||
|
nav: {
|
||||||
|
services: 'خدمات',
|
||||||
|
stack: 'استک',
|
||||||
|
expertise: 'تخصص',
|
||||||
|
portfolio: 'نمونهکارها',
|
||||||
|
blog: 'بلاگ',
|
||||||
|
contact: 'تماس',
|
||||||
|
book: 'رزرو جلسه',
|
||||||
|
},
|
||||||
|
locale: {
|
||||||
|
switchTo: 'EN',
|
||||||
|
label: 'زبان',
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
availability: 'پذیرش پروژههای منتخب فصل سوم ۲۰۲۶',
|
||||||
|
eyebrow: 'مهندس هوش مصنوعی · مشاور · معمار راهکار',
|
||||||
|
name: 'سروش اسعدی',
|
||||||
|
headlineLead: 'طراحی سامانههای',
|
||||||
|
headlineAccent: 'هوش مصنوعی',
|
||||||
|
headlineTrail: 'در مقیاس سازمانی.',
|
||||||
|
sub:
|
||||||
|
'از راهبرد تا تولید — ساخت پایپلاینهای LLM، عاملهای خودکار، و معماریهای ابری که در میلیونها رویداد در روز پایدار میمانند.',
|
||||||
|
roles: [
|
||||||
|
'راهبرد هوش مصنوعی',
|
||||||
|
'مهندسی LLM و RAG',
|
||||||
|
'معماری راهکار',
|
||||||
|
'اتوماسیون عاملمحور',
|
||||||
|
'استک گوگل کلود',
|
||||||
|
],
|
||||||
|
ctaPrimary: 'رزرو جلسه مشاوره',
|
||||||
|
ctaSecondary: 'مشاهده خدمات',
|
||||||
|
scroll: 'اسکرول',
|
||||||
|
metrics: [
|
||||||
|
{ value: '۱۸+', label: 'مدل هوش مصنوعی مستقر' },
|
||||||
|
{ value: '۴۰+', label: 'میکروسرویس تولید' },
|
||||||
|
{ value: '۱۲ms', label: 'تأخیر استنتاج' },
|
||||||
|
{ value: '۹۹٪', label: 'پایداری SLA' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
services: {
|
||||||
|
eyebrow: 'خدمات',
|
||||||
|
title: 'شش حوزه تخصصی',
|
||||||
|
sub:
|
||||||
|
'از اولین جلسهی راهبرد تا استقرار تولید — یک شریک مهندسی برای کل چرخهی عمر هوش مصنوعی شما.',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'strategy',
|
||||||
|
title: 'راهبرد و نقشه راه هوش مصنوعی',
|
||||||
|
description:
|
||||||
|
'ارزیابی بلوغ سازمانی، شناسایی موارد کاربری با بیشترین بازده، و طراحی نقشه راه ۱۲–۱۸ ماهه با KPIهای روشن.',
|
||||||
|
tags: ['Discovery', 'ROI Mapping', 'Roadmap'],
|
||||||
|
color: 'electric',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'automation',
|
||||||
|
title: 'اتوماسیون هوش مصنوعی',
|
||||||
|
description:
|
||||||
|
'ساخت عاملهای خودکار و گردشکارهای n8n که فرایندهای دستی را به سامانههای قابل ممیزی تبدیل میکنند.',
|
||||||
|
tags: ['n8n', 'Agents', 'Workflows'],
|
||||||
|
color: 'violet',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'llm-rag',
|
||||||
|
title: 'مهندسی LLM و RAG',
|
||||||
|
description:
|
||||||
|
'طراحی pipelineهای RAG با پایگاههای برداری، evaluation framework، و سرویسدهی با تأخیر زیر ۵۰ میلیثانیه.',
|
||||||
|
tags: ['RAG', 'Vector DB', 'Eval'],
|
||||||
|
color: 'magenta',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'architecture',
|
||||||
|
title: 'معماری راهکار',
|
||||||
|
description:
|
||||||
|
'طراحی سامانههای توزیعشده روی Kubernetes با میکروسرویسها، event streaming، و الگوهای پایداری در مقیاس بالا.',
|
||||||
|
tags: ['K8s', 'Microservices', 'Event-Driven'],
|
||||||
|
color: 'emerald',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mobile',
|
||||||
|
title: 'اپلیکیشنهای موبایل هوش مصنوعی',
|
||||||
|
description:
|
||||||
|
'برنامههای Flutter، Swift و Kotlin با on-device inference، استریم LLM و تجربهی کاربری بومی.',
|
||||||
|
tags: ['Flutter', 'Swift', 'Kotlin'],
|
||||||
|
color: 'electric',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'google-stack',
|
||||||
|
title: 'تخصص استک گوگل',
|
||||||
|
description:
|
||||||
|
'استقرار روی Vertex AI، GKE و Gemini با بهینهسازی هزینه و الگوهای امنیتی سطح enterprise.',
|
||||||
|
tags: ['Vertex AI', 'GKE', 'Gemini'],
|
||||||
|
color: 'cyan',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
dataflow: {
|
||||||
|
eyebrow: 'پایپلاین',
|
||||||
|
title: 'از سند خام تا پاسخ قابل اتکا',
|
||||||
|
sub:
|
||||||
|
'مسیری که هر پرسش در یک سامانهی RAG تولیدی طی میکند — هر مرحله قابل اندازهگیری، قابل ممیزی و بهینهشده برای تأخیر.',
|
||||||
|
caption: 'تأخیر سرتاسری زیر ۵۰ میلیثانیه · هر مرحله مشاهدهپذیر',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'ingest',
|
||||||
|
label: 'دریافت',
|
||||||
|
desc: 'نرمالسازی، قطعهبندی و پاکسازی اسناد منبع',
|
||||||
|
accent: 'electric',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'embed',
|
||||||
|
label: 'برداریسازی',
|
||||||
|
desc: 'تولید embedding و نمایهسازی در پایگاه برداری',
|
||||||
|
accent: 'violet',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'retrieve',
|
||||||
|
label: 'بازیابی',
|
||||||
|
desc: 'جستجوی ترکیبی معنایی و کلیدواژهای',
|
||||||
|
accent: 'cyan',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rerank',
|
||||||
|
label: 'بازرتبهبندی',
|
||||||
|
desc: 'مرتبسازی مجدد نامزدها با cross-encoder',
|
||||||
|
accent: 'magenta',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'generate',
|
||||||
|
label: 'تولید',
|
||||||
|
desc: 'پاسخ مستند با ارجاع به منبع',
|
||||||
|
accent: 'emerald',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
stack: {
|
||||||
|
eyebrow: 'استک',
|
||||||
|
title: 'ابزارهای روزانه',
|
||||||
|
sub:
|
||||||
|
'هر چه ساخته میشود از این پایهها بیرون میآید — انتخابشده برای عمر طولانی، نه ترند روز.',
|
||||||
|
categories: [
|
||||||
|
{
|
||||||
|
id: 'languages',
|
||||||
|
label: 'زبانها',
|
||||||
|
items: ['Python', 'TypeScript', 'Go', 'Rust', 'SQL'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mobile',
|
||||||
|
label: 'موبایل',
|
||||||
|
items: ['Flutter', 'Swift / SwiftUI', 'Kotlin', 'React Native'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'infra',
|
||||||
|
label: 'زیرساخت',
|
||||||
|
items: ['Kubernetes', 'Terraform', 'Postgres', 'Redis', 'Kafka', 'NATS'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ai',
|
||||||
|
label: 'هوش مصنوعی',
|
||||||
|
items: ['Vertex AI', 'Gemini', 'OpenAI', 'Anthropic', 'LangGraph', 'Pinecone', 'pgvector'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
expertise: {
|
||||||
|
eyebrow: 'تخصص',
|
||||||
|
title: 'اعدادی که اهمیت دارند',
|
||||||
|
sub:
|
||||||
|
'سامانههایی که در میلیونها رویداد در روز پایدار میمانند — اینها معیارهایی هستند که اندازه میگیریم.',
|
||||||
|
bars: [
|
||||||
|
{ label: 'مهندسی LLM و RAG', value: 95 },
|
||||||
|
{ label: 'معماری ابری و Kubernetes', value: 92 },
|
||||||
|
{ label: 'سیستمهای عاملمحور و اتوماسیون', value: 90 },
|
||||||
|
{ label: 'استک گوگل کلود (Vertex / GKE)', value: 88 },
|
||||||
|
{ label: 'موبایل بومی و cross-platform', value: 82 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
blog: {
|
||||||
|
eyebrow: 'بلاگ',
|
||||||
|
title: 'یادداشتهای مهندسی',
|
||||||
|
sub:
|
||||||
|
'یافتهها از پروژههای واقعی — نه ترجمهی مقاله، نه فهرست hype.',
|
||||||
|
readMore: 'ادامه',
|
||||||
|
readTimeSuffix: 'دقیقه',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
slug: 'rag-eval-framework',
|
||||||
|
category: 'LLM',
|
||||||
|
title: 'چارچوب ارزیابی RAG که در تولید کار میکند',
|
||||||
|
excerpt:
|
||||||
|
'چرا BLEU و ROUGE برای RAG ناکافیاند، و معیارهایی که در پروژههای واقعی تصمیم میسازند.',
|
||||||
|
readTime: 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'agentic-n8n-patterns',
|
||||||
|
category: 'Automation',
|
||||||
|
title: 'الگوهای عاملمحور با n8n برای سازمان',
|
||||||
|
excerpt:
|
||||||
|
'چگونه n8n را با LangGraph ترکیب کنیم تا گردشکارهای قابل ممیزی بسازیم.',
|
||||||
|
readTime: 11,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'vertex-cost-control',
|
||||||
|
category: 'Google Stack',
|
||||||
|
title: 'کنترل هزینه روی Vertex AI در مقیاس بالا',
|
||||||
|
excerpt:
|
||||||
|
'سه ضدالگو که در ۸۰٪ پروژههای Vertex میبینم، و چگونه ۶۰٪ هزینه را کاهش دادیم.',
|
||||||
|
readTime: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'k8s-llm-inference',
|
||||||
|
category: 'Infra',
|
||||||
|
title: 'استنتاج LLM روی Kubernetes با تأخیر زیر ۵۰ میلیثانیه',
|
||||||
|
excerpt:
|
||||||
|
'الگوی استقرار با KEDA، GPU sharing، و request hedging برای سرویسدهی پایدار.',
|
||||||
|
readTime: 14,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'flutter-on-device-ai',
|
||||||
|
category: 'Mobile',
|
||||||
|
title: 'هوش مصنوعی on-device در Flutter',
|
||||||
|
excerpt:
|
||||||
|
'استفاده از Gemini Nano و LiteRT برای استنتاج آفلاین در اپلیکیشنهای موبایل.',
|
||||||
|
readTime: 9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'enterprise-ai-roadmap',
|
||||||
|
category: 'Strategy',
|
||||||
|
title: 'نقشه راه هوش مصنوعی سازمانی در ۹۰ روز',
|
||||||
|
excerpt:
|
||||||
|
'چارچوبی که برای CTOها میسازم — از کشف موارد کاربری تا اولین استقرار تولید.',
|
||||||
|
readTime: 7,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
portfolio: {
|
||||||
|
eyebrow: 'نمونهکارها',
|
||||||
|
title: 'سامانههایی که در تولید کار میکنند',
|
||||||
|
sub:
|
||||||
|
'گزیدهای از پروژههای واقعی — از پایپلاین RAG تا مش داده رویدادمحور. روی هر کارت بزنید تا گالری و جزئیات معماری را ببینید.',
|
||||||
|
labels: {
|
||||||
|
role: 'نقش',
|
||||||
|
year: 'سال',
|
||||||
|
client: 'کارفرما',
|
||||||
|
stack: 'استک',
|
||||||
|
view: 'مشاهده پروژه',
|
||||||
|
gallery: 'گالری',
|
||||||
|
close: 'بستن',
|
||||||
|
next: 'بعدی',
|
||||||
|
prev: 'قبلی',
|
||||||
|
},
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'atlas-rag',
|
||||||
|
title: 'اطلس — پلتفرم RAG سازمانی',
|
||||||
|
client: 'بانک ردیفاول',
|
||||||
|
year: '۲۰۲۵',
|
||||||
|
role: 'مهندس ارشد هوش مصنوعی',
|
||||||
|
summary:
|
||||||
|
'دستیار دانش روی بیش از ۴ میلیون سند داخلی؛ بازیابی ترکیبی با pgvector و reranker، چارچوب ارزیابی اختصاصی و سرویسدهی با تأخیر زیر ۴۰ میلیثانیه روی Vertex AI.',
|
||||||
|
accent: 'electric',
|
||||||
|
tags: ['RAG', 'pgvector', 'Vertex AI', 'Eval'],
|
||||||
|
metrics: [
|
||||||
|
{ value: '۴M+', label: 'سند نمایهشده' },
|
||||||
|
{ value: '۳۸ms', label: 'تأخیر p95' },
|
||||||
|
{ value: '۹۲٪', label: 'دقت پاسخ' },
|
||||||
|
],
|
||||||
|
cover: '/portfolio/atlas-rag/cover.svg',
|
||||||
|
gallery: [
|
||||||
|
'/portfolio/atlas-rag/01.svg',
|
||||||
|
'/portfolio/atlas-rag/02.svg',
|
||||||
|
'/portfolio/atlas-rag/03.svg',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sentinel-agents',
|
||||||
|
title: 'سنتینل — اتوماسیون عاملمحور عملیات',
|
||||||
|
client: 'اسکیلآپ SaaS',
|
||||||
|
year: '۲۰۲۵',
|
||||||
|
role: 'معمار راهکار',
|
||||||
|
summary:
|
||||||
|
'سامانه پاسخ خودکار به رخدادها با ترکیب n8n و LangGraph؛ عاملهای قابل ممیزی که هشدارها را دستهبندی، ریشهیابی و در صورت امکان ترمیم میکنند.',
|
||||||
|
accent: 'violet',
|
||||||
|
tags: ['n8n', 'LangGraph', 'Agents', 'Observability'],
|
||||||
|
metrics: [
|
||||||
|
{ value: '۷۰٪', label: 'کاهش MTTR' },
|
||||||
|
{ value: '۲۴/۷', label: 'پوشش on-call' },
|
||||||
|
{ value: '۱۵۰+', label: 'گردشکار خودکار' },
|
||||||
|
],
|
||||||
|
cover: '/portfolio/sentinel-agents/cover.svg',
|
||||||
|
gallery: [
|
||||||
|
'/portfolio/sentinel-agents/01.svg',
|
||||||
|
'/portfolio/sentinel-agents/02.svg',
|
||||||
|
'/portfolio/sentinel-agents/03.svg',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vertex-vision',
|
||||||
|
title: 'ورتکس ویژن — بینایی ماشین بلادرنگ',
|
||||||
|
client: 'زنجیره خردهفروشی',
|
||||||
|
year: '۲۰۲۴',
|
||||||
|
role: 'مهندس هوش مصنوعی',
|
||||||
|
summary:
|
||||||
|
'استنتاج بینایی بلادرنگ روی GKE با Triton و Vertex AI برای تحلیل قفسه و رفتار مشتری در صدها شعبه، با مقیاسپذیری خودکار مبتنی بر صف.',
|
||||||
|
accent: 'cyan',
|
||||||
|
tags: ['Vertex AI', 'GKE', 'Triton', 'Computer Vision'],
|
||||||
|
metrics: [
|
||||||
|
{ value: '۱.۲B', label: 'استنتاج در ماه' },
|
||||||
|
{ value: '۳۰۰+', label: 'شعبه' },
|
||||||
|
{ value: '۶۰٪', label: 'کاهش هزینه GPU' },
|
||||||
|
],
|
||||||
|
cover: '/portfolio/vertex-vision/cover.svg',
|
||||||
|
gallery: [
|
||||||
|
'/portfolio/vertex-vision/01.svg',
|
||||||
|
'/portfolio/vertex-vision/02.svg',
|
||||||
|
'/portfolio/vertex-vision/03.svg',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mirage-mobile',
|
||||||
|
title: 'میراژ — هوش مصنوعی روی دستگاه',
|
||||||
|
client: 'محصول مصرفکننده',
|
||||||
|
year: '۲۰۲۴',
|
||||||
|
role: 'سرپرست موبایل و هوش مصنوعی',
|
||||||
|
summary:
|
||||||
|
'اپلیکیشن Flutter با استنتاج کاملاً آفلاین بهکمک Gemini Nano و LiteRT؛ تجربهی استریم پاسخ بدون وابستگی به شبکه و با حفظ کامل حریم خصوصی.',
|
||||||
|
accent: 'magenta',
|
||||||
|
tags: ['Flutter', 'Gemini Nano', 'LiteRT', 'On-device'],
|
||||||
|
metrics: [
|
||||||
|
{ value: '۰', label: 'وابستگی شبکه' },
|
||||||
|
{ value: '<۸۰ms', label: 'پاسخدهی' },
|
||||||
|
{ value: '۴.۸★', label: 'امتیاز کاربران' },
|
||||||
|
],
|
||||||
|
cover: '/portfolio/mirage-mobile/cover.svg',
|
||||||
|
gallery: [
|
||||||
|
'/portfolio/mirage-mobile/01.svg',
|
||||||
|
'/portfolio/mirage-mobile/02.svg',
|
||||||
|
'/portfolio/mirage-mobile/03.svg',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'flux-stream',
|
||||||
|
title: 'فلاکس — مش داده رویدادمحور',
|
||||||
|
client: 'پلتفرم لجستیک',
|
||||||
|
year: '۲۰۲۳',
|
||||||
|
role: 'معمار پلتفرم',
|
||||||
|
summary:
|
||||||
|
'ستون فقرات استریمینگ با Kafka و NATS روی Kubernetes؛ بیش از ۴۰ میکروسرویس با الگوهای پایداری، tracing سراسری و تحویل دقیقاً یکبار.',
|
||||||
|
accent: 'emerald',
|
||||||
|
tags: ['Kafka', 'NATS', 'Kubernetes', 'Go'],
|
||||||
|
metrics: [
|
||||||
|
{ value: '۴۰+', label: 'میکروسرویس' },
|
||||||
|
{ value: '۲M/s', label: 'رویداد در ثانیه' },
|
||||||
|
{ value: '۹۹.۹٪', label: 'پایداری' },
|
||||||
|
],
|
||||||
|
cover: '/portfolio/flux-stream/cover.svg',
|
||||||
|
gallery: [
|
||||||
|
'/portfolio/flux-stream/01.svg',
|
||||||
|
'/portfolio/flux-stream/02.svg',
|
||||||
|
'/portfolio/flux-stream/03.svg',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'oracle-forecast',
|
||||||
|
title: 'اوراکل — موتور پیشبینی تقاضا',
|
||||||
|
client: 'زنجیره تأمین',
|
||||||
|
year: '۲۰۲۳',
|
||||||
|
role: 'مهندس یادگیری ماشین',
|
||||||
|
summary:
|
||||||
|
'پایپلاین پیشبینی سریزمانی روی BigQuery و dbt با بازآموزی خودکار؛ کاهش چشمگیر هدررفت موجودی و بهبود دقت برنامهریزی تأمین.',
|
||||||
|
accent: 'electric',
|
||||||
|
tags: ['Forecasting', 'BigQuery', 'dbt', 'MLOps'],
|
||||||
|
metrics: [
|
||||||
|
{ value: '۲۳٪', label: 'کاهش هدررفت' },
|
||||||
|
{ value: '۸۹٪', label: 'دقت پیشبینی' },
|
||||||
|
{ value: 'روزانه', label: 'بازآموزی' },
|
||||||
|
],
|
||||||
|
cover: '/portfolio/oracle-forecast/cover.svg',
|
||||||
|
gallery: [
|
||||||
|
'/portfolio/oracle-forecast/01.svg',
|
||||||
|
'/portfolio/oracle-forecast/02.svg',
|
||||||
|
'/portfolio/oracle-forecast/03.svg',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
eyebrow: 'تماس',
|
||||||
|
title: 'یک جلسه ۳۰ دقیقهای رزرو کنید',
|
||||||
|
sub:
|
||||||
|
'هیچ هزینهای، هیچ تعهدی. کاربرد، چالشها و قدم بعدی را با هم بررسی میکنیم.',
|
||||||
|
fields: {
|
||||||
|
name: 'نام',
|
||||||
|
company: 'شرکت',
|
||||||
|
service: 'خدمت مورد نظر',
|
||||||
|
budget: 'بودجه (تقریبی)',
|
||||||
|
message: 'پیام',
|
||||||
|
},
|
||||||
|
placeholders: {
|
||||||
|
name: 'نام و نام خانوادگی',
|
||||||
|
company: 'نام سازمان',
|
||||||
|
message: 'هدف، زمانبندی، و چالشهای فعلی…',
|
||||||
|
},
|
||||||
|
budgets: ['کمتر از $10k', '$10k–$50k', '$50k–$200k', 'بیش از $200k'],
|
||||||
|
submit: 'ارسال درخواست',
|
||||||
|
note: 'پاسخ معمولاً ظرف ۲۴ ساعت کاری.',
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
tagline: 'طراحیشده در تهران · ساختهشده برای enterprise',
|
||||||
|
rights: '© ۲۰۲۶ سروش اسعدی. تمام حقوق محفوظ است.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
meta: {
|
||||||
|
title: 'Soroush Asadi — AI Engineer · Consultant · Solution Architect',
|
||||||
|
description:
|
||||||
|
'Designing and shipping production-grade AI systems for the enterprise — strategy, LLM & RAG, agentic automation, cloud infrastructure, and the Google Stack.',
|
||||||
|
},
|
||||||
|
nav: {
|
||||||
|
services: 'Services',
|
||||||
|
stack: 'Stack',
|
||||||
|
expertise: 'Expertise',
|
||||||
|
portfolio: 'Work',
|
||||||
|
blog: 'Journal',
|
||||||
|
contact: 'Contact',
|
||||||
|
book: 'Book a call',
|
||||||
|
},
|
||||||
|
locale: {
|
||||||
|
switchTo: 'FA',
|
||||||
|
label: 'Language',
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
availability: 'Available for select Q3 2026 engagements',
|
||||||
|
eyebrow: 'AI Engineer · Consultant · Solution Architect',
|
||||||
|
name: 'Soroush Asadi',
|
||||||
|
headlineLead: 'Architecting',
|
||||||
|
headlineAccent: 'production-grade AI',
|
||||||
|
headlineTrail: 'for the enterprise.',
|
||||||
|
sub:
|
||||||
|
'From strategy to deployment — building LLM pipelines, autonomous agents, and cloud architectures that hold up at millions of events per day.',
|
||||||
|
roles: [
|
||||||
|
'AI Strategy',
|
||||||
|
'LLM & RAG Engineering',
|
||||||
|
'Solution Architecture',
|
||||||
|
'Agentic Automation',
|
||||||
|
'Google Cloud Stack',
|
||||||
|
],
|
||||||
|
ctaPrimary: 'Book a consultation',
|
||||||
|
ctaSecondary: 'View services',
|
||||||
|
scroll: 'Scroll',
|
||||||
|
metrics: [
|
||||||
|
{ value: '18+', label: 'AI models in production' },
|
||||||
|
{ value: '40+', label: 'microservices shipped' },
|
||||||
|
{ value: '12ms', label: 'inference latency' },
|
||||||
|
{ value: '99%', label: 'SLA uptime' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
services: {
|
||||||
|
eyebrow: 'Services',
|
||||||
|
title: 'Six areas of practice',
|
||||||
|
sub:
|
||||||
|
'From the first strategy session to production rollout — one engineering partner for the full AI lifecycle.',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'strategy',
|
||||||
|
title: 'AI Strategy & Roadmap',
|
||||||
|
description:
|
||||||
|
'Maturity assessment, highest-ROI use-case discovery, and a 12–18 month roadmap with measurable KPIs.',
|
||||||
|
tags: ['Discovery', 'ROI Mapping', 'Roadmap'],
|
||||||
|
color: 'electric',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'automation',
|
||||||
|
title: 'AI Automation',
|
||||||
|
description:
|
||||||
|
'Autonomous agents and n8n workflows that turn manual processes into auditable, observable systems.',
|
||||||
|
tags: ['n8n', 'Agents', 'Workflows'],
|
||||||
|
color: 'violet',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'llm-rag',
|
||||||
|
title: 'LLM & RAG Engineering',
|
||||||
|
description:
|
||||||
|
'Production RAG pipelines with vector stores, evaluation frameworks, and sub-50ms serving.',
|
||||||
|
tags: ['RAG', 'Vector DB', 'Eval'],
|
||||||
|
color: 'magenta',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'architecture',
|
||||||
|
title: 'Solution Architecture',
|
||||||
|
description:
|
||||||
|
'Distributed systems on Kubernetes — microservices, event streaming, and resilience patterns at scale.',
|
||||||
|
tags: ['K8s', 'Microservices', 'Event-Driven'],
|
||||||
|
color: 'emerald',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mobile',
|
||||||
|
title: 'Mobile AI Apps',
|
||||||
|
description:
|
||||||
|
'Flutter, Swift, and Kotlin apps with on-device inference, streaming LLM UX, and native polish.',
|
||||||
|
tags: ['Flutter', 'Swift', 'Kotlin'],
|
||||||
|
color: 'electric',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'google-stack',
|
||||||
|
title: 'Google Stack Specialist',
|
||||||
|
description:
|
||||||
|
'Vertex AI, GKE, and Gemini deployments with cost optimization and enterprise security patterns.',
|
||||||
|
tags: ['Vertex AI', 'GKE', 'Gemini'],
|
||||||
|
color: 'cyan',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
dataflow: {
|
||||||
|
eyebrow: 'Pipeline',
|
||||||
|
title: 'From raw document to trustworthy answer',
|
||||||
|
sub:
|
||||||
|
'The path every query takes through a production RAG system — each stage measurable, auditable, and tuned for latency.',
|
||||||
|
caption: 'Sub-50ms end-to-end · every stage observable',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'ingest',
|
||||||
|
label: 'Ingest',
|
||||||
|
desc: 'Normalize, chunk, and clean source documents',
|
||||||
|
accent: 'electric',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'embed',
|
||||||
|
label: 'Embed',
|
||||||
|
desc: 'Generate embeddings and index in the vector store',
|
||||||
|
accent: 'violet',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'retrieve',
|
||||||
|
label: 'Retrieve',
|
||||||
|
desc: 'Hybrid semantic + keyword search',
|
||||||
|
accent: 'cyan',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rerank',
|
||||||
|
label: 'Rerank',
|
||||||
|
desc: 'Re-order candidates with a cross-encoder',
|
||||||
|
accent: 'magenta',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'generate',
|
||||||
|
label: 'Generate',
|
||||||
|
desc: 'Grounded answer with source citations',
|
||||||
|
accent: 'emerald',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
stack: {
|
||||||
|
eyebrow: 'Stack',
|
||||||
|
title: 'Daily tooling',
|
||||||
|
sub:
|
||||||
|
'Everything I ship sits on this foundation — chosen for longevity, not hype cycles.',
|
||||||
|
categories: [
|
||||||
|
{
|
||||||
|
id: 'languages',
|
||||||
|
label: 'Languages',
|
||||||
|
items: ['Python', 'TypeScript', 'Go', 'Rust', 'SQL'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mobile',
|
||||||
|
label: 'Mobile',
|
||||||
|
items: ['Flutter', 'Swift / SwiftUI', 'Kotlin', 'React Native'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'infra',
|
||||||
|
label: 'Infrastructure',
|
||||||
|
items: ['Kubernetes', 'Terraform', 'Postgres', 'Redis', 'Kafka', 'NATS'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ai',
|
||||||
|
label: 'AI / ML',
|
||||||
|
items: ['Vertex AI', 'Gemini', 'OpenAI', 'Anthropic', 'LangGraph', 'Pinecone', 'pgvector'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
expertise: {
|
||||||
|
eyebrow: 'Expertise',
|
||||||
|
title: 'The numbers that matter',
|
||||||
|
sub:
|
||||||
|
'Systems that survive millions of events per day — these are the metrics I optimize for.',
|
||||||
|
bars: [
|
||||||
|
{ label: 'LLM & RAG engineering', value: 95 },
|
||||||
|
{ label: 'Cloud architecture & Kubernetes', value: 92 },
|
||||||
|
{ label: 'Agentic systems & automation', value: 90 },
|
||||||
|
{ label: 'Google Cloud stack (Vertex / GKE)', value: 88 },
|
||||||
|
{ label: 'Native + cross-platform mobile', value: 82 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
blog: {
|
||||||
|
eyebrow: 'Journal',
|
||||||
|
title: 'Engineering notes',
|
||||||
|
sub:
|
||||||
|
'Findings from real engagements — not translated articles, not hype lists.',
|
||||||
|
readMore: 'Read',
|
||||||
|
readTimeSuffix: 'min',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
slug: 'rag-eval-framework',
|
||||||
|
category: 'LLM',
|
||||||
|
title: 'A RAG evaluation framework that holds up in production',
|
||||||
|
excerpt:
|
||||||
|
'Why BLEU and ROUGE fall short for RAG, and the metrics that actually drive decisions in real projects.',
|
||||||
|
readTime: 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'agentic-n8n-patterns',
|
||||||
|
category: 'Automation',
|
||||||
|
title: 'Agentic patterns with n8n for the enterprise',
|
||||||
|
excerpt:
|
||||||
|
'How to combine n8n with LangGraph to build auditable, debuggable autonomous workflows.',
|
||||||
|
readTime: 11,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'vertex-cost-control',
|
||||||
|
category: 'Google Stack',
|
||||||
|
title: 'Vertex AI cost control at scale',
|
||||||
|
excerpt:
|
||||||
|
'Three anti-patterns I see in 80% of Vertex projects — and how we cut 60% of monthly spend.',
|
||||||
|
readTime: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'k8s-llm-inference',
|
||||||
|
category: 'Infra',
|
||||||
|
title: 'Sub-50ms LLM inference on Kubernetes',
|
||||||
|
excerpt:
|
||||||
|
'Deployment pattern with KEDA, GPU sharing, and request hedging for stable serving.',
|
||||||
|
readTime: 14,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'flutter-on-device-ai',
|
||||||
|
category: 'Mobile',
|
||||||
|
title: 'On-device AI in Flutter',
|
||||||
|
excerpt:
|
||||||
|
'Using Gemini Nano and LiteRT for offline inference inside mobile apps.',
|
||||||
|
readTime: 9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'enterprise-ai-roadmap',
|
||||||
|
category: 'Strategy',
|
||||||
|
title: 'A 90-day enterprise AI roadmap',
|
||||||
|
excerpt:
|
||||||
|
'The framework I build for CTOs — from use-case discovery to first production deployment.',
|
||||||
|
readTime: 7,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
portfolio: {
|
||||||
|
eyebrow: 'Selected work',
|
||||||
|
title: 'Systems that run in production',
|
||||||
|
sub:
|
||||||
|
'A selection of real engagements — from RAG pipelines to event-driven data meshes. Tap any card for the gallery and the architecture behind it.',
|
||||||
|
labels: {
|
||||||
|
role: 'Role',
|
||||||
|
year: 'Year',
|
||||||
|
client: 'Client',
|
||||||
|
stack: 'Stack',
|
||||||
|
view: 'View project',
|
||||||
|
gallery: 'Gallery',
|
||||||
|
close: 'Close',
|
||||||
|
next: 'Next',
|
||||||
|
prev: 'Previous',
|
||||||
|
},
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'atlas-rag',
|
||||||
|
title: 'Atlas — Enterprise RAG Platform',
|
||||||
|
client: 'Tier-1 bank',
|
||||||
|
year: '2025',
|
||||||
|
role: 'Lead AI Engineer',
|
||||||
|
summary:
|
||||||
|
'A knowledge assistant over 4M+ internal documents — hybrid retrieval with pgvector and a reranker, a bespoke evaluation harness, and sub-40ms serving on Vertex AI.',
|
||||||
|
accent: 'electric',
|
||||||
|
tags: ['RAG', 'pgvector', 'Vertex AI', 'Eval'],
|
||||||
|
metrics: [
|
||||||
|
{ value: '4M+', label: 'docs indexed' },
|
||||||
|
{ value: '38ms', label: 'p95 latency' },
|
||||||
|
{ value: '92%', label: 'answer accuracy' },
|
||||||
|
],
|
||||||
|
cover: '/portfolio/atlas-rag/cover.svg',
|
||||||
|
gallery: [
|
||||||
|
'/portfolio/atlas-rag/01.svg',
|
||||||
|
'/portfolio/atlas-rag/02.svg',
|
||||||
|
'/portfolio/atlas-rag/03.svg',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sentinel-agents',
|
||||||
|
title: 'Sentinel — Agentic Ops Automation',
|
||||||
|
client: 'SaaS scale-up',
|
||||||
|
year: '2025',
|
||||||
|
role: 'Solution Architect',
|
||||||
|
summary:
|
||||||
|
'Autonomous incident response combining n8n and LangGraph — auditable agents that triage alerts, find root cause, and self-heal where it is safe to do so.',
|
||||||
|
accent: 'violet',
|
||||||
|
tags: ['n8n', 'LangGraph', 'Agents', 'Observability'],
|
||||||
|
metrics: [
|
||||||
|
{ value: '70%', label: 'MTTR reduction' },
|
||||||
|
{ value: '24/7', label: 'on-call coverage' },
|
||||||
|
{ value: '150+', label: 'automated flows' },
|
||||||
|
],
|
||||||
|
cover: '/portfolio/sentinel-agents/cover.svg',
|
||||||
|
gallery: [
|
||||||
|
'/portfolio/sentinel-agents/01.svg',
|
||||||
|
'/portfolio/sentinel-agents/02.svg',
|
||||||
|
'/portfolio/sentinel-agents/03.svg',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vertex-vision',
|
||||||
|
title: 'Vertex Vision — Realtime Vision Inference',
|
||||||
|
client: 'Retail chain',
|
||||||
|
year: '2024',
|
||||||
|
role: 'AI Engineer',
|
||||||
|
summary:
|
||||||
|
'Real-time vision inference on GKE with Triton and Vertex AI for shelf analytics and customer flow across hundreds of stores, autoscaled off a work queue.',
|
||||||
|
accent: 'cyan',
|
||||||
|
tags: ['Vertex AI', 'GKE', 'Triton', 'Computer Vision'],
|
||||||
|
metrics: [
|
||||||
|
{ value: '1.2B', label: 'inferences / mo' },
|
||||||
|
{ value: '300+', label: 'stores' },
|
||||||
|
{ value: '60%', label: 'GPU cost cut' },
|
||||||
|
],
|
||||||
|
cover: '/portfolio/vertex-vision/cover.svg',
|
||||||
|
gallery: [
|
||||||
|
'/portfolio/vertex-vision/01.svg',
|
||||||
|
'/portfolio/vertex-vision/02.svg',
|
||||||
|
'/portfolio/vertex-vision/03.svg',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mirage-mobile',
|
||||||
|
title: 'Mirage — On-device AI Suite',
|
||||||
|
client: 'Consumer product',
|
||||||
|
year: '2024',
|
||||||
|
role: 'Mobile + AI Lead',
|
||||||
|
summary:
|
||||||
|
'A Flutter app with fully offline inference via Gemini Nano and LiteRT — streaming response UX with zero network dependency and privacy preserved end to end.',
|
||||||
|
accent: 'magenta',
|
||||||
|
tags: ['Flutter', 'Gemini Nano', 'LiteRT', 'On-device'],
|
||||||
|
metrics: [
|
||||||
|
{ value: '0', label: 'network deps' },
|
||||||
|
{ value: '<80ms', label: 'response' },
|
||||||
|
{ value: '4.8★', label: 'user rating' },
|
||||||
|
],
|
||||||
|
cover: '/portfolio/mirage-mobile/cover.svg',
|
||||||
|
gallery: [
|
||||||
|
'/portfolio/mirage-mobile/01.svg',
|
||||||
|
'/portfolio/mirage-mobile/02.svg',
|
||||||
|
'/portfolio/mirage-mobile/03.svg',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'flux-stream',
|
||||||
|
title: 'Flux — Event-Driven Data Mesh',
|
||||||
|
client: 'Logistics platform',
|
||||||
|
year: '2023',
|
||||||
|
role: 'Platform Architect',
|
||||||
|
summary:
|
||||||
|
'A streaming backbone on Kafka and NATS over Kubernetes — 40+ microservices with resilience patterns, end-to-end tracing, and exactly-once delivery.',
|
||||||
|
accent: 'emerald',
|
||||||
|
tags: ['Kafka', 'NATS', 'Kubernetes', 'Go'],
|
||||||
|
metrics: [
|
||||||
|
{ value: '40+', label: 'microservices' },
|
||||||
|
{ value: '2M/s', label: 'events / sec' },
|
||||||
|
{ value: '99.9%', label: 'uptime' },
|
||||||
|
],
|
||||||
|
cover: '/portfolio/flux-stream/cover.svg',
|
||||||
|
gallery: [
|
||||||
|
'/portfolio/flux-stream/01.svg',
|
||||||
|
'/portfolio/flux-stream/02.svg',
|
||||||
|
'/portfolio/flux-stream/03.svg',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'oracle-forecast',
|
||||||
|
title: 'Oracle — Demand Forecasting Engine',
|
||||||
|
client: 'Supply chain',
|
||||||
|
year: '2023',
|
||||||
|
role: 'ML Engineer',
|
||||||
|
summary:
|
||||||
|
'A time-series forecasting pipeline on BigQuery and dbt with automated retraining — sharply reduced inventory waste and improved supply planning accuracy.',
|
||||||
|
accent: 'electric',
|
||||||
|
tags: ['Forecasting', 'BigQuery', 'dbt', 'MLOps'],
|
||||||
|
metrics: [
|
||||||
|
{ value: '23%', label: 'waste reduction' },
|
||||||
|
{ value: '89%', label: 'forecast accuracy' },
|
||||||
|
{ value: 'daily', label: 'retraining' },
|
||||||
|
],
|
||||||
|
cover: '/portfolio/oracle-forecast/cover.svg',
|
||||||
|
gallery: [
|
||||||
|
'/portfolio/oracle-forecast/01.svg',
|
||||||
|
'/portfolio/oracle-forecast/02.svg',
|
||||||
|
'/portfolio/oracle-forecast/03.svg',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
eyebrow: 'Contact',
|
||||||
|
title: 'Book a 30-minute call',
|
||||||
|
sub:
|
||||||
|
'No cost, no commitment. We map the use case, the constraints, and the next step together.',
|
||||||
|
fields: {
|
||||||
|
name: 'Name',
|
||||||
|
company: 'Company',
|
||||||
|
service: 'Service',
|
||||||
|
budget: 'Budget (rough)',
|
||||||
|
message: 'Message',
|
||||||
|
},
|
||||||
|
placeholders: {
|
||||||
|
name: 'Full name',
|
||||||
|
company: 'Organization',
|
||||||
|
message: 'Goal, timeline, current blockers…',
|
||||||
|
},
|
||||||
|
budgets: ['Under $10k', '$10k–$50k', '$50k–$200k', '$200k+'],
|
||||||
|
submit: 'Send request',
|
||||||
|
note: 'Typical reply within 24 working hours.',
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
tagline: 'Designed in Tehran · Built for the enterprise',
|
||||||
|
rights: '© 2026 Soroush Asadi. All rights reserved.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type Dict = typeof dict.en;
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
import { DEFAULT_LOCALE, dict, type Dict, type Locale } from './dictionaries';
|
||||||
|
|
||||||
|
type Direction = 'rtl' | 'ltr';
|
||||||
|
|
||||||
|
type Ctx = {
|
||||||
|
locale: Locale;
|
||||||
|
dir: Direction;
|
||||||
|
t: Dict;
|
||||||
|
setLocale: (l: Locale) => void;
|
||||||
|
toggle: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LocaleContext = createContext<Ctx | null>(null);
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'sa.locale';
|
||||||
|
|
||||||
|
function dirFor(locale: Locale): Direction {
|
||||||
|
return locale === 'fa' ? 'rtl' : 'ltr';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LocaleProvider({
|
||||||
|
initialLocale,
|
||||||
|
content,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
initialLocale?: Locale;
|
||||||
|
/**
|
||||||
|
* Server-resolved content (dict defaults merged with admin overrides).
|
||||||
|
* When omitted we fall back to the in-code dictionary, so the provider keeps
|
||||||
|
* working in isolation (tests, storybook, etc.).
|
||||||
|
*/
|
||||||
|
content?: { fa: Dict; en: Dict };
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const [locale, setLocaleState] = useState<Locale>(initialLocale ?? DEFAULT_LOCALE);
|
||||||
|
const source = content ?? (dict as unknown as { fa: Dict; en: Dict });
|
||||||
|
|
||||||
|
// Hydrate from localStorage on the client.
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const saved = window.localStorage.getItem(STORAGE_KEY) as Locale | null;
|
||||||
|
if (saved === 'fa' || saved === 'en') {
|
||||||
|
setLocaleState(saved);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reflect locale + direction on the html element without a reload.
|
||||||
|
useEffect(() => {
|
||||||
|
const html = document.documentElement;
|
||||||
|
html.lang = locale;
|
||||||
|
html.dir = dirFor(locale);
|
||||||
|
html.dataset.locale = locale;
|
||||||
|
}, [locale]);
|
||||||
|
|
||||||
|
const setLocale = (l: Locale) => {
|
||||||
|
setLocaleState(l);
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, l);
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = useMemo<Ctx>(
|
||||||
|
() => ({
|
||||||
|
locale,
|
||||||
|
dir: dirFor(locale),
|
||||||
|
t: source[locale],
|
||||||
|
setLocale,
|
||||||
|
toggle: () => setLocale(locale === 'fa' ? 'en' : 'fa'),
|
||||||
|
}),
|
||||||
|
[locale, source],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLocale() {
|
||||||
|
const ctx = useContext(LocaleContext);
|
||||||
|
if (!ctx) throw new Error('useLocale must be used inside <LocaleProvider>');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { SESSION_COOKIE, verifySession } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gate everything under /admin and /api/admin behind the session cookie.
|
||||||
|
* The login page and login endpoint stay public so a fresh visitor can sign in.
|
||||||
|
*/
|
||||||
|
export async function middleware(req: NextRequest) {
|
||||||
|
const { pathname } = req.nextUrl;
|
||||||
|
const isLoginPage = pathname === '/admin/login';
|
||||||
|
const isLoginApi = pathname === '/api/admin/login';
|
||||||
|
|
||||||
|
const token = req.cookies.get(SESSION_COOKIE)?.value;
|
||||||
|
const authed = await verifySession(token);
|
||||||
|
|
||||||
|
// Public auth endpoints.
|
||||||
|
if (isLoginApi) return NextResponse.next();
|
||||||
|
if (isLoginPage) {
|
||||||
|
return authed
|
||||||
|
? NextResponse.redirect(new URL('/admin', req.url))
|
||||||
|
: NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authed) {
|
||||||
|
if (pathname.startsWith('/api/admin')) {
|
||||||
|
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
const url = new URL('/admin/login', req.url);
|
||||||
|
if (pathname !== '/admin') url.searchParams.set('from', pathname);
|
||||||
|
return NextResponse.redirect(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/admin/:path*', '/api/admin/:path*'],
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
// Self-hosted via Docker: emit a minimal standalone server bundle.
|
||||||
|
output: 'standalone',
|
||||||
|
images: {
|
||||||
|
formats: ['image/avif', 'image/webp'],
|
||||||
|
},
|
||||||
|
// better-sqlite3 is a native addon and must stay external to the bundle so
|
||||||
|
// its .node binary is required from node_modules at runtime, not traced.
|
||||||
|
experimental: {
|
||||||
|
serverComponentsExternalPackages: ['better-sqlite3'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "soroush-asadi-site",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^11.5.0",
|
||||||
|
"clsx": "2.1.1",
|
||||||
|
"framer-motion": "11.11.17",
|
||||||
|
"lenis": "1.1.18",
|
||||||
|
"next": "14.2.18",
|
||||||
|
"react": "18.3.1",
|
||||||
|
"react-dom": "18.3.1",
|
||||||
|
"resend": "4.0.1",
|
||||||
|
"tailwind-merge": "2.5.5",
|
||||||
|
"three": "0.170.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/better-sqlite3": "^7.6.11",
|
||||||
|
"@types/node": "22.10.1",
|
||||||
|
"@types/react": "18.3.13",
|
||||||
|
"@types/react-dom": "18.3.1",
|
||||||
|
"@types/three": "0.170.0",
|
||||||
|
"autoprefixer": "10.4.20",
|
||||||
|
"eslint": "8.57.1",
|
||||||
|
"eslint-config-next": "14.2.18",
|
||||||
|
"postcss": "8.4.49",
|
||||||
|
"tailwindcss": "3.4.16",
|
||||||
|
"typescript": "5.7.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" fill="none" role="img" aria-label="Soroush Asadi portrait">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="bg" cx="50%" cy="40%" r="60%">
|
||||||
|
<stop offset="0%" stop-color="#0f1b33"/>
|
||||||
|
<stop offset="60%" stop-color="#050a1a"/>
|
||||||
|
<stop offset="100%" stop-color="#020510"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="rim" x1="0" y1="0" x2="400" y2="400" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0%" stop-color="#38bdf8"/>
|
||||||
|
<stop offset="50%" stop-color="#818cf8"/>
|
||||||
|
<stop offset="100%" stop-color="#e879f9"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="face" x1="100" y1="120" x2="320" y2="360" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0%" stop-color="#1e293b"/>
|
||||||
|
<stop offset="100%" stop-color="#0f1b33"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="hair" x1="120" y1="60" x2="280" y2="200" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0%" stop-color="#0f1b33"/>
|
||||||
|
<stop offset="100%" stop-color="#1e293b"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="acc" x1="0" y1="0" x2="400" y2="0" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0%" stop-color="#22d3ee"/>
|
||||||
|
<stop offset="100%" stop-color="#e879f9"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feGaussianBlur stdDeviation="6" result="b"/>
|
||||||
|
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- backdrop -->
|
||||||
|
<circle cx="200" cy="200" r="200" fill="url(#bg)"/>
|
||||||
|
<!-- faint hex grid -->
|
||||||
|
<g stroke="rgba(56,189,248,0.08)" stroke-width="0.6" fill="none">
|
||||||
|
<path d="M40 200 L80 178 L120 200 L120 244 L80 266 L40 244 Z"/>
|
||||||
|
<path d="M280 200 L320 178 L360 200 L360 244 L320 266 L280 244 Z"/>
|
||||||
|
<path d="M160 80 L200 58 L240 80 L240 124 L200 146 L160 124 Z"/>
|
||||||
|
<path d="M160 280 L200 258 L240 280 L240 324 L200 346 L160 324 Z"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- shoulders -->
|
||||||
|
<path d="M60 400 C 90 320, 150 290, 200 290 C 250 290, 310 320, 340 400 Z"
|
||||||
|
fill="url(#face)" stroke="rgba(56,189,248,0.25)" stroke-width="1"/>
|
||||||
|
|
||||||
|
<!-- collar accent -->
|
||||||
|
<path d="M170 320 L200 340 L230 320" stroke="url(#acc)" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||||
|
<circle cx="200" cy="345" r="2" fill="#38bdf8"/>
|
||||||
|
|
||||||
|
<!-- neck -->
|
||||||
|
<rect x="180" y="260" width="40" height="40" fill="url(#face)"/>
|
||||||
|
|
||||||
|
<!-- head -->
|
||||||
|
<ellipse cx="200" cy="190" rx="88" ry="100" fill="url(#face)"
|
||||||
|
stroke="url(#rim)" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- hair (short, modern) -->
|
||||||
|
<path d="M118 178 C 118 110, 165 80, 200 80 C 235 80, 282 105, 282 178
|
||||||
|
C 282 168, 270 150, 240 144 C 235 130, 215 120, 200 120
|
||||||
|
C 180 120, 165 130, 155 148 C 140 152, 125 162, 118 178 Z"
|
||||||
|
fill="url(#hair)" stroke="rgba(56,189,248,0.3)" stroke-width="1"/>
|
||||||
|
|
||||||
|
<!-- brow -->
|
||||||
|
<path d="M152 192 Q 168 184 184 192" stroke="#cbd5e1" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||||
|
<path d="M216 192 Q 232 184 248 192" stroke="#cbd5e1" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- glasses frame -->
|
||||||
|
<g stroke="url(#rim)" stroke-width="2.2" fill="none" filter="url(#glow)">
|
||||||
|
<rect x="142" y="198" width="48" height="34" rx="8"/>
|
||||||
|
<rect x="210" y="198" width="48" height="34" rx="8"/>
|
||||||
|
<path d="M190 212 L210 212"/>
|
||||||
|
<path d="M142 208 L128 204"/>
|
||||||
|
<path d="M258 208 L272 204"/>
|
||||||
|
</g>
|
||||||
|
<!-- lens shimmer -->
|
||||||
|
<rect x="146" y="202" width="14" height="6" rx="2" fill="rgba(56,189,248,0.25)"/>
|
||||||
|
<rect x="214" y="202" width="14" height="6" rx="2" fill="rgba(56,189,248,0.25)"/>
|
||||||
|
|
||||||
|
<!-- nose -->
|
||||||
|
<path d="M200 222 L196 252 Q 200 258 204 252" stroke="#94a3b8" stroke-width="1.5" fill="none" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- mouth (subtle confident line) -->
|
||||||
|
<path d="M180 268 Q 200 274 220 268" stroke="#cbd5e1" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- jaw highlight -->
|
||||||
|
<path d="M120 220 Q 135 280 200 295" stroke="rgba(56,189,248,0.18)" stroke-width="1" fill="none"/>
|
||||||
|
<path d="M280 220 Q 265 280 200 295" stroke="rgba(232,121,249,0.18)" stroke-width="1" fill="none"/>
|
||||||
|
|
||||||
|
<!-- floating tech nodes -->
|
||||||
|
<g fill="#e879f9">
|
||||||
|
<circle cx="60" cy="120" r="2"/>
|
||||||
|
<circle cx="340" cy="140" r="2"/>
|
||||||
|
<circle cx="50" cy="320" r="2"/>
|
||||||
|
<circle cx="350" cy="300" r="2"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#22d3ee">
|
||||||
|
<circle cx="90" cy="80" r="1.5"/>
|
||||||
|
<circle cx="310" cy="90" r="1.5"/>
|
||||||
|
<circle cx="80" cy="280" r="1.5"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- connecting wires -->
|
||||||
|
<g stroke="rgba(56,189,248,0.25)" stroke-width="0.7" fill="none">
|
||||||
|
<path d="M60 120 Q 100 150 130 200"/>
|
||||||
|
<path d="M340 140 Q 300 160 270 200"/>
|
||||||
|
<path d="M50 320 Q 110 310 160 290"/>
|
||||||
|
<path d="M350 300 Q 290 310 240 290"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- outer rim -->
|
||||||
|
<circle cx="200" cy="200" r="198" stroke="url(#rim)" stroke-width="1.5" opacity="0.6"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,21 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" role="img" aria-label="SA mark">
|
||||||
|
<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>
|
||||||
|
<linearGradient id="g2" x1="64" y1="0" x2="0" y2="64" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0%" stop-color="#22d3ee"/>
|
||||||
|
<stop offset="100%" stop-color="#e879f9"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- hex frame -->
|
||||||
|
<path d="M32 3 L57 17.5 V46.5 L32 61 L7 46.5 V17.5 Z"
|
||||||
|
stroke="url(#g)" stroke-width="2" fill="rgba(56,189,248,0.06)"/>
|
||||||
|
<!-- abstract S+A glyph -->
|
||||||
|
<path d="M22 22 Q22 17 27 17 H38 Q43 17 43 22 Q43 27 38 27 H27 Q22 27 22 32 Q22 37 27 37 H38 Q43 37 43 42 Q43 47 38 47 H22"
|
||||||
|
stroke="url(#g2)" stroke-width="2.4" stroke-linecap="round" fill="none"/>
|
||||||
|
<circle cx="46" cy="18" r="1.6" fill="#e879f9"/>
|
||||||
|
<circle cx="18" cy="46" r="1.6" fill="#22d3ee"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,36 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 64" fill="none" role="img" aria-label="Soroush Asadi">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gw" x1="0" y1="0" x2="320" y2="0" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0%" stop-color="#38bdf8"/>
|
||||||
|
<stop offset="55%" stop-color="#818cf8"/>
|
||||||
|
<stop offset="100%" stop-color="#e879f9"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gm" 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>
|
||||||
|
<linearGradient id="gm2" x1="64" y1="0" x2="0" y2="64" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0%" stop-color="#22d3ee"/>
|
||||||
|
<stop offset="100%" stop-color="#e879f9"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- mark -->
|
||||||
|
<g transform="translate(0,0)">
|
||||||
|
<path d="M32 5 L55 18 V46 L32 59 L9 46 V18 Z"
|
||||||
|
stroke="url(#gm)" stroke-width="2" fill="rgba(56,189,248,0.05)"/>
|
||||||
|
<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(#gm2)" stroke-width="2.2" stroke-linecap="round" fill="none"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- wordmark -->
|
||||||
|
<g font-family="'Syne', 'Helvetica Neue', sans-serif" font-weight="700">
|
||||||
|
<text x="76" y="38" font-size="22" fill="#e2e8f0" letter-spacing="0.5">Soroush</text>
|
||||||
|
<text x="180" y="38" font-size="22" fill="url(#gw)" letter-spacing="0.5">Asadi</text>
|
||||||
|
<text x="76" y="54" font-family="'Space Mono', ui-monospace, monospace"
|
||||||
|
font-size="9" fill="#64748b" letter-spacing="3" font-weight="400">
|
||||||
|
AI · ARCHITECTURE · STRATEGY
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,36 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" width="1600" height="1000">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="glowatlasraga" cx="28%" cy="18%" r="90%">
|
||||||
|
<stop offset="0%" stop-color="hsl(199 90% 62% / 0.55)"/>
|
||||||
|
<stop offset="45%" stop-color="hsl(227 85% 55% / 0.18)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(199 60% 8% / 0)"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="bgatlasraga" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#04060f"/>
|
||||||
|
<stop offset="100%" stop-color="#020308"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="lineatlasraga" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="hsl(199 90% 65%)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(239 90% 68%)"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="1000" fill="url(#bgatlasraga)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#glowatlasraga)"/>
|
||||||
|
<g stroke="hsl(199 40% 60% / 0.06)" stroke-width="1">
|
||||||
|
<line x1="0" y1="0" x2="0" y2="1000"/><line x1="100" y1="0" x2="100" y2="1000"/><line x1="200" y1="0" x2="200" y2="1000"/><line x1="300" y1="0" x2="300" y2="1000"/><line x1="400" y1="0" x2="400" y2="1000"/><line x1="500" y1="0" x2="500" y2="1000"/><line x1="600" y1="0" x2="600" y2="1000"/><line x1="700" y1="0" x2="700" y2="1000"/><line x1="800" y1="0" x2="800" y2="1000"/><line x1="900" y1="0" x2="900" y2="1000"/><line x1="1000" y1="0" x2="1000" y2="1000"/><line x1="1100" y1="0" x2="1100" y2="1000"/><line x1="1200" y1="0" x2="1200" y2="1000"/><line x1="1300" y1="0" x2="1300" y2="1000"/><line x1="1400" y1="0" x2="1400" y2="1000"/><line x1="1500" y1="0" x2="1500" y2="1000"/>
|
||||||
|
<line x1="0" y1="0" x2="1600" y2="0"/><line x1="0" y1="100" x2="1600" y2="100"/><line x1="0" y1="200" x2="1600" y2="200"/><line x1="0" y1="300" x2="1600" y2="300"/><line x1="0" y1="400" x2="1600" y2="400"/><line x1="0" y1="500" x2="1600" y2="500"/><line x1="0" y1="600" x2="1600" y2="600"/><line x1="0" y1="700" x2="1600" y2="700"/><line x1="0" y1="800" x2="1600" y2="800"/><line x1="0" y1="900" x2="1600" y2="900"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="80" y="70" width="1440" height="60" rx="14" fill="hsl(199 30% 18% / 0.4)" stroke="hsl(199 60% 60% / 0.25)"/>
|
||||||
|
<circle cx="120" cy="100" r="9" fill="hsl(0 70% 60% / .7)"/>
|
||||||
|
<circle cx="152" cy="100" r="9" fill="hsl(45 80% 60% / .7)"/>
|
||||||
|
<circle cx="184" cy="100" r="9" fill="hsl(140 60% 55% / .7)"/>
|
||||||
|
<rect x="240" y="88" width="420" height="24" rx="12" fill="hsl(199 30% 40% / 0.25)"/>
|
||||||
|
</g>
|
||||||
|
<rect x="120" y="200" width="700" height="560" rx="20" fill="hsl(199 30% 12% / 0.5)" stroke="hsl(199 60% 60% / 0.16)"/>
|
||||||
|
<text x="160" y="270" font-family="monospace" font-size="30" fill="hsl(199 70% 75%)">throughput / day</text>
|
||||||
|
<rect x="180" y="680" width="54" height="80" rx="8" fill="url(#lineatlasraga)" opacity="0.5"/><rect x="276" y="627" width="54" height="133" rx="8" fill="url(#lineatlasraga)" opacity="0.66"/><rect x="372" y="574" width="54" height="186" rx="8" fill="url(#lineatlasraga)" opacity="0.8200000000000001"/><rect x="468" y="521" width="54" height="239" rx="8" fill="url(#lineatlasraga)" opacity="0.5"/><rect x="564" y="468" width="54" height="292" rx="8" fill="url(#lineatlasraga)" opacity="0.66"/><rect x="660" y="675" width="54" height="85" rx="8" fill="url(#lineatlasraga)" opacity="0.8200000000000001"/><rect x="756" y="622" width="54" height="138" rx="8" fill="url(#lineatlasraga)" opacity="0.5"/><rect x="852" y="569" width="54" height="191" rx="8" fill="url(#lineatlasraga)" opacity="0.66"/><rect x="948" y="516" width="54" height="244" rx="8" fill="url(#lineatlasraga)" opacity="0.8200000000000001"/>
|
||||||
|
<rect x="880" y="200" width="600" height="560" rx="20" fill="hsl(199 30% 12% / 0.5)" stroke="hsl(199 60% 60% / 0.16)"/>
|
||||||
|
<path d="M 1050 620 L 1050 620 L 1104 535.3906523385778 L 1158 489.21163083534515 L 1212 501.10554730457 L 1266 562.3244720582771 L 1320 640.00659821042 L 1374 693.2416333172939 L 1428 693.0709129595766 L 1482 636.6446346063387" fill="none" stroke="url(#lineatlasraga)" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<circle cx="1482" cy="636.6446346063387" r="12" fill="hsl(199 90% 70%)"/>
|
||||||
|
<text x="920" y="270" font-family="monospace" font-size="30" fill="hsl(199 70% 75%)">latency p95</text></svg>
|
||||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,36 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" width="1600" height="1000">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="glowatlasragb" cx="28%" cy="18%" r="90%">
|
||||||
|
<stop offset="0%" stop-color="hsl(199 90% 62% / 0.55)"/>
|
||||||
|
<stop offset="45%" stop-color="hsl(227 85% 55% / 0.18)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(199 60% 8% / 0)"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="bgatlasragb" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#04060f"/>
|
||||||
|
<stop offset="100%" stop-color="#020308"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="lineatlasragb" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="hsl(199 90% 65%)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(239 90% 68%)"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="1000" fill="url(#bgatlasragb)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#glowatlasragb)"/>
|
||||||
|
<g stroke="hsl(199 40% 60% / 0.06)" stroke-width="1">
|
||||||
|
<line x1="0" y1="0" x2="0" y2="1000"/><line x1="100" y1="0" x2="100" y2="1000"/><line x1="200" y1="0" x2="200" y2="1000"/><line x1="300" y1="0" x2="300" y2="1000"/><line x1="400" y1="0" x2="400" y2="1000"/><line x1="500" y1="0" x2="500" y2="1000"/><line x1="600" y1="0" x2="600" y2="1000"/><line x1="700" y1="0" x2="700" y2="1000"/><line x1="800" y1="0" x2="800" y2="1000"/><line x1="900" y1="0" x2="900" y2="1000"/><line x1="1000" y1="0" x2="1000" y2="1000"/><line x1="1100" y1="0" x2="1100" y2="1000"/><line x1="1200" y1="0" x2="1200" y2="1000"/><line x1="1300" y1="0" x2="1300" y2="1000"/><line x1="1400" y1="0" x2="1400" y2="1000"/><line x1="1500" y1="0" x2="1500" y2="1000"/>
|
||||||
|
<line x1="0" y1="0" x2="1600" y2="0"/><line x1="0" y1="100" x2="1600" y2="100"/><line x1="0" y1="200" x2="1600" y2="200"/><line x1="0" y1="300" x2="1600" y2="300"/><line x1="0" y1="400" x2="1600" y2="400"/><line x1="0" y1="500" x2="1600" y2="500"/><line x1="0" y1="600" x2="1600" y2="600"/><line x1="0" y1="700" x2="1600" y2="700"/><line x1="0" y1="800" x2="1600" y2="800"/><line x1="0" y1="900" x2="1600" y2="900"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="80" y="70" width="1440" height="60" rx="14" fill="hsl(199 30% 18% / 0.4)" stroke="hsl(199 60% 60% / 0.25)"/>
|
||||||
|
<circle cx="120" cy="100" r="9" fill="hsl(0 70% 60% / .7)"/>
|
||||||
|
<circle cx="152" cy="100" r="9" fill="hsl(45 80% 60% / .7)"/>
|
||||||
|
<circle cx="184" cy="100" r="9" fill="hsl(140 60% 55% / .7)"/>
|
||||||
|
<rect x="240" y="88" width="420" height="24" rx="12" fill="hsl(199 30% 40% / 0.25)"/>
|
||||||
|
</g>
|
||||||
|
<path d="M 350 305 C 470 305, 470 215, 590 215" fill="none" stroke="url(#lineatlasragb)" stroke-width="4" opacity="0.7"/><path d="M 350 305 C 470 305, 470 405, 590 405" fill="none" stroke="url(#lineatlasragb)" stroke-width="4" opacity="0.7"/><path d="M 820 215 C 940 215, 940 305, 1060 305" fill="none" stroke="url(#lineatlasragb)" stroke-width="4" opacity="0.7"/><path d="M 820 405 C 940 405, 940 305, 1060 305" fill="none" stroke="url(#lineatlasragb)" stroke-width="4" opacity="0.7"/><path d="M 1290 305 C 1290 305, 1290 305, 1290 305" fill="none" stroke="url(#lineatlasragb)" stroke-width="4" opacity="0.7"/>
|
||||||
|
<g><rect x="120" y="250" width="230" height="110" rx="18" fill="hsl(199 35% 14% / 0.7)" stroke="hsl(199 70% 62% / 0.5)"/><text x="235" y="312" text-anchor="middle" font-family="monospace" font-size="26" fill="hsl(199 70% 80%)">ingest</text></g>
|
||||||
|
<g><rect x="590" y="160" width="230" height="110" rx="18" fill="hsl(199 35% 14% / 0.7)" stroke="hsl(199 70% 62% / 0.5)"/><text x="705" y="222" text-anchor="middle" font-family="monospace" font-size="26" fill="hsl(199 70% 80%)">embed</text></g>
|
||||||
|
<g><rect x="590" y="350" width="230" height="110" rx="18" fill="hsl(199 35% 14% / 0.7)" stroke="hsl(199 70% 62% / 0.5)"/><text x="705" y="412" text-anchor="middle" font-family="monospace" font-size="26" fill="hsl(199 70% 80%)">retrieve</text></g>
|
||||||
|
<g><rect x="1060" y="250" width="230" height="110" rx="18" fill="hsl(199 35% 14% / 0.7)" stroke="hsl(199 70% 62% / 0.5)"/><text x="1175" y="312" text-anchor="middle" font-family="monospace" font-size="26" fill="hsl(199 70% 80%)">rerank</text></g>
|
||||||
|
<g><rect x="1300" y="250" width="230" height="110" rx="18" fill="hsl(199 35% 14% / 0.7)" stroke="hsl(199 70% 62% / 0.5)"/><text x="1415" y="312" text-anchor="middle" font-family="monospace" font-size="26" fill="hsl(199 70% 80%)">generate</text></g>
|
||||||
|
<g opacity="0.5"><circle cx="260" cy="700" r="6" fill="hsl(199 90% 70%)"/><circle cx="460" cy="700" r="6" fill="hsl(199 90% 70%)"/><circle cx="660" cy="700" r="6" fill="hsl(199 90% 70%)"/><circle cx="860" cy="700" r="6" fill="hsl(199 90% 70%)"/><circle cx="1060" cy="700" r="6" fill="hsl(199 90% 70%)"/><circle cx="1260" cy="700" r="6" fill="hsl(199 90% 70%)"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 4.6 KiB |
@@ -0,0 +1,31 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" width="1600" height="1000">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="glowatlasragc" cx="28%" cy="18%" r="90%">
|
||||||
|
<stop offset="0%" stop-color="hsl(199 90% 62% / 0.55)"/>
|
||||||
|
<stop offset="45%" stop-color="hsl(227 85% 55% / 0.18)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(199 60% 8% / 0)"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="bgatlasragc" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#04060f"/>
|
||||||
|
<stop offset="100%" stop-color="#020308"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="lineatlasragc" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="hsl(199 90% 65%)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(239 90% 68%)"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="1000" fill="url(#bgatlasragc)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#glowatlasragc)"/>
|
||||||
|
<g stroke="hsl(199 40% 60% / 0.06)" stroke-width="1">
|
||||||
|
<line x1="0" y1="0" x2="0" y2="1000"/><line x1="100" y1="0" x2="100" y2="1000"/><line x1="200" y1="0" x2="200" y2="1000"/><line x1="300" y1="0" x2="300" y2="1000"/><line x1="400" y1="0" x2="400" y2="1000"/><line x1="500" y1="0" x2="500" y2="1000"/><line x1="600" y1="0" x2="600" y2="1000"/><line x1="700" y1="0" x2="700" y2="1000"/><line x1="800" y1="0" x2="800" y2="1000"/><line x1="900" y1="0" x2="900" y2="1000"/><line x1="1000" y1="0" x2="1000" y2="1000"/><line x1="1100" y1="0" x2="1100" y2="1000"/><line x1="1200" y1="0" x2="1200" y2="1000"/><line x1="1300" y1="0" x2="1300" y2="1000"/><line x1="1400" y1="0" x2="1400" y2="1000"/><line x1="1500" y1="0" x2="1500" y2="1000"/>
|
||||||
|
<line x1="0" y1="0" x2="1600" y2="0"/><line x1="0" y1="100" x2="1600" y2="100"/><line x1="0" y1="200" x2="1600" y2="200"/><line x1="0" y1="300" x2="1600" y2="300"/><line x1="0" y1="400" x2="1600" y2="400"/><line x1="0" y1="500" x2="1600" y2="500"/><line x1="0" y1="600" x2="1600" y2="600"/><line x1="0" y1="700" x2="1600" y2="700"/><line x1="0" y1="800" x2="1600" y2="800"/><line x1="0" y1="900" x2="1600" y2="900"/>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(560 120)">
|
||||||
|
<rect width="480" height="780" rx="56" fill="hsl(199 30% 10% / 0.9)" stroke="hsl(199 60% 60% / 0.35)" stroke-width="3"/>
|
||||||
|
<rect x="22" y="22" width="436" height="736" rx="40" fill="#04060f"/>
|
||||||
|
<rect x="180" y="40" width="120" height="22" rx="11" fill="hsl(199 30% 25%)"/>
|
||||||
|
<circle cx="240" cy="230" r="86" fill="none" stroke="url(#lineatlasragc)" stroke-width="10"/>
|
||||||
|
<circle cx="240" cy="230" r="56" fill="hsl(199 80% 60% / 0.18)"/>
|
||||||
|
<rect x="70" y="380" width="340" height="46" rx="14" fill="hsl(199 35% 18% / 0.8)"/><rect x="70" y="460" width="300" height="46" rx="14" fill="hsl(199 35% 18% / 0.8)"/><rect x="70" y="540" width="260" height="46" rx="14" fill="hsl(199 35% 18% / 0.8)"/><rect x="70" y="620" width="220" height="46" rx="14" fill="hsl(199 35% 18% / 0.8)"/>
|
||||||
|
<rect x="70" y="700" width="340" height="56" rx="18" fill="url(#lineatlasragc)"/>
|
||||||
|
</g></svg>
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,36 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" width="1600" height="1000">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="glowatlasrag" cx="28%" cy="18%" r="90%">
|
||||||
|
<stop offset="0%" stop-color="hsl(199 90% 62% / 0.55)"/>
|
||||||
|
<stop offset="45%" stop-color="hsl(227 85% 55% / 0.18)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(199 60% 8% / 0)"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="bgatlasrag" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#04060f"/>
|
||||||
|
<stop offset="100%" stop-color="#020308"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="lineatlasrag" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="hsl(199 90% 65%)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(239 90% 68%)"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="1000" fill="url(#bgatlasrag)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#glowatlasrag)"/>
|
||||||
|
<g stroke="hsl(199 40% 60% / 0.06)" stroke-width="1">
|
||||||
|
<line x1="0" y1="0" x2="0" y2="1000"/><line x1="100" y1="0" x2="100" y2="1000"/><line x1="200" y1="0" x2="200" y2="1000"/><line x1="300" y1="0" x2="300" y2="1000"/><line x1="400" y1="0" x2="400" y2="1000"/><line x1="500" y1="0" x2="500" y2="1000"/><line x1="600" y1="0" x2="600" y2="1000"/><line x1="700" y1="0" x2="700" y2="1000"/><line x1="800" y1="0" x2="800" y2="1000"/><line x1="900" y1="0" x2="900" y2="1000"/><line x1="1000" y1="0" x2="1000" y2="1000"/><line x1="1100" y1="0" x2="1100" y2="1000"/><line x1="1200" y1="0" x2="1200" y2="1000"/><line x1="1300" y1="0" x2="1300" y2="1000"/><line x1="1400" y1="0" x2="1400" y2="1000"/><line x1="1500" y1="0" x2="1500" y2="1000"/>
|
||||||
|
<line x1="0" y1="0" x2="1600" y2="0"/><line x1="0" y1="100" x2="1600" y2="100"/><line x1="0" y1="200" x2="1600" y2="200"/><line x1="0" y1="300" x2="1600" y2="300"/><line x1="0" y1="400" x2="1600" y2="400"/><line x1="0" y1="500" x2="1600" y2="500"/><line x1="0" y1="600" x2="1600" y2="600"/><line x1="0" y1="700" x2="1600" y2="700"/><line x1="0" y1="800" x2="1600" y2="800"/><line x1="0" y1="900" x2="1600" y2="900"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="80" y="70" width="1440" height="60" rx="14" fill="hsl(199 30% 18% / 0.4)" stroke="hsl(199 60% 60% / 0.25)"/>
|
||||||
|
<circle cx="120" cy="100" r="9" fill="hsl(0 70% 60% / .7)"/>
|
||||||
|
<circle cx="152" cy="100" r="9" fill="hsl(45 80% 60% / .7)"/>
|
||||||
|
<circle cx="184" cy="100" r="9" fill="hsl(140 60% 55% / .7)"/>
|
||||||
|
<rect x="240" y="88" width="420" height="24" rx="12" fill="hsl(199 30% 40% / 0.25)"/>
|
||||||
|
</g>
|
||||||
|
<rect x="120" y="200" width="700" height="560" rx="20" fill="hsl(199 30% 12% / 0.5)" stroke="hsl(199 60% 60% / 0.16)"/>
|
||||||
|
<text x="160" y="270" font-family="monospace" font-size="30" fill="hsl(199 70% 75%)">throughput / day</text>
|
||||||
|
<rect x="180" y="680" width="54" height="80" rx="8" fill="url(#lineatlasrag)" opacity="0.5"/><rect x="276" y="627" width="54" height="133" rx="8" fill="url(#lineatlasrag)" opacity="0.66"/><rect x="372" y="574" width="54" height="186" rx="8" fill="url(#lineatlasrag)" opacity="0.8200000000000001"/><rect x="468" y="521" width="54" height="239" rx="8" fill="url(#lineatlasrag)" opacity="0.5"/><rect x="564" y="468" width="54" height="292" rx="8" fill="url(#lineatlasrag)" opacity="0.66"/><rect x="660" y="675" width="54" height="85" rx="8" fill="url(#lineatlasrag)" opacity="0.8200000000000001"/><rect x="756" y="622" width="54" height="138" rx="8" fill="url(#lineatlasrag)" opacity="0.5"/><rect x="852" y="569" width="54" height="191" rx="8" fill="url(#lineatlasrag)" opacity="0.66"/><rect x="948" y="516" width="54" height="244" rx="8" fill="url(#lineatlasrag)" opacity="0.8200000000000001"/>
|
||||||
|
<rect x="880" y="200" width="600" height="560" rx="20" fill="hsl(199 30% 12% / 0.5)" stroke="hsl(199 60% 60% / 0.16)"/>
|
||||||
|
<path d="M 1050 620 L 1050 620 L 1104 535.3906523385778 L 1158 489.21163083534515 L 1212 501.10554730457 L 1266 562.3244720582771 L 1320 640.00659821042 L 1374 693.2416333172939 L 1428 693.0709129595766 L 1482 636.6446346063387" fill="none" stroke="url(#lineatlasrag)" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<circle cx="1482" cy="636.6446346063387" r="12" fill="hsl(199 90% 70%)"/>
|
||||||
|
<text x="920" y="270" font-family="monospace" font-size="30" fill="hsl(199 70% 75%)">latency p95</text></svg>
|
||||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,36 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" width="1600" height="1000">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="glowfluxstreama" cx="28%" cy="18%" r="90%">
|
||||||
|
<stop offset="0%" stop-color="hsl(158 90% 62% / 0.55)"/>
|
||||||
|
<stop offset="45%" stop-color="hsl(186 85% 55% / 0.18)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(158 60% 8% / 0)"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="bgfluxstreama" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#04060f"/>
|
||||||
|
<stop offset="100%" stop-color="#020308"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="linefluxstreama" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="hsl(158 90% 65%)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(198 90% 68%)"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="1000" fill="url(#bgfluxstreama)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#glowfluxstreama)"/>
|
||||||
|
<g stroke="hsl(158 40% 60% / 0.06)" stroke-width="1">
|
||||||
|
<line x1="0" y1="0" x2="0" y2="1000"/><line x1="100" y1="0" x2="100" y2="1000"/><line x1="200" y1="0" x2="200" y2="1000"/><line x1="300" y1="0" x2="300" y2="1000"/><line x1="400" y1="0" x2="400" y2="1000"/><line x1="500" y1="0" x2="500" y2="1000"/><line x1="600" y1="0" x2="600" y2="1000"/><line x1="700" y1="0" x2="700" y2="1000"/><line x1="800" y1="0" x2="800" y2="1000"/><line x1="900" y1="0" x2="900" y2="1000"/><line x1="1000" y1="0" x2="1000" y2="1000"/><line x1="1100" y1="0" x2="1100" y2="1000"/><line x1="1200" y1="0" x2="1200" y2="1000"/><line x1="1300" y1="0" x2="1300" y2="1000"/><line x1="1400" y1="0" x2="1400" y2="1000"/><line x1="1500" y1="0" x2="1500" y2="1000"/>
|
||||||
|
<line x1="0" y1="0" x2="1600" y2="0"/><line x1="0" y1="100" x2="1600" y2="100"/><line x1="0" y1="200" x2="1600" y2="200"/><line x1="0" y1="300" x2="1600" y2="300"/><line x1="0" y1="400" x2="1600" y2="400"/><line x1="0" y1="500" x2="1600" y2="500"/><line x1="0" y1="600" x2="1600" y2="600"/><line x1="0" y1="700" x2="1600" y2="700"/><line x1="0" y1="800" x2="1600" y2="800"/><line x1="0" y1="900" x2="1600" y2="900"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="80" y="70" width="1440" height="60" rx="14" fill="hsl(158 30% 18% / 0.4)" stroke="hsl(158 60% 60% / 0.25)"/>
|
||||||
|
<circle cx="120" cy="100" r="9" fill="hsl(0 70% 60% / .7)"/>
|
||||||
|
<circle cx="152" cy="100" r="9" fill="hsl(45 80% 60% / .7)"/>
|
||||||
|
<circle cx="184" cy="100" r="9" fill="hsl(140 60% 55% / .7)"/>
|
||||||
|
<rect x="240" y="88" width="420" height="24" rx="12" fill="hsl(158 30% 40% / 0.25)"/>
|
||||||
|
</g>
|
||||||
|
<rect x="120" y="200" width="700" height="560" rx="20" fill="hsl(158 30% 12% / 0.5)" stroke="hsl(158 60% 60% / 0.16)"/>
|
||||||
|
<text x="160" y="270" font-family="monospace" font-size="30" fill="hsl(158 70% 75%)">throughput / day</text>
|
||||||
|
<rect x="180" y="680" width="54" height="80" rx="8" fill="url(#linefluxstreama)" opacity="0.5"/><rect x="276" y="627" width="54" height="133" rx="8" fill="url(#linefluxstreama)" opacity="0.66"/><rect x="372" y="574" width="54" height="186" rx="8" fill="url(#linefluxstreama)" opacity="0.8200000000000001"/><rect x="468" y="521" width="54" height="239" rx="8" fill="url(#linefluxstreama)" opacity="0.5"/><rect x="564" y="468" width="54" height="292" rx="8" fill="url(#linefluxstreama)" opacity="0.66"/><rect x="660" y="675" width="54" height="85" rx="8" fill="url(#linefluxstreama)" opacity="0.8200000000000001"/><rect x="756" y="622" width="54" height="138" rx="8" fill="url(#linefluxstreama)" opacity="0.5"/><rect x="852" y="569" width="54" height="191" rx="8" fill="url(#linefluxstreama)" opacity="0.66"/><rect x="948" y="516" width="54" height="244" rx="8" fill="url(#linefluxstreama)" opacity="0.8200000000000001"/>
|
||||||
|
<rect x="880" y="200" width="600" height="560" rx="20" fill="hsl(158 30% 12% / 0.5)" stroke="hsl(158 60% 60% / 0.16)"/>
|
||||||
|
<path d="M 1050 620 L 1050 620 L 1104 535.3906523385778 L 1158 489.21163083534515 L 1212 501.10554730457 L 1266 562.3244720582771 L 1320 640.00659821042 L 1374 693.2416333172939 L 1428 693.0709129595766 L 1482 636.6446346063387" fill="none" stroke="url(#linefluxstreama)" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<circle cx="1482" cy="636.6446346063387" r="12" fill="hsl(158 90% 70%)"/>
|
||||||
|
<text x="920" y="270" font-family="monospace" font-size="30" fill="hsl(158 70% 75%)">latency p95</text></svg>
|
||||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,36 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" width="1600" height="1000">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="glowfluxstreamb" cx="28%" cy="18%" r="90%">
|
||||||
|
<stop offset="0%" stop-color="hsl(158 90% 62% / 0.55)"/>
|
||||||
|
<stop offset="45%" stop-color="hsl(186 85% 55% / 0.18)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(158 60% 8% / 0)"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="bgfluxstreamb" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#04060f"/>
|
||||||
|
<stop offset="100%" stop-color="#020308"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="linefluxstreamb" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="hsl(158 90% 65%)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(198 90% 68%)"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="1000" fill="url(#bgfluxstreamb)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#glowfluxstreamb)"/>
|
||||||
|
<g stroke="hsl(158 40% 60% / 0.06)" stroke-width="1">
|
||||||
|
<line x1="0" y1="0" x2="0" y2="1000"/><line x1="100" y1="0" x2="100" y2="1000"/><line x1="200" y1="0" x2="200" y2="1000"/><line x1="300" y1="0" x2="300" y2="1000"/><line x1="400" y1="0" x2="400" y2="1000"/><line x1="500" y1="0" x2="500" y2="1000"/><line x1="600" y1="0" x2="600" y2="1000"/><line x1="700" y1="0" x2="700" y2="1000"/><line x1="800" y1="0" x2="800" y2="1000"/><line x1="900" y1="0" x2="900" y2="1000"/><line x1="1000" y1="0" x2="1000" y2="1000"/><line x1="1100" y1="0" x2="1100" y2="1000"/><line x1="1200" y1="0" x2="1200" y2="1000"/><line x1="1300" y1="0" x2="1300" y2="1000"/><line x1="1400" y1="0" x2="1400" y2="1000"/><line x1="1500" y1="0" x2="1500" y2="1000"/>
|
||||||
|
<line x1="0" y1="0" x2="1600" y2="0"/><line x1="0" y1="100" x2="1600" y2="100"/><line x1="0" y1="200" x2="1600" y2="200"/><line x1="0" y1="300" x2="1600" y2="300"/><line x1="0" y1="400" x2="1600" y2="400"/><line x1="0" y1="500" x2="1600" y2="500"/><line x1="0" y1="600" x2="1600" y2="600"/><line x1="0" y1="700" x2="1600" y2="700"/><line x1="0" y1="800" x2="1600" y2="800"/><line x1="0" y1="900" x2="1600" y2="900"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="80" y="70" width="1440" height="60" rx="14" fill="hsl(158 30% 18% / 0.4)" stroke="hsl(158 60% 60% / 0.25)"/>
|
||||||
|
<circle cx="120" cy="100" r="9" fill="hsl(0 70% 60% / .7)"/>
|
||||||
|
<circle cx="152" cy="100" r="9" fill="hsl(45 80% 60% / .7)"/>
|
||||||
|
<circle cx="184" cy="100" r="9" fill="hsl(140 60% 55% / .7)"/>
|
||||||
|
<rect x="240" y="88" width="420" height="24" rx="12" fill="hsl(158 30% 40% / 0.25)"/>
|
||||||
|
</g>
|
||||||
|
<path d="M 350 305 C 470 305, 470 215, 590 215" fill="none" stroke="url(#linefluxstreamb)" stroke-width="4" opacity="0.7"/><path d="M 350 305 C 470 305, 470 405, 590 405" fill="none" stroke="url(#linefluxstreamb)" stroke-width="4" opacity="0.7"/><path d="M 820 215 C 940 215, 940 305, 1060 305" fill="none" stroke="url(#linefluxstreamb)" stroke-width="4" opacity="0.7"/><path d="M 820 405 C 940 405, 940 305, 1060 305" fill="none" stroke="url(#linefluxstreamb)" stroke-width="4" opacity="0.7"/><path d="M 1290 305 C 1290 305, 1290 305, 1290 305" fill="none" stroke="url(#linefluxstreamb)" stroke-width="4" opacity="0.7"/>
|
||||||
|
<g><rect x="120" y="250" width="230" height="110" rx="18" fill="hsl(158 35% 14% / 0.7)" stroke="hsl(158 70% 62% / 0.5)"/><text x="235" y="312" text-anchor="middle" font-family="monospace" font-size="26" fill="hsl(158 70% 80%)">ingest</text></g>
|
||||||
|
<g><rect x="590" y="160" width="230" height="110" rx="18" fill="hsl(158 35% 14% / 0.7)" stroke="hsl(158 70% 62% / 0.5)"/><text x="705" y="222" text-anchor="middle" font-family="monospace" font-size="26" fill="hsl(158 70% 80%)">embed</text></g>
|
||||||
|
<g><rect x="590" y="350" width="230" height="110" rx="18" fill="hsl(158 35% 14% / 0.7)" stroke="hsl(158 70% 62% / 0.5)"/><text x="705" y="412" text-anchor="middle" font-family="monospace" font-size="26" fill="hsl(158 70% 80%)">retrieve</text></g>
|
||||||
|
<g><rect x="1060" y="250" width="230" height="110" rx="18" fill="hsl(158 35% 14% / 0.7)" stroke="hsl(158 70% 62% / 0.5)"/><text x="1175" y="312" text-anchor="middle" font-family="monospace" font-size="26" fill="hsl(158 70% 80%)">rerank</text></g>
|
||||||
|
<g><rect x="1300" y="250" width="230" height="110" rx="18" fill="hsl(158 35% 14% / 0.7)" stroke="hsl(158 70% 62% / 0.5)"/><text x="1415" y="312" text-anchor="middle" font-family="monospace" font-size="26" fill="hsl(158 70% 80%)">generate</text></g>
|
||||||
|
<g opacity="0.5"><circle cx="260" cy="700" r="6" fill="hsl(158 90% 70%)"/><circle cx="460" cy="700" r="6" fill="hsl(158 90% 70%)"/><circle cx="660" cy="700" r="6" fill="hsl(158 90% 70%)"/><circle cx="860" cy="700" r="6" fill="hsl(158 90% 70%)"/><circle cx="1060" cy="700" r="6" fill="hsl(158 90% 70%)"/><circle cx="1260" cy="700" r="6" fill="hsl(158 90% 70%)"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 4.6 KiB |
@@ -0,0 +1,31 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" width="1600" height="1000">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="glowfluxstreamc" cx="28%" cy="18%" r="90%">
|
||||||
|
<stop offset="0%" stop-color="hsl(158 90% 62% / 0.55)"/>
|
||||||
|
<stop offset="45%" stop-color="hsl(186 85% 55% / 0.18)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(158 60% 8% / 0)"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="bgfluxstreamc" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#04060f"/>
|
||||||
|
<stop offset="100%" stop-color="#020308"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="linefluxstreamc" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="hsl(158 90% 65%)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(198 90% 68%)"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="1000" fill="url(#bgfluxstreamc)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#glowfluxstreamc)"/>
|
||||||
|
<g stroke="hsl(158 40% 60% / 0.06)" stroke-width="1">
|
||||||
|
<line x1="0" y1="0" x2="0" y2="1000"/><line x1="100" y1="0" x2="100" y2="1000"/><line x1="200" y1="0" x2="200" y2="1000"/><line x1="300" y1="0" x2="300" y2="1000"/><line x1="400" y1="0" x2="400" y2="1000"/><line x1="500" y1="0" x2="500" y2="1000"/><line x1="600" y1="0" x2="600" y2="1000"/><line x1="700" y1="0" x2="700" y2="1000"/><line x1="800" y1="0" x2="800" y2="1000"/><line x1="900" y1="0" x2="900" y2="1000"/><line x1="1000" y1="0" x2="1000" y2="1000"/><line x1="1100" y1="0" x2="1100" y2="1000"/><line x1="1200" y1="0" x2="1200" y2="1000"/><line x1="1300" y1="0" x2="1300" y2="1000"/><line x1="1400" y1="0" x2="1400" y2="1000"/><line x1="1500" y1="0" x2="1500" y2="1000"/>
|
||||||
|
<line x1="0" y1="0" x2="1600" y2="0"/><line x1="0" y1="100" x2="1600" y2="100"/><line x1="0" y1="200" x2="1600" y2="200"/><line x1="0" y1="300" x2="1600" y2="300"/><line x1="0" y1="400" x2="1600" y2="400"/><line x1="0" y1="500" x2="1600" y2="500"/><line x1="0" y1="600" x2="1600" y2="600"/><line x1="0" y1="700" x2="1600" y2="700"/><line x1="0" y1="800" x2="1600" y2="800"/><line x1="0" y1="900" x2="1600" y2="900"/>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(560 120)">
|
||||||
|
<rect width="480" height="780" rx="56" fill="hsl(158 30% 10% / 0.9)" stroke="hsl(158 60% 60% / 0.35)" stroke-width="3"/>
|
||||||
|
<rect x="22" y="22" width="436" height="736" rx="40" fill="#04060f"/>
|
||||||
|
<rect x="180" y="40" width="120" height="22" rx="11" fill="hsl(158 30% 25%)"/>
|
||||||
|
<circle cx="240" cy="230" r="86" fill="none" stroke="url(#linefluxstreamc)" stroke-width="10"/>
|
||||||
|
<circle cx="240" cy="230" r="56" fill="hsl(158 80% 60% / 0.18)"/>
|
||||||
|
<rect x="70" y="380" width="340" height="46" rx="14" fill="hsl(158 35% 18% / 0.8)"/><rect x="70" y="460" width="300" height="46" rx="14" fill="hsl(158 35% 18% / 0.8)"/><rect x="70" y="540" width="260" height="46" rx="14" fill="hsl(158 35% 18% / 0.8)"/><rect x="70" y="620" width="220" height="46" rx="14" fill="hsl(158 35% 18% / 0.8)"/>
|
||||||
|
<rect x="70" y="700" width="340" height="56" rx="18" fill="url(#linefluxstreamc)"/>
|
||||||
|
</g></svg>
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,36 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" width="1600" height="1000">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="glowfluxstream" cx="28%" cy="18%" r="90%">
|
||||||
|
<stop offset="0%" stop-color="hsl(158 90% 62% / 0.55)"/>
|
||||||
|
<stop offset="45%" stop-color="hsl(186 85% 55% / 0.18)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(158 60% 8% / 0)"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="bgfluxstream" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#04060f"/>
|
||||||
|
<stop offset="100%" stop-color="#020308"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="linefluxstream" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="hsl(158 90% 65%)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(198 90% 68%)"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="1000" fill="url(#bgfluxstream)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#glowfluxstream)"/>
|
||||||
|
<g stroke="hsl(158 40% 60% / 0.06)" stroke-width="1">
|
||||||
|
<line x1="0" y1="0" x2="0" y2="1000"/><line x1="100" y1="0" x2="100" y2="1000"/><line x1="200" y1="0" x2="200" y2="1000"/><line x1="300" y1="0" x2="300" y2="1000"/><line x1="400" y1="0" x2="400" y2="1000"/><line x1="500" y1="0" x2="500" y2="1000"/><line x1="600" y1="0" x2="600" y2="1000"/><line x1="700" y1="0" x2="700" y2="1000"/><line x1="800" y1="0" x2="800" y2="1000"/><line x1="900" y1="0" x2="900" y2="1000"/><line x1="1000" y1="0" x2="1000" y2="1000"/><line x1="1100" y1="0" x2="1100" y2="1000"/><line x1="1200" y1="0" x2="1200" y2="1000"/><line x1="1300" y1="0" x2="1300" y2="1000"/><line x1="1400" y1="0" x2="1400" y2="1000"/><line x1="1500" y1="0" x2="1500" y2="1000"/>
|
||||||
|
<line x1="0" y1="0" x2="1600" y2="0"/><line x1="0" y1="100" x2="1600" y2="100"/><line x1="0" y1="200" x2="1600" y2="200"/><line x1="0" y1="300" x2="1600" y2="300"/><line x1="0" y1="400" x2="1600" y2="400"/><line x1="0" y1="500" x2="1600" y2="500"/><line x1="0" y1="600" x2="1600" y2="600"/><line x1="0" y1="700" x2="1600" y2="700"/><line x1="0" y1="800" x2="1600" y2="800"/><line x1="0" y1="900" x2="1600" y2="900"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="80" y="70" width="1440" height="60" rx="14" fill="hsl(158 30% 18% / 0.4)" stroke="hsl(158 60% 60% / 0.25)"/>
|
||||||
|
<circle cx="120" cy="100" r="9" fill="hsl(0 70% 60% / .7)"/>
|
||||||
|
<circle cx="152" cy="100" r="9" fill="hsl(45 80% 60% / .7)"/>
|
||||||
|
<circle cx="184" cy="100" r="9" fill="hsl(140 60% 55% / .7)"/>
|
||||||
|
<rect x="240" y="88" width="420" height="24" rx="12" fill="hsl(158 30% 40% / 0.25)"/>
|
||||||
|
</g>
|
||||||
|
<rect x="120" y="200" width="700" height="560" rx="20" fill="hsl(158 30% 12% / 0.5)" stroke="hsl(158 60% 60% / 0.16)"/>
|
||||||
|
<text x="160" y="270" font-family="monospace" font-size="30" fill="hsl(158 70% 75%)">throughput / day</text>
|
||||||
|
<rect x="180" y="680" width="54" height="80" rx="8" fill="url(#linefluxstream)" opacity="0.5"/><rect x="276" y="627" width="54" height="133" rx="8" fill="url(#linefluxstream)" opacity="0.66"/><rect x="372" y="574" width="54" height="186" rx="8" fill="url(#linefluxstream)" opacity="0.8200000000000001"/><rect x="468" y="521" width="54" height="239" rx="8" fill="url(#linefluxstream)" opacity="0.5"/><rect x="564" y="468" width="54" height="292" rx="8" fill="url(#linefluxstream)" opacity="0.66"/><rect x="660" y="675" width="54" height="85" rx="8" fill="url(#linefluxstream)" opacity="0.8200000000000001"/><rect x="756" y="622" width="54" height="138" rx="8" fill="url(#linefluxstream)" opacity="0.5"/><rect x="852" y="569" width="54" height="191" rx="8" fill="url(#linefluxstream)" opacity="0.66"/><rect x="948" y="516" width="54" height="244" rx="8" fill="url(#linefluxstream)" opacity="0.8200000000000001"/>
|
||||||
|
<rect x="880" y="200" width="600" height="560" rx="20" fill="hsl(158 30% 12% / 0.5)" stroke="hsl(158 60% 60% / 0.16)"/>
|
||||||
|
<path d="M 1050 620 L 1050 620 L 1104 535.3906523385778 L 1158 489.21163083534515 L 1212 501.10554730457 L 1266 562.3244720582771 L 1320 640.00659821042 L 1374 693.2416333172939 L 1428 693.0709129595766 L 1482 636.6446346063387" fill="none" stroke="url(#linefluxstream)" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<circle cx="1482" cy="636.6446346063387" r="12" fill="hsl(158 90% 70%)"/>
|
||||||
|
<text x="920" y="270" font-family="monospace" font-size="30" fill="hsl(158 70% 75%)">latency p95</text></svg>
|
||||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,36 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" width="1600" height="1000">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="glowmiragemobilea" cx="28%" cy="18%" r="90%">
|
||||||
|
<stop offset="0%" stop-color="hsl(292 90% 62% / 0.55)"/>
|
||||||
|
<stop offset="45%" stop-color="hsl(320 85% 55% / 0.18)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(292 60% 8% / 0)"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="bgmiragemobilea" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#04060f"/>
|
||||||
|
<stop offset="100%" stop-color="#020308"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="linemiragemobilea" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="hsl(292 90% 65%)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(332 90% 68%)"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="1000" fill="url(#bgmiragemobilea)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#glowmiragemobilea)"/>
|
||||||
|
<g stroke="hsl(292 40% 60% / 0.06)" stroke-width="1">
|
||||||
|
<line x1="0" y1="0" x2="0" y2="1000"/><line x1="100" y1="0" x2="100" y2="1000"/><line x1="200" y1="0" x2="200" y2="1000"/><line x1="300" y1="0" x2="300" y2="1000"/><line x1="400" y1="0" x2="400" y2="1000"/><line x1="500" y1="0" x2="500" y2="1000"/><line x1="600" y1="0" x2="600" y2="1000"/><line x1="700" y1="0" x2="700" y2="1000"/><line x1="800" y1="0" x2="800" y2="1000"/><line x1="900" y1="0" x2="900" y2="1000"/><line x1="1000" y1="0" x2="1000" y2="1000"/><line x1="1100" y1="0" x2="1100" y2="1000"/><line x1="1200" y1="0" x2="1200" y2="1000"/><line x1="1300" y1="0" x2="1300" y2="1000"/><line x1="1400" y1="0" x2="1400" y2="1000"/><line x1="1500" y1="0" x2="1500" y2="1000"/>
|
||||||
|
<line x1="0" y1="0" x2="1600" y2="0"/><line x1="0" y1="100" x2="1600" y2="100"/><line x1="0" y1="200" x2="1600" y2="200"/><line x1="0" y1="300" x2="1600" y2="300"/><line x1="0" y1="400" x2="1600" y2="400"/><line x1="0" y1="500" x2="1600" y2="500"/><line x1="0" y1="600" x2="1600" y2="600"/><line x1="0" y1="700" x2="1600" y2="700"/><line x1="0" y1="800" x2="1600" y2="800"/><line x1="0" y1="900" x2="1600" y2="900"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="80" y="70" width="1440" height="60" rx="14" fill="hsl(292 30% 18% / 0.4)" stroke="hsl(292 60% 60% / 0.25)"/>
|
||||||
|
<circle cx="120" cy="100" r="9" fill="hsl(0 70% 60% / .7)"/>
|
||||||
|
<circle cx="152" cy="100" r="9" fill="hsl(45 80% 60% / .7)"/>
|
||||||
|
<circle cx="184" cy="100" r="9" fill="hsl(140 60% 55% / .7)"/>
|
||||||
|
<rect x="240" y="88" width="420" height="24" rx="12" fill="hsl(292 30% 40% / 0.25)"/>
|
||||||
|
</g>
|
||||||
|
<rect x="120" y="200" width="700" height="560" rx="20" fill="hsl(292 30% 12% / 0.5)" stroke="hsl(292 60% 60% / 0.16)"/>
|
||||||
|
<text x="160" y="270" font-family="monospace" font-size="30" fill="hsl(292 70% 75%)">throughput / day</text>
|
||||||
|
<rect x="180" y="680" width="54" height="80" rx="8" fill="url(#linemiragemobilea)" opacity="0.5"/><rect x="276" y="627" width="54" height="133" rx="8" fill="url(#linemiragemobilea)" opacity="0.66"/><rect x="372" y="574" width="54" height="186" rx="8" fill="url(#linemiragemobilea)" opacity="0.8200000000000001"/><rect x="468" y="521" width="54" height="239" rx="8" fill="url(#linemiragemobilea)" opacity="0.5"/><rect x="564" y="468" width="54" height="292" rx="8" fill="url(#linemiragemobilea)" opacity="0.66"/><rect x="660" y="675" width="54" height="85" rx="8" fill="url(#linemiragemobilea)" opacity="0.8200000000000001"/><rect x="756" y="622" width="54" height="138" rx="8" fill="url(#linemiragemobilea)" opacity="0.5"/><rect x="852" y="569" width="54" height="191" rx="8" fill="url(#linemiragemobilea)" opacity="0.66"/><rect x="948" y="516" width="54" height="244" rx="8" fill="url(#linemiragemobilea)" opacity="0.8200000000000001"/>
|
||||||
|
<rect x="880" y="200" width="600" height="560" rx="20" fill="hsl(292 30% 12% / 0.5)" stroke="hsl(292 60% 60% / 0.16)"/>
|
||||||
|
<path d="M 1050 620 L 1050 620 L 1104 535.3906523385778 L 1158 489.21163083534515 L 1212 501.10554730457 L 1266 562.3244720582771 L 1320 640.00659821042 L 1374 693.2416333172939 L 1428 693.0709129595766 L 1482 636.6446346063387" fill="none" stroke="url(#linemiragemobilea)" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<circle cx="1482" cy="636.6446346063387" r="12" fill="hsl(292 90% 70%)"/>
|
||||||
|
<text x="920" y="270" font-family="monospace" font-size="30" fill="hsl(292 70% 75%)">latency p95</text></svg>
|
||||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,36 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" width="1600" height="1000">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="glowmiragemobileb" cx="28%" cy="18%" r="90%">
|
||||||
|
<stop offset="0%" stop-color="hsl(292 90% 62% / 0.55)"/>
|
||||||
|
<stop offset="45%" stop-color="hsl(320 85% 55% / 0.18)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(292 60% 8% / 0)"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="bgmiragemobileb" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#04060f"/>
|
||||||
|
<stop offset="100%" stop-color="#020308"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="linemiragemobileb" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="hsl(292 90% 65%)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(332 90% 68%)"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="1000" fill="url(#bgmiragemobileb)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#glowmiragemobileb)"/>
|
||||||
|
<g stroke="hsl(292 40% 60% / 0.06)" stroke-width="1">
|
||||||
|
<line x1="0" y1="0" x2="0" y2="1000"/><line x1="100" y1="0" x2="100" y2="1000"/><line x1="200" y1="0" x2="200" y2="1000"/><line x1="300" y1="0" x2="300" y2="1000"/><line x1="400" y1="0" x2="400" y2="1000"/><line x1="500" y1="0" x2="500" y2="1000"/><line x1="600" y1="0" x2="600" y2="1000"/><line x1="700" y1="0" x2="700" y2="1000"/><line x1="800" y1="0" x2="800" y2="1000"/><line x1="900" y1="0" x2="900" y2="1000"/><line x1="1000" y1="0" x2="1000" y2="1000"/><line x1="1100" y1="0" x2="1100" y2="1000"/><line x1="1200" y1="0" x2="1200" y2="1000"/><line x1="1300" y1="0" x2="1300" y2="1000"/><line x1="1400" y1="0" x2="1400" y2="1000"/><line x1="1500" y1="0" x2="1500" y2="1000"/>
|
||||||
|
<line x1="0" y1="0" x2="1600" y2="0"/><line x1="0" y1="100" x2="1600" y2="100"/><line x1="0" y1="200" x2="1600" y2="200"/><line x1="0" y1="300" x2="1600" y2="300"/><line x1="0" y1="400" x2="1600" y2="400"/><line x1="0" y1="500" x2="1600" y2="500"/><line x1="0" y1="600" x2="1600" y2="600"/><line x1="0" y1="700" x2="1600" y2="700"/><line x1="0" y1="800" x2="1600" y2="800"/><line x1="0" y1="900" x2="1600" y2="900"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="80" y="70" width="1440" height="60" rx="14" fill="hsl(292 30% 18% / 0.4)" stroke="hsl(292 60% 60% / 0.25)"/>
|
||||||
|
<circle cx="120" cy="100" r="9" fill="hsl(0 70% 60% / .7)"/>
|
||||||
|
<circle cx="152" cy="100" r="9" fill="hsl(45 80% 60% / .7)"/>
|
||||||
|
<circle cx="184" cy="100" r="9" fill="hsl(140 60% 55% / .7)"/>
|
||||||
|
<rect x="240" y="88" width="420" height="24" rx="12" fill="hsl(292 30% 40% / 0.25)"/>
|
||||||
|
</g>
|
||||||
|
<path d="M 350 305 C 470 305, 470 215, 590 215" fill="none" stroke="url(#linemiragemobileb)" stroke-width="4" opacity="0.7"/><path d="M 350 305 C 470 305, 470 405, 590 405" fill="none" stroke="url(#linemiragemobileb)" stroke-width="4" opacity="0.7"/><path d="M 820 215 C 940 215, 940 305, 1060 305" fill="none" stroke="url(#linemiragemobileb)" stroke-width="4" opacity="0.7"/><path d="M 820 405 C 940 405, 940 305, 1060 305" fill="none" stroke="url(#linemiragemobileb)" stroke-width="4" opacity="0.7"/><path d="M 1290 305 C 1290 305, 1290 305, 1290 305" fill="none" stroke="url(#linemiragemobileb)" stroke-width="4" opacity="0.7"/>
|
||||||
|
<g><rect x="120" y="250" width="230" height="110" rx="18" fill="hsl(292 35% 14% / 0.7)" stroke="hsl(292 70% 62% / 0.5)"/><text x="235" y="312" text-anchor="middle" font-family="monospace" font-size="26" fill="hsl(292 70% 80%)">ingest</text></g>
|
||||||
|
<g><rect x="590" y="160" width="230" height="110" rx="18" fill="hsl(292 35% 14% / 0.7)" stroke="hsl(292 70% 62% / 0.5)"/><text x="705" y="222" text-anchor="middle" font-family="monospace" font-size="26" fill="hsl(292 70% 80%)">embed</text></g>
|
||||||
|
<g><rect x="590" y="350" width="230" height="110" rx="18" fill="hsl(292 35% 14% / 0.7)" stroke="hsl(292 70% 62% / 0.5)"/><text x="705" y="412" text-anchor="middle" font-family="monospace" font-size="26" fill="hsl(292 70% 80%)">retrieve</text></g>
|
||||||
|
<g><rect x="1060" y="250" width="230" height="110" rx="18" fill="hsl(292 35% 14% / 0.7)" stroke="hsl(292 70% 62% / 0.5)"/><text x="1175" y="312" text-anchor="middle" font-family="monospace" font-size="26" fill="hsl(292 70% 80%)">rerank</text></g>
|
||||||
|
<g><rect x="1300" y="250" width="230" height="110" rx="18" fill="hsl(292 35% 14% / 0.7)" stroke="hsl(292 70% 62% / 0.5)"/><text x="1415" y="312" text-anchor="middle" font-family="monospace" font-size="26" fill="hsl(292 70% 80%)">generate</text></g>
|
||||||
|
<g opacity="0.5"><circle cx="260" cy="700" r="6" fill="hsl(292 90% 70%)"/><circle cx="460" cy="700" r="6" fill="hsl(292 90% 70%)"/><circle cx="660" cy="700" r="6" fill="hsl(292 90% 70%)"/><circle cx="860" cy="700" r="6" fill="hsl(292 90% 70%)"/><circle cx="1060" cy="700" r="6" fill="hsl(292 90% 70%)"/><circle cx="1260" cy="700" r="6" fill="hsl(292 90% 70%)"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,31 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" width="1600" height="1000">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="glowmiragemobilec" cx="28%" cy="18%" r="90%">
|
||||||
|
<stop offset="0%" stop-color="hsl(292 90% 62% / 0.55)"/>
|
||||||
|
<stop offset="45%" stop-color="hsl(320 85% 55% / 0.18)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(292 60% 8% / 0)"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="bgmiragemobilec" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#04060f"/>
|
||||||
|
<stop offset="100%" stop-color="#020308"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="linemiragemobilec" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="hsl(292 90% 65%)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(332 90% 68%)"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="1000" fill="url(#bgmiragemobilec)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#glowmiragemobilec)"/>
|
||||||
|
<g stroke="hsl(292 40% 60% / 0.06)" stroke-width="1">
|
||||||
|
<line x1="0" y1="0" x2="0" y2="1000"/><line x1="100" y1="0" x2="100" y2="1000"/><line x1="200" y1="0" x2="200" y2="1000"/><line x1="300" y1="0" x2="300" y2="1000"/><line x1="400" y1="0" x2="400" y2="1000"/><line x1="500" y1="0" x2="500" y2="1000"/><line x1="600" y1="0" x2="600" y2="1000"/><line x1="700" y1="0" x2="700" y2="1000"/><line x1="800" y1="0" x2="800" y2="1000"/><line x1="900" y1="0" x2="900" y2="1000"/><line x1="1000" y1="0" x2="1000" y2="1000"/><line x1="1100" y1="0" x2="1100" y2="1000"/><line x1="1200" y1="0" x2="1200" y2="1000"/><line x1="1300" y1="0" x2="1300" y2="1000"/><line x1="1400" y1="0" x2="1400" y2="1000"/><line x1="1500" y1="0" x2="1500" y2="1000"/>
|
||||||
|
<line x1="0" y1="0" x2="1600" y2="0"/><line x1="0" y1="100" x2="1600" y2="100"/><line x1="0" y1="200" x2="1600" y2="200"/><line x1="0" y1="300" x2="1600" y2="300"/><line x1="0" y1="400" x2="1600" y2="400"/><line x1="0" y1="500" x2="1600" y2="500"/><line x1="0" y1="600" x2="1600" y2="600"/><line x1="0" y1="700" x2="1600" y2="700"/><line x1="0" y1="800" x2="1600" y2="800"/><line x1="0" y1="900" x2="1600" y2="900"/>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(560 120)">
|
||||||
|
<rect width="480" height="780" rx="56" fill="hsl(292 30% 10% / 0.9)" stroke="hsl(292 60% 60% / 0.35)" stroke-width="3"/>
|
||||||
|
<rect x="22" y="22" width="436" height="736" rx="40" fill="#04060f"/>
|
||||||
|
<rect x="180" y="40" width="120" height="22" rx="11" fill="hsl(292 30% 25%)"/>
|
||||||
|
<circle cx="240" cy="230" r="86" fill="none" stroke="url(#linemiragemobilec)" stroke-width="10"/>
|
||||||
|
<circle cx="240" cy="230" r="56" fill="hsl(292 80% 60% / 0.18)"/>
|
||||||
|
<rect x="70" y="380" width="340" height="46" rx="14" fill="hsl(292 35% 18% / 0.8)"/><rect x="70" y="460" width="300" height="46" rx="14" fill="hsl(292 35% 18% / 0.8)"/><rect x="70" y="540" width="260" height="46" rx="14" fill="hsl(292 35% 18% / 0.8)"/><rect x="70" y="620" width="220" height="46" rx="14" fill="hsl(292 35% 18% / 0.8)"/>
|
||||||
|
<rect x="70" y="700" width="340" height="56" rx="18" fill="url(#linemiragemobilec)"/>
|
||||||
|
</g></svg>
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,36 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" width="1600" height="1000">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="glowmiragemobile" cx="28%" cy="18%" r="90%">
|
||||||
|
<stop offset="0%" stop-color="hsl(292 90% 62% / 0.55)"/>
|
||||||
|
<stop offset="45%" stop-color="hsl(320 85% 55% / 0.18)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(292 60% 8% / 0)"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="bgmiragemobile" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#04060f"/>
|
||||||
|
<stop offset="100%" stop-color="#020308"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="linemiragemobile" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="hsl(292 90% 65%)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(332 90% 68%)"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="1000" fill="url(#bgmiragemobile)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#glowmiragemobile)"/>
|
||||||
|
<g stroke="hsl(292 40% 60% / 0.06)" stroke-width="1">
|
||||||
|
<line x1="0" y1="0" x2="0" y2="1000"/><line x1="100" y1="0" x2="100" y2="1000"/><line x1="200" y1="0" x2="200" y2="1000"/><line x1="300" y1="0" x2="300" y2="1000"/><line x1="400" y1="0" x2="400" y2="1000"/><line x1="500" y1="0" x2="500" y2="1000"/><line x1="600" y1="0" x2="600" y2="1000"/><line x1="700" y1="0" x2="700" y2="1000"/><line x1="800" y1="0" x2="800" y2="1000"/><line x1="900" y1="0" x2="900" y2="1000"/><line x1="1000" y1="0" x2="1000" y2="1000"/><line x1="1100" y1="0" x2="1100" y2="1000"/><line x1="1200" y1="0" x2="1200" y2="1000"/><line x1="1300" y1="0" x2="1300" y2="1000"/><line x1="1400" y1="0" x2="1400" y2="1000"/><line x1="1500" y1="0" x2="1500" y2="1000"/>
|
||||||
|
<line x1="0" y1="0" x2="1600" y2="0"/><line x1="0" y1="100" x2="1600" y2="100"/><line x1="0" y1="200" x2="1600" y2="200"/><line x1="0" y1="300" x2="1600" y2="300"/><line x1="0" y1="400" x2="1600" y2="400"/><line x1="0" y1="500" x2="1600" y2="500"/><line x1="0" y1="600" x2="1600" y2="600"/><line x1="0" y1="700" x2="1600" y2="700"/><line x1="0" y1="800" x2="1600" y2="800"/><line x1="0" y1="900" x2="1600" y2="900"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="80" y="70" width="1440" height="60" rx="14" fill="hsl(292 30% 18% / 0.4)" stroke="hsl(292 60% 60% / 0.25)"/>
|
||||||
|
<circle cx="120" cy="100" r="9" fill="hsl(0 70% 60% / .7)"/>
|
||||||
|
<circle cx="152" cy="100" r="9" fill="hsl(45 80% 60% / .7)"/>
|
||||||
|
<circle cx="184" cy="100" r="9" fill="hsl(140 60% 55% / .7)"/>
|
||||||
|
<rect x="240" y="88" width="420" height="24" rx="12" fill="hsl(292 30% 40% / 0.25)"/>
|
||||||
|
</g>
|
||||||
|
<rect x="120" y="200" width="700" height="560" rx="20" fill="hsl(292 30% 12% / 0.5)" stroke="hsl(292 60% 60% / 0.16)"/>
|
||||||
|
<text x="160" y="270" font-family="monospace" font-size="30" fill="hsl(292 70% 75%)">throughput / day</text>
|
||||||
|
<rect x="180" y="680" width="54" height="80" rx="8" fill="url(#linemiragemobile)" opacity="0.5"/><rect x="276" y="627" width="54" height="133" rx="8" fill="url(#linemiragemobile)" opacity="0.66"/><rect x="372" y="574" width="54" height="186" rx="8" fill="url(#linemiragemobile)" opacity="0.8200000000000001"/><rect x="468" y="521" width="54" height="239" rx="8" fill="url(#linemiragemobile)" opacity="0.5"/><rect x="564" y="468" width="54" height="292" rx="8" fill="url(#linemiragemobile)" opacity="0.66"/><rect x="660" y="675" width="54" height="85" rx="8" fill="url(#linemiragemobile)" opacity="0.8200000000000001"/><rect x="756" y="622" width="54" height="138" rx="8" fill="url(#linemiragemobile)" opacity="0.5"/><rect x="852" y="569" width="54" height="191" rx="8" fill="url(#linemiragemobile)" opacity="0.66"/><rect x="948" y="516" width="54" height="244" rx="8" fill="url(#linemiragemobile)" opacity="0.8200000000000001"/>
|
||||||
|
<rect x="880" y="200" width="600" height="560" rx="20" fill="hsl(292 30% 12% / 0.5)" stroke="hsl(292 60% 60% / 0.16)"/>
|
||||||
|
<path d="M 1050 620 L 1050 620 L 1104 535.3906523385778 L 1158 489.21163083534515 L 1212 501.10554730457 L 1266 562.3244720582771 L 1320 640.00659821042 L 1374 693.2416333172939 L 1428 693.0709129595766 L 1482 636.6446346063387" fill="none" stroke="url(#linemiragemobile)" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<circle cx="1482" cy="636.6446346063387" r="12" fill="hsl(292 90% 70%)"/>
|
||||||
|
<text x="920" y="270" font-family="monospace" font-size="30" fill="hsl(292 70% 75%)">latency p95</text></svg>
|
||||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,36 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" width="1600" height="1000">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="gloworacleforecasta" cx="28%" cy="18%" r="90%">
|
||||||
|
<stop offset="0%" stop-color="hsl(205 90% 62% / 0.55)"/>
|
||||||
|
<stop offset="45%" stop-color="hsl(233 85% 55% / 0.18)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(205 60% 8% / 0)"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="bgoracleforecasta" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#04060f"/>
|
||||||
|
<stop offset="100%" stop-color="#020308"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="lineoracleforecasta" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="hsl(205 90% 65%)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(245 90% 68%)"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="1000" fill="url(#bgoracleforecasta)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#gloworacleforecasta)"/>
|
||||||
|
<g stroke="hsl(205 40% 60% / 0.06)" stroke-width="1">
|
||||||
|
<line x1="0" y1="0" x2="0" y2="1000"/><line x1="100" y1="0" x2="100" y2="1000"/><line x1="200" y1="0" x2="200" y2="1000"/><line x1="300" y1="0" x2="300" y2="1000"/><line x1="400" y1="0" x2="400" y2="1000"/><line x1="500" y1="0" x2="500" y2="1000"/><line x1="600" y1="0" x2="600" y2="1000"/><line x1="700" y1="0" x2="700" y2="1000"/><line x1="800" y1="0" x2="800" y2="1000"/><line x1="900" y1="0" x2="900" y2="1000"/><line x1="1000" y1="0" x2="1000" y2="1000"/><line x1="1100" y1="0" x2="1100" y2="1000"/><line x1="1200" y1="0" x2="1200" y2="1000"/><line x1="1300" y1="0" x2="1300" y2="1000"/><line x1="1400" y1="0" x2="1400" y2="1000"/><line x1="1500" y1="0" x2="1500" y2="1000"/>
|
||||||
|
<line x1="0" y1="0" x2="1600" y2="0"/><line x1="0" y1="100" x2="1600" y2="100"/><line x1="0" y1="200" x2="1600" y2="200"/><line x1="0" y1="300" x2="1600" y2="300"/><line x1="0" y1="400" x2="1600" y2="400"/><line x1="0" y1="500" x2="1600" y2="500"/><line x1="0" y1="600" x2="1600" y2="600"/><line x1="0" y1="700" x2="1600" y2="700"/><line x1="0" y1="800" x2="1600" y2="800"/><line x1="0" y1="900" x2="1600" y2="900"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="80" y="70" width="1440" height="60" rx="14" fill="hsl(205 30% 18% / 0.4)" stroke="hsl(205 60% 60% / 0.25)"/>
|
||||||
|
<circle cx="120" cy="100" r="9" fill="hsl(0 70% 60% / .7)"/>
|
||||||
|
<circle cx="152" cy="100" r="9" fill="hsl(45 80% 60% / .7)"/>
|
||||||
|
<circle cx="184" cy="100" r="9" fill="hsl(140 60% 55% / .7)"/>
|
||||||
|
<rect x="240" y="88" width="420" height="24" rx="12" fill="hsl(205 30% 40% / 0.25)"/>
|
||||||
|
</g>
|
||||||
|
<rect x="120" y="200" width="700" height="560" rx="20" fill="hsl(205 30% 12% / 0.5)" stroke="hsl(205 60% 60% / 0.16)"/>
|
||||||
|
<text x="160" y="270" font-family="monospace" font-size="30" fill="hsl(205 70% 75%)">throughput / day</text>
|
||||||
|
<rect x="180" y="680" width="54" height="80" rx="8" fill="url(#lineoracleforecasta)" opacity="0.5"/><rect x="276" y="627" width="54" height="133" rx="8" fill="url(#lineoracleforecasta)" opacity="0.66"/><rect x="372" y="574" width="54" height="186" rx="8" fill="url(#lineoracleforecasta)" opacity="0.8200000000000001"/><rect x="468" y="521" width="54" height="239" rx="8" fill="url(#lineoracleforecasta)" opacity="0.5"/><rect x="564" y="468" width="54" height="292" rx="8" fill="url(#lineoracleforecasta)" opacity="0.66"/><rect x="660" y="675" width="54" height="85" rx="8" fill="url(#lineoracleforecasta)" opacity="0.8200000000000001"/><rect x="756" y="622" width="54" height="138" rx="8" fill="url(#lineoracleforecasta)" opacity="0.5"/><rect x="852" y="569" width="54" height="191" rx="8" fill="url(#lineoracleforecasta)" opacity="0.66"/><rect x="948" y="516" width="54" height="244" rx="8" fill="url(#lineoracleforecasta)" opacity="0.8200000000000001"/>
|
||||||
|
<rect x="880" y="200" width="600" height="560" rx="20" fill="hsl(205 30% 12% / 0.5)" stroke="hsl(205 60% 60% / 0.16)"/>
|
||||||
|
<path d="M 1050 620 L 1050 620 L 1104 535.3906523385778 L 1158 489.21163083534515 L 1212 501.10554730457 L 1266 562.3244720582771 L 1320 640.00659821042 L 1374 693.2416333172939 L 1428 693.0709129595766 L 1482 636.6446346063387" fill="none" stroke="url(#lineoracleforecasta)" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<circle cx="1482" cy="636.6446346063387" r="12" fill="hsl(205 90% 70%)"/>
|
||||||
|
<text x="920" y="270" font-family="monospace" font-size="30" fill="hsl(205 70% 75%)">latency p95</text></svg>
|
||||||
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1,36 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" width="1600" height="1000">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="gloworacleforecastb" cx="28%" cy="18%" r="90%">
|
||||||
|
<stop offset="0%" stop-color="hsl(205 90% 62% / 0.55)"/>
|
||||||
|
<stop offset="45%" stop-color="hsl(233 85% 55% / 0.18)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(205 60% 8% / 0)"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="bgoracleforecastb" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#04060f"/>
|
||||||
|
<stop offset="100%" stop-color="#020308"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="lineoracleforecastb" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="hsl(205 90% 65%)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(245 90% 68%)"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="1000" fill="url(#bgoracleforecastb)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#gloworacleforecastb)"/>
|
||||||
|
<g stroke="hsl(205 40% 60% / 0.06)" stroke-width="1">
|
||||||
|
<line x1="0" y1="0" x2="0" y2="1000"/><line x1="100" y1="0" x2="100" y2="1000"/><line x1="200" y1="0" x2="200" y2="1000"/><line x1="300" y1="0" x2="300" y2="1000"/><line x1="400" y1="0" x2="400" y2="1000"/><line x1="500" y1="0" x2="500" y2="1000"/><line x1="600" y1="0" x2="600" y2="1000"/><line x1="700" y1="0" x2="700" y2="1000"/><line x1="800" y1="0" x2="800" y2="1000"/><line x1="900" y1="0" x2="900" y2="1000"/><line x1="1000" y1="0" x2="1000" y2="1000"/><line x1="1100" y1="0" x2="1100" y2="1000"/><line x1="1200" y1="0" x2="1200" y2="1000"/><line x1="1300" y1="0" x2="1300" y2="1000"/><line x1="1400" y1="0" x2="1400" y2="1000"/><line x1="1500" y1="0" x2="1500" y2="1000"/>
|
||||||
|
<line x1="0" y1="0" x2="1600" y2="0"/><line x1="0" y1="100" x2="1600" y2="100"/><line x1="0" y1="200" x2="1600" y2="200"/><line x1="0" y1="300" x2="1600" y2="300"/><line x1="0" y1="400" x2="1600" y2="400"/><line x1="0" y1="500" x2="1600" y2="500"/><line x1="0" y1="600" x2="1600" y2="600"/><line x1="0" y1="700" x2="1600" y2="700"/><line x1="0" y1="800" x2="1600" y2="800"/><line x1="0" y1="900" x2="1600" y2="900"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="80" y="70" width="1440" height="60" rx="14" fill="hsl(205 30% 18% / 0.4)" stroke="hsl(205 60% 60% / 0.25)"/>
|
||||||
|
<circle cx="120" cy="100" r="9" fill="hsl(0 70% 60% / .7)"/>
|
||||||
|
<circle cx="152" cy="100" r="9" fill="hsl(45 80% 60% / .7)"/>
|
||||||
|
<circle cx="184" cy="100" r="9" fill="hsl(140 60% 55% / .7)"/>
|
||||||
|
<rect x="240" y="88" width="420" height="24" rx="12" fill="hsl(205 30% 40% / 0.25)"/>
|
||||||
|
</g>
|
||||||
|
<path d="M 350 305 C 470 305, 470 215, 590 215" fill="none" stroke="url(#lineoracleforecastb)" stroke-width="4" opacity="0.7"/><path d="M 350 305 C 470 305, 470 405, 590 405" fill="none" stroke="url(#lineoracleforecastb)" stroke-width="4" opacity="0.7"/><path d="M 820 215 C 940 215, 940 305, 1060 305" fill="none" stroke="url(#lineoracleforecastb)" stroke-width="4" opacity="0.7"/><path d="M 820 405 C 940 405, 940 305, 1060 305" fill="none" stroke="url(#lineoracleforecastb)" stroke-width="4" opacity="0.7"/><path d="M 1290 305 C 1290 305, 1290 305, 1290 305" fill="none" stroke="url(#lineoracleforecastb)" stroke-width="4" opacity="0.7"/>
|
||||||
|
<g><rect x="120" y="250" width="230" height="110" rx="18" fill="hsl(205 35% 14% / 0.7)" stroke="hsl(205 70% 62% / 0.5)"/><text x="235" y="312" text-anchor="middle" font-family="monospace" font-size="26" fill="hsl(205 70% 80%)">ingest</text></g>
|
||||||
|
<g><rect x="590" y="160" width="230" height="110" rx="18" fill="hsl(205 35% 14% / 0.7)" stroke="hsl(205 70% 62% / 0.5)"/><text x="705" y="222" text-anchor="middle" font-family="monospace" font-size="26" fill="hsl(205 70% 80%)">embed</text></g>
|
||||||
|
<g><rect x="590" y="350" width="230" height="110" rx="18" fill="hsl(205 35% 14% / 0.7)" stroke="hsl(205 70% 62% / 0.5)"/><text x="705" y="412" text-anchor="middle" font-family="monospace" font-size="26" fill="hsl(205 70% 80%)">retrieve</text></g>
|
||||||
|
<g><rect x="1060" y="250" width="230" height="110" rx="18" fill="hsl(205 35% 14% / 0.7)" stroke="hsl(205 70% 62% / 0.5)"/><text x="1175" y="312" text-anchor="middle" font-family="monospace" font-size="26" fill="hsl(205 70% 80%)">rerank</text></g>
|
||||||
|
<g><rect x="1300" y="250" width="230" height="110" rx="18" fill="hsl(205 35% 14% / 0.7)" stroke="hsl(205 70% 62% / 0.5)"/><text x="1415" y="312" text-anchor="middle" font-family="monospace" font-size="26" fill="hsl(205 70% 80%)">generate</text></g>
|
||||||
|
<g opacity="0.5"><circle cx="260" cy="700" r="6" fill="hsl(205 90% 70%)"/><circle cx="460" cy="700" r="6" fill="hsl(205 90% 70%)"/><circle cx="660" cy="700" r="6" fill="hsl(205 90% 70%)"/><circle cx="860" cy="700" r="6" fill="hsl(205 90% 70%)"/><circle cx="1060" cy="700" r="6" fill="hsl(205 90% 70%)"/><circle cx="1260" cy="700" r="6" fill="hsl(205 90% 70%)"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,31 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" width="1600" height="1000">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="gloworacleforecastc" cx="28%" cy="18%" r="90%">
|
||||||
|
<stop offset="0%" stop-color="hsl(205 90% 62% / 0.55)"/>
|
||||||
|
<stop offset="45%" stop-color="hsl(233 85% 55% / 0.18)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(205 60% 8% / 0)"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="bgoracleforecastc" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#04060f"/>
|
||||||
|
<stop offset="100%" stop-color="#020308"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="lineoracleforecastc" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="hsl(205 90% 65%)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(245 90% 68%)"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="1000" fill="url(#bgoracleforecastc)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#gloworacleforecastc)"/>
|
||||||
|
<g stroke="hsl(205 40% 60% / 0.06)" stroke-width="1">
|
||||||
|
<line x1="0" y1="0" x2="0" y2="1000"/><line x1="100" y1="0" x2="100" y2="1000"/><line x1="200" y1="0" x2="200" y2="1000"/><line x1="300" y1="0" x2="300" y2="1000"/><line x1="400" y1="0" x2="400" y2="1000"/><line x1="500" y1="0" x2="500" y2="1000"/><line x1="600" y1="0" x2="600" y2="1000"/><line x1="700" y1="0" x2="700" y2="1000"/><line x1="800" y1="0" x2="800" y2="1000"/><line x1="900" y1="0" x2="900" y2="1000"/><line x1="1000" y1="0" x2="1000" y2="1000"/><line x1="1100" y1="0" x2="1100" y2="1000"/><line x1="1200" y1="0" x2="1200" y2="1000"/><line x1="1300" y1="0" x2="1300" y2="1000"/><line x1="1400" y1="0" x2="1400" y2="1000"/><line x1="1500" y1="0" x2="1500" y2="1000"/>
|
||||||
|
<line x1="0" y1="0" x2="1600" y2="0"/><line x1="0" y1="100" x2="1600" y2="100"/><line x1="0" y1="200" x2="1600" y2="200"/><line x1="0" y1="300" x2="1600" y2="300"/><line x1="0" y1="400" x2="1600" y2="400"/><line x1="0" y1="500" x2="1600" y2="500"/><line x1="0" y1="600" x2="1600" y2="600"/><line x1="0" y1="700" x2="1600" y2="700"/><line x1="0" y1="800" x2="1600" y2="800"/><line x1="0" y1="900" x2="1600" y2="900"/>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(560 120)">
|
||||||
|
<rect width="480" height="780" rx="56" fill="hsl(205 30% 10% / 0.9)" stroke="hsl(205 60% 60% / 0.35)" stroke-width="3"/>
|
||||||
|
<rect x="22" y="22" width="436" height="736" rx="40" fill="#04060f"/>
|
||||||
|
<rect x="180" y="40" width="120" height="22" rx="11" fill="hsl(205 30% 25%)"/>
|
||||||
|
<circle cx="240" cy="230" r="86" fill="none" stroke="url(#lineoracleforecastc)" stroke-width="10"/>
|
||||||
|
<circle cx="240" cy="230" r="56" fill="hsl(205 80% 60% / 0.18)"/>
|
||||||
|
<rect x="70" y="380" width="340" height="46" rx="14" fill="hsl(205 35% 18% / 0.8)"/><rect x="70" y="460" width="300" height="46" rx="14" fill="hsl(205 35% 18% / 0.8)"/><rect x="70" y="540" width="260" height="46" rx="14" fill="hsl(205 35% 18% / 0.8)"/><rect x="70" y="620" width="220" height="46" rx="14" fill="hsl(205 35% 18% / 0.8)"/>
|
||||||
|
<rect x="70" y="700" width="340" height="56" rx="18" fill="url(#lineoracleforecastc)"/>
|
||||||
|
</g></svg>
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,36 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" width="1600" height="1000">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="gloworacleforecast" cx="28%" cy="18%" r="90%">
|
||||||
|
<stop offset="0%" stop-color="hsl(205 90% 62% / 0.55)"/>
|
||||||
|
<stop offset="45%" stop-color="hsl(233 85% 55% / 0.18)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(205 60% 8% / 0)"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="bgoracleforecast" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#04060f"/>
|
||||||
|
<stop offset="100%" stop-color="#020308"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="lineoracleforecast" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="hsl(205 90% 65%)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(245 90% 68%)"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="1000" fill="url(#bgoracleforecast)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#gloworacleforecast)"/>
|
||||||
|
<g stroke="hsl(205 40% 60% / 0.06)" stroke-width="1">
|
||||||
|
<line x1="0" y1="0" x2="0" y2="1000"/><line x1="100" y1="0" x2="100" y2="1000"/><line x1="200" y1="0" x2="200" y2="1000"/><line x1="300" y1="0" x2="300" y2="1000"/><line x1="400" y1="0" x2="400" y2="1000"/><line x1="500" y1="0" x2="500" y2="1000"/><line x1="600" y1="0" x2="600" y2="1000"/><line x1="700" y1="0" x2="700" y2="1000"/><line x1="800" y1="0" x2="800" y2="1000"/><line x1="900" y1="0" x2="900" y2="1000"/><line x1="1000" y1="0" x2="1000" y2="1000"/><line x1="1100" y1="0" x2="1100" y2="1000"/><line x1="1200" y1="0" x2="1200" y2="1000"/><line x1="1300" y1="0" x2="1300" y2="1000"/><line x1="1400" y1="0" x2="1400" y2="1000"/><line x1="1500" y1="0" x2="1500" y2="1000"/>
|
||||||
|
<line x1="0" y1="0" x2="1600" y2="0"/><line x1="0" y1="100" x2="1600" y2="100"/><line x1="0" y1="200" x2="1600" y2="200"/><line x1="0" y1="300" x2="1600" y2="300"/><line x1="0" y1="400" x2="1600" y2="400"/><line x1="0" y1="500" x2="1600" y2="500"/><line x1="0" y1="600" x2="1600" y2="600"/><line x1="0" y1="700" x2="1600" y2="700"/><line x1="0" y1="800" x2="1600" y2="800"/><line x1="0" y1="900" x2="1600" y2="900"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="80" y="70" width="1440" height="60" rx="14" fill="hsl(205 30% 18% / 0.4)" stroke="hsl(205 60% 60% / 0.25)"/>
|
||||||
|
<circle cx="120" cy="100" r="9" fill="hsl(0 70% 60% / .7)"/>
|
||||||
|
<circle cx="152" cy="100" r="9" fill="hsl(45 80% 60% / .7)"/>
|
||||||
|
<circle cx="184" cy="100" r="9" fill="hsl(140 60% 55% / .7)"/>
|
||||||
|
<rect x="240" y="88" width="420" height="24" rx="12" fill="hsl(205 30% 40% / 0.25)"/>
|
||||||
|
</g>
|
||||||
|
<rect x="120" y="200" width="700" height="560" rx="20" fill="hsl(205 30% 12% / 0.5)" stroke="hsl(205 60% 60% / 0.16)"/>
|
||||||
|
<text x="160" y="270" font-family="monospace" font-size="30" fill="hsl(205 70% 75%)">throughput / day</text>
|
||||||
|
<rect x="180" y="680" width="54" height="80" rx="8" fill="url(#lineoracleforecast)" opacity="0.5"/><rect x="276" y="627" width="54" height="133" rx="8" fill="url(#lineoracleforecast)" opacity="0.66"/><rect x="372" y="574" width="54" height="186" rx="8" fill="url(#lineoracleforecast)" opacity="0.8200000000000001"/><rect x="468" y="521" width="54" height="239" rx="8" fill="url(#lineoracleforecast)" opacity="0.5"/><rect x="564" y="468" width="54" height="292" rx="8" fill="url(#lineoracleforecast)" opacity="0.66"/><rect x="660" y="675" width="54" height="85" rx="8" fill="url(#lineoracleforecast)" opacity="0.8200000000000001"/><rect x="756" y="622" width="54" height="138" rx="8" fill="url(#lineoracleforecast)" opacity="0.5"/><rect x="852" y="569" width="54" height="191" rx="8" fill="url(#lineoracleforecast)" opacity="0.66"/><rect x="948" y="516" width="54" height="244" rx="8" fill="url(#lineoracleforecast)" opacity="0.8200000000000001"/>
|
||||||
|
<rect x="880" y="200" width="600" height="560" rx="20" fill="hsl(205 30% 12% / 0.5)" stroke="hsl(205 60% 60% / 0.16)"/>
|
||||||
|
<path d="M 1050 620 L 1050 620 L 1104 535.3906523385778 L 1158 489.21163083534515 L 1212 501.10554730457 L 1266 562.3244720582771 L 1320 640.00659821042 L 1374 693.2416333172939 L 1428 693.0709129595766 L 1482 636.6446346063387" fill="none" stroke="url(#lineoracleforecast)" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<circle cx="1482" cy="636.6446346063387" r="12" fill="hsl(205 90% 70%)"/>
|
||||||
|
<text x="920" y="270" font-family="monospace" font-size="30" fill="hsl(205 70% 75%)">latency p95</text></svg>
|
||||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,36 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" width="1600" height="1000">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="glowsentinelagentsa" cx="28%" cy="18%" r="90%">
|
||||||
|
<stop offset="0%" stop-color="hsl(245 90% 62% / 0.55)"/>
|
||||||
|
<stop offset="45%" stop-color="hsl(273 85% 55% / 0.18)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(245 60% 8% / 0)"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="bgsentinelagentsa" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#04060f"/>
|
||||||
|
<stop offset="100%" stop-color="#020308"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="linesentinelagentsa" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="hsl(245 90% 65%)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(285 90% 68%)"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="1000" fill="url(#bgsentinelagentsa)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#glowsentinelagentsa)"/>
|
||||||
|
<g stroke="hsl(245 40% 60% / 0.06)" stroke-width="1">
|
||||||
|
<line x1="0" y1="0" x2="0" y2="1000"/><line x1="100" y1="0" x2="100" y2="1000"/><line x1="200" y1="0" x2="200" y2="1000"/><line x1="300" y1="0" x2="300" y2="1000"/><line x1="400" y1="0" x2="400" y2="1000"/><line x1="500" y1="0" x2="500" y2="1000"/><line x1="600" y1="0" x2="600" y2="1000"/><line x1="700" y1="0" x2="700" y2="1000"/><line x1="800" y1="0" x2="800" y2="1000"/><line x1="900" y1="0" x2="900" y2="1000"/><line x1="1000" y1="0" x2="1000" y2="1000"/><line x1="1100" y1="0" x2="1100" y2="1000"/><line x1="1200" y1="0" x2="1200" y2="1000"/><line x1="1300" y1="0" x2="1300" y2="1000"/><line x1="1400" y1="0" x2="1400" y2="1000"/><line x1="1500" y1="0" x2="1500" y2="1000"/>
|
||||||
|
<line x1="0" y1="0" x2="1600" y2="0"/><line x1="0" y1="100" x2="1600" y2="100"/><line x1="0" y1="200" x2="1600" y2="200"/><line x1="0" y1="300" x2="1600" y2="300"/><line x1="0" y1="400" x2="1600" y2="400"/><line x1="0" y1="500" x2="1600" y2="500"/><line x1="0" y1="600" x2="1600" y2="600"/><line x1="0" y1="700" x2="1600" y2="700"/><line x1="0" y1="800" x2="1600" y2="800"/><line x1="0" y1="900" x2="1600" y2="900"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="80" y="70" width="1440" height="60" rx="14" fill="hsl(245 30% 18% / 0.4)" stroke="hsl(245 60% 60% / 0.25)"/>
|
||||||
|
<circle cx="120" cy="100" r="9" fill="hsl(0 70% 60% / .7)"/>
|
||||||
|
<circle cx="152" cy="100" r="9" fill="hsl(45 80% 60% / .7)"/>
|
||||||
|
<circle cx="184" cy="100" r="9" fill="hsl(140 60% 55% / .7)"/>
|
||||||
|
<rect x="240" y="88" width="420" height="24" rx="12" fill="hsl(245 30% 40% / 0.25)"/>
|
||||||
|
</g>
|
||||||
|
<rect x="120" y="200" width="700" height="560" rx="20" fill="hsl(245 30% 12% / 0.5)" stroke="hsl(245 60% 60% / 0.16)"/>
|
||||||
|
<text x="160" y="270" font-family="monospace" font-size="30" fill="hsl(245 70% 75%)">throughput / day</text>
|
||||||
|
<rect x="180" y="680" width="54" height="80" rx="8" fill="url(#linesentinelagentsa)" opacity="0.5"/><rect x="276" y="627" width="54" height="133" rx="8" fill="url(#linesentinelagentsa)" opacity="0.66"/><rect x="372" y="574" width="54" height="186" rx="8" fill="url(#linesentinelagentsa)" opacity="0.8200000000000001"/><rect x="468" y="521" width="54" height="239" rx="8" fill="url(#linesentinelagentsa)" opacity="0.5"/><rect x="564" y="468" width="54" height="292" rx="8" fill="url(#linesentinelagentsa)" opacity="0.66"/><rect x="660" y="675" width="54" height="85" rx="8" fill="url(#linesentinelagentsa)" opacity="0.8200000000000001"/><rect x="756" y="622" width="54" height="138" rx="8" fill="url(#linesentinelagentsa)" opacity="0.5"/><rect x="852" y="569" width="54" height="191" rx="8" fill="url(#linesentinelagentsa)" opacity="0.66"/><rect x="948" y="516" width="54" height="244" rx="8" fill="url(#linesentinelagentsa)" opacity="0.8200000000000001"/>
|
||||||
|
<rect x="880" y="200" width="600" height="560" rx="20" fill="hsl(245 30% 12% / 0.5)" stroke="hsl(245 60% 60% / 0.16)"/>
|
||||||
|
<path d="M 1050 620 L 1050 620 L 1104 535.3906523385778 L 1158 489.21163083534515 L 1212 501.10554730457 L 1266 562.3244720582771 L 1320 640.00659821042 L 1374 693.2416333172939 L 1428 693.0709129595766 L 1482 636.6446346063387" fill="none" stroke="url(#linesentinelagentsa)" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<circle cx="1482" cy="636.6446346063387" r="12" fill="hsl(245 90% 70%)"/>
|
||||||
|
<text x="920" y="270" font-family="monospace" font-size="30" fill="hsl(245 70% 75%)">latency p95</text></svg>
|
||||||
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1,36 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" width="1600" height="1000">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="glowsentinelagentsb" cx="28%" cy="18%" r="90%">
|
||||||
|
<stop offset="0%" stop-color="hsl(245 90% 62% / 0.55)"/>
|
||||||
|
<stop offset="45%" stop-color="hsl(273 85% 55% / 0.18)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(245 60% 8% / 0)"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="bgsentinelagentsb" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#04060f"/>
|
||||||
|
<stop offset="100%" stop-color="#020308"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="linesentinelagentsb" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="hsl(245 90% 65%)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(285 90% 68%)"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="1000" fill="url(#bgsentinelagentsb)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#glowsentinelagentsb)"/>
|
||||||
|
<g stroke="hsl(245 40% 60% / 0.06)" stroke-width="1">
|
||||||
|
<line x1="0" y1="0" x2="0" y2="1000"/><line x1="100" y1="0" x2="100" y2="1000"/><line x1="200" y1="0" x2="200" y2="1000"/><line x1="300" y1="0" x2="300" y2="1000"/><line x1="400" y1="0" x2="400" y2="1000"/><line x1="500" y1="0" x2="500" y2="1000"/><line x1="600" y1="0" x2="600" y2="1000"/><line x1="700" y1="0" x2="700" y2="1000"/><line x1="800" y1="0" x2="800" y2="1000"/><line x1="900" y1="0" x2="900" y2="1000"/><line x1="1000" y1="0" x2="1000" y2="1000"/><line x1="1100" y1="0" x2="1100" y2="1000"/><line x1="1200" y1="0" x2="1200" y2="1000"/><line x1="1300" y1="0" x2="1300" y2="1000"/><line x1="1400" y1="0" x2="1400" y2="1000"/><line x1="1500" y1="0" x2="1500" y2="1000"/>
|
||||||
|
<line x1="0" y1="0" x2="1600" y2="0"/><line x1="0" y1="100" x2="1600" y2="100"/><line x1="0" y1="200" x2="1600" y2="200"/><line x1="0" y1="300" x2="1600" y2="300"/><line x1="0" y1="400" x2="1600" y2="400"/><line x1="0" y1="500" x2="1600" y2="500"/><line x1="0" y1="600" x2="1600" y2="600"/><line x1="0" y1="700" x2="1600" y2="700"/><line x1="0" y1="800" x2="1600" y2="800"/><line x1="0" y1="900" x2="1600" y2="900"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="80" y="70" width="1440" height="60" rx="14" fill="hsl(245 30% 18% / 0.4)" stroke="hsl(245 60% 60% / 0.25)"/>
|
||||||
|
<circle cx="120" cy="100" r="9" fill="hsl(0 70% 60% / .7)"/>
|
||||||
|
<circle cx="152" cy="100" r="9" fill="hsl(45 80% 60% / .7)"/>
|
||||||
|
<circle cx="184" cy="100" r="9" fill="hsl(140 60% 55% / .7)"/>
|
||||||
|
<rect x="240" y="88" width="420" height="24" rx="12" fill="hsl(245 30% 40% / 0.25)"/>
|
||||||
|
</g>
|
||||||
|
<path d="M 350 305 C 470 305, 470 215, 590 215" fill="none" stroke="url(#linesentinelagentsb)" stroke-width="4" opacity="0.7"/><path d="M 350 305 C 470 305, 470 405, 590 405" fill="none" stroke="url(#linesentinelagentsb)" stroke-width="4" opacity="0.7"/><path d="M 820 215 C 940 215, 940 305, 1060 305" fill="none" stroke="url(#linesentinelagentsb)" stroke-width="4" opacity="0.7"/><path d="M 820 405 C 940 405, 940 305, 1060 305" fill="none" stroke="url(#linesentinelagentsb)" stroke-width="4" opacity="0.7"/><path d="M 1290 305 C 1290 305, 1290 305, 1290 305" fill="none" stroke="url(#linesentinelagentsb)" stroke-width="4" opacity="0.7"/>
|
||||||
|
<g><rect x="120" y="250" width="230" height="110" rx="18" fill="hsl(245 35% 14% / 0.7)" stroke="hsl(245 70% 62% / 0.5)"/><text x="235" y="312" text-anchor="middle" font-family="monospace" font-size="26" fill="hsl(245 70% 80%)">ingest</text></g>
|
||||||
|
<g><rect x="590" y="160" width="230" height="110" rx="18" fill="hsl(245 35% 14% / 0.7)" stroke="hsl(245 70% 62% / 0.5)"/><text x="705" y="222" text-anchor="middle" font-family="monospace" font-size="26" fill="hsl(245 70% 80%)">embed</text></g>
|
||||||
|
<g><rect x="590" y="350" width="230" height="110" rx="18" fill="hsl(245 35% 14% / 0.7)" stroke="hsl(245 70% 62% / 0.5)"/><text x="705" y="412" text-anchor="middle" font-family="monospace" font-size="26" fill="hsl(245 70% 80%)">retrieve</text></g>
|
||||||
|
<g><rect x="1060" y="250" width="230" height="110" rx="18" fill="hsl(245 35% 14% / 0.7)" stroke="hsl(245 70% 62% / 0.5)"/><text x="1175" y="312" text-anchor="middle" font-family="monospace" font-size="26" fill="hsl(245 70% 80%)">rerank</text></g>
|
||||||
|
<g><rect x="1300" y="250" width="230" height="110" rx="18" fill="hsl(245 35% 14% / 0.7)" stroke="hsl(245 70% 62% / 0.5)"/><text x="1415" y="312" text-anchor="middle" font-family="monospace" font-size="26" fill="hsl(245 70% 80%)">generate</text></g>
|
||||||
|
<g opacity="0.5"><circle cx="260" cy="700" r="6" fill="hsl(245 90% 70%)"/><circle cx="460" cy="700" r="6" fill="hsl(245 90% 70%)"/><circle cx="660" cy="700" r="6" fill="hsl(245 90% 70%)"/><circle cx="860" cy="700" r="6" fill="hsl(245 90% 70%)"/><circle cx="1060" cy="700" r="6" fill="hsl(245 90% 70%)"/><circle cx="1260" cy="700" r="6" fill="hsl(245 90% 70%)"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,31 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" width="1600" height="1000">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="glowsentinelagentsc" cx="28%" cy="18%" r="90%">
|
||||||
|
<stop offset="0%" stop-color="hsl(245 90% 62% / 0.55)"/>
|
||||||
|
<stop offset="45%" stop-color="hsl(273 85% 55% / 0.18)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(245 60% 8% / 0)"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="bgsentinelagentsc" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#04060f"/>
|
||||||
|
<stop offset="100%" stop-color="#020308"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="linesentinelagentsc" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="hsl(245 90% 65%)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(285 90% 68%)"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="1000" fill="url(#bgsentinelagentsc)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#glowsentinelagentsc)"/>
|
||||||
|
<g stroke="hsl(245 40% 60% / 0.06)" stroke-width="1">
|
||||||
|
<line x1="0" y1="0" x2="0" y2="1000"/><line x1="100" y1="0" x2="100" y2="1000"/><line x1="200" y1="0" x2="200" y2="1000"/><line x1="300" y1="0" x2="300" y2="1000"/><line x1="400" y1="0" x2="400" y2="1000"/><line x1="500" y1="0" x2="500" y2="1000"/><line x1="600" y1="0" x2="600" y2="1000"/><line x1="700" y1="0" x2="700" y2="1000"/><line x1="800" y1="0" x2="800" y2="1000"/><line x1="900" y1="0" x2="900" y2="1000"/><line x1="1000" y1="0" x2="1000" y2="1000"/><line x1="1100" y1="0" x2="1100" y2="1000"/><line x1="1200" y1="0" x2="1200" y2="1000"/><line x1="1300" y1="0" x2="1300" y2="1000"/><line x1="1400" y1="0" x2="1400" y2="1000"/><line x1="1500" y1="0" x2="1500" y2="1000"/>
|
||||||
|
<line x1="0" y1="0" x2="1600" y2="0"/><line x1="0" y1="100" x2="1600" y2="100"/><line x1="0" y1="200" x2="1600" y2="200"/><line x1="0" y1="300" x2="1600" y2="300"/><line x1="0" y1="400" x2="1600" y2="400"/><line x1="0" y1="500" x2="1600" y2="500"/><line x1="0" y1="600" x2="1600" y2="600"/><line x1="0" y1="700" x2="1600" y2="700"/><line x1="0" y1="800" x2="1600" y2="800"/><line x1="0" y1="900" x2="1600" y2="900"/>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(560 120)">
|
||||||
|
<rect width="480" height="780" rx="56" fill="hsl(245 30% 10% / 0.9)" stroke="hsl(245 60% 60% / 0.35)" stroke-width="3"/>
|
||||||
|
<rect x="22" y="22" width="436" height="736" rx="40" fill="#04060f"/>
|
||||||
|
<rect x="180" y="40" width="120" height="22" rx="11" fill="hsl(245 30% 25%)"/>
|
||||||
|
<circle cx="240" cy="230" r="86" fill="none" stroke="url(#linesentinelagentsc)" stroke-width="10"/>
|
||||||
|
<circle cx="240" cy="230" r="56" fill="hsl(245 80% 60% / 0.18)"/>
|
||||||
|
<rect x="70" y="380" width="340" height="46" rx="14" fill="hsl(245 35% 18% / 0.8)"/><rect x="70" y="460" width="300" height="46" rx="14" fill="hsl(245 35% 18% / 0.8)"/><rect x="70" y="540" width="260" height="46" rx="14" fill="hsl(245 35% 18% / 0.8)"/><rect x="70" y="620" width="220" height="46" rx="14" fill="hsl(245 35% 18% / 0.8)"/>
|
||||||
|
<rect x="70" y="700" width="340" height="56" rx="18" fill="url(#linesentinelagentsc)"/>
|
||||||
|
</g></svg>
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,36 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" width="1600" height="1000">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="glowsentinelagents" cx="28%" cy="18%" r="90%">
|
||||||
|
<stop offset="0%" stop-color="hsl(245 90% 62% / 0.55)"/>
|
||||||
|
<stop offset="45%" stop-color="hsl(273 85% 55% / 0.18)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(245 60% 8% / 0)"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="bgsentinelagents" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#04060f"/>
|
||||||
|
<stop offset="100%" stop-color="#020308"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="linesentinelagents" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="hsl(245 90% 65%)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(285 90% 68%)"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="1000" fill="url(#bgsentinelagents)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#glowsentinelagents)"/>
|
||||||
|
<g stroke="hsl(245 40% 60% / 0.06)" stroke-width="1">
|
||||||
|
<line x1="0" y1="0" x2="0" y2="1000"/><line x1="100" y1="0" x2="100" y2="1000"/><line x1="200" y1="0" x2="200" y2="1000"/><line x1="300" y1="0" x2="300" y2="1000"/><line x1="400" y1="0" x2="400" y2="1000"/><line x1="500" y1="0" x2="500" y2="1000"/><line x1="600" y1="0" x2="600" y2="1000"/><line x1="700" y1="0" x2="700" y2="1000"/><line x1="800" y1="0" x2="800" y2="1000"/><line x1="900" y1="0" x2="900" y2="1000"/><line x1="1000" y1="0" x2="1000" y2="1000"/><line x1="1100" y1="0" x2="1100" y2="1000"/><line x1="1200" y1="0" x2="1200" y2="1000"/><line x1="1300" y1="0" x2="1300" y2="1000"/><line x1="1400" y1="0" x2="1400" y2="1000"/><line x1="1500" y1="0" x2="1500" y2="1000"/>
|
||||||
|
<line x1="0" y1="0" x2="1600" y2="0"/><line x1="0" y1="100" x2="1600" y2="100"/><line x1="0" y1="200" x2="1600" y2="200"/><line x1="0" y1="300" x2="1600" y2="300"/><line x1="0" y1="400" x2="1600" y2="400"/><line x1="0" y1="500" x2="1600" y2="500"/><line x1="0" y1="600" x2="1600" y2="600"/><line x1="0" y1="700" x2="1600" y2="700"/><line x1="0" y1="800" x2="1600" y2="800"/><line x1="0" y1="900" x2="1600" y2="900"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="80" y="70" width="1440" height="60" rx="14" fill="hsl(245 30% 18% / 0.4)" stroke="hsl(245 60% 60% / 0.25)"/>
|
||||||
|
<circle cx="120" cy="100" r="9" fill="hsl(0 70% 60% / .7)"/>
|
||||||
|
<circle cx="152" cy="100" r="9" fill="hsl(45 80% 60% / .7)"/>
|
||||||
|
<circle cx="184" cy="100" r="9" fill="hsl(140 60% 55% / .7)"/>
|
||||||
|
<rect x="240" y="88" width="420" height="24" rx="12" fill="hsl(245 30% 40% / 0.25)"/>
|
||||||
|
</g>
|
||||||
|
<rect x="120" y="200" width="700" height="560" rx="20" fill="hsl(245 30% 12% / 0.5)" stroke="hsl(245 60% 60% / 0.16)"/>
|
||||||
|
<text x="160" y="270" font-family="monospace" font-size="30" fill="hsl(245 70% 75%)">throughput / day</text>
|
||||||
|
<rect x="180" y="680" width="54" height="80" rx="8" fill="url(#linesentinelagents)" opacity="0.5"/><rect x="276" y="627" width="54" height="133" rx="8" fill="url(#linesentinelagents)" opacity="0.66"/><rect x="372" y="574" width="54" height="186" rx="8" fill="url(#linesentinelagents)" opacity="0.8200000000000001"/><rect x="468" y="521" width="54" height="239" rx="8" fill="url(#linesentinelagents)" opacity="0.5"/><rect x="564" y="468" width="54" height="292" rx="8" fill="url(#linesentinelagents)" opacity="0.66"/><rect x="660" y="675" width="54" height="85" rx="8" fill="url(#linesentinelagents)" opacity="0.8200000000000001"/><rect x="756" y="622" width="54" height="138" rx="8" fill="url(#linesentinelagents)" opacity="0.5"/><rect x="852" y="569" width="54" height="191" rx="8" fill="url(#linesentinelagents)" opacity="0.66"/><rect x="948" y="516" width="54" height="244" rx="8" fill="url(#linesentinelagents)" opacity="0.8200000000000001"/>
|
||||||
|
<rect x="880" y="200" width="600" height="560" rx="20" fill="hsl(245 30% 12% / 0.5)" stroke="hsl(245 60% 60% / 0.16)"/>
|
||||||
|
<path d="M 1050 620 L 1050 620 L 1104 535.3906523385778 L 1158 489.21163083534515 L 1212 501.10554730457 L 1266 562.3244720582771 L 1320 640.00659821042 L 1374 693.2416333172939 L 1428 693.0709129595766 L 1482 636.6446346063387" fill="none" stroke="url(#linesentinelagents)" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<circle cx="1482" cy="636.6446346063387" r="12" fill="hsl(245 90% 70%)"/>
|
||||||
|
<text x="920" y="270" font-family="monospace" font-size="30" fill="hsl(245 70% 75%)">latency p95</text></svg>
|
||||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,36 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" width="1600" height="1000">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="glowvertexvisiona" cx="28%" cy="18%" r="90%">
|
||||||
|
<stop offset="0%" stop-color="hsl(187 90% 62% / 0.55)"/>
|
||||||
|
<stop offset="45%" stop-color="hsl(215 85% 55% / 0.18)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(187 60% 8% / 0)"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="bgvertexvisiona" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#04060f"/>
|
||||||
|
<stop offset="100%" stop-color="#020308"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="linevertexvisiona" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="hsl(187 90% 65%)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(227 90% 68%)"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="1000" fill="url(#bgvertexvisiona)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#glowvertexvisiona)"/>
|
||||||
|
<g stroke="hsl(187 40% 60% / 0.06)" stroke-width="1">
|
||||||
|
<line x1="0" y1="0" x2="0" y2="1000"/><line x1="100" y1="0" x2="100" y2="1000"/><line x1="200" y1="0" x2="200" y2="1000"/><line x1="300" y1="0" x2="300" y2="1000"/><line x1="400" y1="0" x2="400" y2="1000"/><line x1="500" y1="0" x2="500" y2="1000"/><line x1="600" y1="0" x2="600" y2="1000"/><line x1="700" y1="0" x2="700" y2="1000"/><line x1="800" y1="0" x2="800" y2="1000"/><line x1="900" y1="0" x2="900" y2="1000"/><line x1="1000" y1="0" x2="1000" y2="1000"/><line x1="1100" y1="0" x2="1100" y2="1000"/><line x1="1200" y1="0" x2="1200" y2="1000"/><line x1="1300" y1="0" x2="1300" y2="1000"/><line x1="1400" y1="0" x2="1400" y2="1000"/><line x1="1500" y1="0" x2="1500" y2="1000"/>
|
||||||
|
<line x1="0" y1="0" x2="1600" y2="0"/><line x1="0" y1="100" x2="1600" y2="100"/><line x1="0" y1="200" x2="1600" y2="200"/><line x1="0" y1="300" x2="1600" y2="300"/><line x1="0" y1="400" x2="1600" y2="400"/><line x1="0" y1="500" x2="1600" y2="500"/><line x1="0" y1="600" x2="1600" y2="600"/><line x1="0" y1="700" x2="1600" y2="700"/><line x1="0" y1="800" x2="1600" y2="800"/><line x1="0" y1="900" x2="1600" y2="900"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="80" y="70" width="1440" height="60" rx="14" fill="hsl(187 30% 18% / 0.4)" stroke="hsl(187 60% 60% / 0.25)"/>
|
||||||
|
<circle cx="120" cy="100" r="9" fill="hsl(0 70% 60% / .7)"/>
|
||||||
|
<circle cx="152" cy="100" r="9" fill="hsl(45 80% 60% / .7)"/>
|
||||||
|
<circle cx="184" cy="100" r="9" fill="hsl(140 60% 55% / .7)"/>
|
||||||
|
<rect x="240" y="88" width="420" height="24" rx="12" fill="hsl(187 30% 40% / 0.25)"/>
|
||||||
|
</g>
|
||||||
|
<rect x="120" y="200" width="700" height="560" rx="20" fill="hsl(187 30% 12% / 0.5)" stroke="hsl(187 60% 60% / 0.16)"/>
|
||||||
|
<text x="160" y="270" font-family="monospace" font-size="30" fill="hsl(187 70% 75%)">throughput / day</text>
|
||||||
|
<rect x="180" y="680" width="54" height="80" rx="8" fill="url(#linevertexvisiona)" opacity="0.5"/><rect x="276" y="627" width="54" height="133" rx="8" fill="url(#linevertexvisiona)" opacity="0.66"/><rect x="372" y="574" width="54" height="186" rx="8" fill="url(#linevertexvisiona)" opacity="0.8200000000000001"/><rect x="468" y="521" width="54" height="239" rx="8" fill="url(#linevertexvisiona)" opacity="0.5"/><rect x="564" y="468" width="54" height="292" rx="8" fill="url(#linevertexvisiona)" opacity="0.66"/><rect x="660" y="675" width="54" height="85" rx="8" fill="url(#linevertexvisiona)" opacity="0.8200000000000001"/><rect x="756" y="622" width="54" height="138" rx="8" fill="url(#linevertexvisiona)" opacity="0.5"/><rect x="852" y="569" width="54" height="191" rx="8" fill="url(#linevertexvisiona)" opacity="0.66"/><rect x="948" y="516" width="54" height="244" rx="8" fill="url(#linevertexvisiona)" opacity="0.8200000000000001"/>
|
||||||
|
<rect x="880" y="200" width="600" height="560" rx="20" fill="hsl(187 30% 12% / 0.5)" stroke="hsl(187 60% 60% / 0.16)"/>
|
||||||
|
<path d="M 1050 620 L 1050 620 L 1104 535.3906523385778 L 1158 489.21163083534515 L 1212 501.10554730457 L 1266 562.3244720582771 L 1320 640.00659821042 L 1374 693.2416333172939 L 1428 693.0709129595766 L 1482 636.6446346063387" fill="none" stroke="url(#linevertexvisiona)" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<circle cx="1482" cy="636.6446346063387" r="12" fill="hsl(187 90% 70%)"/>
|
||||||
|
<text x="920" y="270" font-family="monospace" font-size="30" fill="hsl(187 70% 75%)">latency p95</text></svg>
|
||||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,36 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" width="1600" height="1000">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="glowvertexvisionb" cx="28%" cy="18%" r="90%">
|
||||||
|
<stop offset="0%" stop-color="hsl(187 90% 62% / 0.55)"/>
|
||||||
|
<stop offset="45%" stop-color="hsl(215 85% 55% / 0.18)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(187 60% 8% / 0)"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="bgvertexvisionb" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#04060f"/>
|
||||||
|
<stop offset="100%" stop-color="#020308"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="linevertexvisionb" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="hsl(187 90% 65%)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(227 90% 68%)"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="1000" fill="url(#bgvertexvisionb)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#glowvertexvisionb)"/>
|
||||||
|
<g stroke="hsl(187 40% 60% / 0.06)" stroke-width="1">
|
||||||
|
<line x1="0" y1="0" x2="0" y2="1000"/><line x1="100" y1="0" x2="100" y2="1000"/><line x1="200" y1="0" x2="200" y2="1000"/><line x1="300" y1="0" x2="300" y2="1000"/><line x1="400" y1="0" x2="400" y2="1000"/><line x1="500" y1="0" x2="500" y2="1000"/><line x1="600" y1="0" x2="600" y2="1000"/><line x1="700" y1="0" x2="700" y2="1000"/><line x1="800" y1="0" x2="800" y2="1000"/><line x1="900" y1="0" x2="900" y2="1000"/><line x1="1000" y1="0" x2="1000" y2="1000"/><line x1="1100" y1="0" x2="1100" y2="1000"/><line x1="1200" y1="0" x2="1200" y2="1000"/><line x1="1300" y1="0" x2="1300" y2="1000"/><line x1="1400" y1="0" x2="1400" y2="1000"/><line x1="1500" y1="0" x2="1500" y2="1000"/>
|
||||||
|
<line x1="0" y1="0" x2="1600" y2="0"/><line x1="0" y1="100" x2="1600" y2="100"/><line x1="0" y1="200" x2="1600" y2="200"/><line x1="0" y1="300" x2="1600" y2="300"/><line x1="0" y1="400" x2="1600" y2="400"/><line x1="0" y1="500" x2="1600" y2="500"/><line x1="0" y1="600" x2="1600" y2="600"/><line x1="0" y1="700" x2="1600" y2="700"/><line x1="0" y1="800" x2="1600" y2="800"/><line x1="0" y1="900" x2="1600" y2="900"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="80" y="70" width="1440" height="60" rx="14" fill="hsl(187 30% 18% / 0.4)" stroke="hsl(187 60% 60% / 0.25)"/>
|
||||||
|
<circle cx="120" cy="100" r="9" fill="hsl(0 70% 60% / .7)"/>
|
||||||
|
<circle cx="152" cy="100" r="9" fill="hsl(45 80% 60% / .7)"/>
|
||||||
|
<circle cx="184" cy="100" r="9" fill="hsl(140 60% 55% / .7)"/>
|
||||||
|
<rect x="240" y="88" width="420" height="24" rx="12" fill="hsl(187 30% 40% / 0.25)"/>
|
||||||
|
</g>
|
||||||
|
<path d="M 350 305 C 470 305, 470 215, 590 215" fill="none" stroke="url(#linevertexvisionb)" stroke-width="4" opacity="0.7"/><path d="M 350 305 C 470 305, 470 405, 590 405" fill="none" stroke="url(#linevertexvisionb)" stroke-width="4" opacity="0.7"/><path d="M 820 215 C 940 215, 940 305, 1060 305" fill="none" stroke="url(#linevertexvisionb)" stroke-width="4" opacity="0.7"/><path d="M 820 405 C 940 405, 940 305, 1060 305" fill="none" stroke="url(#linevertexvisionb)" stroke-width="4" opacity="0.7"/><path d="M 1290 305 C 1290 305, 1290 305, 1290 305" fill="none" stroke="url(#linevertexvisionb)" stroke-width="4" opacity="0.7"/>
|
||||||
|
<g><rect x="120" y="250" width="230" height="110" rx="18" fill="hsl(187 35% 14% / 0.7)" stroke="hsl(187 70% 62% / 0.5)"/><text x="235" y="312" text-anchor="middle" font-family="monospace" font-size="26" fill="hsl(187 70% 80%)">ingest</text></g>
|
||||||
|
<g><rect x="590" y="160" width="230" height="110" rx="18" fill="hsl(187 35% 14% / 0.7)" stroke="hsl(187 70% 62% / 0.5)"/><text x="705" y="222" text-anchor="middle" font-family="monospace" font-size="26" fill="hsl(187 70% 80%)">embed</text></g>
|
||||||
|
<g><rect x="590" y="350" width="230" height="110" rx="18" fill="hsl(187 35% 14% / 0.7)" stroke="hsl(187 70% 62% / 0.5)"/><text x="705" y="412" text-anchor="middle" font-family="monospace" font-size="26" fill="hsl(187 70% 80%)">retrieve</text></g>
|
||||||
|
<g><rect x="1060" y="250" width="230" height="110" rx="18" fill="hsl(187 35% 14% / 0.7)" stroke="hsl(187 70% 62% / 0.5)"/><text x="1175" y="312" text-anchor="middle" font-family="monospace" font-size="26" fill="hsl(187 70% 80%)">rerank</text></g>
|
||||||
|
<g><rect x="1300" y="250" width="230" height="110" rx="18" fill="hsl(187 35% 14% / 0.7)" stroke="hsl(187 70% 62% / 0.5)"/><text x="1415" y="312" text-anchor="middle" font-family="monospace" font-size="26" fill="hsl(187 70% 80%)">generate</text></g>
|
||||||
|
<g opacity="0.5"><circle cx="260" cy="700" r="6" fill="hsl(187 90% 70%)"/><circle cx="460" cy="700" r="6" fill="hsl(187 90% 70%)"/><circle cx="660" cy="700" r="6" fill="hsl(187 90% 70%)"/><circle cx="860" cy="700" r="6" fill="hsl(187 90% 70%)"/><circle cx="1060" cy="700" r="6" fill="hsl(187 90% 70%)"/><circle cx="1260" cy="700" r="6" fill="hsl(187 90% 70%)"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,31 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" width="1600" height="1000">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="glowvertexvisionc" cx="28%" cy="18%" r="90%">
|
||||||
|
<stop offset="0%" stop-color="hsl(187 90% 62% / 0.55)"/>
|
||||||
|
<stop offset="45%" stop-color="hsl(215 85% 55% / 0.18)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(187 60% 8% / 0)"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="bgvertexvisionc" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#04060f"/>
|
||||||
|
<stop offset="100%" stop-color="#020308"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="linevertexvisionc" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="hsl(187 90% 65%)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(227 90% 68%)"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="1000" fill="url(#bgvertexvisionc)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#glowvertexvisionc)"/>
|
||||||
|
<g stroke="hsl(187 40% 60% / 0.06)" stroke-width="1">
|
||||||
|
<line x1="0" y1="0" x2="0" y2="1000"/><line x1="100" y1="0" x2="100" y2="1000"/><line x1="200" y1="0" x2="200" y2="1000"/><line x1="300" y1="0" x2="300" y2="1000"/><line x1="400" y1="0" x2="400" y2="1000"/><line x1="500" y1="0" x2="500" y2="1000"/><line x1="600" y1="0" x2="600" y2="1000"/><line x1="700" y1="0" x2="700" y2="1000"/><line x1="800" y1="0" x2="800" y2="1000"/><line x1="900" y1="0" x2="900" y2="1000"/><line x1="1000" y1="0" x2="1000" y2="1000"/><line x1="1100" y1="0" x2="1100" y2="1000"/><line x1="1200" y1="0" x2="1200" y2="1000"/><line x1="1300" y1="0" x2="1300" y2="1000"/><line x1="1400" y1="0" x2="1400" y2="1000"/><line x1="1500" y1="0" x2="1500" y2="1000"/>
|
||||||
|
<line x1="0" y1="0" x2="1600" y2="0"/><line x1="0" y1="100" x2="1600" y2="100"/><line x1="0" y1="200" x2="1600" y2="200"/><line x1="0" y1="300" x2="1600" y2="300"/><line x1="0" y1="400" x2="1600" y2="400"/><line x1="0" y1="500" x2="1600" y2="500"/><line x1="0" y1="600" x2="1600" y2="600"/><line x1="0" y1="700" x2="1600" y2="700"/><line x1="0" y1="800" x2="1600" y2="800"/><line x1="0" y1="900" x2="1600" y2="900"/>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(560 120)">
|
||||||
|
<rect width="480" height="780" rx="56" fill="hsl(187 30% 10% / 0.9)" stroke="hsl(187 60% 60% / 0.35)" stroke-width="3"/>
|
||||||
|
<rect x="22" y="22" width="436" height="736" rx="40" fill="#04060f"/>
|
||||||
|
<rect x="180" y="40" width="120" height="22" rx="11" fill="hsl(187 30% 25%)"/>
|
||||||
|
<circle cx="240" cy="230" r="86" fill="none" stroke="url(#linevertexvisionc)" stroke-width="10"/>
|
||||||
|
<circle cx="240" cy="230" r="56" fill="hsl(187 80% 60% / 0.18)"/>
|
||||||
|
<rect x="70" y="380" width="340" height="46" rx="14" fill="hsl(187 35% 18% / 0.8)"/><rect x="70" y="460" width="300" height="46" rx="14" fill="hsl(187 35% 18% / 0.8)"/><rect x="70" y="540" width="260" height="46" rx="14" fill="hsl(187 35% 18% / 0.8)"/><rect x="70" y="620" width="220" height="46" rx="14" fill="hsl(187 35% 18% / 0.8)"/>
|
||||||
|
<rect x="70" y="700" width="340" height="56" rx="18" fill="url(#linevertexvisionc)"/>
|
||||||
|
</g></svg>
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,36 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" width="1600" height="1000">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="glowvertexvision" cx="28%" cy="18%" r="90%">
|
||||||
|
<stop offset="0%" stop-color="hsl(187 90% 62% / 0.55)"/>
|
||||||
|
<stop offset="45%" stop-color="hsl(215 85% 55% / 0.18)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(187 60% 8% / 0)"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="bgvertexvision" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#04060f"/>
|
||||||
|
<stop offset="100%" stop-color="#020308"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="linevertexvision" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="hsl(187 90% 65%)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(227 90% 68%)"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="1000" fill="url(#bgvertexvision)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#glowvertexvision)"/>
|
||||||
|
<g stroke="hsl(187 40% 60% / 0.06)" stroke-width="1">
|
||||||
|
<line x1="0" y1="0" x2="0" y2="1000"/><line x1="100" y1="0" x2="100" y2="1000"/><line x1="200" y1="0" x2="200" y2="1000"/><line x1="300" y1="0" x2="300" y2="1000"/><line x1="400" y1="0" x2="400" y2="1000"/><line x1="500" y1="0" x2="500" y2="1000"/><line x1="600" y1="0" x2="600" y2="1000"/><line x1="700" y1="0" x2="700" y2="1000"/><line x1="800" y1="0" x2="800" y2="1000"/><line x1="900" y1="0" x2="900" y2="1000"/><line x1="1000" y1="0" x2="1000" y2="1000"/><line x1="1100" y1="0" x2="1100" y2="1000"/><line x1="1200" y1="0" x2="1200" y2="1000"/><line x1="1300" y1="0" x2="1300" y2="1000"/><line x1="1400" y1="0" x2="1400" y2="1000"/><line x1="1500" y1="0" x2="1500" y2="1000"/>
|
||||||
|
<line x1="0" y1="0" x2="1600" y2="0"/><line x1="0" y1="100" x2="1600" y2="100"/><line x1="0" y1="200" x2="1600" y2="200"/><line x1="0" y1="300" x2="1600" y2="300"/><line x1="0" y1="400" x2="1600" y2="400"/><line x1="0" y1="500" x2="1600" y2="500"/><line x1="0" y1="600" x2="1600" y2="600"/><line x1="0" y1="700" x2="1600" y2="700"/><line x1="0" y1="800" x2="1600" y2="800"/><line x1="0" y1="900" x2="1600" y2="900"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="80" y="70" width="1440" height="60" rx="14" fill="hsl(187 30% 18% / 0.4)" stroke="hsl(187 60% 60% / 0.25)"/>
|
||||||
|
<circle cx="120" cy="100" r="9" fill="hsl(0 70% 60% / .7)"/>
|
||||||
|
<circle cx="152" cy="100" r="9" fill="hsl(45 80% 60% / .7)"/>
|
||||||
|
<circle cx="184" cy="100" r="9" fill="hsl(140 60% 55% / .7)"/>
|
||||||
|
<rect x="240" y="88" width="420" height="24" rx="12" fill="hsl(187 30% 40% / 0.25)"/>
|
||||||
|
</g>
|
||||||
|
<rect x="120" y="200" width="700" height="560" rx="20" fill="hsl(187 30% 12% / 0.5)" stroke="hsl(187 60% 60% / 0.16)"/>
|
||||||
|
<text x="160" y="270" font-family="monospace" font-size="30" fill="hsl(187 70% 75%)">throughput / day</text>
|
||||||
|
<rect x="180" y="680" width="54" height="80" rx="8" fill="url(#linevertexvision)" opacity="0.5"/><rect x="276" y="627" width="54" height="133" rx="8" fill="url(#linevertexvision)" opacity="0.66"/><rect x="372" y="574" width="54" height="186" rx="8" fill="url(#linevertexvision)" opacity="0.8200000000000001"/><rect x="468" y="521" width="54" height="239" rx="8" fill="url(#linevertexvision)" opacity="0.5"/><rect x="564" y="468" width="54" height="292" rx="8" fill="url(#linevertexvision)" opacity="0.66"/><rect x="660" y="675" width="54" height="85" rx="8" fill="url(#linevertexvision)" opacity="0.8200000000000001"/><rect x="756" y="622" width="54" height="138" rx="8" fill="url(#linevertexvision)" opacity="0.5"/><rect x="852" y="569" width="54" height="191" rx="8" fill="url(#linevertexvision)" opacity="0.66"/><rect x="948" y="516" width="54" height="244" rx="8" fill="url(#linevertexvision)" opacity="0.8200000000000001"/>
|
||||||
|
<rect x="880" y="200" width="600" height="560" rx="20" fill="hsl(187 30% 12% / 0.5)" stroke="hsl(187 60% 60% / 0.16)"/>
|
||||||
|
<path d="M 1050 620 L 1050 620 L 1104 535.3906523385778 L 1158 489.21163083534515 L 1212 501.10554730457 L 1266 562.3244720582771 L 1320 640.00659821042 L 1374 693.2416333172939 L 1428 693.0709129595766 L 1482 636.6446346063387" fill="none" stroke="url(#linevertexvision)" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<circle cx="1482" cy="636.6446346063387" r="12" fill="hsl(187 90% 70%)"/>
|
||||||
|
<text x="920" y="270" font-family="monospace" font-size="30" fill="hsl(187 70% 75%)">latency p95</text></svg>
|
||||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,117 @@
|
|||||||
|
// Generates abstract dark "product shot" SVGs for the portfolio gallery.
|
||||||
|
// Run once: `node scripts/gen-portfolio-art.mjs`. Output -> public/portfolio/<id>/*.svg
|
||||||
|
// These are tasteful placeholders; the admin panel can upload real screenshots later.
|
||||||
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const OUT = join(process.cwd(), 'public', 'portfolio');
|
||||||
|
|
||||||
|
// id -> base hue (matches the accent assigned in the dictionary)
|
||||||
|
const PROJECTS = {
|
||||||
|
'atlas-rag': 199, // electric
|
||||||
|
'sentinel-agents': 245, // violet
|
||||||
|
'vertex-vision': 187, // cyan
|
||||||
|
'mirage-mobile': 292, // magenta
|
||||||
|
'flux-stream': 158, // emerald
|
||||||
|
'oracle-forecast': 205, // electric-2
|
||||||
|
};
|
||||||
|
|
||||||
|
const W = 1600;
|
||||||
|
const H = 1000;
|
||||||
|
|
||||||
|
const defs = (hue, id) => `
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="glow${id}" cx="28%" cy="18%" r="90%">
|
||||||
|
<stop offset="0%" stop-color="hsl(${hue} 90% 62% / 0.55)"/>
|
||||||
|
<stop offset="45%" stop-color="hsl(${(hue + 28) % 360} 85% 55% / 0.18)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(${hue} 60% 8% / 0)"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="bg${id}" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#04060f"/>
|
||||||
|
<stop offset="100%" stop-color="#020308"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="line${id}" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="hsl(${hue} 90% 65%)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(${(hue + 40) % 360} 90% 68%)"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="${W}" height="${H}" fill="url(#bg${id})"/>
|
||||||
|
<rect width="${W}" height="${H}" fill="url(#glow${id})"/>
|
||||||
|
<g stroke="hsl(${hue} 40% 60% / 0.06)" stroke-width="1">
|
||||||
|
${Array.from({ length: 16 }, (_, i) => `<line x1="${i * 100}" y1="0" x2="${i * 100}" y2="${H}"/>`).join('')}
|
||||||
|
${Array.from({ length: 10 }, (_, i) => `<line x1="0" y1="${i * 100}" x2="${W}" y2="${i * 100}"/>`).join('')}
|
||||||
|
</g>`;
|
||||||
|
|
||||||
|
const chrome = (hue, id) => `
|
||||||
|
<g>
|
||||||
|
<rect x="80" y="70" width="${W - 160}" height="60" rx="14" fill="hsl(${hue} 30% 18% / 0.4)" stroke="hsl(${hue} 60% 60% / 0.25)"/>
|
||||||
|
<circle cx="120" cy="100" r="9" fill="hsl(0 70% 60% / .7)"/>
|
||||||
|
<circle cx="152" cy="100" r="9" fill="hsl(45 80% 60% / .7)"/>
|
||||||
|
<circle cx="184" cy="100" r="9" fill="hsl(140 60% 55% / .7)"/>
|
||||||
|
<rect x="240" y="88" width="420" height="24" rx="12" fill="hsl(${hue} 30% 40% / 0.25)"/>
|
||||||
|
</g>`;
|
||||||
|
|
||||||
|
function dashboard(hue, id) {
|
||||||
|
const bars = Array.from({ length: 9 }, (_, i) => {
|
||||||
|
const bh = 80 + ((i * 53) % 260);
|
||||||
|
return `<rect x="${180 + i * 96}" y="${760 - bh}" width="54" height="${bh}" rx="8" fill="url(#line${id})" opacity="${0.5 + (i % 3) * 0.16}"/>`;
|
||||||
|
}).join('');
|
||||||
|
const path = `M 1050 620 ${Array.from({ length: 9 }, (_, i) => `L ${1050 + i * 54} ${620 - Math.sin(i / 1.4) * 120 - i * 6}`).join(' ')}`;
|
||||||
|
return `${defs(hue, id)}${chrome(hue, id)}
|
||||||
|
<rect x="120" y="200" width="700" height="560" rx="20" fill="hsl(${hue} 30% 12% / 0.5)" stroke="hsl(${hue} 60% 60% / 0.16)"/>
|
||||||
|
<text x="160" y="270" font-family="monospace" font-size="30" fill="hsl(${hue} 70% 75%)">throughput / day</text>
|
||||||
|
${bars}
|
||||||
|
<rect x="880" y="200" width="600" height="560" rx="20" fill="hsl(${hue} 30% 12% / 0.5)" stroke="hsl(${hue} 60% 60% / 0.16)"/>
|
||||||
|
<path d="${path}" fill="none" stroke="url(#line${id})" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<circle cx="1482" cy="${620 - Math.sin(8 / 1.4) * 120 - 48}" r="12" fill="hsl(${hue} 90% 70%)"/>
|
||||||
|
<text x="920" y="270" font-family="monospace" font-size="30" fill="hsl(${hue} 70% 75%)">latency p95</text>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flow(hue, id) {
|
||||||
|
const node = (x, y, label) =>
|
||||||
|
`<g><rect x="${x}" y="${y}" width="230" height="110" rx="18" fill="hsl(${hue} 35% 14% / 0.7)" stroke="hsl(${hue} 70% 62% / 0.5)"/><text x="${x + 115}" y="${y + 62}" text-anchor="middle" font-family="monospace" font-size="26" fill="hsl(${hue} 70% 80%)">${label}</text></g>`;
|
||||||
|
const edge = (x1, y1, x2, y2) =>
|
||||||
|
`<path d="M ${x1} ${y1} C ${(x1 + x2) / 2} ${y1}, ${(x1 + x2) / 2} ${y2}, ${x2} ${y2}" fill="none" stroke="url(#line${id})" stroke-width="4" opacity="0.7"/>`;
|
||||||
|
return `${defs(hue, id)}${chrome(hue, id)}
|
||||||
|
${edge(350, 305, 590, 215)}${edge(350, 305, 590, 405)}${edge(820, 215, 1060, 305)}${edge(820, 405, 1060, 305)}${edge(1290, 305, 1290, 305)}
|
||||||
|
${node(120, 250, 'ingest')}
|
||||||
|
${node(590, 160, 'embed')}
|
||||||
|
${node(590, 350, 'retrieve')}
|
||||||
|
${node(1060, 250, 'rerank')}
|
||||||
|
${node(1300, 250, 'generate')}
|
||||||
|
<g opacity="0.5">${Array.from({ length: 6 }, (_, i) => `<circle cx="${260 + i * 200}" cy="700" r="6" fill="hsl(${hue} 90% 70%)"/>`).join('')}</g>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mobile(hue, id) {
|
||||||
|
return `${defs(hue, id)}
|
||||||
|
<g transform="translate(560 120)">
|
||||||
|
<rect width="480" height="780" rx="56" fill="hsl(${hue} 30% 10% / 0.9)" stroke="hsl(${hue} 60% 60% / 0.35)" stroke-width="3"/>
|
||||||
|
<rect x="22" y="22" width="436" height="736" rx="40" fill="#04060f"/>
|
||||||
|
<rect x="180" y="40" width="120" height="22" rx="11" fill="hsl(${hue} 30% 25%)"/>
|
||||||
|
<circle cx="240" cy="230" r="86" fill="none" stroke="url(#line${id})" stroke-width="10"/>
|
||||||
|
<circle cx="240" cy="230" r="56" fill="hsl(${hue} 80% 60% / 0.18)"/>
|
||||||
|
${Array.from({ length: 4 }, (_, i) => `<rect x="70" y="${380 + i * 80}" width="${340 - i * 40}" height="46" rx="14" fill="hsl(${hue} 35% 18% / 0.8)"/>`).join('')}
|
||||||
|
<rect x="70" y="700" width="340" height="56" rx="18" fill="url(#line${id})"/>
|
||||||
|
</g>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHOTS = [dashboard, flow, mobile];
|
||||||
|
|
||||||
|
mkdirSync(OUT, { recursive: true });
|
||||||
|
for (const [id, hue] of Object.entries(PROJECTS)) {
|
||||||
|
const dir = join(OUT, id);
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
// cover = dashboard variant; gallery = all three variants
|
||||||
|
const files = {
|
||||||
|
'cover.svg': dashboard(hue, id.replace(/\W/g, '')),
|
||||||
|
'01.svg': dashboard(hue, id.replace(/\W/g, '') + 'a'),
|
||||||
|
'02.svg': flow(hue, id.replace(/\W/g, '') + 'b'),
|
||||||
|
'03.svg': mobile(hue, id.replace(/\W/g, '') + 'c'),
|
||||||
|
};
|
||||||
|
for (const [name, inner] of Object.entries(files)) {
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">${inner}</svg>`;
|
||||||
|
writeFileSync(join(dir, name), svg);
|
||||||
|
}
|
||||||
|
console.log('wrote', id);
|
||||||
|
}
|
||||||
|
console.log('done');
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import type { Config } from 'tailwindcss';
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: [
|
||||||
|
'./app/**/*.{ts,tsx}',
|
||||||
|
'./components/**/*.{ts,tsx}',
|
||||||
|
'./lib/**/*.{ts,tsx}',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
// Deep space base
|
||||||
|
base: {
|
||||||
|
DEFAULT: '#020510',
|
||||||
|
900: '#020510',
|
||||||
|
800: '#050a1a',
|
||||||
|
700: '#0a1224',
|
||||||
|
600: '#0f1b33',
|
||||||
|
500: '#1a2747',
|
||||||
|
},
|
||||||
|
// Brand accents per spec
|
||||||
|
electric: '#38bdf8', // primary
|
||||||
|
violet: '#818cf8', // secondary
|
||||||
|
magenta: '#e879f9', // AI / LLM highlight
|
||||||
|
emerald: '#34d399', // infra / success
|
||||||
|
cyan: '#22d3ee', // Google Stack
|
||||||
|
// Surface / borders
|
||||||
|
glass: 'rgba(255, 255, 255, 0.03)',
|
||||||
|
hairline: 'rgba(56, 189, 248, 0.12)',
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
// Wired in via next/font/local CSS variables in layout.tsx.
|
||||||
|
// Vazirmatn is split Arabic + Latin — list Arabic first so Persian
|
||||||
|
// glyphs win and Latin chars fall through.
|
||||||
|
sans: ['var(--font-vaz-ar)', 'var(--font-vaz-lat)', 'var(--font-syne)', 'system-ui', 'sans-serif'],
|
||||||
|
display: ['var(--font-syne)', 'var(--font-vaz-ar)', 'var(--font-vaz-lat)', 'sans-serif'],
|
||||||
|
fa: ['var(--font-vaz-ar)', 'var(--font-vaz-lat)', 'sans-serif'],
|
||||||
|
en: ['var(--font-syne)', 'sans-serif'],
|
||||||
|
mono: ['var(--font-space-mono)', 'ui-monospace', 'SFMono-Regular', 'monospace'],
|
||||||
|
},
|
||||||
|
backgroundImage: {
|
||||||
|
'grid-faint':
|
||||||
|
'linear-gradient(rgba(56,189,248,0.05) 1px, transparent 1px), linear-gradient(90deg, rgba(56,189,248,0.05) 1px, transparent 1px)',
|
||||||
|
'radial-aurora':
|
||||||
|
'radial-gradient(ellipse 80% 50% at 50% -20%, rgba(56,189,248,0.18), transparent 60%), radial-gradient(ellipse 60% 40% at 80% 10%, rgba(232,121,249,0.10), transparent 60%), radial-gradient(ellipse 60% 40% at 10% 30%, rgba(129,140,248,0.10), transparent 60%)',
|
||||||
|
'brand-gradient':
|
||||||
|
'linear-gradient(135deg, #38bdf8 0%, #818cf8 45%, #e879f9 100%)',
|
||||||
|
},
|
||||||
|
backgroundSize: {
|
||||||
|
grid: '64px 64px',
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
'glow-electric': '0 0 40px -8px rgba(56,189,248,0.55)',
|
||||||
|
'glow-magenta': '0 0 40px -8px rgba(232,121,249,0.55)',
|
||||||
|
'glow-violet': '0 0 40px -8px rgba(129,140,248,0.55)',
|
||||||
|
'glow-emerald': '0 0 40px -8px rgba(52,211,153,0.55)',
|
||||||
|
'glass-inner': 'inset 0 1px 0 0 rgba(255,255,255,0.06)',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
'pulse-dot': {
|
||||||
|
'0%, 100%': { opacity: '1', transform: 'scale(1)' },
|
||||||
|
'50%': { opacity: '0.6', transform: 'scale(1.4)' },
|
||||||
|
},
|
||||||
|
'gradient-pan': {
|
||||||
|
'0%, 100%': { backgroundPosition: '0% 50%' },
|
||||||
|
'50%': { backgroundPosition: '100% 50%' },
|
||||||
|
},
|
||||||
|
'caret-blink': {
|
||||||
|
'0%, 49%': { opacity: '1' },
|
||||||
|
'50%, 100%': { opacity: '0' },
|
||||||
|
},
|
||||||
|
'float-y': {
|
||||||
|
'0%, 100%': { transform: 'translateY(0)' },
|
||||||
|
'50%': { transform: 'translateY(-6px)' },
|
||||||
|
},
|
||||||
|
'flow-dash': {
|
||||||
|
'0%': { strokeDashoffset: '0' },
|
||||||
|
'100%': { strokeDashoffset: '-66' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'pulse-dot': 'pulse-dot 1.8s ease-in-out infinite',
|
||||||
|
'gradient-pan': 'gradient-pan 8s ease-in-out infinite',
|
||||||
|
'caret-blink': 'caret-blink 1s steps(2) infinite',
|
||||||
|
'float-y': 'float-y 4s ease-in-out infinite',
|
||||||
|
'flow-dash': 'flow-dash 1.1s linear infinite',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||