Compare commits

...

28 Commits

Author SHA1 Message Date
soroush.asadi 4c759851ce Redesign homepage as an Apple-style bento grid
deploy / deploy (push) Successful in 24s
Replace the flat minimal sections with a bento layout (ui-ux-pro-max "Bento
Box Grid" style) while keeping the light theme and single blue accent.

- Bento grid system in CSS: 4-col -> 2-col -> 1-col, varied spans (span-2,
  row-2), 22px tiles, hover lift, dark/accent/tint tile variants
- Hero is now a bento: dark name/anchor tile + value-prop tile + accent
  availability tile + social tile
- Services: bento tiles with a tinted featured tile and a dark AI tile
- Stack: four category tiles (AI/ML tinted)
- Portfolio: featured 2x2 tile + colored covers per project
- Pipeline / expertise / blog / contact kept as different layouts for rhythm

Verified: 4-col desktop, clean 1-col mobile with no horizontal overflow,
no console errors. Tailwind bundle rebuilt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 15:52:59 +03:30
soroush.asadi 7a7542d77b Translate blog articles to Persian (locale-aware bodies)
deploy / deploy (push) Successful in 25s
The 6 blog post bodies were English-only even in Persian mode. Add natural
Persian translations and select the body by locale (IsFa ? BodyFa : BodyEn),
so a Persian reader gets a fully Persian article. Also removed the em/en
dashes from the English bodies (taste compliance) and stripped stray bidi
control chars (kept ZWNJ).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 12:12:36 +03:30
soroush.asadi 154e06ef54 Localize the budget dropdown to Persian
deploy / deploy (push) Successful in 24s
The budget options were English/USD even in Persian mode. Make them
locale-aware (stable option values) so the FA form is fully Persian.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 11:13:34 +03:30
soroush.asadi ffba74a727 Cache-busting so deploys are visible immediately
deploy / deploy (push) Successful in 26s
The site sits behind a CDN and shipped static assets with no Cache-Control
and no versioning, so browsers/CDN kept serving stale css/js after a deploy
(the "design didn't change" symptom).

- HTML responses now send Cache-Control: no-cache, no-store, must-revalidate
  so the page itself is always revalidated.
- Static assets get a long cache and are fingerprinted via asp-append-version
  (/css/site.css?v=<contenthash>), so they bust automatically on every change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 11:03:37 +03:30
soroush.asadi 8896740895 Harden UX & accessibility (ui-ux-pro-max pass)
deploy / deploy (push) Successful in 24s
Audited the site with the ui-ux-pro-max skill. It validated the brand blue
(#2563EB == its SaaS primary) but flagged real high-severity gaps:

- Contrast: muted grays were zinc-400 (~2.8:1, fails WCAG AA). Bumped the
  muted token + all text-zinc-400 to zinc-500 (#71717a, ~4.6:1).
- Touch targets: social buttons 38px -> 44x44 (meets 44pt minimum).
- Cursor + disabled: cursor-pointer on buttons; disabled state dims + blocks.
- Form a11y: required-field asterisks (name/service/budget/message),
  autocomplete on name/company, and role=status aria-live=polite on the
  submit status so screen readers announce success/error.

Kept Syne + system fonts and the blue accent (skill suggested Inter + an
AI-purple palette its own anti-patterns reject). Rebuilt Tailwind bundles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 05:33:44 +03:30
soroush.asadi 93f7873dd1 Reposition from AI-only to engineering + apps + AI
deploy / deploy (push) Successful in 23s
Broaden the messaging so AI is one strong pillar, not the whole story
(matches the real portfolio: web/SaaS, mobile, a game, plus AI tools).

- Hero: "software, enterprise apps, and AI solutions"; role is now
  "Software & AI Engineer, Solution Architect"
- Services reframed: Web & enterprise apps, Mobile apps, Solution
  architecture & cloud, AI solutions, Automation & integrations, Strategy
  (replaces the six AI-centric ones; new "apps" icon)
- Expertise areas lead with architecture + web/enterprise apps, AI as one
- Contact service options, meta description, title, footer blurb updated

English and Persian both. No CSS/JS changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 04:03:24 +03:30
soroush.asadi 255e8d25e5 Humanize all Persian copy across the site
deploy / deploy (push) Successful in 24s
Rewrite the FA strings sitewide in natural, human Persian (English unchanged),
removing translation calques like «معمار راهکار», «هوش مصنوعی تولیدی»,
«موارد کاربری», «چرخه‌های هیجان», «استقرار در تولید», «محیط تولید».

Covers: hero, services, pipeline, stack, expertise, portfolio, blog, contact
(Index), nav/meta/footer (_Layout), the /blog list + per-post FA titles
(BlogIndexModel, PostModel). Also removed two stray English em-dashes in the
blog excerpts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 03:50:21 +03:30
soroush.asadi 29b5f07ebf Humanize Persian hero copy
deploy / deploy (push) Successful in 24s
The FA hero read as machine-translated calque. Replace with natural Persian:
- subhead: «هوش مصنوعی‌ای می‌سازم که فقط روی کاغذ نمی‌ماند؛ از طراحی تا اجرا، در مقیاس سازمانی.»
- role: «مهندس هوش مصنوعی و معمار نرم‌افزار.» (was the calque «معمار راهکار»)

English copy unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 03:32:53 +03:30
soroush.asadi b721f01e14 Replace placeholder portfolio with real projects
deploy / deploy (push) Successful in 24s
Swap the fabricated case studies (Atlas/Sentinel/etc. with invented metrics)
for the four real shipped products, each linking to its live site:

- Hamkadr (hamkadr.ir) - healthcare staffing marketplace
- Meezi (meezi.ir) - cafe/restaurant management SaaS
- Barge Vasat (bargevasat.ir) - online Hokm card game
- Flatrender (flatrender.ir) - AI video/image studio

Cards are now external links (2x2 grid), no invented numbers or clients.
Regenerated the purged Tailwind bundle for the new classes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 03:20:52 +03:30
soroush.asadi 33efeac98f Replace Tailwind Play CDN with a prebuilt, purged stylesheet
deploy / deploy (push) Successful in 37s
The runtime CDN (cdn.tailwindcss.com) is not production-grade: FOUC, no
purging, and an external request that is slow/blocked from some networks.

- Add Tailwind v3 build (package.json `npm run build`) with two scoped configs:
  public (accent + zinc) -> wwwroot/css/tailwind.css, and admin (dark base/
  electric/violet/emerald, separate to avoid the emerald flat-vs-scale clash)
  -> wwwroot/css/tailwind-admin.css. Both minified + content-purged.
- Layouts now link the built CSS instead of the CDN script; built artifacts
  are committed so Docker/CI need no Node step. node_modules stays ignored.
- Verified: utilities (incl. arbitrary values like aspect-[16/9], grid-cols-
  [8rem_1fr]) resolve; public + admin render; no console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 03:06:24 +03:30
soroush.asadi cfff934bdd Fix SQLite advisory: bump SQLitePCLRaw to 3.0.x
The transitive SQLitePCLRaw.lib.e_sqlite3 2.1.11 (via EF Core 10 Sqlite) is
flagged High by GHSA-2m69-gcr7-jv3q, and the 2.x line has no patched release
(first_patched_version: null). Pin SQLitePCLRaw.bundle_e_sqlite3 3.0.3, which
is outside the vulnerable range (<= 2.1.11). Runtime-verified: EnsureCreated
and a DB read both succeed; `dotnet list package --vulnerable` is now clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 03:06:10 +03:30
soroush.asadi dd882287df Elevate public site UX: premium hero, motion, scrollspy, shared footer
deploy / deploy (push) Successful in 6m30s
Redesign-preserve pass on the light editorial theme (dials 7/5/3):

- Hero: live availability status, accent value-prop, role line, social row,
  staggered entrance
- Motion (all motivated, reduced-motion safe): CSS scroll-driven reading
  progress bar, scrollspy nav with animated underline, CTA/blog arrow nudges,
  service hover accent rule, portfolio cover scale, card lift
- Shared multi-column footer across home + blog (brand, nav, contact, social)
- Fix anchor scroll offset under the fixed navbar (scroll-margin-top)
- Wire real social: LinkedIn, Instagram, email (code.soroush@gmail.com)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 02:48:22 +03:30
soroush.asadi 5cc9ed976c Add logo mark and use it for nav, footer, and favicon
deploy / deploy (push) Successful in 24s
Geometric "S" monogram in a near-black rounded tile with a single #2563eb
accent dot at the bottom-left. Crisp from 16px (favicon) up. All references
already point to /logo-mark.svg, so nav, footer, and favicon update together.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 05:40:48 +03:30
soroush.asadi 97bd2a12df Redesign public site: minimal light editorial theme
deploy / deploy (push) Successful in 1m20s
Full design refactor of the public surface (home, blog, layout) using the
taste-skill anti-slop rules. Admin CMS is untouched.

- Single locked light theme: #fafafa bg, #18181b text, one accent #2563eb
- Syne headings + system body + Vazirmatn (fa); hairline rules, no glows/cards
- Remove AI tells: 5-colour palette, gradient text, neon glows, custom cursor,
  particle canvas, typewriter, scroll cue, per-section eyebrows, progress bars
- Replace window scroll listener with an IntersectionObserver sentinel
- 8 distinct section layouts; portfolio uses typographic covers (no broken imgs)
- Zero em-dashes in visible copy; fix relative-path-safe asset refs
- Add missing wwwroot/logo-mark.svg (was 404)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 05:16:06 +03:30
soroush.asadi a55c75b928 CI
deploy / deploy (push) Successful in 12s
2026-06-01 09:22:33 +03:30
soroush.asadi fcc476e432 Fix critical port conflict: move portfolio off Gitea's port 3000
deploy / deploy (push) Successful in 12s
Host port 3000 is Gitea (git.soroushasadi.com proxies to :3000). The
portfolio was publishing 3000:3000 AND the deploy had a "Free Port 3000"
step that force-removed every container on :3000 — which evicted the
Gitea container.

- compose: publish 3020:3000 instead of 3000:3000
- deploy: delete the "Free Port 3000" step entirely; compose recreates
  only our own named container and must never touch other stacks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 08:43:23 +03:30
soroush.asadi e79d2d6108 Ignore stale Next.js build output (.next/, node_modules/)
deploy / deploy (push) Failing after 44m43s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 08:30:06 +03:30
soroush.asadi 059c67bf76 Fix deploy: free port 3000 by removing any container publishing it
The previous name-based stop only removed soroushasadi-site, but the
container holding :3000 can have a different name (old Next.js build or
an orphan from a previously-named compose project), so the bind kept
failing. Now we remove every container publishing :3000 by filter, then
also remove by our known name as a fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 08:29:14 +03:30
soroush.asadi e5094b53ff Fix deploy: stop old container before starting new one
deploy / deploy (push) Failing after 2s
Port 3000 was already allocated by the previous container from the
Next.js era. --remove-orphans only removes containers within the same
compose project, so the old one survived. Explicitly stop+rm the named
container before docker compose up so the port is always free.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 08:17:11 +03:30
soroush.asadi f2d6300d72 Fix: rename Data/ to Database/ to avoid gitignore collision
deploy / deploy (push) Failing after 13s
.gitignore has '/data' which Windows git (case-insensitive) silently
matched '/Data/', so AppDbContext.cs was never committed and the Docker
build (Linux, case-sensitive) failed with CS0234 'Data' not found.
Renaming the directory to 'Database/' sidesteps the collision.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 08:00:54 +03:30
soroush.asadi 1b3a8b493e Rewrite: Next.js → ASP.NET Core 10 Razor Pages
deploy / deploy (push) Failing after 1m21s
Full rewrite of the portfolio site from Next.js 14 to .NET 10:

- ASP.NET Core 10 Razor Pages, no Node.js dependency
- EF Core 10 + SQLite (same schema as before — data survives upgrade)
- Cookie authentication (same single-password model)
- Resend contact form via HttpClient
- Bilingual FA/EN via locale cookie + BasePageModel
- All UI ported to Razor Pages with Tailwind CDN + custom CSS
- Vanilla JS: particles, typewriter, cursor, animations, portfolio modal
- Dockerfile: SDK 10.0-alpine → aspnet 10.0-alpine (no npm/Node needed)
- CI/CD: dropped NPM_TOKEN, ADMIN_SESSION_SECRET — pure dotnet publish

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 07:46:56 +03:30
soroush.asadi bcea9dc2f6 Bust npm ci cache, add post-install next verification
deploy / deploy (push) Failing after 1m17s
The npm ci layer was cached with a broken result (node_modules/next missing).
Changing the RUN command text invalidates that cache entry and forces a fresh
install. The added post-install check will show the exact npm ci error if next
is still missing, instead of failing silently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 15:43:19 +03:30
soroush.asadi 213a9d4f1c Debug next not found: add diagnostic + use direct node invocation
deploy / deploy (push) Failing after 1s
Replace `npm run build` with `node node_modules/next/dist/bin/next build`
to bypass PATH/symlink resolution issues on Alpine. Also adds a diagnostic
`ls node_modules/.bin/next` so CI logs show whether the binary is present.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 15:35:48 +03:30
soroush.asadi b2ed75cf2d Fix build: collapse deps+builder, add libc6-compat
deploy / deploy (push) Failing after 3m16s
The cross-stage COPY --from=deps /app/node_modules caused
sh: next: not found in the builder stage. Fix by merging deps
and builder into one stage — npm ci and next build run in the
same layer so node_modules is always present when building.

Also add libc6-compat (required by Next.js SWC binaries on Alpine)
to both builder and runner stages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 15:17:07 +03:30
soroush.asadi 20e14d3a28 Fix base image: use mirror direct path, switch to node:20-alpine
deploy / deploy (push) Failing after 7m35s
Mirror exposes images as mirror.soroushasadi.com/<image> directly,
not via /repository/docker-group/ path. Also node:20-alpine is already
cached on the server; node:20-slim was never pulled.

Dockerfile: apk instead of apt-get, addgroup/adduser instead of
groupadd/useradd (Alpine BusyBox compatibility).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 14:46:05 +03:30
soroush.asadi 98e100557f fixed
deploy / deploy (push) Failing after 14s
2026-05-31 14:22:40 +03:30
soroush.asadi be658e9d77 Correct mirror hostname to soroushasadi.com, HTTP protocol
deploy / deploy (push) Failing after 11s
ci / build (pull_request) Failing after 5s
Previous commit introduced a typo (soroushasad vs soroushasadi).
All references now use:
  npm    -> http://mirror.soroushasadi.com/repository/npm-group/
  docker -> mirror.soroushasadi.com/repository/docker-group/  (no protocol in image refs)

Also restore Dockerfile ARG NPM_TOKEN + COPY .npmrc that were lost
when the soroush-cicd skill regenerated the file, and set the
NODE_IMAGE ARG default back to the mirror path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 13:49:51 +03:30
soroush.asadi 13241612fe Fix mirror hostname and protocol (soroushasad.com, HTTP)
All references updated from mirror.soroushasadi.com (https) to
mirror.soroushasad.com (http):
  - .npmrc: registry uses http://
  - Dockerfile: syntax frontend + NODE_IMAGE ARG + npm auth line
  - docker-compose.yml: NODE_IMAGE default
  - deploy.yml: docker login/logout + NODE_IMAGE env
  - ci.yml: container image + NODE_IMAGE build-arg + npm auth line

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 13:47:18 +03:30
118 changed files with 2538 additions and 13109 deletions
+11 -11
View File
@@ -1,14 +1,14 @@
node_modules bin/
.next obj/
.git data/
.gitea .git/
data .gitea/
npm-debug.log* .claude/
.env*.local .vs/
.vscode/
.idea/
.env .env
.DS_Store .env.local
*.tsbuildinfo *.user
README.md
Dockerfile Dockerfile
.dockerignore
docker-compose.yml docker-compose.yml
+1 -7
View File
@@ -29,11 +29,5 @@ jobs:
git checkout FETCH_HEAD git checkout FETCH_HEAD
- name: Docker Build Test - name: Docker Build Test
env:
NODE_IMAGE: mirror.soroushasadi.com/repository/docker-group/node:20-slim
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: | run: |
docker build \ docker build -t soroushasadi-site:test .
--build-arg NODE_IMAGE="$NODE_IMAGE" \
--build-arg NPM_TOKEN="$NPM_TOKEN" \
-t soroushasadi-site:test .
+8 -15
View File
@@ -32,45 +32,38 @@ jobs:
run: | run: |
cat > .env << EOF cat > .env << EOF
ADMIN_PASSWORD=${{ secrets.ADMIN_PASSWORD }} ADMIN_PASSWORD=${{ secrets.ADMIN_PASSWORD }}
ADMIN_SESSION_SECRET=${{ secrets.ADMIN_SESSION_SECRET }}
RESEND_API_KEY=${{ secrets.RESEND_API_KEY }} RESEND_API_KEY=${{ secrets.RESEND_API_KEY }}
CONTACT_INBOX=${{ secrets.CONTACT_INBOX }} CONTACT_INBOX=${{ secrets.CONTACT_INBOX }}
CONTACT_FROM=${{ secrets.CONTACT_FROM }} CONTACT_FROM=${{ secrets.CONTACT_FROM }}
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
EOF EOF
- name: Build Container - name: Build Container
env: run: docker compose build
NODE_IMAGE: mirror.soroushasadi.com/repository/docker-group/node:20-slim
run: |
docker compose build
- name: Deploy - name: Deploy
run: | # Compose recreates ONLY our own container (container_name:
docker compose up -d --remove-orphans # soroushasadi-site, project: soroushasadi). It must never touch
# other stacks. Do NOT add any step that removes containers by
# published port — port 3000 is Gitea and 5xxx/3xxx belong to
# other apps on this host.
run: docker compose up -d
- name: Wait For Health Check - name: Wait For Health Check
run: | run: |
for i in $(seq 1 30); do for i in $(seq 1 30); do
STATUS=$(docker inspect \ STATUS=$(docker inspect \
--format='{{.State.Health.Status}}' \ --format='{{.State.Health.Status}}' \
soroushasadi-site 2>/dev/null) soroushasadi-site 2>/dev/null)
echo "Status: $STATUS" echo "Status: $STATUS"
if [ "$STATUS" = "healthy" ]; then if [ "$STATUS" = "healthy" ]; then
echo "Deployment successful" echo "Deployment successful"
exit 0 exit 0
fi fi
sleep 5 sleep 5
done done
docker logs soroushasadi-site --tail 100 docker logs soroushasadi-site --tail 100
exit 1 exit 1
- name: Cleanup - name: Cleanup
if: success() if: success()
run: | run: docker image prune -f
docker image prune -f
+24 -33
View File
@@ -1,39 +1,30 @@
# dependencies # .NET
node_modules bin/
.pnp obj/
.pnp.js *.user
.vs/
appsettings.*.local.json
# next.js # Stale Next.js build output (project migrated to .NET)
.next/ .next/
out/ node_modules/
build/
# production # CMS data (SQLite DB + uploads live in the Docker volume)
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 /data
# local tooling / agent state # Environment
.claude/ .env
.env.local
.env*.local
# IDE
.idea/
.vscode/
# OS
.DS_Store
*.pem
Thumbs.db
# Claude agent state
.claude/
-13
View File
@@ -1,13 +0,0 @@
# 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
+21
View File
@@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore;
using SoroushAsadi.Models;
namespace SoroushAsadi.Database;
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<ContentSection> ContentSections => Set<ContentSection>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ContentSection>(e =>
{
e.ToTable("sections");
e.HasKey(x => x.Key);
e.Property(x => x.Key).HasColumnName("key");
e.Property(x => x.DataJson).HasColumnName("data");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
});
}
}
+27 -56
View File
@@ -1,67 +1,38 @@
# All base images flow through the Nexus docker-group proxy, which aggregates ARG DOTNET_IMAGE=mcr.microsoft.com/dotnet/aspnet:10.0-alpine
# Docker Hub, Microsoft Container Registry (mcr.microsoft.com) and GitHub ARG SDK_IMAGE=mcr.microsoft.com/dotnet/sdk:10.0-alpine
# Container Registry (ghcr.io) behind one path — any upstream image is reachable
# as mirror.soroushasadi.com/repository/docker-group/<image>. # ── Build ─────────────────────────────────────────────────────────────────────
# Build directly against Docker Hub instead with: FROM ${SDK_IMAGE} AS build
# --build-arg NODE_IMAGE=node:20-slim WORKDIR /src
ARG NODE_IMAGE=node:20-slim
# --------------------------------------------------------------------------- COPY SoroushAsadi.Web.csproj ./
# 1. Dependencies — installs node_modules and compiles the better-sqlite3 RUN dotnet restore --runtime linux-musl-x64
# 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 . . COPY . .
RUN npm run build RUN dotnet publish SoroushAsadi.Web.csproj \
--no-restore \
--runtime linux-musl-x64 \
--self-contained false \
-c Release \
-o /app/publish
# --------------------------------------------------------------------------- # ── Runtime ───────────────────────────────────────────────────────────────────
# 3. Runner — minimal runtime image. Content DB + uploads live in /data, FROM ${DOTNET_IMAGE} AS runner
# which is a mounted volume so they survive image rebuilds.
# ---------------------------------------------------------------------------
FROM ${NODE_IMAGE} AS runner
WORKDIR /app 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 RUN apk add --no-cache ca-certificates \
&& addgroup -g 1001 dotnet \
&& adduser -u 1001 -G dotnet -h /home/dotnet -D dotnet
# Standalone server, static assets, and the public/ tree (portfolio art etc.). COPY --from=build /app/publish ./
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 ENV ASPNETCORE_ENVIRONMENT=Production \
# we copy the compiled .node and bindings explicitly as a safety net. ASPNETCORE_URLS=http://+:3000 \
COPY --from=builder /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3 DataDir=/data
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 RUN mkdir -p /data/uploads && chown -R dotnet:dotnet /data /app
USER nextjs USER dotnet
VOLUME ["/data"] VOLUME ["/data"]
EXPOSE 3000 EXPOSE 3000
ENTRYPOINT ["dotnet", "SoroushAsadi.Web.dll"]
CMD ["node", "server.js"]
+13
View File
@@ -0,0 +1,13 @@
namespace SoroushAsadi.Models;
/// <summary>
/// Maps to the existing `sections` SQLite table.
/// data column is a JSON string — either {"fa":…,"en":…} for bilingual sections,
/// or {"slug": PostContent, …} for the "posts" key.
/// </summary>
public class ContentSection
{
public string Key { get; set; } = "";
public string DataJson { get; set; } = "{}";
public long UpdatedAt { get; set; }
}
+26
View File
@@ -0,0 +1,26 @@
@page "/Admin"
@model SoroushAsadi.Pages.Admin.AdminIndexModel
@{
Layout = "_AdminLayout";
ViewData["Title"] = "Dashboard";
}
<h1 class="font-display text-2xl font-bold text-white mb-8">Dashboard</h1>
<div class="grid grid-cols-1 gap-5 sm:grid-cols-3">
<a href="/Admin/Sections" class="glass block p-6 hover:border-electric/40 transition-colors">
<div class="text-3xl font-bold text-electric mb-1">@Model.SectionCount</div>
<div class="text-sm text-slate-400">Section overrides</div>
<div class="mt-2 text-xs text-slate-500">Edit bilingual section content</div>
</a>
<a href="/Admin/Posts" class="glass block p-6 hover:border-violet/40 transition-colors">
<div class="text-3xl font-bold text-violet mb-1">6</div>
<div class="text-sm text-slate-400">Blog posts</div>
<div class="mt-2 text-xs text-slate-500">Edit article bodies</div>
</a>
<div class="glass p-6">
<div class="text-3xl font-bold text-emerald mb-1">↑</div>
<div class="text-sm text-slate-400">Site status</div>
<div class="mt-2 text-xs text-slate-500"><a href="/" class="text-electric hover:underline">View live site →</a></div>
</div>
</div>
+11
View File
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Authorization;
using SoroushAsadi.Services;
namespace SoroushAsadi.Pages.Admin;
[Authorize]
public class AdminIndexModel(ContentService content) : Microsoft.AspNetCore.Mvc.RazorPages.PageModel
{
public int SectionCount { get; private set; }
public void OnGet() => SectionCount = content.GetSectionKeys().Count;
}
+29
View File
@@ -0,0 +1,29 @@
@page "/Admin/Login"
@model SoroushAsadi.Pages.Admin.LoginModel
@{
Layout = "_AdminLayout";
ViewData["Title"] = "Sign in";
}
<div class="flex min-h-[60vh] items-center justify-center">
<div class="w-full max-w-sm">
<div class="mb-8 text-center">
<img src="/logo-mark.svg" alt="" width="40" height="40" class="mx-auto mb-4" />
<h1 class="font-display text-2xl font-bold text-white">Admin sign in</h1>
</div>
@if (!string.IsNullOrEmpty(Model.Error))
{
<div class="mb-4 rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-400">@Model.Error</div>
}
<form method="post" class="glass p-6 space-y-4">
<div>
<label class="label-mono mb-2 block" for="password">Password</label>
<input id="password" name="password" type="password" required autofocus
class="w-full rounded-xl border border-white/10 bg-white/[.03] px-4 py-3 text-sm text-white outline-none focus:border-electric/60 transition-colors" />
</div>
<button type="submit" class="btn-primary w-full justify-center">Sign in</button>
</form>
</div>
</div>
+33
View File
@@ -0,0 +1,33 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using SoroushAsadi.Services;
namespace SoroushAsadi.Pages.Admin;
public class LoginModel(AuthService auth) : PageModel
{
public string Error { get; private set; } = "";
public void OnGet() { }
public async Task<IActionResult> OnPostAsync(string password, string returnUrl = "/Admin")
{
if (!auth.VerifyPassword(password))
{
Error = "Incorrect password.";
return Page();
}
var claims = new[] { new Claim(ClaimTypes.Name, "admin") };
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(identity));
if (!Url.IsLocalUrl(returnUrl)) returnUrl = "/Admin";
return LocalRedirect(returnUrl);
}
}
+2
View File
@@ -0,0 +1,2 @@
@page "/Admin/Logout"
@model SoroushAsadi.Pages.Admin.LogoutModel
+16
View File
@@ -0,0 +1,16 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace SoroushAsadi.Pages.Admin;
[IgnoreAntiforgeryToken]
public class LogoutModel : PageModel
{
public async Task<IActionResult> OnPostAsync()
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return RedirectToPage("/Admin/Login");
}
}
+35
View File
@@ -0,0 +1,35 @@
@page "/Admin/Posts/{slug}"
@model SoroushAsadi.Pages.Admin.Posts.PostEditModel
@{
Layout = "_AdminLayout";
ViewData["Title"] = "Edit post: " + Model.Slug;
}
<div class="mb-6 flex items-center gap-4">
<a href="/Admin/Posts" class="text-slate-400 hover:text-white transition-colors text-sm">← Posts</a>
<h1 class="font-display text-xl font-bold text-white">@Model.Slug</h1>
<a href="/blog/@Model.Slug" target="_blank" class="text-xs text-slate-400 hover:text-white transition-colors">View ↗</a>
</div>
@if (!string.IsNullOrEmpty(Model.Message))
{
<div class="mb-4 rounded-lg border border-emerald/30 bg-emerald/10 px-4 py-3 text-sm text-emerald">@Model.Message</div>
}
<form method="post" class="space-y-4">
<div>
<label class="label-mono mb-2 block">Body (Markdown)</label>
<p class="text-xs text-slate-500 mb-2">Supports: ## headings, **bold**, `code`, - list items, paragraphs</p>
<textarea name="body" rows="30"
class="w-full rounded-xl border border-white/10 bg-white/[.03] px-4 py-3 font-mono text-xs text-slate-200 outline-none focus:border-electric/60 transition-colors resize-y"
spellcheck="false">@Model.CurrentBody</textarea>
</div>
<div class="flex gap-3">
<button type="submit" class="btn-primary">Save</button>
@if (Model.HasOverride)
{
<button type="submit" name="reset" value="1" class="rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-2 text-sm text-red-400 hover:bg-red-500/20 transition-colors">Reset to default</button>
}
<a href="/Admin/Posts" class="btn-ghost">Cancel</a>
</div>
</form>
+71
View File
@@ -0,0 +1,71 @@
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using SoroushAsadi.Services;
namespace SoroushAsadi.Pages.Admin.Posts;
[Authorize]
public class PostEditModel(ContentService content) : PageModel
{
[BindProperty(SupportsGet = true)]
public string Slug { get; set; } = "";
public string CurrentBody { get; private set; } = "";
public string Message { get; private set; } = "";
public bool HasOverride { get; private set; }
// Known default bodies live in Blog/Post.cshtml.cs (DefaultBodies)
private static readonly Dictionary<string, string> _defaults = new()
{
["rag-eval-framework"] = SoroushAsadi.Pages.Blog.DefaultBodies.RagEval,
["agentic-n8n-patterns"] = SoroushAsadi.Pages.Blog.DefaultBodies.N8nPatterns,
["vertex-cost-control"] = SoroushAsadi.Pages.Blog.DefaultBodies.VertexCost,
["k8s-llm-inference"] = SoroushAsadi.Pages.Blog.DefaultBodies.K8sInference,
["flutter-on-device-ai"] = SoroushAsadi.Pages.Blog.DefaultBodies.FlutterAI,
["enterprise-ai-roadmap"] = SoroushAsadi.Pages.Blog.DefaultBodies.EnterpriseRoadmap,
};
public void OnGet()
{
var overrides = content.GetPostOverrides();
if (overrides.TryGetValue(Slug, out var node) && node["body"]?.GetValue<string>() is { } body)
{
CurrentBody = body;
HasOverride = true;
}
else
{
CurrentBody = _defaults.GetValueOrDefault(Slug, "");
}
}
public IActionResult OnPost(string body, string? reset)
{
if (reset == "1")
{
// Remove override — default body shows through
var existing = content.GetPostOverrides();
existing.Remove(Slug);
// Rebuild the posts JSON without this slug
var obj = new JsonObject();
foreach (var kv in existing) obj[kv.Key] = kv.Value.DeepClone();
content.SaveSection(ContentService.PostsKey, obj.ToJsonString());
Message = "Reset to default.";
HasOverride = false;
CurrentBody = _defaults.GetValueOrDefault(Slug, "");
return Page();
}
content.SavePost(Slug, new JsonObject { ["body"] = body });
HasOverride = true;
CurrentBody = body;
Message = "Saved.";
return Page();
}
}
// Re-export DefaultBodies from the Blog page so this page can use them
+29
View File
@@ -0,0 +1,29 @@
@page "/Admin/Posts"
@model SoroushAsadi.Pages.Admin.Posts.PostsIndexModel
@{
Layout = "_AdminLayout";
ViewData["Title"] = "Blog posts";
var slugs = new[]{ "rag-eval-framework","agentic-n8n-patterns","vertex-cost-control","k8s-llm-inference","flutter-on-device-ai","enterprise-ai-roadmap" };
}
<h1 class="font-display text-2xl font-bold text-white mb-8">Blog posts</h1>
<div class="space-y-2">
@foreach (var slug in slugs)
{
var hasOverride = Model.OverrideSlugs.Contains(slug);
<div class="glass flex items-center justify-between p-4">
<div class="flex items-center gap-3">
<span class="font-mono text-sm text-white">@slug</span>
@if (hasOverride)
{
<span class="rounded-full bg-violet/10 px-2 py-0.5 font-mono text-[.65rem] text-violet">customized</span>
}
</div>
<div class="flex gap-2">
<a href="/Admin/Posts/@slug" class="btn-ghost text-xs py-1.5 px-3">Edit</a>
<a href="/blog/@slug" target="_blank" class="text-xs text-slate-400 hover:text-white transition-colors self-center">View ↗</a>
</div>
</div>
}
</div>
+13
View File
@@ -0,0 +1,13 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
using SoroushAsadi.Services;
namespace SoroushAsadi.Pages.Admin.Posts;
[Authorize]
public class PostsIndexModel(ContentService content) : PageModel
{
public IReadOnlySet<string> OverrideSlugs { get; private set; } = new HashSet<string>();
public void OnGet() => OverrideSlugs = content.GetPostOverrides().Keys.ToHashSet();
}
+29
View File
@@ -0,0 +1,29 @@
@page "/Admin/Sections/{key}"
@model SoroushAsadi.Pages.Admin.Sections.SectionEditModel
@{
Layout = "_AdminLayout";
ViewData["Title"] = "Edit: " + Model.SectionKey;
}
<div class="mb-6 flex items-center gap-4">
<a href="/Admin/Sections" class="text-slate-400 hover:text-white transition-colors text-sm">← Sections</a>
<h1 class="font-display text-xl font-bold text-white">@Model.SectionKey</h1>
</div>
@if (!string.IsNullOrEmpty(Model.Message))
{
<div class="mb-4 rounded-lg border border-emerald/30 bg-emerald/10 px-4 py-3 text-sm text-emerald">@Model.Message</div>
}
<form method="post" class="space-y-4">
<div>
<label class="label-mono mb-2 block">JSON ({"fa": {...}, "en": {...}})</label>
<textarea name="json" rows="24"
class="w-full rounded-xl border border-white/10 bg-white/[.03] px-4 py-3 font-mono text-xs text-slate-200 outline-none focus:border-electric/60 transition-colors resize-y"
spellcheck="false">@Model.CurrentJson</textarea>
</div>
<div class="flex gap-3">
<button type="submit" class="btn-primary">Save</button>
<a href="/Admin/Sections" class="btn-ghost">Cancel</a>
</div>
</form>
+43
View File
@@ -0,0 +1,43 @@
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using SoroushAsadi.Services;
namespace SoroushAsadi.Pages.Admin.Sections;
[Authorize]
public class SectionEditModel(ContentService content) : PageModel
{
[BindProperty(SupportsGet = true)]
public string SectionKey { get; set; } = "";
public string CurrentJson { get; private set; } = "{\n \"fa\": {},\n \"en\": {}\n}";
public string Message { get; private set; } = "";
public void OnGet()
{
var node = content.GetSection(SectionKey);
if (node is not null)
CurrentJson = node.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
}
public IActionResult OnPost(string json)
{
try
{
// Validate JSON
JsonDocument.Parse(json);
content.SaveSection(SectionKey, json);
Message = "Saved.";
CurrentJson = json;
return Page();
}
catch (JsonException ex)
{
Message = $"Invalid JSON: {ex.Message}";
CurrentJson = json;
return Page();
}
}
}
+37
View File
@@ -0,0 +1,37 @@
@page "/Admin/Sections"
@model SoroushAsadi.Pages.Admin.Sections.SectionsIndexModel
@{
Layout = "_AdminLayout";
ViewData["Title"] = "Sections";
var all = new[]{ "hero","services","dataflow","stack","expertise","portfolio","blog","contact","footer" };
}
<div class="mb-6 flex items-center justify-between">
<h1 class="font-display text-2xl font-bold text-white">Sections</h1>
</div>
<div class="space-y-2">
@foreach (var key in all)
{
var hasOverride = Model.OverrideKeys.Contains(key);
<div class="glass flex items-center justify-between p-4">
<div class="flex items-center gap-3">
<span class="font-mono text-sm text-white">@key</span>
@if (hasOverride)
{
<span class="rounded-full bg-electric/10 px-2 py-0.5 font-mono text-[.65rem] text-electric">customized</span>
}
</div>
<div class="flex gap-2">
<a href="/Admin/Sections/@key" class="btn-ghost text-xs py-1.5 px-3">Edit</a>
@if (hasOverride)
{
<form method="post" asp-page-handler="Reset" class="inline">
<input type="hidden" name="key" value="@key" />
<button type="submit" class="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-1.5 text-xs text-red-400 hover:bg-red-500/20 transition-colors">Reset</button>
</form>
}
</div>
</div>
}
</div>
+19
View File
@@ -0,0 +1,19 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using SoroushAsadi.Services;
namespace SoroushAsadi.Pages.Admin.Sections;
[Authorize]
public class SectionsIndexModel(ContentService content) : PageModel
{
public IReadOnlySet<string> OverrideKeys { get; private set; } = new HashSet<string>();
public void OnGet() => OverrideKeys = content.GetSectionKeys().ToHashSet();
public IActionResult OnPostReset(string key)
{
content.DeleteSection(key);
return RedirectToPage();
}
}
+17
View File
@@ -0,0 +1,17 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace SoroushAsadi.Pages;
/// <summary>Base class that reads the locale cookie and exposes Locale + IsFa helpers.</summary>
public abstract class BasePageModel : PageModel
{
public string Locale { get; private set; } = "fa";
public bool IsFa => Locale == "fa";
public override void OnPageHandlerExecuting(Microsoft.AspNetCore.Mvc.Filters.PageHandlerExecutingContext context)
{
Locale = Request.Cookies["locale"] is "en" ? "en" : "fa";
ViewData["Locale"] = Locale;
base.OnPageHandlerExecuting(context);
}
}
+33
View File
@@ -0,0 +1,33 @@
@page "/blog"
@model SoroushAsadi.Pages.Blog.BlogIndexModel
@{
ViewData["Title"] = Model.IsFa ? "بلاگ - سروش اسعدی" : "Blog - Soroush Asadi";
var fa = Model.IsFa;
}
<div class="px-5 pt-28 pb-24 sm:px-8 sm:pt-32">
<div class="mx-auto max-w-4xl">
<div class="sec-head">
<h1 class="@(fa ? "font-fa" : "")" style="font-size:clamp(2rem,4vw,2.75rem)">
@(fa ? "یادداشت‌های مهندسی" : "Engineering notes")
</h1>
<p class="lede mt-4">@(fa ? "درس‌هایی از پروژه‌های واقعی. نه ترجمه‌ی مقاله، نه شعار توخالی." : "Findings from real engagements. Not translated articles, not hype lists.")</p>
</div>
<div class="border-b border-zinc-200">
@foreach (var post in Model.Posts)
{
<a href="/blog/@post.Slug" class="group reveal grid grid-cols-1 gap-2 border-t border-zinc-200 py-6 sm:grid-cols-[8rem_1fr] sm:gap-8">
<div class="flex items-baseline justify-between sm:flex-col sm:gap-1">
<span class="kicker">@post.Category</span>
<span class="text-[.78rem] text-zinc-500">@post.ReadTime @(fa ? "دقیقه" : "min")</span>
</div>
<div>
<h2 class="text-[1.1rem] font-semibold transition-colors group-hover:text-accent @(fa ? "font-fa" : "")">@post.Title</h2>
<p class="mt-1.5 text-[.9rem] leading-relaxed text-zinc-600">@post.Excerpt</p>
</div>
</a>
}
</div>
</div>
</div>
+28
View File
@@ -0,0 +1,28 @@
namespace SoroushAsadi.Pages.Blog;
public class BlogIndexModel : BasePageModel
{
public record BlogPost(string Slug, string Category, string Title, string Excerpt, int ReadTime);
public IReadOnlyList<BlogPost> Posts { get; private set; } = [];
public void OnGet()
{
var fa = IsFa;
Posts = fa ? new BlogPost[]{
new("rag-eval-framework","LLM","چارچوب ارزیابی RAG که در عمل جواب می‌دهد","چرا BLEU و ROUGE برای RAG کافی نیستند، و معیارهایی که واقعاً به تصمیم کمک می‌کنند.",8),
new("agentic-n8n-patterns","Automation","الگوهای عامل‌محور با n8n برای سازمان","چطور n8n را با LangGraph ترکیب کنیم تا گردش‌کارهای خودکار و قابل‌ردیابی بسازیم.",11),
new("vertex-cost-control","Google Stack","کنترل هزینه روی Vertex AI در مقیاس بالا","سه اشتباه رایج که در بیشتر پروژه‌های Vertex می‌بینم، و اینکه چطور ۶۰٪ هزینه را کم کردیم.",6),
new("k8s-llm-inference","Infra","اجرای LLM روی Kubernetes با تأخیر زیر ۵۰ میلی‌ثانیه","الگوی استقرار با KEDA، اشتراک GPU و request hedging برای سرویس‌دهی پایدار.",14),
new("flutter-on-device-ai","Mobile","هوش مصنوعی روی دستگاه در Flutter","استفاده از Gemini Nano و LiteRT برای پردازش آفلاین در اپ‌های موبایل.",9),
new("enterprise-ai-roadmap","Strategy","نقشه‌ی راه هوش مصنوعی سازمانی در ۹۰ روز","چارچوبی که برای مدیران فنی می‌چینم؛ از پیدا کردن بهترین ایده تا اولین اجرای واقعی.",7),
} : new BlogPost[]{
new("rag-eval-framework","LLM","A RAG evaluation framework that holds up in production","Why BLEU and ROUGE fall short for RAG, and the metrics that actually drive decisions in real projects.",8),
new("agentic-n8n-patterns","Automation","Agentic patterns with n8n for the enterprise","How to combine n8n with LangGraph to build auditable, debuggable autonomous workflows.",11),
new("vertex-cost-control","Google Stack","Vertex AI cost control at scale","Three anti-patterns I see in 80% of Vertex projects, and how we cut 60% of monthly spend.",6),
new("k8s-llm-inference","Infra","Sub-50ms LLM inference on Kubernetes","Deployment pattern with KEDA, GPU sharing, and request hedging for stable serving.",14),
new("flutter-on-device-ai","Mobile","On-device AI in Flutter","Using Gemini Nano and LiteRT for offline inference inside mobile apps.",9),
new("enterprise-ai-roadmap","Strategy","A 90-day enterprise AI roadmap","The framework I build for CTOs, from use-case discovery to first production deployment.",7),
};
}
}
+32
View File
@@ -0,0 +1,32 @@
@page "/blog/{slug}"
@model SoroushAsadi.Pages.Blog.PostModel
@{
ViewData["Title"] = Model.Title + " - Soroush Asadi";
var fa = Model.IsFa;
}
<div class="px-5 pt-28 pb-24 sm:px-8 sm:pt-32">
<div class="mx-auto max-w-2xl">
<a href="/blog" class="mb-10 inline-flex items-center gap-2 text-sm text-zinc-500 transition-colors hover:text-zinc-900 @(fa ? "flex-row-reverse" : "")">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" class="@(fa ? "" : "rotate-180")"><path d="M5 12H19"/><path d="M13 6L19 12L13 18"/></svg>
@(fa ? "بازگشت به بلاگ" : "Back to blog")
</a>
@if (Model.PostNotFound)
{
<p class="text-zinc-600">@(fa ? "مقاله پیدا نشد." : "Post not found.")</p>
}
else
{
<header class="mb-8">
<span class="kicker">@Model.Category</span>
<h1 class="mt-3 @(fa ? "font-fa" : "")" style="font-size:clamp(1.8rem,4vw,2.5rem)">@Model.Title</h1>
<p class="mt-3 text-sm text-zinc-500">@Model.ReadTime @(fa ? "دقیقه مطالعه" : "min read")</p>
</header>
<article class="prose-custom">
@Html.Raw(Model.BodyHtml)
</article>
}
</div>
</div>
+345
View File
@@ -0,0 +1,345 @@
using System.Text;
using SoroushAsadi.Services;
namespace SoroushAsadi.Pages.Blog;
public class PostModel(ContentService content) : BasePageModel
{
[Microsoft.AspNetCore.Mvc.BindProperty(SupportsGet = true)]
public string Slug { get; set; } = "";
public string Title { get; private set; } = "";
public string Category { get; private set; } = "";
public int ReadTime { get; private set; }
public string BodyHtml { get; private set; } = "";
public bool PostNotFound { get; private set; }
// Default bodies (Markdown-lite, rendered server-side). Body is locale-aware.
private static readonly Dictionary<string, (string Cat, string TitleEn, string TitleFa, int RT, string BodyEn, string BodyFa)> _defaults = new()
{
["rag-eval-framework"] = ("LLM", "A RAG evaluation framework that holds up in production", "چارچوب ارزیابی RAG که در عمل جواب می‌دهد", 8, DefaultBodies.RagEval, DefaultBodies.RagEvalFa),
["agentic-n8n-patterns"] = ("Automation", "Agentic patterns with n8n for the enterprise", "الگوهای عامل‌محور با n8n برای سازمان", 11, DefaultBodies.N8nPatterns, DefaultBodies.N8nPatternsFa),
["vertex-cost-control"] = ("Google Stack", "Vertex AI cost control at scale", "کنترل هزینه روی Vertex AI در مقیاس بالا", 6, DefaultBodies.VertexCost, DefaultBodies.VertexCostFa),
["k8s-llm-inference"] = ("Infra", "Sub-50ms LLM inference on Kubernetes", "اجرای LLM روی Kubernetes با تأخیر زیر ۵۰ میلی‌ثانیه", 14, DefaultBodies.K8sInference, DefaultBodies.K8sInferenceFa),
["flutter-on-device-ai"] = ("Mobile", "On-device AI in Flutter", "هوش مصنوعی روی دستگاه در Flutter", 9, DefaultBodies.FlutterAI, DefaultBodies.FlutterAIFa),
["enterprise-ai-roadmap"] = ("Strategy", "A 90-day enterprise AI roadmap", "نقشه‌ی راه هوش مصنوعی سازمانی در ۹۰ روز", 7, DefaultBodies.EnterpriseRoadmap, DefaultBodies.EnterpriseRoadmapFa),
};
public void OnGet()
{
if (!_defaults.TryGetValue(Slug, out var def)) { PostNotFound = true; return; }
string body = IsFa ? def.BodyFa : def.BodyEn;
// Check for DB override (stored under "posts" key as slug→{body,...})
var overrides = content.GetPostOverrides();
if (overrides.TryGetValue(Slug, out var node) && node["body"]?.GetValue<string>() is { } dbBody)
body = dbBody;
Title = IsFa ? def.TitleFa : def.TitleEn;
Category = def.Cat;
ReadTime = def.RT;
BodyHtml = SimpleMarkdown(body);
}
// Minimal Markdown → HTML (headings, bold, code, paragraphs)
private static string SimpleMarkdown(string md)
{
if (string.IsNullOrWhiteSpace(md)) return "";
var sb = new StringBuilder();
foreach (var rawLine in md.Split('\n'))
{
var line = rawLine.TrimEnd();
if (line.StartsWith("## ")) { sb.Append($"<h2>{Inline(line[3..])}</h2>\n"); continue; }
if (line.StartsWith("### ")) { sb.Append($"<h3>{Inline(line[4..])}</h3>\n"); continue; }
if (line.StartsWith("- ")) { sb.Append($"<li>{Inline(line[2..])}</li>\n"); continue; }
if (string.IsNullOrWhiteSpace(line)) { sb.Append('\n'); continue; }
sb.Append($"<p>{Inline(line)}</p>\n");
}
return sb.ToString();
}
private static string Inline(string s)
{
// **bold**, `code`, &, <, >
var sb = new StringBuilder();
int i = 0;
while (i < s.Length)
{
if (i + 1 < s.Length && s[i] == '*' && s[i + 1] == '*')
{
int end = s.IndexOf("**", i + 2);
if (end >= 0) { sb.Append("<strong>"); sb.Append(Esc(s[(i + 2)..end])); sb.Append("</strong>"); i = end + 2; continue; }
}
if (s[i] == '`')
{
int end = s.IndexOf('`', i + 1);
if (end >= 0) { sb.Append("<code>"); sb.Append(Esc(s[(i + 1)..end])); sb.Append("</code>"); i = end + 1; continue; }
}
sb.Append(s[i] switch { '&' => "&amp;", '<' => "&lt;", '>' => "&gt;", _ => s[i].ToString() });
i++;
}
return sb.ToString();
}
private static string Esc(string s) => s.Replace("&","&amp;").Replace("<","&lt;").Replace(">","&gt;");
}
/// Default article bodies (Markdown). EN + FA per post.
internal static class DefaultBodies
{
public const string RagEval = """
## Why standard metrics fail for RAG
BLEU and ROUGE measure n-gram overlap against a reference answer. In a RAG system there is often no single correct reference: a question about company policy may have dozens of valid phrasings. High BLEU does not mean the system cited the right source; low BLEU does not mean it was wrong.
## The three metrics that actually matter
**Faithfulness** measures whether every claim in the generated answer can be traced back to a retrieved passage. A faithfulness score of 1.0 means the model invented nothing. Tools like RAGAS implement this with an LLM judge.
**Context Precision** asks: of the passages retrieved, how many were actually relevant to the question? Low precision wastes context window and increases hallucination risk.
**Answer Relevancy** checks whether the final response actually addresses what was asked, not just whether it sounds good.
## Building an eval harness
Start with a **golden dataset**: 100-200 question/answer pairs that domain experts have verified. Run your pipeline against them nightly. Track the three metrics above over time. A drop in Faithfulness after a model upgrade is a red flag; a drop in Context Precision after a chunking change means your retrieval is degrading.
The harness does not have to be complex. A spreadsheet with automatic scoring via the OpenAI or Anthropic API is enough to start catching regressions before they reach production.
""";
public const string RagEvalFa = """
## چرا معیارهای استاندارد برای RAG جواب نمیدهند
BLEU و ROUGE میزان همپوشانی n-gram را با یک پاسخ مرجع میسنجند. در یک سامانهی RAG معمولاً پاسخ مرجع واحدی وجود ندارد؛ یک پرسش دربارهی سیاستهای سازمان میتواند دهها بیان درست داشته باشد. BLEU بالا به این معنا نیست که سیستم به منبع درست ارجاع داده، و BLEU پایین هم به این معنا نیست که اشتباه کرده.
## سه معیاری که واقعاً مهماند
**وفاداری (Faithfulness)** میسنجد که آیا هر ادعای پاسخ تولیدشده را میتوان به یک قطعهی بازیابیشده ردیابی کرد. امتیاز وفاداری ۱.۰ یعنی مدل چیزی از خودش نساخته. ابزارهایی مثل RAGAS این را با یک داور LLM پیاده میکنند.
**دقت زمینه (Context Precision)** میپرسد: از میان قطعههای بازیابیشده، چند تا واقعاً به پرسش مربوط بودند؟ دقت پایین، پنجرهی زمینه را هدر میدهد و خطر توهم را بالا میبرد.
**مرتبطبودن پاسخ (Answer Relevancy)** بررسی میکند که پاسخ نهایی واقعاً به آنچه پرسیده شده جواب میدهد، نه اینکه فقط خوب به نظر برسد.
## ساختن یک بستر ارزیابی
با یک **دیتاست طلایی** شروع کنید: ۱۰۰ تا ۲۰۰ جفت پرسش و پاسخ که کارشناسان حوزه تأییدشان کردهاند. هر شب پایپلاین را روی آنها اجرا کنید و این سه معیار را در طول زمان دنبال کنید. افت وفاداری بعد از ارتقای مدل یک هشدار جدی است؛ افت دقت زمینه بعد از تغییر قطعهبندی یعنی بازیابیتان دارد بدتر میشود.
بستر ارزیابی لازم نیست پیچیده باشد. یک صفحهگسترده با امتیازدهی خودکار از طریق API اوپنایآی یا Anthropic، برای شروع و گرفتن افت کیفیت پیش از رسیدن به تولید کافی است.
""";
public const string N8nPatterns = """
## The problem with "just use n8n"
n8n is excellent for integrating SaaS tools. It becomes fragile when you try to use it as an agent orchestrator: long-running loops, conditional retries, and LLM calls that can fail in non-obvious ways.
## Separating orchestration from integration
The pattern that works: **n8n handles triggers and integrations; LangGraph handles agent logic**.
An n8n workflow watches a Slack channel. When a message matches a pattern, it calls a LangGraph endpoint with the raw payload. LangGraph runs the multi-step reasoning loop, maintains state, and returns a structured result. n8n takes that result and routes it: posts to Jira, sends an email, updates a database row.
## Making agents auditable
Every LangGraph state transition should emit an event to a structured log. We use a Postgres table with columns: `run_id`, `step`, `input`, `output`, `timestamp`. This table becomes the audit trail that compliance teams and on-call engineers both need.
Add a `human_in_the_loop` node for any action that cannot be undone: deleting records, sending external emails, approving payments. The node pauses execution and posts to Slack; a human approves or rejects; execution resumes.
## Handling failures gracefully
LLM calls fail. Build **retry with exponential backoff** into every LangGraph node that calls an LLM. Set a hard limit of 3 retries, then route to a dead-letter state that pages the on-call engineer. Never silently swallow errors in agentic pipelines. A swallowed error is an invisible outage.
""";
public const string N8nPatternsFa = """
## مشکلِ «فقط از n8n استفاده کن»
n8n برای اتصال ابزارهای SaaS عالی است. اما وقتی بخواهید از آن بهعنوان ارکستراتورِ عامل استفاده کنید شکننده میشود؛ حلقههای طولانی، تلاشهای مجدد شرطی، و فراخوانیهای LLM که میتوانند به شکلهای غیرمنتظره شکست بخورند.
## جدا کردن ارکستراسیون از یکپارچهسازی
الگویی که جواب میدهد: **n8n تریگرها و یکپارچهسازیها را مدیریت کند، LangGraph منطقِ عامل را**.
یک گردشکار n8n یک کانال Slack را زیر نظر میگیرد. وقتی پیامی با الگو مطابقت کرد، یک endpoint از LangGraph را با دادهی خام صدا میزند. LangGraph حلقهی استدلال چندمرحلهای را اجرا میکند، حالت را نگه میدارد و یک نتیجهی ساختارمند برمیگرداند. بعد n8n آن نتیجه را مسیردهی میکند؛ در Jira ثبت میکند، ایمیل میفرستد، یا یک ردیف پایگاهداده را بهروز میکند.
## قابلممیزی کردنِ عاملها
هر گذارِ حالت در LangGraph باید یک رویداد در یک لاگ ساختارمند ثبت کند. ما از یک جدول Postgres با ستونهای `run_id`، `step`، `input`، `output` و `timestamp` استفاده میکنیم. این جدول همان ردِ ممیزیای میشود که هم تیمهای انطباق و هم مهندسان کشیک به آن نیاز دارند.
برای هر کاری که برگشتپذیر نیست یک گرهی `human_in_the_loop` اضافه کنید؛ حذف رکورد، ارسال ایمیل بیرونی، تأیید پرداخت. این گره اجرا را متوقف میکند و در Slack پیام میگذارد؛ یک انسان تأیید یا رد میکند و اجرا ادامه پیدا میکند.
## مدیریت درستِ خطاها
فراخوانیهای LLM شکست میخورند. در هر گرهی LangGraph که LLM را صدا میزند **تلاش مجدد با backoff نمایی** بسازید. سقف سه بار تلاش بگذارید، بعد به یک حالت dead-letter مسیردهی کنید که مهندس کشیک را خبر کند. در پایپلاینهای عاملمحور هیچوقت خطا را بیصدا فرو نخورید؛ یک خطای فروخورده، یک قطعی نامرئی است.
""";
public const string VertexCost = """
## Anti-pattern 1: calling Gemini Ultra for everything
Gemini Ultra (or GPT-4-class models) costs 10 to 30 times more per token than smaller models. Many teams default to the most capable model because it "just works" during prototyping, then never re-evaluate.
**Fix**: build a **model router**. Classify each incoming request by complexity. Simple lookups, short summaries, and classification tasks go to Gemini Flash or Haiku. Only complex reasoning, multi-step synthesis, and long-context tasks go to Pro or Ultra. In most production systems, 60-80% of requests can be served by the cheaper tier.
## Anti-pattern 2: no context caching
Vertex AI supports prompt caching (as does the Anthropic API). A system prompt that is 10k tokens, sent with every request at $3/M tokens, costs $30 for every million calls before the user has typed a single word.
**Fix**: cache any context that is static or changes infrequently: system prompts, retrieved document sets, few-shot examples. Cache hits cost about 10% of full input price.
## Anti-pattern 3: synchronous batch jobs
Teams run nightly document processing jobs synchronously, one document at a time, each blocked on the previous. This is slow and expensive because you pay for idle wait time between calls.
**Fix**: use the Vertex AI batch prediction API for jobs over ~1,000 documents. Batch jobs run asynchronously, are eligible for spot discounts, and typically cost 50% less per token than online serving.
""";
public const string VertexCostFa = """
## ضدالگوی اول: صدا زدن Gemini Ultra برای همهچیز
Gemini Ultra (یا مدلهای همردهی GPT-4) به ازای هر توکن ۱۰ تا ۳۰ برابر گرانتر از مدلهای کوچکترند. خیلی از تیمها در مرحلهی نمونهسازی سراغ توانمندترین مدل میروند چون «همینجوری کار میکند» و بعد دیگر هیچوقت بازنگری نمیکنند.
**راهحل**: یک **مسیردهندهی مدل (model router)** بسازید. هر درخواست ورودی را بر اساس پیچیدگی دستهبندی کنید. جستوجوهای ساده، خلاصههای کوتاه و کارهای دستهبندی به Gemini Flash یا Haiku بروند. فقط استدلالهای پیچیده، ترکیب چندمرحلهای و کارهای با زمینهی طولانی به Pro یا Ultra. در بیشتر سامانههای تولیدی، ۶۰ تا ۸۰ درصد درخواستها را میشود با ردهی ارزانتر سرویس داد.
## ضدالگوی دوم: نبودِ کشِ زمینه
Vertex AI از کش کردن prompt پشتیبانی میکند (مثل API اَنتروپیک). یک system prompt دههزار توکنی که با هر درخواست و با نرخ ۳ دلار به ازای هر میلیون توکن فرستاده میشود، پیش از آنکه کاربر حتی یک کلمه تایپ کند، برای هر میلیون فراخوانی ۳۰ دلار خرج برمیدارد.
**راهحل**: هر زمینهای که ثابت است یا کم تغییر میکند را کش کنید؛ system prompt‌ها، مجموعهی اسناد بازیابیشده، نمونههای few-shot. هزینهی hit کش حدود ۱۰ درصد قیمت کامل ورودی است.
## ضدالگوی سوم: کارهای دستهای همگام
تیمها کارهای شبانهی پردازش سند را همگام اجرا میکنند؛ سند به سند، هرکدام منتظر قبلی. این کُند و گران است چون بابت زمان انتظارِ بیکار بین فراخوانیها هم پول میدهید.
**راهحل**: برای کارهای بالای حدود ۱۰۰۰ سند از batch prediction API در Vertex AI استفاده کنید. کارهای دستهای ناهمگام اجرا میشوند، واجد تخفیف spot هستند و معمولاً به ازای هر توکن ۵۰ درصد ارزانتر از سرویسدهی آنلاین تمام میشوند.
""";
public const string K8sInference = """
## The baseline architecture
A single Kubernetes `Deployment` behind a `ClusterIP` `Service`, fronted by an Ingress. Works fine up to ~50 RPS for a small model. Falls apart when traffic spikes, when GPU pods take 3 minutes to schedule, or when the model server has a 2-second cold-start.
## Autoscaling with KEDA
HPA (Horizontal Pod Autoscaler) scales on CPU and memory. LLM inference is GPU-bound and queue-depth-bound, and neither maps to CPU utilization well.
KEDA (Kubernetes Event-Driven Autoscaling) scales on arbitrary metrics: queue depth, Pub/Sub lag, Redis list length. We publish inference request counts to a Redis stream; KEDA scales the model server pods when the stream depth exceeds a threshold. Scaling-up latency drops from minutes (cluster autoscaler cold start) to seconds (replica scale-up from 1 to N).
## GPU sharing with time-slicing
For models that fit in 4 to 8 GB VRAM, full GPU dedication is wasteful. NVIDIA's time-slicing MIG (Multi-Instance GPU) lets multiple pods share one A100, each getting a guaranteed slice.
Configure `nvidia.com/gpu: 1` and set the time-slice profile to `1g.10gb`. A single A100 80GB can serve 8 concurrent model instances at 10 GB each, 8 times the throughput per GPU.
## Request hedging for tail latency
p50 latency is 12ms. p99 is 280ms. The tail is dominated by KV-cache misses and occasional GC pauses. **Hedged requests**: after 40ms, send a duplicate request to a second replica. Take whichever response arrives first; cancel the other. This cuts p99 from 280ms to ~45ms with only ~15% increase in total compute.
""";
public const string K8sInferenceFa = """
## معماری پایه
یک `Deployment` در Kubernetes پشتِ یک `Service` از نوع `ClusterIP` که یک Ingress جلویش قرار گرفته. تا حدود ۵۰ درخواست بر ثانیه برای یک مدل کوچک خوب کار میکند. اما وقتی ترافیک ناگهان بالا میرود، یا زمانبندی pod‌های GPU سه دقیقه طول میکشد، یا سرور مدل دو ثانیه cold-start دارد، از هم میپاشد.
## مقیاس خودکار با KEDA
HPA (مقیاسگذار افقی pod) بر اساس CPU و حافظه مقیاس میدهد. اما استنتاج LLM به GPU و عمق صف وابسته است و هیچکدام با مصرف CPU خوب نگاشت نمیشوند.
KEDA (مقیاس خودکار رویدادمحور Kubernetes) بر اساس هر معیار دلخواهی مقیاس میدهد؛ عمق صف، تأخیر Pub/Sub، طول لیست Redis. ما تعداد درخواستهای استنتاج را در یک stream در Redis منتشر میکنیم؛ KEDA وقتی عمق stream از یک آستانه عبور کند pod‌های سرور مدل را مقیاس میدهد. تأخیر مقیاسگرفتن از چند دقیقه (cold-start مقیاسگذار خوشه) به چند ثانیه (افزایش replica از ۱ به N) میرسد.
## اشتراک GPU با time-slicing
برای مدلهایی که در ۴ تا ۸ گیگابایت VRAM جا میشوند، اختصاص کاملِ GPU اسراف است. فناوری time-slicing و MIG انویدیا (GPU چنداینستنسه) اجازه میدهد چند pod یک A100 را به اشتراک بگذارند و هرکدام یک سهم تضمینشده بگیرند.
`nvidia.com/gpu: 1` را تنظیم کنید و پروفایل time-slice را روی `1g.10gb` بگذارید. یک A100 هشتادگیگابایتی میتواند ۸ اینستنس مدل را همزمان، هرکدام با ۱۰ گیگابایت، سرویس بدهد؛ یعنی ۸ برابرِ توان عبوری به ازای هر GPU.
## hedging درخواست برای تأخیر دنباله
تأخیر p50 برابر ۱۲ میلیثانیه است و p99 برابر ۲۸۰ میلیثانیه. دنباله را عمدتاً miss‌های KV-cache و مکثهای گاهبهگاه GC میسازند. **درخواستهای hedged**: بعد از ۴۰ میلیثانیه یک درخواست تکراری به replica دوم بفرستید. هر پاسخی زودتر رسید همان را بردارید و دیگری را لغو کنید. این کار p99 را از ۲۸۰ به حدود ۴۵ میلیثانیه میرساند، با تنها حدود ۱۵ درصد افزایش در کل محاسبات.
""";
public const string FlutterAI = """
## Why on-device inference matters
Cloud inference requires a network round-trip, exposes user data to a server, and fails in offline scenarios. For consumer apps (messaging, health, productivity) on-device inference is often a requirement, not a nice-to-have.
## Gemini Nano and LiteRT
Google's Gemini Nano is a 1.8B parameter model quantized to run on mobile NPUs (Neural Processing Units). The Flutter integration uses the `google_ai_dart_sdk` package with `GeminiNanoModel`, falling back to cloud inference when the device model is unavailable.
LiteRT (formerly TensorFlow Lite) handles vision and custom small models. For classification and embedding tasks, a 50MB quantized model runs in under 20ms on a mid-range Android device.
## Streaming UX without a network
The key insight: users tolerate slightly slower responses if they can see text appearing token by token. Even on-device inference can stream. Gemini Nano's Dart SDK exposes a `generateContentStream` method. Pipe tokens directly to a Flutter `StreamBuilder` for a responsive feel regardless of total generation time.
## Battery and thermal management
On-device inference heats the chip. Implement **thermal throttling**: check `DeviceInfo.thermalState` (iOS) or subscribe to the battery API on Android. Reduce `maxTokens` from 512 to 128 during sustained load. Schedule background inference tasks during charging. Users notice neither the throttling nor the scheduling. They notice when their phone gets too hot.
""";
public const string FlutterAIFa = """
## چرا استنتاج روی دستگاه مهم است
استنتاج ابری یک رفتوبرگشتِ شبکه میخواهد، دادهی کاربر را در معرض سرور میگذارد، و در حالت آفلاین شکست میخورد. برای اپهای مصرفی مثل پیامرسان، سلامت و بهرهوری، استنتاج روی دستگاه اغلب یک الزام است، نه یک امکانِ خوببهداشتن.
## Gemini Nano و LiteRT
Gemini Nano گوگل یک مدل ۱ میلیارد پارامتری است که کوانتیزه شده تا روی NPU‌های موبایل (واحدهای پردازش عصبی) اجرا شود. یکپارچهسازی با Flutter از پکیج `google_ai_dart_sdk` و `GeminiNanoModel` استفاده میکند و وقتی مدلِ روی دستگاه در دسترس نباشد به استنتاج ابری برمیگردد.
LiteRT (همان TensorFlow Lite سابق) بینایی و مدلهای کوچک سفارشی را مدیریت میکند. برای کارهای دستهبندی و embedding، یک مدل کوانتیزهی ۵۰ مگابایتی روی یک گوشی اندرویدی میانرده در کمتر از ۲۰ میلیثانیه اجرا میشود.
## تجربهی استریم بدون شبکه
نکتهی کلیدی: کاربرها پاسخ کمی کندتر را تحمل میکنند اگر ببینند متن توکنبهتوکن ظاهر میشود. حتی استنتاج روی دستگاه هم میتواند استریم کند. Dart SDK مربوط به Gemini Nano متد `generateContentStream` را در اختیار میگذارد. توکنها را مستقیم به یک `StreamBuilder` در Flutter بدهید تا فارغ از کل زمان تولید، حسی پاسخگو داشته باشید.
## مدیریت باتری و دما
استنتاج روی دستگاه تراشه را گرم میکند. **throttling حرارتی** پیاده کنید: `DeviceInfo.thermalState` را در iOS بررسی کنید یا در اندروید به API باتری گوش بدهید. زیر بار طولانی `maxTokens` را از ۵۱۲ به ۱۲۸ کم کنید. کارهای استنتاجِ پسزمینه را برای زمان شارژ زمانبندی کنید. کاربر نه throttling را میفهمد و نه زمانبندی را؛ فقط وقتی گوشیاش داغ شود متوجه میشود.
""";
public const string EnterpriseRoadmap = """
## Days 1-30: discovery
The most expensive mistake in enterprise AI is building the wrong thing fast. Discovery is not a formality. It is the work.
Interview 8 to 12 stakeholders across business units. For each, ask: what manual task takes more than 2 hours per week? What decision do you make with incomplete information? What report do you wish existed but is too expensive to build?
Map the candidates on a 2x2: **impact** (revenue, cost, risk) vs **feasibility** (data quality, integration complexity, regulatory constraints). The top-right quadrant is your first sprint.
## Days 31-60: prototype and validate
Pick one use case from the top-right. Build a prototype in 3 weeks. The prototype does not have to be production-grade. It has to be **testable by domain experts**.
Run a structured eval: 100 questions, domain expert scores each answer 1 to 5. Set a threshold (e.g. 4.0 average or higher) before the sprint begins. If the prototype clears it, proceed to production hardening. If it does not, investigate root cause (usually data quality or chunking strategy) before committing engineering resources.
## Days 61-90: first production deployment
Scope the first deployment to a single team of 10 to 20 people. This limits blast radius and generates real usage data fast.
Instrument everything: latency, cost per query, thumbs-up/thumbs-down from users, faithfulness score from the automated harness. Review metrics weekly with the business owner. Adjust chunking, retrieval strategy, or model tier based on what the data shows, not intuition.
At day 90, you have a live system, a tuned eval harness, and a clear picture of what the second use case should be. That is the foundation for a credible 12-month roadmap.
""";
public const string EnterpriseRoadmapFa = """
## روز ۱ تا ۳۰: کشف
گرانترین اشتباه در هوش مصنوعی سازمانی، سریعساختنِ چیز اشتباه است. کشف یک تشریفات نیست؛ خودِ کار است.
با ۸ تا ۱۲ ذینفع در واحدهای مختلفِ کسبوکار مصاحبه کنید. از هرکدام بپرسید: کدام کار دستی بیش از دو ساعت در هفته وقت میگیرد؟ کدام تصمیم را با اطلاعات ناقص میگیرید؟ کدام گزارش را آرزو دارید داشته باشید ولی ساختنش گران است؟
نامزدها را روی یک ماتریس ۲×۲ بچینید: **اثر** (درآمد، هزینه، ریسک) در برابر **امکانپذیری** (کیفیت داده، پیچیدگی یکپارچهسازی، محدودیتهای مقرراتی). ربعِ بالا-راست، اولین sprint شماست.
## روز ۳۱ تا ۶۰: نمونهی اولیه و اعتبارسنجی
یک مورد کاربری از ربعِ بالا-راست بردارید. در سه هفته یک نمونهی اولیه بسازید. نمونه لازم نیست در سطح تولید باشد؛ باید **توسط کارشناسان حوزه قابلآزمون** باشد.
یک ارزیابی ساختارمند اجرا کنید: ۱۰۰ پرسش، کارشناس حوزه به هر پاسخ از ۱ تا ۵ امتیاز میدهد. پیش از شروع sprint یک آستانه بگذارید (مثلاً میانگین ۴.۰ یا بالاتر). اگر نمونه از آن گذشت، به سمتِ سختسازی برای تولید بروید. اگر نگذشت، ریشه را پیدا کنید (معمولاً کیفیت داده یا راهبرد قطعهبندی) و بعد منابع مهندسی را متعهد کنید.
## روز ۶۱ تا ۹۰: اولین استقرار تولید
اولین استقرار را به یک تیم ۱۰ تا ۲۰ نفره محدود کنید. این کار شعاع آسیب را کم میکند و سریع دادهی استفادهی واقعی میسازد.
همهچیز را اندازه بگیرید: تأخیر، هزینه به ازای هر پرسش، بازخورد مثبت و منفی کاربرها، امتیاز وفاداری از بسترِ خودکار. هفتگی با صاحب کسبوکار معیارها را مرور کنید. قطعهبندی، راهبرد بازیابی یا ردهی مدل را بر اساس آنچه داده نشان میدهد تنظیم کنید، نه بر اساس حدس.
در روز ۹۰ یک سامانهی زنده، یک بسترِ ارزیابیِ تنظیمشده، و تصویری روشن از اینکه دومین مورد کاربری چه باید باشد دارید. این، پایهی یک نقشهی راهِ ۱۲ماههی معتبر است.
""";
}
+2
View File
@@ -0,0 +1,2 @@
@page "/contact"
@model SoroushAsadi.Pages.ContactModel
+30
View File
@@ -0,0 +1,30 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using SoroushAsadi.Services;
namespace SoroushAsadi.Pages;
/// <summary>POST /contact — JSON endpoint for the contact form.</summary>
[IgnoreAntiforgeryToken]
public class ContactModel(EmailService email) : PageModel
{
public record ContactBody(
string? Name, string? Company, string? Service,
string? Budget, string? Message, string? Locale);
public async Task<IActionResult> OnPostAsync([FromBody] ContactBody body)
{
if (string.IsNullOrWhiteSpace(body.Name) || body.Name.Length < 2 ||
string.IsNullOrWhiteSpace(body.Service) || body.Service.Length < 2 ||
string.IsNullOrWhiteSpace(body.Budget) || body.Budget.Length < 2 ||
string.IsNullOrWhiteSpace(body.Message) || body.Message.Length < 2)
return BadRequest(new { error = "Missing required fields" });
var err = await email.SendContactAsync(new EmailService.ContactForm(
body.Name!, body.Company ?? "", body.Service!, body.Budget!, body.Message!, body.Locale ?? "en"));
return err is null
? new JsonResult(new { ok = true })
: StatusCode(502, new { error = err });
}
}
+399
View File
@@ -0,0 +1,399 @@
@page
@model SoroushAsadi.Pages.IndexModel
@{
var fa = Model.IsFa;
var locale = Model.Locale;
}
<!-- ─── HERO (bento) ─────────────────────────────────────────────────── -->
<section id="top" class="px-5 pt-24 pb-12 sm:px-8 sm:pt-28">
<div class="mx-auto max-w-6xl">
<div class="bento">
<!-- Name / anchor tile -->
<div class="tile tile-dark span-2 row-2 reveal">
<span class="kicker @(fa ? "font-fa" : "")" style="color:#a1a1aa">@(fa ? "مهندس نرم‌افزار و هوش مصنوعی" : "Software & AI Engineer")</span>
<h1 class="mt-3 @(fa ? "font-fa" : "")" style="font-size:clamp(2.4rem,5.5vw,4rem)">@(fa ? "سروش اسعدی" : "Soroush Asadi")</h1>
<p class="t-sub mt-3 text-[1rem] @(fa ? "font-fa" : "")">@(fa ? "معمار سیستم. از ایده تا اجرا." : "Solution architect. From idea to production.")</p>
<div class="mt-auto flex flex-wrap gap-3 pt-8">
<a href="#contact" class="btn">
@(fa ? "رزرو جلسه" : "Book a call")
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" class="@(fa ? "rotate-180" : "")" aria-hidden="true"><path d="M5 12h14"/><path d="m13 6 6 6-6 6"/></svg>
</a>
<a href="#portfolio" class="btn-ghost btn-on-dark">@(fa ? "نمونه‌کارها" : "View work")</a>
</div>
</div>
<!-- Value-prop tile -->
<div class="tile span-2 reveal" style="transition-delay:.06s">
<p class="mt-auto text-balance leading-snug text-zinc-800 @(fa ? "font-fa" : "")" style="font-size:clamp(1.15rem,2vw,1.5rem)">
@(fa ? "نرم‌افزار، اپلیکیشن‌های سازمانی و " : "I build software, enterprise apps, and ")<span class="accent-text font-semibold">@(fa ? "راهکارهای هوش مصنوعی" : "AI solutions")</span>@(fa ? " می‌سازم که در عمل و در مقیاس واقعی کار می‌کنند." : " that hold up in production, at real scale.")
</p>
</div>
<!-- Availability tile -->
<div class="tile tile-accent reveal" style="transition-delay:.12s">
<span class="inline-flex items-center gap-2 text-[.82rem]" style="color:rgba(255,255,255,.9)"><span style="width:7px;height:7px;border-radius:99px;background:#fff;display:inline-block"></span>@(fa ? "وضعیت" : "Status")</span>
<p class="mt-auto pt-6 text-[1.05rem] font-semibold @(fa ? "font-fa" : "")" style="color:#fff">@(fa ? "پذیرای پروژه‌های جدید" : "Open for new projects")</p>
</div>
<!-- Social tile -->
<div class="tile reveal" style="transition-delay:.18s">
<span class="kicker @(fa ? "font-fa" : "")">@(fa ? "ارتباط" : "Connect")</span>
<div class="mt-auto flex items-center gap-2.5 pt-6">
<a class="social" href="https://www.linkedin.com/in/soroushdes/" target="_blank" rel="noopener" aria-label="LinkedIn"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5ZM3 9h4v12H3V9Zm6 0h3.8v1.64h.05c.53-1 1.83-2.06 3.76-2.06 4.02 0 4.76 2.65 4.76 6.1V21h-4v-5.4c0-1.29-.02-2.95-1.8-2.95-1.8 0-2.07 1.4-2.07 2.85V21H9V9Z"/></svg></a>
<a class="social" href="https://www.instagram.com/soroushasadicom/" target="_blank" rel="noopener" aria-label="Instagram"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="5"/><circle cx="12" cy="12" r="4"/><circle cx="17.2" cy="6.8" r="1.1" fill="currentColor" stroke="none"/></svg></a>
<a class="social" href="mailto:code.soroush@gmail.com" aria-label="Email"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></svg></a>
</div>
</div>
</div>
</div>
</section>
<!-- ─── SERVICES (text-block grid) ───────────────────────────────────── -->
<section id="services" class="px-5 py-24 sm:px-8 sm:py-28">
<div class="mx-auto max-w-6xl">
<div class="sec-head">
<h2>@(fa ? "شش کاری که انجام می‌دهم" : "Six areas of practice")</h2>
<p class="lede">@(fa ? "از همان جلسه‌ی اول تا وقتی محصول روی پای خودش می‌ایستد، کنارتان هستم؛ در تمام مسیر مهندسی و محصول." : "From the first idea to production rollout, one engineering partner across the whole product.")</p>
</div>
<div class="bento">
@{
var services = fa ? new[]{
("apps","اپلیکیشن‌های وب و سازمانی","پلتفرم‌های وب و SaaS از صفر تا صد: داشبورد، چندمستاجری، صورت‌حساب و پنل مدیریت، ساخته‌شده برای رشد.",new[]{"Web","SaaS","Dashboards"}),
("mobile","اپلیکیشن‌های موبایل","اپ‌های بومی و چندسکویی با Flutter، Swift و Kotlin، با حسی روان و نزدیک به تجربه‌ی بومی.",new[]{"Flutter","Swift","Kotlin"}),
("architecture","معماری راهکار و زیرساخت ابری","سیستم‌های توزیع‌شده روی Kubernetes؛ میکروسرویس، استریم رویداد، و پایداری زیر بار سنگین.",new[]{"K8s","Microservices","Cloud"}),
("llm-rag","راهکارهای هوش مصنوعی","قابلیت‌های LLM و RAG، عامل‌ها و اتوماسیون که داخل محصول واقعی کار می‌کنند، نه فقط دمو.",new[]{"LLM","RAG","Agents"}),
("automation","اتوماسیون و یکپارچه‌سازی","ابزارهایتان را به هم وصل می‌کنم و کارهای دستی را حذف؛ با گردش‌کارهای n8n، API و وب‌هوک.",new[]{"n8n","APIs","Webhooks"}),
("strategy","راهبرد و نقشه‌ی راه","راهبرد فنی، بازبینی معماری، و نقشه‌ی راهی روشن از ایده تا عرضه.",new[]{"Discovery","Architecture","Roadmap"}),
} : new[]{
("apps","Web & enterprise apps","End-to-end web platforms and SaaS: dashboards, multi-tenant, billing, and admin, built to scale.",new[]{"Web","SaaS","Dashboards"}),
("mobile","Mobile apps","Native and cross-platform apps with Flutter, Swift, and Kotlin, with a smooth native feel.",new[]{"Flutter","Swift","Kotlin"}),
("architecture","Solution architecture & cloud","Distributed systems on Kubernetes: microservices, event streaming, and resilience at scale.",new[]{"K8s","Microservices","Cloud"}),
("llm-rag","AI solutions","LLM and RAG features, agents, and automation built into real products, not just demos.",new[]{"LLM","RAG","Agents"}),
("automation","Automation & integrations","Connect your tools and remove manual work with n8n workflows, APIs, and webhooks.",new[]{"n8n","APIs","Webhooks"}),
("strategy","Strategy & roadmap","Technical strategy, architecture review, and a clear roadmap from idea to launch.",new[]{"Discovery","Architecture","Roadmap"}),
};
}
@{ int si = 0; }
@foreach (var (id, title, desc, tags) in services)
{
var (spanCls, variant) = id switch {
"apps" => ("span-2", "tile-tint"),
"llm-rag" => ("span-2", "tile-dark"),
_ => ("", ""),
};
var descCls = variant == "tile-dark" ? "t-sub" : "text-zinc-600";
<article class="tile reveal @spanCls @variant" style="transition-delay:@(si * 60)ms">
<span class="tile-icon" aria-hidden="true">@Html.Raw(ServiceIcon(id))</span>
<h3 class="mt-4 text-lg font-semibold @(fa ? "font-fa" : "")">@title</h3>
<p class="mt-2.5 text-[.93rem] leading-relaxed @descCls">@desc</p>
<div class="mt-auto flex flex-wrap gap-1.5 pt-5">
@foreach (var tag in tags) { <span class="chip">@tag</span> }
</div>
</article>
si++;
}
</div>
</div>
</section>
<!-- ─── PIPELINE (horizontal stepper) ────────────────────────────────── -->
<section id="dataflow" class="px-5 py-24 sm:px-8 sm:py-28">
<div class="mx-auto max-w-6xl">
<div class="sec-head">
<h2>@(fa ? "از سند خام تا پاسخ قابل اتکا" : "From raw document to a trustworthy answer")</h2>
<p class="lede">@(fa ? "مسیری که هر پرسش در یک سامانه‌ی RAG واقعی طی می‌کند. هر مرحله را می‌شود اندازه گرفت، دنبال کرد و برای سرعت بهتر کرد." : "The path every query takes through a production RAG system. Each stage is measurable, auditable, and tuned for latency.")</p>
</div>
<ol class="grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-3 lg:grid-cols-5">
@{
var nodes = fa ? new[]{
("دریافت","نرمال‌سازی، تکه‌تکه‌کردن و پاک‌سازی سندهای منبع"),
("برداری‌سازی","ساخت embedding و نمایه‌کردن در پایگاه برداری"),
("بازیابی","جستجوی ترکیبی معنایی و کلیدواژه‌ای"),
("بازرتبه‌بندی","چیدن دوباره‌ی نتایج با cross-encoder"),
("تولید","پاسخ مستند همراه با ذکر منبع"),
} : new[]{
("Ingest","Normalize, chunk, and clean source documents"),
("Embed","Generate embeddings and index in the vector store"),
("Retrieve","Hybrid semantic and keyword search"),
("Rerank","Re-order candidates with a cross-encoder"),
("Generate","Grounded answer with source citations"),
};
int stepN = 0;
}
@foreach (var (nlabel, ndesc) in nodes)
{
stepN++;
<li class="reveal border-t border-zinc-200 pt-4" style="transition-delay:@((stepN-1) * 40)ms">
<span class="font-display text-sm text-zinc-500">@stepN.ToString("D2")</span>
<h3 class="mt-2 text-base font-semibold @(fa ? "font-fa" : "")">@nlabel</h3>
<p class="mt-1.5 text-[.85rem] leading-relaxed text-zinc-600">@ndesc</p>
</li>
}
</ol>
<p class="mt-8 text-sm text-zinc-500">@(fa ? "تأخیر کل زیر ۵۰ میلی‌ثانیه؛ هر مرحله قابل مشاهده." : "Sub-50ms end-to-end, every stage observable.")</p>
</div>
</section>
<!-- ─── STACK (grouped tag clusters) ─────────────────────────────────── -->
<section id="stack" class="px-5 py-24 sm:px-8 sm:py-28">
<div class="mx-auto max-w-6xl">
<div class="sec-head">
<h2>@(fa ? "ابزار روزمره" : "Daily tooling")</h2>
<p class="lede">@(fa ? "هر چیزی که می‌سازم روی این‌ها بنا می‌شود؛ انتخاب‌شان کرده‌ام چون می‌مانند، نه چون مد روزند." : "Everything I ship sits on this foundation, chosen for longevity, not hype cycles.")</p>
</div>
<div class="bento">
@{
var cats = fa ? new[]{
("زبان‌ها", new[]{"Python","TypeScript","Go","Rust","SQL"}),
("موبایل", new[]{"Flutter","Swift / SwiftUI","Kotlin","React Native"}),
("زیرساخت", new[]{"Kubernetes","Terraform","Postgres","Redis","Kafka","NATS"}),
("هوش مصنوعی", new[]{"Vertex AI","Gemini","OpenAI","Anthropic","LangGraph","Pinecone","pgvector"}),
} : new[]{
("Languages", new[]{"Python","TypeScript","Go","Rust","SQL"}),
("Mobile", new[]{"Flutter","Swift / SwiftUI","Kotlin","React Native"}),
("Infrastructure",new[]{"Kubernetes","Terraform","Postgres","Redis","Kafka","NATS"}),
("AI / ML", new[]{"Vertex AI","Gemini","OpenAI","Anthropic","LangGraph","Pinecone","pgvector"}),
};
}
@{ int ci = 0; }
@foreach (var (catLabel, items) in cats)
{
<div class="tile reveal @(ci == 3 ? "tile-tint" : "")" style="transition-delay:@(ci * 60)ms">
<h3 class="text-sm font-semibold @(fa ? "font-fa" : "")">@catLabel</h3>
<div class="mt-4 flex flex-wrap gap-1.5">
@foreach (var item in items) { <span class="chip">@item</span> }
</div>
</div>
ci++;
}
</div>
</div>
</section>
<!-- ─── EXPERTISE (definition list) ──────────────────────────────────── -->
<section id="expertise" class="px-5 py-24 sm:px-8 sm:py-28">
<div class="mx-auto max-w-4xl">
<div class="sec-head">
<h2>@(fa ? "جاهایی که عمیق شده‌ام" : "What I go deep on")</h2>
<p class="lede">@(fa ? "سامانه‌هایی که روزانه میلیون‌ها رویداد را تاب می‌آورند. این‌ها همان چیزهایی‌اند که سال‌ها رویشان کار کرده‌ام." : "Systems that survive millions of events per day. These are the areas I optimize for.")</p>
</div>
<dl>
@{
var areas = fa ? new[]{
("معماری نرم‌افزار و سیستم‌های توزیع‌شده","میکروسرویس، استریم رویداد و الگوهای پایداری زیر بار سنگین."),
("اپلیکیشن‌های وب و سازمانی","پلتفرم‌های چندمستاجری، داشبورد و سیستم‌های پرترافیک."),
("راهکارهای هوش مصنوعی (LLM و RAG)","بازیابی، ارزیابی و تولید پاسخ مستند، داخل محصول واقعی."),
("زیرساخت ابری و Kubernetes","استقرار، مقیاس‌پذیری خودکار و حواس‌جمعی روی هزینه."),
("موبایل بومی و چندسکویی","Flutter، Swift و Kotlin برای اپ‌های روان و سریع."),
} : new[]{
("Software architecture & distributed systems","Microservices, event streaming, and resilience patterns at scale."),
("Web & enterprise applications","Multi-tenant platforms, dashboards, and high-traffic systems."),
("AI solutions (LLM & RAG)","Retrieval, evals, and grounded generation, inside real products."),
("Cloud infrastructure & Kubernetes","Deployment, autoscaling, and real cost discipline."),
("Native & cross-platform mobile","Flutter, Swift, and Kotlin for smooth, fast apps."),
};
}
@foreach (var (alabel, adesc) in areas)
{
<div class="reveal grid grid-cols-1 gap-1 border-t border-zinc-200 py-5 sm:grid-cols-[1fr_1.5fr] sm:gap-8">
<dt class="text-base font-semibold @(fa ? "font-fa" : "")">@alabel</dt>
<dd class="text-[.95rem] leading-relaxed text-zinc-600">@adesc</dd>
</div>
}
</dl>
</div>
</section>
<!-- ─── PORTFOLIO (card grid, typographic covers) ────────────────────── -->
<section id="portfolio" class="px-5 py-24 sm:px-8 sm:py-28">
<div class="mx-auto max-w-6xl">
<div class="sec-head">
<h2>@(fa ? "نمونه‌کارهای منتخب" : "Selected work")</h2>
<p class="lede">@(fa ? "محصولاتی که خودم طراحی و ساخته‌ام. روی هر کارت بزنید تا خودِ سایت را ببینید." : "Products I have designed and built. Tap any card to open the live site.")</p>
</div>
<div class="bento">
@{
var projects = fa ? new[]{
("hamkadr","همکادر","hamkadr.ir","بازاری که کادر درمان را برای شیفت و استخدام به بیمارستان‌ها و کلینیک‌ها وصل می‌کند؛ با پروفایل، فیلتر، تقویم هفتگی و اپ موبایل.",new[]{"Marketplace","Healthcare","Mobile"}),
("meezi","میزی","meezi.ir","سامانه‌ی یکپارچه برای کافه و رستوران: سفارش با QR، صندوق فروش، انبار، کارکنان و تحلیل فروش، روی زیرساخت داخلی.",new[]{"SaaS","POS","Analytics"}),
("bargevasat","برگ وسط","bargevasat.ir","بازی آنلاین حکم به‌صورت بلادرنگ مقابل بازیکنان واقعی یا ربات‌های هوشمند؛ با لیگ، رتبه‌بندی، جایزه‌ی روزانه و همگام‌سازی چنددستگاهه.",new[]{"Realtime","Multiplayer","Game"}),
("flatrender","فلت‌رندر","flatrender.ir","استودیوی هوش مصنوعی که بیش از ۱۲۰۰ قالب را در چند دقیقه به ویدیو و تصویر آماده‌ی انتشار تبدیل می‌کند؛ بر پایه‌ی ثانیه‌ی رندر. در نسخه‌ی بتا.",new[]{"AI","Video","SaaS"}),
} : new[]{
("hamkadr","Hamkadr","hamkadr.ir","A marketplace connecting healthcare staff with hospitals and clinics for shifts and hiring, with profiles, filters, weekly scheduling, and a mobile app.",new[]{"Marketplace","Healthcare","Mobile"}),
("meezi","Meezi","meezi.ir","An all-in-one SaaS for cafes and restaurants: QR ordering, POS, inventory, staff, and sales analytics, hosted in Iran.",new[]{"SaaS","POS","Analytics"}),
("bargevasat","Barge Vasat","bargevasat.ir","A real-time multiplayer Hokm card game against people or AI bots, with leagues, rankings, daily rewards, and cross-device play.",new[]{"Realtime","Multiplayer","Game"}),
("flatrender","Flatrender","flatrender.ir","An AI studio that turns 1,200+ templates into platform-ready videos and images in minutes, billed by render-seconds. In beta.",new[]{"AI","Video","SaaS"}),
};
}
@{ int pi = 0; }
@foreach (var (pid, pname, pdomain, pdesc, ptags) in projects)
{
var initial = char.ToUpperInvariant(pid[0]);
var (spanCls, coverBg, coverFg) = pi switch {
0 => ("span-2 row-2", "#18181b", "#fafafa"),
1 => ("span-2", "#2563eb", "#ffffff"),
2 => ("", "#eff4ff", "#2563eb"),
_ => ("", "#f4f4f5", "#a1a1aa"),
};
<a href="https://@pdomain" target="_blank" rel="noopener" aria-label="@pname"
class="group tile tile-link reveal @spanCls" style="padding:0;transition-delay:@(pi * 60)ms">
<div class="pcover" style="background:@coverBg;@(pi == 0 ? "min-height:210px" : "min-height:104px")">
<span class="font-display font-bold" style="font-size:@(pi == 0 ? "5rem" : "2.6rem");color:@coverFg">@initial</span>
</div>
<div class="flex flex-1 flex-col p-5">
<div class="mb-3 flex flex-wrap gap-1.5">
@foreach (var tag in ptags) { <span class="chip">@tag</span> }
</div>
<h3 class="text-[1.1rem] font-semibold @(fa ? "font-fa" : "")">@pname</h3>
<p class="mt-1 text-[.78rem] text-zinc-500" dir="ltr">@pdomain</p>
<p class="mt-2.5 text-[.88rem] leading-relaxed text-zinc-600 @(pi == 0 ? "" : "line-clamp-3")">@pdesc</p>
<span class="arrow-link mt-auto pt-4">
@(fa ? "مشاهده‌ی سایت" : "Visit site")
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 17 17 7"/><path d="M8 7h9v9"/></svg>
</span>
</div>
</a>
pi++;
}
</div>
</div>
</section>
<!-- ─── BLOG (editorial list) ────────────────────────────────────────── -->
<section id="blog" class="px-5 py-24 sm:px-8 sm:py-28">
<div class="mx-auto max-w-4xl">
<div class="sec-head">
<h2>@(fa ? "یادداشت‌های مهندسی" : "Engineering notes")</h2>
<p class="lede">@(fa ? "درس‌هایی از پروژه‌های واقعی. نه ترجمه‌ی مقاله، نه شعار توخالی." : "Findings from real engagements. Not translated articles, not hype lists.")</p>
</div>
<div class="border-b border-zinc-200">
@{
var posts = fa ? new[]{
("rag-eval-framework","LLM","چارچوب ارزیابی RAG که در عمل جواب می‌دهد","چرا BLEU و ROUGE برای RAG کافی نیستند، و معیارهایی که واقعاً به تصمیم کمک می‌کنند.",8),
("agentic-n8n-patterns","Automation","الگوهای عامل‌محور با n8n برای سازمان","چطور n8n را با LangGraph ترکیب کنیم تا گردش‌کارهای خودکار و قابل‌ردیابی بسازیم.",11),
("vertex-cost-control","Google Stack","کنترل هزینه روی Vertex AI در مقیاس بالا","سه اشتباه رایج که در بیشتر پروژه‌های Vertex می‌بینم، و اینکه چطور ۶۰٪ هزینه را کم کردیم.",6),
("k8s-llm-inference","Infra","اجرای LLM روی Kubernetes با تأخیر زیر ۵۰ میلی‌ثانیه","الگوی استقرار با KEDA، اشتراک GPU و request hedging برای سرویس‌دهی پایدار.",14),
("flutter-on-device-ai","Mobile","هوش مصنوعی روی دستگاه در Flutter","استفاده از Gemini Nano و LiteRT برای پردازش آفلاین در اپ‌های موبایل.",9),
("enterprise-ai-roadmap","Strategy","نقشه‌ی راه هوش مصنوعی سازمانی در ۹۰ روز","چارچوبی که برای مدیران فنی می‌چینم؛ از پیدا کردن بهترین ایده تا اولین اجرای واقعی.",7),
} : new[]{
("rag-eval-framework","LLM","A RAG evaluation framework that holds up in production","Why BLEU and ROUGE fall short for RAG, and the metrics that actually drive decisions in real projects.",8),
("agentic-n8n-patterns","Automation","Agentic patterns with n8n for the enterprise","How to combine n8n with LangGraph to build auditable, debuggable autonomous workflows.",11),
("vertex-cost-control","Google Stack","Vertex AI cost control at scale","Three anti-patterns I see in 80% of Vertex projects, and how we cut 60% of monthly spend.",6),
("k8s-llm-inference","Infra","Sub-50ms LLM inference on Kubernetes","Deployment pattern with KEDA, GPU sharing, and request hedging for stable serving.",14),
("flutter-on-device-ai","Mobile","On-device AI in Flutter","Using Gemini Nano and LiteRT for offline inference inside mobile apps.",9),
("enterprise-ai-roadmap","Strategy","A 90-day enterprise AI roadmap","The framework I build for CTOs, from use-case discovery to first production deployment.",7),
};
}
@foreach (var (slug, cat, btitle, excerpt, readTime) in posts)
{
<a href="/blog/@slug" class="group reveal grid grid-cols-1 gap-2 border-t border-zinc-200 py-6 sm:grid-cols-[8rem_1fr] sm:gap-8">
<div class="flex items-baseline justify-between sm:flex-col sm:gap-1">
<span class="kicker">@cat</span>
<span class="text-[.78rem] text-zinc-500">@readTime @(fa ? "دقیقه" : "min")</span>
</div>
<div>
<h3 class="text-[1.1rem] font-semibold transition-colors group-hover:text-accent @(fa ? "font-fa" : "")">@btitle</h3>
<p class="mt-1.5 text-[.9rem] leading-relaxed text-zinc-600">@excerpt</p>
<span class="arrow-link mt-3">
@(fa ? "خواندن" : "Read")
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="@(fa ? "rotate-180" : "")" aria-hidden="true"><path d="M5 12h14"/><path d="m13 6 6 6-6 6"/></svg>
</span>
</div>
</a>
}
</div>
</div>
</section>
<!-- ─── CONTACT ──────────────────────────────────────────────────────── -->
<section id="contact" class="px-5 py-24 sm:px-8 sm:py-28">
<div class="mx-auto max-w-2xl">
<div class="sec-head">
<h2>@(fa ? "رزرو یک جلسه‌ی ۳۰ دقیقه‌ای" : "Book a 30-minute call")</h2>
<p class="lede">@(fa ? "بدون هزینه، بدون تعهد. با هم می‌بینیم چه می‌خواهید، چه محدودیت‌هایی هست، و قدم بعد چیست." : "No cost, no commitment. We map the use case, the constraints, and the next step together.")</p>
</div>
<form id="contact-form" class="card space-y-5 p-6 sm:p-8"
data-success-msg="@(fa ? "پیام رسید! معمولاً ظرف ۲۴ ساعت کاری جواب می‌دهم." : "Sent. Typical reply within 24 working hours.")"
data-error-msg="@(fa ? "یک مشکلی پیش آمد. لطفاً دوباره تلاش کنید." : "Something went wrong. Please try again.")">
<input type="hidden" name="locale" value="@locale" />
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2">
<div>
<label class="flabel" for="name">@(fa ? "نام" : "Name")<span class="text-red-600" aria-hidden="true"> *</span></label>
<input id="name" name="name" type="text" required autocomplete="name" placeholder="@(fa ? "نام و نام خانوادگی" : "Full name")" class="field" />
</div>
<div>
<label class="flabel" for="company">@(fa ? "سازمان" : "Company")</label>
<input id="company" name="company" type="text" autocomplete="organization" placeholder="@(fa ? "نام سازمان" : "Organization")" class="field" />
</div>
</div>
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2">
<div>
<label class="flabel" for="service">@(fa ? "خدمت" : "Service")<span class="text-red-600" aria-hidden="true"> *</span></label>
<select id="service" name="service" required class="field">
<option value="" disabled selected>@(fa ? "انتخاب کنید" : "Select…")</option>
@if (fa)
{
<option value="apps">اپلیکیشن وب و سازمانی</option>
<option value="mobile">اپلیکیشن موبایل</option>
<option value="architecture">معماری و زیرساخت ابری</option>
<option value="ai">راهکار هوش مصنوعی</option>
<option value="automation">اتوماسیون و یکپارچه‌سازی</option>
<option value="strategy">راهبرد و نقشه‌ی راه</option>
}
else
{
<option value="apps">Web & enterprise apps</option>
<option value="mobile">Mobile apps</option>
<option value="architecture">Solution architecture & cloud</option>
<option value="ai">AI solutions</option>
<option value="automation">Automation & integrations</option>
<option value="strategy">Strategy & roadmap</option>
}
</select>
</div>
<div>
<label class="flabel" for="budget">@(fa ? "بودجه (تقریبی)" : "Budget (rough)")<span class="text-red-600" aria-hidden="true"> *</span></label>
<select id="budget" name="budget" required class="field">
<option value="" disabled selected>@(fa ? "انتخاب کنید" : "Select…")</option>
<option value="under-10k">@(fa ? "زیر ۱۰ هزار دلار" : "Under $10k")</option>
<option value="10-50k">@(fa ? "۱۰ تا ۵۰ هزار دلار" : "$10k - $50k")</option>
<option value="50-200k">@(fa ? "۵۰ تا ۲۰۰ هزار دلار" : "$50k - $200k")</option>
<option value="200k-plus">@(fa ? "بیش از ۲۰۰ هزار دلار" : "$200k+")</option>
</select>
</div>
</div>
<div>
<label class="flabel" for="message">@(fa ? "پیام" : "Message")<span class="text-red-600" aria-hidden="true"> *</span></label>
<textarea id="message" name="message" required rows="4" placeholder="@(fa ? "هدف، بازه‌ی زمانی، و چیزی که الان گیرتان انداخته…" : "Goal, timeline, current blockers…")" class="field resize-none"></textarea>
</div>
<button type="submit" class="btn w-full">@(fa ? "ارسال پیام" : "Send request")</button>
<p id="contact-status" role="status" aria-live="polite" class="mt-1 text-sm text-zinc-500">@(fa ? "معمولاً ظرف ۲۴ ساعت کاری جواب می‌دهم." : "Typical reply within 24 working hours.")</p>
</form>
</div>
</section>
@functions {
static string ServiceIcon(string id) => id switch {
"strategy" => """<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M9 20H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h4"/><polyline points="9,9 4,9"/><polyline points="9,12 4,12"/><polyline points="9,15 4,15"/><rect x="9" y="2" width="6" height="6"/><path d="M15 8h4a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2h-4"/></svg>""",
"automation" => """<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M4.22 4.22l2.12 2.12M17.66 17.66l2.12 2.12M2 12h3M19 12h3M4.22 19.78l2.12-2.12M17.66 6.34l2.12-2.12"/></svg>""",
"llm-rag" => """<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>""",
"architecture" => """<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><rect x="2" y="3" width="6" height="6"/><rect x="16" y="3" width="6" height="6"/><rect x="9" y="15" width="6" height="6"/><path d="M5 9v3h14V9M12 12v3"/></svg>""",
"mobile" => """<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><rect x="5" y="2" width="14" height="20" rx="2"/><path d="M12 18h.01"/></svg>""",
"apps" => """<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18"/><path d="M7 6.5h.01"/></svg>""",
_ => """<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/></svg>""",
};
}
+34
View File
@@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Mvc;
using SoroushAsadi.Services;
namespace SoroushAsadi.Pages;
[Microsoft.AspNetCore.Mvc.IgnoreAntiforgeryToken]
public class IndexModel : BasePageModel
{
public string BlogReadMore { get; private set; } = "Read";
public string BlogReadSuffix { get; private set; } = "min";
public void OnGet()
{
BlogReadMore = IsFa ? "ادامه" : "Read";
BlogReadSuffix = IsFa ? "دقیقه" : "min";
}
public async Task<IActionResult> OnPostContactAsync(
[FromServices] EmailService email,
string name, string company, string service,
string budget, string message)
{
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(service) ||
string.IsNullOrWhiteSpace(budget) || string.IsNullOrWhiteSpace(message))
return BadRequest(new { error = "Missing required fields" });
var err = await email.SendContactAsync(
new EmailService.ContactForm(name, company ?? "", service, budget, message, Locale));
return err is null
? new JsonResult(new { ok = true })
: StatusCode(502, new { error = err });
}
}
+2
View File
@@ -0,0 +1,2 @@
@page "/locale"
@model SoroushAsadi.Pages.LocalePageModel
+25
View File
@@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace SoroushAsadi.Pages;
/// <summary>POST /locale — sets the locale cookie and redirects back.</summary>
[IgnoreAntiforgeryToken]
public class LocalePageModel : PageModel
{
public IActionResult OnPost(string locale, string returnUrl = "/")
{
if (locale is not "fa" and not "en") locale = "fa";
Response.Cookies.Append("locale", locale, new CookieOptions
{
Expires = DateTimeOffset.UtcNow.AddYears(1),
HttpOnly = false,
SameSite = SameSiteMode.Lax,
Path = "/"
});
if (!Url.IsLocalUrl(returnUrl)) returnUrl = "/";
return LocalRedirect(returnUrl);
}
}
+49
View File
@@ -0,0 +1,49 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>@ViewData["Title"] — Admin</title>
<style>
@@font-face { font-family:'Syne'; src:url('/fonts/Syne-Variable.woff2') format('woff2'); font-weight:100 900; font-display:swap; }
@@font-face { font-family:'SpaceMono'; src:url('/fonts/SpaceMono-Regular.woff2') format('woff2'); font-display:swap; }
</style>
<!-- Tailwind: prebuilt + purged admin stylesheet (`npm run build`). No runtime CDN. -->
<link rel="stylesheet" href="/css/tailwind-admin.css" asp-append-version="true" />
<link rel="stylesheet" href="/css/site.css" asp-append-version="true" />
</head>
<body class="min-h-screen bg-base text-slate-200 antialiased">
<div class="flex min-h-screen">
<!-- Sidebar -->
<aside class="hidden w-56 shrink-0 flex-col border-r border-white/5 bg-base-800 md:flex">
<div class="flex h-14 items-center gap-2 border-b border-white/5 px-4">
<img src="/logo-mark.svg" alt="" width="22" height="22" />
<span class="font-display text-sm font-semibold text-white">CMS</span>
</div>
<nav class="flex flex-1 flex-col gap-1 p-3 text-sm">
<a href="/Admin" class="admin-nav-link">Dashboard</a>
<a href="/Admin/Sections" class="admin-nav-link">Sections</a>
<a href="/Admin/Posts" class="admin-nav-link">Blog posts</a>
<div class="mt-auto pt-4">
<form method="post" action="/Admin/Logout">
<button class="admin-nav-link w-full text-start text-red-400 hover:text-red-300">Sign out</button>
</form>
</div>
</nav>
</aside>
<!-- Main -->
<div class="flex flex-1 flex-col">
<header class="flex h-14 items-center justify-between border-b border-white/5 bg-base-800 px-6 md:hidden">
<span class="font-display text-sm font-semibold text-white">CMS</span>
<form method="post" action="/Admin/Logout">
<button class="text-xs text-red-400">Sign out</button>
</form>
</header>
<main class="flex-1 overflow-auto p-6">
@RenderBody()
</main>
</div>
</div>
</body>
</html>
+161
View File
@@ -0,0 +1,161 @@
@{
var locale = (string)(ViewData["Locale"] ?? "fa");
var fa = locale == "fa";
var isRtl = locale == "fa";
var dir = isRtl ? "rtl" : "ltr";
var lang = locale == "fa" ? "fa" : "en";
var title = (string?)ViewData["Title"] ?? (locale == "fa"
? "سروش اسعدی - مهندس نرم‌افزار و هوش مصنوعی"
: "Soroush Asadi - Software & AI Engineer");
}
<!doctype html>
<html lang="@lang" dir="@dir">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>@title</title>
<meta name="description" content="@(locale == "fa"
? "نرم‌افزار، اپلیکیشن‌های سازمانی و راهکارهای هوش مصنوعی می‌سازم: پلتفرم‌های وب و موبایل، سیستم‌های توزیع‌شده، زیرساخت ابری و قابلیت‌های LLM و RAG که به تولید می‌رسند."
: "I design and build software, enterprise apps, and AI solutions: web and mobile platforms, distributed systems, cloud, and LLM/RAG features that ship to production.")" />
<meta name="theme-color" content="#fafafa" />
<!-- Fonts: Syne (display) + Vazirmatn (Persian). Body is system sans. -->
<style>
@@font-face { font-family:'Syne'; src:url('/fonts/Syne-Variable.woff2') format('woff2'); font-weight:100 900; font-display:swap; }
@@font-face { font-family:'Vazirmatn'; src:url('/fonts/Vazirmatn-Arabic.woff2') format('woff2'); font-display:swap; }
</style>
<!-- Tailwind: prebuilt + purged stylesheet (`npm run build`). No runtime CDN. -->
<link rel="stylesheet" href="/css/tailwind.css" asp-append-version="true" />
<link rel="stylesheet" href="/css/site.css" asp-append-version="true" />
<link rel="icon" href="/logo-mark.svg" type="image/svg+xml" asp-append-version="true" />
</head>
<body class="site antialiased">
<!-- Reading progress (CSS scroll-driven) -->
<div class="scroll-progress" aria-hidden="true"></div>
<!-- Sentinel for the navbar border (observed by IntersectionObserver) -->
<div id="nav-sentinel" aria-hidden="true"></div>
<!-- Navbar -->
<header id="navbar" class="fixed inset-x-0 top-0 z-50">
<div class="mx-auto flex max-w-6xl items-center justify-between px-5 py-3.5 sm:px-8">
<!-- Logo -->
<a href="/#top" class="flex items-center gap-2.5" aria-label="@(locale == "fa" ? "خانه" : "Home")">
<img src="/logo-mark.svg" alt="" width="26" height="26" class="h-[26px] w-[26px]" />
<span class="font-display text-[15px] font-bold text-zinc-900 @(isRtl ? "font-fa" : "")">
@(locale == "fa" ? "سروش اسعدی" : "Soroush Asadi")
</span>
</a>
<!-- Desktop nav -->
<nav class="hidden items-center gap-7 md:flex" aria-label="Main">
@if (locale == "fa")
{
<a href="/#services" class="nav-link">خدمات</a>
<a href="/#stack" class="nav-link">استک</a>
<a href="/#expertise" class="nav-link">تخصص</a>
<a href="/#portfolio" class="nav-link">نمونه‌کارها</a>
<a href="/#blog" class="nav-link">بلاگ</a>
}
else
{
<a href="/#services" class="nav-link">Services</a>
<a href="/#stack" class="nav-link">Stack</a>
<a href="/#expertise" class="nav-link">Expertise</a>
<a href="/#portfolio" class="nav-link">Portfolio</a>
<a href="/#blog" class="nav-link">Blog</a>
}
<a href="/#contact" class="btn text-sm">@(locale == "fa" ? "رزرو جلسه" : "Book a call")</a>
<!-- Locale toggle -->
<form method="post" action="/locale">
<input type="hidden" name="locale" value="@(locale == "fa" ? "en" : "fa")" />
<input type="hidden" name="returnUrl" value="@Context.Request.Path@Context.Request.QueryString" />
<button type="submit" class="nav-link text-xs tracking-wide">@(locale == "fa" ? "EN" : "FA")</button>
</form>
</nav>
<!-- Mobile menu button -->
<button id="menu-btn" class="flex flex-col gap-1.5 p-2 md:hidden" aria-label="@(locale == "fa" ? "منو" : "Menu")" aria-expanded="false">
<span class="block h-0.5 w-5 bg-zinc-800"></span>
<span class="block h-0.5 w-5 bg-zinc-800"></span>
<span class="block h-0.5 w-5 bg-zinc-800"></span>
</button>
</div>
<!-- Mobile drawer -->
<div id="mobile-menu" class="hidden border-t border-zinc-200 bg-white/95 backdrop-blur-xl md:hidden">
<nav class="flex flex-col gap-1 px-5 py-4">
@if (locale == "fa")
{
<a href="/#services" class="nav-link py-2">خدمات</a>
<a href="/#stack" class="nav-link py-2">استک</a>
<a href="/#expertise" class="nav-link py-2">تخصص</a>
<a href="/#portfolio" class="nav-link py-2">نمونه‌کارها</a>
<a href="/#blog" class="nav-link py-2">بلاگ</a>
<a href="/#contact" class="nav-link py-2">تماس</a>
}
else
{
<a href="/#services" class="nav-link py-2">Services</a>
<a href="/#stack" class="nav-link py-2">Stack</a>
<a href="/#expertise" class="nav-link py-2">Expertise</a>
<a href="/#portfolio" class="nav-link py-2">Portfolio</a>
<a href="/#blog" class="nav-link py-2">Blog</a>
<a href="/#contact" class="nav-link py-2">Contact</a>
}
<form method="post" action="/locale" class="mt-2">
<input type="hidden" name="locale" value="@(locale == "fa" ? "en" : "fa")" />
<input type="hidden" name="returnUrl" value="@Context.Request.Path@Context.Request.QueryString" />
<button type="submit" class="nav-link text-xs tracking-wide">@(locale == "fa" ? "Switch to English" : "تغییر به فارسی")</button>
</form>
</nav>
</div>
</header>
<main>
@RenderBody()
</main>
<!-- Footer (shared across all public pages) -->
<footer class="site-footer mt-8 px-5 py-14 sm:px-8">
<div class="mx-auto grid max-w-6xl grid-cols-1 gap-10 sm:grid-cols-2 lg:grid-cols-[1.5fr_1fr_1fr]">
<div>
<a href="/#top" class="flex items-center gap-2.5" aria-label="@(locale == "fa" ? "خانه" : "Home")">
<img src="/logo-mark.svg" alt="" width="26" height="26" class="h-[26px] w-[26px]" />
<span class="font-display text-[15px] font-bold text-zinc-900 @(isRtl ? "font-fa" : "")">@(locale == "fa" ? "سروش اسعدی" : "Soroush Asadi")</span>
</a>
<p class="mt-4 max-w-xs text-sm leading-relaxed text-zinc-600">@(fa ? "نرم‌افزار، اپلیکیشن‌های سازمانی و راهکارهای هوش مصنوعی که در عمل کار می‌کنند." : "Software, enterprise apps, and AI solutions, engineered to last.")</p>
<div class="mt-5 flex gap-2.5">
<a class="social" href="https://www.linkedin.com/in/soroushdes/" target="_blank" rel="noopener" aria-label="LinkedIn"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5ZM3 9h4v12H3V9Zm6 0h3.8v1.64h.05c.53-1 1.83-2.06 3.76-2.06 4.02 0 4.76 2.65 4.76 6.1V21h-4v-5.4c0-1.29-.02-2.95-1.8-2.95-1.8 0-2.07 1.4-2.07 2.85V21H9V9Z"/></svg></a>
<a class="social" href="https://www.instagram.com/soroushasadicom/" target="_blank" rel="noopener" aria-label="Instagram"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="5"/><circle cx="12" cy="12" r="4"/><circle cx="17.2" cy="6.8" r="1.1" fill="currentColor" stroke="none"/></svg></a>
<a class="social" href="mailto:code.soroush@gmail.com" aria-label="Email"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></svg></a>
</div>
</div>
<nav aria-label="Footer" class="flex flex-col gap-2.5">
<span class="kicker mb-1">@(fa ? "پیمایش" : "Navigate")</span>
<a href="/#services" class="foot-link">@(fa ? "خدمات" : "Services")</a>
<a href="/#stack" class="foot-link">@(fa ? "استک" : "Stack")</a>
<a href="/#portfolio" class="foot-link">@(fa ? "نمونه‌کارها" : "Work")</a>
<a href="/blog" class="foot-link">@(fa ? "بلاگ" : "Blog")</a>
</nav>
<div class="flex flex-col gap-2.5">
<span class="kicker mb-1">@(fa ? "تماس" : "Get in touch")</span>
<a href="/#contact" class="foot-link">@(fa ? "رزرو جلسه" : "Book a call")</a>
<a href="mailto:code.soroush@gmail.com" class="foot-link">code.soroush@gmail.com</a>
</div>
</div>
<div class="mx-auto mt-12 flex max-w-6xl flex-col items-center gap-2 border-t border-zinc-200 pt-6 text-center sm:flex-row sm:justify-between sm:text-start">
<p class="text-[.78rem] text-zinc-500">© 2026 Soroush Asadi. @(fa ? "تمام حقوق محفوظ است." : "All rights reserved.")</p>
<p class="text-[.78rem] text-zinc-500">@(fa ? "ساخته‌شده با دقت در تهران." : "Built with care in Tehran.")</p>
</div>
</footer>
<script src="/js/app.js" defer asp-append-version="true"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
@using SoroushAsadi
@using SoroushAsadi.Services
@using System.Text.Json.Nodes
@namespace SoroushAsadi.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
+3
View File
@@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}
+90
View File
@@ -0,0 +1,90 @@
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.EntityFrameworkCore;
using SoroushAsadi.Database;
using SoroushAsadi.Services;
var builder = WebApplication.CreateBuilder(args);
// --- Razor Pages ---
builder.Services.AddRazorPages();
// --- Authentication (single-password cookie auth) ---
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(opt =>
{
opt.LoginPath = "/Admin/Login";
opt.LogoutPath = "/Admin/Logout";
opt.Cookie.Name = "sa_admin";
opt.Cookie.HttpOnly = true;
opt.Cookie.SameSite = SameSiteMode.Lax;
opt.ExpireTimeSpan = TimeSpan.FromDays(7);
opt.SlidingExpiration = true;
});
builder.Services.AddAuthorization();
// --- EF Core + SQLite ---
var dataDir = builder.Configuration["DataDir"]
?? Path.Combine(builder.Environment.ContentRootPath, "data");
Directory.CreateDirectory(dataDir);
Directory.CreateDirectory(Path.Combine(dataDir, "uploads"));
builder.Services.AddDbContext<AppDbContext>(opt =>
opt.UseSqlite($"Data Source={Path.Combine(dataDir, "cms.db")}"));
// --- App services ---
builder.Services.AddScoped<ContentService>();
builder.Services.AddScoped<AuthService>();
builder.Services.AddScoped<EmailService>();
builder.Services.AddHttpClient<EmailService>();
// --- Static file serving for /data/uploads ---
builder.Services.Configure<StaticFileOptions>(opt => { });
var app = builder.Build();
// Run EF migrations on startup (creates DB if missing)
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.EnsureCreated();
}
app.UseStatusCodePagesWithReExecute("/Error/{0}");
// HTML pages must never be cached by the browser or CDN, so a new deploy is
// visible immediately. Static assets are fingerprinted via asp-append-version
// (?v=hash), so they can be cached aggressively and bust automatically.
app.Use(async (context, next) =>
{
context.Response.OnStarting(() =>
{
if (context.Response.ContentType?.Contains("text/html", StringComparison.OrdinalIgnoreCase) == true)
{
context.Response.Headers.CacheControl = "no-cache, no-store, must-revalidate";
context.Response.Headers.Pragma = "no-cache";
}
return Task.CompletedTask;
});
await next();
});
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = ctx =>
ctx.Context.Response.Headers.CacheControl = "public, max-age=31536000"
});
// Serve uploaded files from /data/uploads under /uploads/*
var uploadsPath = Path.Combine(dataDir, "uploads");
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(uploadsPath),
RequestPath = "/uploads"
});
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapRazorPages();
app.Run();
+37
View File
@@ -0,0 +1,37 @@
using System.Security.Cryptography;
using System.Text;
namespace SoroushAsadi.Services;
/// <summary>Single-password authentication for the admin panel.</summary>
public class AuthService(IConfiguration config, IWebHostEnvironment env)
{
private string? GetPassword()
{
var pw = config["ADMIN_PASSWORD"] ?? Environment.GetEnvironmentVariable("ADMIN_PASSWORD");
if (!string.IsNullOrEmpty(pw)) return pw;
// Allow "admin" in Development only
return env.IsDevelopment() ? "admin" : null;
}
/// <summary>True when the submitted password matches the configured one (constant-time).</summary>
public bool VerifyPassword(string input)
{
var expected = GetPassword();
if (expected is null) return false;
var a = SHA256Hash(input);
var b = SHA256Hash(expected);
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(a),
Encoding.UTF8.GetBytes(b));
}
public bool IsConfigured() => GetPassword() is not null;
private static string SHA256Hash(string input)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
}
+107
View File
@@ -0,0 +1,107 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using SoroushAsadi.Database;
namespace SoroushAsadi.Services;
/// <summary>
/// Merges hardcoded default content with admin overrides stored in SQLite.
/// Sections are keyed by name ("hero", "services", etc.).
/// The stored JSON is {"fa": {...}, "en": {...}} for bilingual sections,
/// or a slug-keyed map for "posts".
/// </summary>
public class ContentService(AppDbContext db)
{
private static readonly JsonSerializerOptions _json =
new() { PropertyNameCaseInsensitive = true };
// ── Public API ────────────────────────────────────────────────────────
/// <summary>Returns the merged content for a section as a JsonNode.
/// Callers get the locale-specific sub-object (e.g. node["en"]).</summary>
public JsonNode? GetSection(string key)
{
try
{
var row = db.ContentSections.Find(key);
if (row is null) return null;
return JsonNode.Parse(row.DataJson);
}
catch { return null; }
}
/// <summary>Returns merged bilingual content for the given section + locale.</summary>
public JsonNode? GetSectionLocale(string key, string locale)
{
var node = GetSection(key);
return node?[locale];
}
/// <summary>Returns all section rows (for admin listing).</summary>
public IReadOnlyList<string> GetSectionKeys() =>
db.ContentSections.Select(s => s.Key).ToList();
/// <summary>Upserts a section's JSON data.</summary>
public void SaveSection(string key, string json)
{
var row = db.ContentSections.Find(key);
if (row is null)
{
row = new Models.ContentSection { Key = key };
db.ContentSections.Add(row);
}
row.DataJson = json;
row.UpdatedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
db.SaveChanges();
}
/// <summary>Deletes a section override (restores built-in default).</summary>
public void DeleteSection(string key)
{
var row = db.ContentSections.Find(key);
if (row is not null)
{
db.ContentSections.Remove(row);
db.SaveChanges();
}
}
// ── Posts (stored under key "posts") ─────────────────────────────────
public const string PostsKey = "posts";
/// <summary>Returns the slug→PostContent map from DB, or empty.</summary>
public Dictionary<string, JsonNode> GetPostOverrides()
{
try
{
var row = db.ContentSections.Find(PostsKey);
if (row is null) return [];
var parsed = JsonNode.Parse(row.DataJson);
if (parsed is not JsonObject obj) return [];
return obj.ToDictionary(kv => kv.Key, kv => kv.Value!);
}
catch { return []; }
}
/// <summary>Saves a single post override.</summary>
public void SavePost(string slug, JsonNode content)
{
var row = db.ContentSections.Find(PostsKey);
JsonObject obj;
if (row is null)
{
obj = [];
row = new Models.ContentSection { Key = PostsKey };
db.ContentSections.Add(row);
}
else
{
obj = JsonNode.Parse(row.DataJson) as JsonObject ?? [];
}
obj[slug] = content.DeepClone();
row.DataJson = obj.ToJsonString();
row.UpdatedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
db.SaveChanges();
}
}
+69
View File
@@ -0,0 +1,69 @@
using System.Net.Http.Json;
using System.Text.Json;
namespace SoroushAsadi.Services;
public class EmailService(HttpClient http, IConfiguration config, ILogger<EmailService> logger)
{
private string? ApiKey => config["RESEND_API_KEY"] ?? Environment.GetEnvironmentVariable("RESEND_API_KEY");
private string? Inbox => config["CONTACT_INBOX"] ?? Environment.GetEnvironmentVariable("CONTACT_INBOX");
private string? From => config["CONTACT_FROM"] ?? Environment.GetEnvironmentVariable("CONTACT_FROM");
public record ContactForm(
string Name, string Company, string Service,
string Budget, string Message, string Locale);
/// <returns>null on success, error string on failure.</returns>
public async Task<string?> SendContactAsync(ContactForm form)
{
if (ApiKey is null || Inbox is null || From is null)
{
logger.LogInformation("[contact] received (no Resend key — logging only): {Name}", form.Name);
return null; // dev no-op
}
var 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>{Esc(form.Name)}</td></tr>
<tr><td style="padding:4px 12px 4px 0;color:#475569">Company</td><td>{Esc(form.Company)}</td></tr>
<tr><td style="padding:4px 12px 4px 0;color:#475569">Service</td><td>{Esc(form.Service)}</td></tr>
<tr><td style="padding:4px 12px 4px 0;color:#475569">Budget</td><td>{Esc(form.Budget)}</td></tr>
<tr><td style="padding:4px 12px 4px 0;color:#475569">Locale</td><td>{Esc(form.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">{Esc(form.Message)}</p>
</div>
""";
using var req = new HttpRequestMessage(HttpMethod.Post, "https://api.resend.com/emails");
req.Headers.Add("Authorization", $"Bearer {ApiKey}");
req.Content = JsonContent.Create(new
{
from = From,
to = new[] { Inbox },
subject = $"New consultation request — {form.Name}",
html
});
try
{
var res = await http.SendAsync(req);
if (!res.IsSuccessStatusCode)
{
var body = await res.Content.ReadAsStringAsync();
logger.LogError("[contact] Resend error {Status}: {Body}", res.StatusCode, body);
return "Email service rejected the request.";
}
return null;
}
catch (Exception ex)
{
logger.LogError(ex, "[contact] Send failed");
return "Email service unreachable.";
}
}
private static string Esc(string? s) => System.Web.HttpUtility.HtmlEncode(s ?? "");
}
+21
View File
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>SoroushAsadi</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<!-- Override the transitive SQLitePCLRaw 2.1.11 (GHSA-2m69-gcr7-jv3q, no 2.x patch)
with the 3.0.x line, which is outside the vulnerable range (<= 2.1.11). -->
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" />
</ItemGroup>
</Project>
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
-91
View File
@@ -1,91 +0,0 @@
'use client';
import { Suspense, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
function LoginInner() {
const router = useRouter();
const params = useSearchParams();
const from = params.get('from') || '/admin';
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
try {
const res = await fetch('/api/admin/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ password }),
});
if (res.ok) {
router.replace(from);
router.refresh();
} else {
setError('Incorrect password.');
setBusy(false);
}
} catch {
setError('Something went wrong. Try again.');
setBusy(false);
}
}
return (
<div dir="ltr" className="flex min-h-screen items-center justify-center px-5">
<div
aria-hidden
className="pointer-events-none fixed inset-0 -z-10 bg-radial-aurora opacity-50"
/>
<form
onSubmit={submit}
className="w-full max-w-sm rounded-2xl border border-white/10 bg-base-900/70 p-8 backdrop-blur-xl"
>
<div className="mb-6 flex items-center gap-3">
<span className="grid h-10 w-10 place-items-center rounded-xl bg-electric/15 font-mono text-sm font-bold text-electric">
SA
</span>
<div>
<h1 className="text-base font-semibold text-white">Content CMS</h1>
<p className="font-mono text-[0.65rem] uppercase tracking-wider text-slate-500">
soroushasadi.ir
</p>
</div>
</div>
<label className="mb-1.5 block font-mono text-[0.68rem] uppercase tracking-wider text-slate-400">
Admin password
</label>
<input
type="password"
autoFocus
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-lg border border-white/10 bg-base-900/60 px-3 py-2.5 text-sm text-slate-100 outline-none focus:border-electric/60"
placeholder="••••••••"
/>
{error && <p className="mt-3 text-sm text-magenta">{error}</p>}
<button
type="submit"
disabled={busy || !password}
className="mt-5 w-full rounded-lg bg-electric px-4 py-2.5 text-sm font-semibold text-base-900 transition-opacity hover:opacity-90 disabled:opacity-50"
>
{busy ? 'Signing in…' : 'Sign in'}
</button>
</form>
</div>
);
}
export default function AdminLoginPage() {
return (
<Suspense fallback={null}>
<LoginInner />
</Suspense>
);
}
-95
View File
@@ -1,95 +0,0 @@
import Link from 'next/link';
import { AdminShell } from '@/components/admin/AdminShell';
import { EDITABLE_SECTIONS } from '@/lib/content/sections';
import { sectionStatus } from '@/lib/db/store';
import { passwordConfigured } from '@/lib/auth/session';
export const dynamic = 'force-dynamic';
function timeAgo(ts: number): string {
const s = Math.floor((Date.now() - ts) / 1000);
if (s < 60) return 'just now';
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
export default function AdminDashboard() {
const status = sectionStatus();
const usingDefaultPassword = !process.env.ADMIN_PASSWORD && passwordConfigured();
const edited = Object.keys(status).length;
return (
<AdminShell>
<div className="mx-auto max-w-4xl">
<div className="mb-8">
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
<p className="mt-1 text-sm text-slate-400">
Edit every section of the site. {edited > 0 ? `${edited} section${edited > 1 ? 's' : ''} customized.` : 'All sections are at their defaults.'}
</p>
</div>
{usingDefaultPassword && (
<div className="mb-6 rounded-xl border border-magenta/30 bg-magenta/5 p-4 text-sm text-magenta">
<strong>Heads up:</strong> no <code className="font-mono">ADMIN_PASSWORD</code> is set, so the dev default
(<code className="font-mono">admin</code>) is in use. Set one in your environment before going live.
</div>
)}
<div className="grid gap-3 sm:grid-cols-2">
{EDITABLE_SECTIONS.map((s) => {
const edited = status[s.key];
return (
<Link
key={s.key}
href={`/admin/sections/${s.key}`}
className="group rounded-xl border border-white/8 bg-white/[0.02] p-5 transition-colors hover:border-electric/30 hover:bg-electric/[0.03]"
>
<div className="flex items-center justify-between">
<h2 className="font-semibold text-white group-hover:text-electric">
{s.label.en}
<span className="ms-2 font-fa text-sm font-normal text-slate-500">
{s.label.fa}
</span>
</h2>
{edited ? (
<span className="rounded-full border border-emerald/30 bg-emerald/5 px-2 py-0.5 font-mono text-[0.6rem] uppercase tracking-wider text-emerald">
edited · {timeAgo(edited)}
</span>
) : (
<span className="rounded-full border border-white/10 px-2 py-0.5 font-mono text-[0.6rem] uppercase tracking-wider text-slate-500">
default
</span>
)}
</div>
<p className="mt-2 text-sm text-slate-400">{s.desc.en}</p>
</Link>
);
})}
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<Link
href="/admin/posts"
className="group rounded-xl border border-violet/20 bg-violet/[0.03] p-5 transition-colors hover:border-violet/40 hover:bg-violet/[0.06]"
>
<div className="flex items-center justify-between">
<h2 className="font-semibold text-white group-hover:text-violet">
Journal articles
<span className="ms-2 font-fa text-sm font-normal text-slate-500">مقالات</span>
</h2>
<span className="font-mono text-[0.65rem] uppercase tracking-wider text-violet">
bodies
</span>
</div>
<p className="mt-2 text-sm text-slate-400">
Edit the full bilingual body of each blog post (lead + content blocks).
</p>
</Link>
</div>
</div>
</AdminShell>
);
}
-51
View File
@@ -1,51 +0,0 @@
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { AdminShell } from '@/components/admin/AdminShell';
import { PostEditor } from '@/components/admin/PostEditor';
import type { JsonValue } from '@/components/admin/JsonForm';
import { loadContent } from '@/lib/content/load';
import { loadPost, loadPostOverrides, isKnownSlug } from '@/lib/content/posts-store';
// Always render on demand so the editor mirrors current DB state.
export const dynamic = 'force-dynamic';
export default function AdminPostEditorPage({ params }: { params: { slug: string } }) {
const { slug } = params;
if (!isKnownSlug(slug)) notFound();
const post = loadPost(slug);
if (!post) notFound();
const overridden = slug in loadPostOverrides();
const { en } = loadContent();
const title = en.blog.items.find((p) => p.slug === slug)?.title ?? slug;
return (
<AdminShell>
<div className="mx-auto max-w-3xl">
<Link
href="/admin/posts"
className="font-mono text-[0.7rem] uppercase tracking-wider text-slate-500 transition-colors hover:text-electric"
>
Journal articles
</Link>
<h1 className="mb-1 mt-3 text-2xl font-bold text-white">{title}</h1>
<p className="mb-6 text-sm text-slate-400">
Edit the lead and body blocks for both languages, then save. Changes go live immediately.
</p>
<PostEditor
slug={slug}
title={title}
initial={{
date: post.date as JsonValue,
accent: post.accent as JsonValue,
fa: post.fa as unknown as JsonValue,
en: post.en as unknown as JsonValue,
}}
isOverridden={overridden}
/>
</div>
</AdminShell>
);
}
-74
View File
@@ -1,74 +0,0 @@
import Link from 'next/link';
import { AdminShell } from '@/components/admin/AdminShell';
import { loadContent } from '@/lib/content/load';
import { loadAllPosts, loadPostOverrides } from '@/lib/content/posts-store';
// Always reflect live DB state in the editor list.
export const dynamic = 'force-dynamic';
export default function AdminPostsPage() {
const posts = loadAllPosts();
const overrides = loadPostOverrides();
const { en } = loadContent();
const cardBySlug = new Map<string, (typeof en.blog.items)[number]>(
en.blog.items.map((p) => [p.slug, p]),
);
const slugs = Object.keys(posts);
const editedCount = Object.keys(overrides).length;
return (
<AdminShell>
<div className="mx-auto max-w-4xl">
<div className="mb-8">
<h1 className="text-2xl font-bold text-white">Journal articles</h1>
<p className="mt-1 text-sm text-slate-400">
Edit the full bilingual body of each post.{' '}
{editedCount > 0
? `${editedCount} article${editedCount > 1 ? 's' : ''} customized.`
: 'All articles are at their defaults.'}{' '}
Titles, excerpts and read time live under the{' '}
<Link href="/admin/sections/blog" className="text-electric hover:underline">
Journal
</Link>{' '}
section.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{slugs.map((slug) => {
const card = cardBySlug.get(slug);
const post = posts[slug];
const edited = slug in overrides;
return (
<Link
key={slug}
href={`/admin/posts/${slug}`}
className="group rounded-xl border border-white/8 bg-white/[0.02] p-5 transition-colors hover:border-electric/30 hover:bg-electric/[0.03]"
>
<div className="flex items-start justify-between gap-3">
<h2 className="font-semibold leading-snug text-white group-hover:text-electric">
{card?.title ?? slug}
</h2>
{edited ? (
<span className="shrink-0 rounded-full border border-emerald/30 bg-emerald/5 px-2 py-0.5 font-mono text-[0.6rem] uppercase tracking-wider text-emerald">
edited
</span>
) : (
<span className="shrink-0 rounded-full border border-white/10 px-2 py-0.5 font-mono text-[0.6rem] uppercase tracking-wider text-slate-500">
default
</span>
)}
</div>
<div className="mt-2 flex items-center gap-3 font-mono text-[0.65rem] uppercase tracking-wider text-slate-500">
<span>{card?.category ?? '—'}</span>
<span>·</span>
<span>{post.date}</span>
</div>
</Link>
);
})}
</div>
</div>
</AdminShell>
);
}
-50
View File
@@ -1,50 +0,0 @@
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { AdminShell } from '@/components/admin/AdminShell';
import { SectionEditor } from '@/components/admin/SectionEditor';
import type { JsonValue } from '@/components/admin/JsonForm';
import { isEditableKey, sectionLabel } from '@/lib/content/sections';
import { loadSection } from '@/lib/content/load';
import { getSection } from '@/lib/db/store';
// Always render on demand: the editor must reflect the current DB state, and
// generateStaticParams would otherwise bake build-time defaults into the page.
export const dynamic = 'force-dynamic';
export default function SectionEditorPage({ params }: { params: { key: string } }) {
const { key } = params;
if (!isEditableKey(key)) notFound();
const data = loadSection(key);
const label = sectionLabel(key);
const isOverridden = getSection(key) !== null;
return (
<AdminShell>
<div className="mx-auto max-w-3xl">
<Link
href="/admin"
className="font-mono text-[0.7rem] uppercase tracking-wider text-slate-500 transition-colors hover:text-electric"
>
Dashboard
</Link>
<h1 className="mt-3 text-2xl font-bold text-white">
{label.en}
<span className="ms-2 font-fa text-lg font-normal text-slate-500">
{label.fa}
</span>
</h1>
<p className="mb-6 mt-1 text-sm text-slate-400">
Edit both languages with the FA / EN tabs, then save. Changes go live immediately.
</p>
<SectionEditor
sectionKey={key}
title={label.en}
initial={{ fa: data.fa as JsonValue, en: data.en as JsonValue }}
isOverridden={isOverridden}
/>
</div>
</AdminShell>
);
}
-16
View File
@@ -1,16 +0,0 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Admin · Content CMS',
robots: { index: false, follow: false },
};
// Admin chrome is independent of the public LocaleProvider/Navbar; the
// route-group split means these pages never inherit the marketing layout.
export default function AdminRootLayout({
children,
}: {
children: React.ReactNode;
}) {
return <div className="min-h-screen bg-base-900">{children}</div>;
}
-46
View File
@@ -1,46 +0,0 @@
import { notFound } from 'next/navigation';
import { loadContent } from '@/lib/content/load';
import { loadPost } from '@/lib/content/posts-store';
import { BlogArticle } from '@/components/blog/BlogArticle';
// Live content: bodies and card meta both come from the CMS-merged tree, so
// admin edits show immediately. (No generateStaticParams — render on demand.)
export const dynamic = 'force-dynamic';
type Params = { slug: string };
export function generateMetadata({ params }: { params: Params }) {
const { en } = loadContent();
const post = en.blog.items.find((p) => p.slug === params.slug);
if (!post) return {};
return {
title: post.title,
description: post.excerpt,
openGraph: { title: post.title, description: post.excerpt, type: 'article' },
alternates: {
canonical: `/blog/${post.slug}`,
languages: {
'fa-IR': `/blog/${post.slug}`,
'en-US': `/blog/${post.slug}`,
},
},
};
}
export default function BlogPostPage({ params }: { params: Params }) {
const content = loadPost(params.slug);
const { en: enContent, fa: faContent } = loadContent();
const en = enContent.blog.items.find((p) => p.slug === params.slug);
const fa = faContent.blog.items.find((p) => p.slug === params.slug);
if (!content || !en || !fa) notFound();
return (
<BlogArticle
content={content}
meta={{
en: { title: en.title, category: en.category, readTime: en.readTime },
fa: { title: fa.title, category: fa.category, readTime: fa.readTime },
}}
/>
);
}
-38
View File
@@ -1,38 +0,0 @@
import { LocaleProvider } from '@/lib/i18n/locale-context';
import { loadContent } from '@/lib/content/load';
import { Navbar } from '@/components/nav/Navbar';
import { CustomCursor } from '@/components/ui/CustomCursor';
/**
* Public site shell. Reads the live content tree (dict defaults merged with
* any admin overrides) on every request so edits made in the panel appear
* immediately, then feeds it to the client-side LocaleProvider.
*/
export const dynamic = 'force-dynamic';
export default function SiteLayout({ children }: { children: React.ReactNode }) {
const content = loadContent();
return (
<LocaleProvider content={content}>
{/* Ambient backdrop */}
<div
aria-hidden
className="pointer-events-none fixed inset-0 -z-10 bg-radial-aurora"
/>
<div
aria-hidden
className="pointer-events-none fixed inset-0 -z-10 bg-grid-faint bg-grid opacity-40"
style={{
maskImage:
'radial-gradient(ellipse at center, black 30%, transparent 75%)',
WebkitMaskImage:
'radial-gradient(ellipse at center, black 30%, transparent 75%)',
}}
/>
<CustomCursor />
<Navbar />
<main>{children}</main>
</LocaleProvider>
);
}
-25
View File
@@ -1,25 +0,0 @@
import { Hero } from '@/components/hero/Hero';
import { Services } from '@/components/sections/Services';
import { DataFlow } from '@/components/sections/DataFlow';
import { Stack } from '@/components/sections/Stack';
import { Expertise } from '@/components/sections/Expertise';
import { Portfolio } from '@/components/sections/Portfolio';
import { Blog } from '@/components/sections/Blog';
import { Contact } from '@/components/sections/Contact';
import { Footer } from '@/components/sections/Footer';
export default function HomePage() {
return (
<>
<Hero />
<Services />
<DataFlow />
<Stack />
<Expertise />
<Portfolio />
<Blog />
<Contact />
<Footer />
</>
);
}
-84
View File
@@ -1,84 +0,0 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { dict, SERVICE_IDS, type ServiceId } from '@/lib/i18n/dictionaries';
type Params = { slug: string };
export function generateStaticParams() {
return SERVICE_IDS.map((slug) => ({ slug }));
}
export function generateMetadata({ params }: { params: Params }) {
const id = params.slug as ServiceId;
const fa = dict.fa.services.items.find((s) => s.id === id);
const en = dict.en.services.items.find((s) => s.id === id);
if (!en) return {};
return {
title: en.title,
description: en.description,
openGraph: { title: en.title, description: en.description },
alternates: { canonical: `/services/${id}`, languages: { 'fa-IR': `/services/${id}`, 'en-US': `/services/${id}` } },
other: { 'fa-title': fa?.title ?? '' },
};
}
export default function ServiceDetailPage({ params }: { params: Params }) {
const id = params.slug as ServiceId;
if (!SERVICE_IDS.includes(id)) notFound();
const en = dict.en.services.items.find((s) => s.id === id)!;
const fa = dict.fa.services.items.find((s) => s.id === id)!;
return (
<article className="relative px-5 py-32 sm:px-8">
<div className="mx-auto max-w-3xl">
<Link
href="/#services"
className="label-mono inline-flex items-center gap-2 text-slate-400 hover:text-electric"
>
{dict.en.nav.services}
</Link>
<h1 className="mt-6 font-display text-[clamp(2rem,4.5vw,3.4rem)] font-extrabold leading-tight text-white">
{en.title}
</h1>
<p
dir="rtl"
className="mt-2 font-fa text-[clamp(1.1rem,2vw,1.5rem)] text-slate-400"
>
{fa.title}
</p>
<div className="mt-8 flex flex-wrap gap-2">
{en.tags.map((t) => (
<span
key={t}
className="rounded-full border border-electric/30 bg-electric/5 px-3 py-1 font-mono text-xs text-electric"
>
{t}
</span>
))}
</div>
<p className="mt-10 text-[1.05rem] leading-relaxed text-slate-300">
{en.description}
</p>
<p
dir="rtl"
className="mt-6 font-fa text-[1rem] leading-loose text-slate-400"
>
{fa.description}
</p>
<div className="mt-12 flex flex-wrap gap-3">
<Link href="/#contact" className="btn-primary">
Book a consultation
</Link>
<Link href="/#services" className="btn-ghost">
All services
</Link>
</div>
</div>
</article>
);
}
-35
View File
@@ -1,35 +0,0 @@
import { NextResponse } from 'next/server';
import {
SESSION_COOKIE,
SESSION_MAX_AGE,
createSession,
verifyPassword,
} from '@/lib/auth/session';
export const runtime = 'nodejs';
export async function POST(req: Request) {
let password = '';
try {
const body = await req.json();
password = typeof body?.password === 'string' ? body.password : '';
} catch {
return NextResponse.json({ error: 'bad request' }, { status: 400 });
}
if (!(await verifyPassword(password))) {
// Small constant delay-ish guard; password compare is already constant-time.
return NextResponse.json({ error: 'invalid' }, { status: 401 });
}
const token = await createSession();
const res = NextResponse.json({ ok: true });
res.cookies.set(SESSION_COOKIE, token, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: SESSION_MAX_AGE,
});
return res;
}
-16
View File
@@ -1,16 +0,0 @@
import { NextResponse } from 'next/server';
import { SESSION_COOKIE } from '@/lib/auth/session';
export const runtime = 'nodejs';
export async function POST() {
const res = NextResponse.json({ ok: true });
res.cookies.set(SESSION_COOKIE, '', {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: 0,
});
return res;
}
-97
View File
@@ -1,97 +0,0 @@
import { NextResponse } from 'next/server';
import { revalidatePath } from 'next/cache';
import { setSection, resetSection } from '@/lib/db/store';
import {
POSTS_KEY,
loadPost,
loadPostOverrides,
isKnownSlug,
} from '@/lib/content/posts-store';
import type { PostContent } from '@/lib/content/posts';
export const runtime = 'nodejs';
const ACCENTS = ['electric', 'violet', 'magenta', 'emerald', 'cyan'];
/** Minimal structural validation for an incoming PostContent payload. */
function validPost(data: unknown): data is {
date: string;
accent: string;
en: { lead: string; blocks: unknown[] };
fa: { lead: string; blocks: unknown[] };
} {
if (!data || typeof data !== 'object') return false;
const d = data as Record<string, unknown>;
if (typeof d.date !== 'string') return false;
if (typeof d.accent !== 'string' || !ACCENTS.includes(d.accent)) return false;
for (const loc of ['en', 'fa'] as const) {
const art = d[loc] as Record<string, unknown> | undefined;
if (!art || typeof art !== 'object') return false;
if (typeof art.lead !== 'string') return false;
if (!Array.isArray(art.blocks)) return false;
}
return true;
}
// GET ?slug=rag-eval-framework -> live (merged) article + override flag
export async function GET(req: Request) {
const slug = new URL(req.url).searchParams.get('slug') ?? '';
if (!isKnownSlug(slug)) {
return NextResponse.json({ error: 'unknown post' }, { status: 400 });
}
return NextResponse.json({
slug,
post: loadPost(slug),
overridden: slug in loadPostOverrides(),
});
}
// POST { slug, data: PostContent } -> save the article override
export async function POST(req: Request) {
let body: { slug?: string; data?: unknown };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'bad json' }, { status: 400 });
}
const slug = body.slug ?? '';
if (!isKnownSlug(slug)) {
return NextResponse.json({ error: 'unknown post' }, { status: 400 });
}
if (!validPost(body.data)) {
return NextResponse.json(
{ error: 'expected a { date, accent, en:{lead,blocks}, fa:{lead,blocks} } payload' },
{ status: 400 },
);
}
const overrides = loadPostOverrides();
// validPost has confirmed the shape (incl. accent ∈ ACCENTS) above.
overrides[slug] = body.data as unknown as PostContent;
setSection(POSTS_KEY, overrides);
revalidatePath(`/blog/${slug}`);
revalidatePath('/', 'layout');
return NextResponse.json({ ok: true });
}
// DELETE ?slug=… -> revert one article to its in-code default
export async function DELETE(req: Request) {
const slug = new URL(req.url).searchParams.get('slug') ?? '';
if (!isKnownSlug(slug)) {
return NextResponse.json({ error: 'unknown post' }, { status: 400 });
}
const overrides = loadPostOverrides();
delete overrides[slug];
if (Object.keys(overrides).length === 0) {
resetSection(POSTS_KEY);
} else {
setSection(POSTS_KEY, overrides);
}
revalidatePath(`/blog/${slug}`);
revalidatePath('/', 'layout');
return NextResponse.json({ ok: true });
}
-49
View File
@@ -1,49 +0,0 @@
import { NextResponse } from 'next/server';
import { revalidatePath } from 'next/cache';
import { setSection, resetSection, getSection } from '@/lib/db/store';
import { isEditableKey } from '@/lib/content/sections';
export const runtime = 'nodejs';
// GET ?key=hero -> current stored override (or null)
export async function GET(req: Request) {
const key = new URL(req.url).searchParams.get('key') ?? '';
if (!isEditableKey(key)) {
return NextResponse.json({ error: 'unknown section' }, { status: 400 });
}
return NextResponse.json({ key, override: getSection(key) });
}
// POST { key, data: { fa, en } } -> save override
export async function POST(req: Request) {
let body: { key?: string; data?: { fa?: unknown; en?: unknown } };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'bad json' }, { status: 400 });
}
const key = body.key ?? '';
if (!isEditableKey(key)) {
return NextResponse.json({ error: 'unknown section' }, { status: 400 });
}
if (!body.data || typeof body.data !== 'object' || !('fa' in body.data) || !('en' in body.data)) {
return NextResponse.json({ error: 'expected { fa, en } payload' }, { status: 400 });
}
setSection(key, { fa: body.data.fa, en: body.data.en });
// (site) layout is force-dynamic, but revalidate keeps any cached routes fresh.
revalidatePath('/', 'layout');
return NextResponse.json({ ok: true });
}
// DELETE ?key=hero -> revert to in-code default
export async function DELETE(req: Request) {
const key = new URL(req.url).searchParams.get('key') ?? '';
if (!isEditableKey(key)) {
return NextResponse.json({ error: 'unknown section' }, { status: 400 });
}
resetSection(key);
revalidatePath('/', 'layout');
return NextResponse.json({ ok: true });
}
-44
View File
@@ -1,44 +0,0 @@
import { NextResponse } from 'next/server';
import { mkdir, writeFile } from 'node:fs/promises';
import { extname, join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { UPLOADS_DIR } from '@/lib/db/store';
export const runtime = 'nodejs';
const ALLOWED = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.svg', '.avif']);
const MAX_BYTES = 8 * 1024 * 1024; // 8 MB
export async function POST(req: Request) {
let form: FormData;
try {
form = await req.formData();
} catch {
return NextResponse.json({ error: 'expected multipart form' }, { status: 400 });
}
const file = form.get('file');
if (!(file instanceof File)) {
return NextResponse.json({ error: 'no file' }, { status: 400 });
}
if (file.size > MAX_BYTES) {
return NextResponse.json({ error: 'file too large (max 8MB)' }, { status: 413 });
}
const ext = extname(file.name).toLowerCase();
if (!ALLOWED.has(ext)) {
return NextResponse.json({ error: `unsupported type ${ext}` }, { status: 415 });
}
const name = `${Date.now()}-${randomUUID().slice(0, 8)}${ext}`;
const buffer = Buffer.from(await file.arrayBuffer());
try {
await mkdir(UPLOADS_DIR, { recursive: true });
await writeFile(join(UPLOADS_DIR, name), buffer);
} catch {
return NextResponse.json({ error: 'write failed' }, { status: 500 });
}
return NextResponse.json({ url: `/api/uploads/${name}`, name });
}
-99
View File
@@ -1,99 +0,0 @@
import { NextResponse } from 'next/server';
import { Resend } from 'resend';
export const runtime = 'edge';
type ContactPayload = {
name?: string;
company?: string;
service?: string;
budget?: string;
message?: string;
locale?: 'fa' | 'en';
};
const required = ['name', 'service', 'budget', 'message'] as const;
function escape(str: string) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
export async function POST(req: Request) {
let body: ContactPayload;
try {
body = (await req.json()) as ContactPayload;
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
for (const k of required) {
if (!body[k] || String(body[k]).trim().length < 2) {
return NextResponse.json(
{ error: `Missing field: ${k}` },
{ status: 422 },
);
}
}
const apiKey = process.env.RESEND_API_KEY;
const inbox = process.env.CONTACT_INBOX;
const from = process.env.CONTACT_FROM;
// Graceful no-op for local dev so the form UX can be validated
// without forcing a Resend key. Production should set these.
if (!apiKey || !inbox || !from) {
if (process.env.NODE_ENV === 'production') {
return NextResponse.json(
{ error: 'Email service is not configured' },
{ status: 500 },
);
}
console.info('[contact] received (no Resend key — logging only):', body);
return NextResponse.json({ ok: true, dev: true });
}
const resend = new Resend(apiKey);
const subject = `New consultation request — ${body.name}`;
const html = `
<div style="font-family: ui-sans-serif, system-ui, sans-serif; line-height: 1.55;">
<h2 style="margin: 0 0 12px;">New consultation request</h2>
<table style="border-collapse: collapse;">
<tr><td style="padding: 4px 12px 4px 0; color:#475569;">Name</td><td>${escape(body.name!)}</td></tr>
<tr><td style="padding: 4px 12px 4px 0; color:#475569;">Company</td><td>${escape(body.company ?? '')}</td></tr>
<tr><td style="padding: 4px 12px 4px 0; color:#475569;">Service</td><td>${escape(body.service!)}</td></tr>
<tr><td style="padding: 4px 12px 4px 0; color:#475569;">Budget</td><td>${escape(body.budget!)}</td></tr>
<tr><td style="padding: 4px 12px 4px 0; color:#475569;">Locale</td><td>${escape(body.locale ?? '')}</td></tr>
</table>
<h3 style="margin: 20px 0 6px;">Message</h3>
<p style="white-space: pre-wrap; background:#f8fafc; padding:12px; border-radius:8px;">${escape(body.message!)}</p>
</div>
`;
try {
const { error } = await resend.emails.send({
from,
to: inbox,
subject,
html,
replyTo: body.company ? `${body.name} <${body.company}>` : undefined,
});
if (error) {
console.error('[contact] resend error', error);
return NextResponse.json(
{ error: 'Email service rejected the request' },
{ status: 502 },
);
}
return NextResponse.json({ ok: true });
} catch (err) {
console.error('[contact] send failed', err);
return NextResponse.json(
{ error: 'Email service unreachable' },
{ status: 502 },
);
}
}
-45
View File
@@ -1,45 +0,0 @@
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 });
}
}
-182
View File
@@ -1,182 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ---------- Base ---------- */
:root {
--bg: #020510;
--fg: #e2e8f0;
--muted: #94a3b8;
--electric: #38bdf8;
--violet: #818cf8;
--magenta: #e879f9;
--emerald: #34d399;
--cyan: #22d3ee;
--radius: 14px;
color-scheme: dark;
}
html,
body {
background: var(--bg);
color: var(--fg);
font-feature-settings: 'ss01', 'cv11';
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html {
scroll-behavior: smooth;
}
html[dir='rtl'] body {
font-family: var(--font-vaz-ar), var(--font-vaz-lat), var(--font-syne),
system-ui, sans-serif;
}
html[dir='ltr'] body {
font-family: var(--font-syne), var(--font-vaz-ar), var(--font-vaz-lat),
system-ui, sans-serif;
}
/* ---------- Selection ---------- */
::selection {
background: rgba(56, 189, 248, 0.35);
color: #f8fafc;
}
/* ---------- Scrollbar ---------- */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: #050a1a;
}
::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #38bdf8, #818cf8);
border-radius: 999px;
border: 2px solid #050a1a;
}
/* ---------- Hide native cursor on desktop when custom cursor is active ---------- */
@media (min-width: 900px) {
html.has-cursor,
html.has-cursor * {
cursor: none !important;
}
}
/* ---------- Glass surface ---------- */
@layer components {
.glass {
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.04) 0%,
rgba(255, 255, 255, 0.015) 100%
);
border: 1px solid rgba(56, 189, 248, 0.14);
backdrop-filter: blur(14px);
box-shadow:
inset 0 1px 0 0 rgba(255, 255, 255, 0.06),
0 30px 60px -30px rgba(0, 0, 0, 0.6);
border-radius: var(--radius);
}
.hairline {
border: 1px solid rgba(255, 255, 255, 0.06);
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.75rem;
border-radius: 999px;
border: 1px solid rgba(52, 211, 153, 0.25);
background: rgba(52, 211, 153, 0.06);
color: #a7f3d0;
font-family: var(--font-space-mono), ui-monospace, monospace;
font-size: 0.72rem;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.label-mono {
font-family: var(--font-space-mono), ui-monospace, monospace;
font-size: 0.7rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: #94a3b8;
}
.gradient-text {
background: linear-gradient(135deg, #38bdf8 0%, #818cf8 45%, #e879f9 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
background-size: 200% 200%;
animation: gradient-pan 8s ease-in-out infinite;
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.6rem;
padding: 0.85rem 1.4rem;
border-radius: 999px;
font-weight: 600;
color: #020510;
background: linear-gradient(135deg, #38bdf8 0%, #818cf8 60%, #e879f9 100%);
background-size: 200% 200%;
transition:
transform 0.25s ease,
box-shadow 0.25s ease,
background-position 0.6s ease;
box-shadow: 0 12px 40px -12px rgba(56, 189, 248, 0.55);
}
.btn-primary:hover {
transform: translateY(-1px);
background-position: 100% 0;
box-shadow: 0 18px 50px -10px rgba(232, 121, 249, 0.55);
}
.btn-ghost {
display: inline-flex;
align-items: center;
gap: 0.6rem;
padding: 0.8rem 1.35rem;
border-radius: 999px;
font-weight: 500;
color: #e2e8f0;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.02);
transition:
border-color 0.25s ease,
background 0.25s ease,
transform 0.25s ease;
}
.btn-ghost:hover {
border-color: rgba(56, 189, 248, 0.6);
background: rgba(56, 189, 248, 0.06);
transform: translateY(-1px);
}
}
/* ---------- Persian numerals helper ---------- */
.fa-nums {
font-feature-settings: 'ss01';
}
/* ---------- Reduced motion ---------- */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
}
-13
View File
@@ -1,13 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 730 B

-96
View File
@@ -1,96 +0,0 @@
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>
);
}
+9
View File
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Warning",
"Microsoft.AspNetCore": "Warning"
}
},
"DataDir": "/data"
}
+10
View File
@@ -0,0 +1,10 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"DataDir": "data"
}
-156
View File
@@ -1,156 +0,0 @@
'use client';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { EDITABLE_SECTIONS } from '@/lib/content/sections';
import { cn } from '@/lib/utils';
export function AdminShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const router = useRouter();
async function logout() {
await fetch('/api/admin/logout', { method: 'POST' });
router.replace('/admin/login');
router.refresh();
}
return (
<div dir="ltr" className="flex min-h-screen bg-base-900 text-slate-200">
{/* Sidebar */}
<aside className="hidden w-64 shrink-0 flex-col border-e border-white/8 bg-base-900/60 p-4 md:flex">
<Link href="/admin" className="mb-6 flex items-center gap-2 px-2">
<span className="grid h-8 w-8 place-items-center rounded-lg bg-electric/15 font-mono text-sm font-bold text-electric">
SA
</span>
<span className="text-sm font-semibold text-white">Content CMS</span>
</Link>
<nav className="flex flex-col gap-0.5">
<SideLink href="/admin" active={pathname === '/admin'}>
Dashboard
</SideLink>
<div className="mt-3 px-3 pb-1 font-mono text-[0.6rem] uppercase tracking-wider text-slate-600">
Sections
</div>
{EDITABLE_SECTIONS.map((s) => {
const href = `/admin/sections/${s.key}`;
return (
<SideLink key={s.key} href={href} active={pathname === href}>
{s.label.en}
</SideLink>
);
})}
<div className="mt-3 px-3 pb-1 font-mono text-[0.6rem] uppercase tracking-wider text-slate-600">
Content
</div>
<SideLink href="/admin/posts" active={pathname.startsWith('/admin/posts')}>
Journal articles
</SideLink>
</nav>
<div className="mt-auto flex flex-col gap-1 pt-4">
<a
href="/"
target="_blank"
rel="noreferrer"
className="rounded-lg px-3 py-2 text-sm text-slate-400 transition-colors hover:bg-white/[0.04] hover:text-white"
>
View site
</a>
<button
type="button"
onClick={logout}
className="rounded-lg px-3 py-2 text-start text-sm text-slate-400 transition-colors hover:bg-white/[0.04] hover:text-white"
>
Log out
</button>
</div>
</aside>
{/* Mobile top bar */}
<div className="flex min-w-0 grow flex-col">
<header className="flex items-center justify-between border-b border-white/8 px-5 py-3 md:hidden">
<Link href="/admin" className="text-sm font-semibold text-white">
Content CMS
</Link>
<button
type="button"
onClick={logout}
className="text-sm text-slate-400 hover:text-white"
>
Log out
</button>
</header>
{/* Mobile section selector */}
<nav className="flex gap-2 overflow-x-auto border-b border-white/8 px-5 py-2 md:hidden">
<MobileChip href="/admin" active={pathname === '/admin'} label="Dashboard" />
{EDITABLE_SECTIONS.map((s) => (
<MobileChip
key={s.key}
href={`/admin/sections/${s.key}`}
active={pathname === `/admin/sections/${s.key}`}
label={s.label.en}
/>
))}
<MobileChip
href="/admin/posts"
active={pathname.startsWith('/admin/posts')}
label="Articles"
/>
</nav>
<main className="grow px-6 py-6 sm:px-8">{children}</main>
</div>
</div>
);
}
function SideLink({
href,
active,
children,
}: {
href: string;
active: boolean;
children: React.ReactNode;
}) {
return (
<Link
href={href}
className={cn(
'rounded-lg px-3 py-2 text-sm transition-colors',
active
? 'bg-electric/12 font-medium text-electric'
: 'text-slate-400 hover:bg-white/[0.04] hover:text-white',
)}
>
{children}
</Link>
);
}
function MobileChip({
href,
active,
label,
}: {
href: string;
active: boolean;
label: string;
}) {
return (
<Link
href={href}
className={cn(
'shrink-0 rounded-full border px-3 py-1 text-xs transition-colors',
active
? 'border-electric/40 bg-electric/10 text-electric'
: 'border-white/10 text-slate-400',
)}
>
{label}
</Link>
);
}
-308
View File
@@ -1,308 +0,0 @@
'use client';
import { useRef, useState } from 'react';
export type JsonValue =
| string
| number
| boolean
| null
| JsonValue[]
| { [k: string]: JsonValue };
const IMAGE_KEYS = new Set(['cover', 'image', 'avatar', 'gallery', 'logo', 'icon']);
const IMAGE_RE = /\.(png|jpe?g|svg|webp|gif|avif)$/i;
function looksLikeImage(key: string | undefined, value: string): boolean {
if (key && IMAGE_KEYS.has(key)) return true;
return IMAGE_RE.test(value) || value.startsWith('/api/uploads/') || value.startsWith('/portfolio/');
}
/** Produce an "empty" clone of a sample value, for new array entries. */
function emptyLike(sample: JsonValue | undefined): JsonValue {
if (sample === undefined || sample === null) return '';
if (typeof sample === 'string') return '';
if (typeof sample === 'number') return 0;
if (typeof sample === 'boolean') return false;
if (Array.isArray(sample)) return [];
const out: Record<string, JsonValue> = {};
for (const [k, v] of Object.entries(sample)) out[k] = emptyLike(v);
return out;
}
function humanize(key: string): string {
return key
.replace(/[_-]/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/^\w/, (c) => c.toUpperCase());
}
export function JsonForm({
value,
onChange,
fieldKey,
depth = 0,
}: {
value: JsonValue;
onChange: (v: JsonValue) => void;
fieldKey?: string;
depth?: number;
}) {
// ---- Primitive: string ----
if (typeof value === 'string') {
if (looksLikeImage(fieldKey, value)) {
return <ImageInput value={value} onChange={onChange} />;
}
const multiline = value.length > 64 || value.includes('\n');
return multiline ? (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
rows={Math.min(8, Math.max(2, Math.ceil(value.length / 60)))}
className="w-full resize-y rounded-lg border border-white/10 bg-base-900/60 px-3 py-2 text-sm text-slate-100 outline-none focus:border-electric/60"
/>
) : (
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full rounded-lg border border-white/10 bg-base-900/60 px-3 py-2 text-sm text-slate-100 outline-none focus:border-electric/60"
/>
);
}
// ---- Primitive: number ----
if (typeof value === 'number') {
return (
<input
type="number"
value={value}
onChange={(e) => onChange(e.target.value === '' ? 0 : Number(e.target.value))}
className="w-40 rounded-lg border border-white/10 bg-base-900/60 px-3 py-2 text-sm text-slate-100 outline-none focus:border-electric/60"
/>
);
}
// ---- Primitive: boolean ----
if (typeof value === 'boolean') {
return (
<label className="inline-flex cursor-pointer items-center gap-2 text-sm text-slate-300">
<input
type="checkbox"
checked={value}
onChange={(e) => onChange(e.target.checked)}
className="h-4 w-4 accent-electric"
/>
{value ? 'true' : 'false'}
</label>
);
}
// ---- null ----
if (value === null) {
return (
<input
type="text"
value=""
placeholder="(empty)"
onChange={(e) => onChange(e.target.value)}
className="w-full rounded-lg border border-white/10 bg-base-900/60 px-3 py-2 text-sm text-slate-100 outline-none focus:border-electric/60"
/>
);
}
// ---- Array ----
if (Array.isArray(value)) {
return (
<div className="flex flex-col gap-3">
{value.map((item, i) => (
<div
key={i}
className="rounded-xl border border-white/8 bg-white/[0.015] p-3"
>
<div className="mb-2 flex items-center justify-between">
<span className="font-mono text-[0.65rem] uppercase tracking-wider text-slate-500">
{fieldKey ? humanize(fieldKey).replace(/s$/, '') : 'Item'} #{i + 1}
</span>
<div className="flex gap-1">
{i > 0 && (
<MiniBtn label="↑" title="Move up" onClick={() => {
const cp = [...value];
[cp[i - 1], cp[i]] = [cp[i], cp[i - 1]];
onChange(cp);
}} />
)}
{i < value.length - 1 && (
<MiniBtn label="↓" title="Move down" onClick={() => {
const cp = [...value];
[cp[i + 1], cp[i]] = [cp[i], cp[i + 1]];
onChange(cp);
}} />
)}
<MiniBtn
label="✕"
title="Remove"
danger
onClick={() => onChange(value.filter((_, j) => j !== i))}
/>
</div>
</div>
<JsonForm
value={item}
fieldKey={fieldKey}
depth={depth + 1}
onChange={(nv) => {
const cp = [...value];
cp[i] = nv;
onChange(cp);
}}
/>
</div>
))}
<button
type="button"
onClick={() => onChange([...value, emptyLike(value[0])])}
className="self-start rounded-lg border border-electric/30 bg-electric/5 px-3 py-1.5 text-xs font-medium text-electric transition-colors hover:bg-electric/10"
>
+ Add {fieldKey ? humanize(fieldKey).replace(/s$/, '').toLowerCase() : 'item'}
</button>
</div>
);
}
// ---- Object ----
return (
<div
className={
depth === 0
? 'flex flex-col gap-5'
: 'flex flex-col gap-4 rounded-xl border-s-2 border-white/10 ps-4'
}
>
{Object.entries(value).map(([k, v]) => {
const primitive =
v === null || ['string', 'number', 'boolean'].includes(typeof v);
return (
<div
key={k}
className={primitive ? 'flex flex-col gap-1.5' : 'flex flex-col gap-2'}
>
<label className="font-mono text-[0.68rem] uppercase tracking-wider text-slate-400">
{humanize(k)}
</label>
<JsonForm
value={v}
fieldKey={k}
depth={depth + 1}
onChange={(nv) => onChange({ ...value, [k]: nv })}
/>
</div>
);
})}
</div>
);
}
function MiniBtn({
label,
title,
onClick,
danger,
}: {
label: string;
title: string;
onClick: () => void;
danger?: boolean;
}) {
return (
<button
type="button"
title={title}
onClick={onClick}
className={
'inline-flex h-6 w-6 items-center justify-center rounded-md border text-xs transition-colors ' +
(danger
? 'border-magenta/30 text-magenta hover:bg-magenta/10'
: 'border-white/10 text-slate-400 hover:bg-white/[0.06] hover:text-white')
}
>
{label}
</button>
);
}
function ImageInput({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [err, setErr] = useState<string | null>(null);
async function upload(file: File) {
setUploading(true);
setErr(null);
try {
const fd = new FormData();
fd.append('file', file);
const res = await fetch('/api/admin/upload', { method: 'POST', body: fd });
if (!res.ok) throw new Error(`upload failed (${res.status})`);
const json = await res.json();
if (json?.url) onChange(json.url as string);
else throw new Error('no url returned');
} catch (e) {
setErr(e instanceof Error ? e.message : 'upload error');
} finally {
setUploading(false);
}
}
return (
<div className="flex items-start gap-3">
<div className="relative h-16 w-24 shrink-0 overflow-hidden rounded-lg border border-white/10 bg-base-900/60">
{value ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={value} alt="" className="h-full w-full object-cover" />
) : (
<span className="flex h-full w-full items-center justify-center text-[0.6rem] text-slate-600">
no image
</span>
)}
</div>
<div className="flex grow flex-col gap-2">
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="/api/uploads/… or /portfolio/…"
className="w-full rounded-lg border border-white/10 bg-base-900/60 px-3 py-2 text-sm text-slate-100 outline-none focus:border-electric/60"
/>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs text-slate-200 transition-colors hover:bg-white/[0.08] disabled:opacity-50"
>
{uploading ? 'Uploading…' : 'Upload image'}
</button>
{err && <span className="text-xs text-magenta">{err}</span>}
</div>
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) upload(f);
e.target.value = '';
}}
/>
</div>
</div>
);
}
-165
View File
@@ -1,165 +0,0 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { JsonForm, type JsonValue } from './JsonForm';
type Status = 'idle' | 'saving' | 'saved' | 'error';
type Tab = 'meta' | 'fa' | 'en';
/**
* Edits a single blog article body. Top-level `date` / `accent` live in the
* "Meta" tab; the long-form FA and EN articles each get their own tab so the
* Persian body renders RTL. Saving stores the whole PostContent under the
* article's slug via /api/admin/posts.
*/
export function PostEditor({
slug,
title,
initial,
isOverridden,
}: {
slug: string;
title: string;
initial: { date: JsonValue; accent: JsonValue; fa: JsonValue; en: JsonValue };
isOverridden: boolean;
}) {
const router = useRouter();
const [data, setData] = useState(initial);
const [tab, setTab] = useState<Tab>('meta');
const [status, setStatus] = useState<Status>('idle');
const [overridden, setOverridden] = useState(isOverridden);
async function save() {
setStatus('saving');
try {
const payload = {
date: data.date,
accent: data.accent,
en: data.en,
fa: data.fa,
};
const res = await fetch('/api/admin/posts', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ slug, data: payload }),
});
if (!res.ok) throw new Error(String(res.status));
setStatus('saved');
setOverridden(true);
router.refresh();
setTimeout(() => setStatus('idle'), 2500);
} catch {
setStatus('error');
}
}
async function reset() {
if (!confirm('Revert this article to its built-in default? Your edits will be removed.')) return;
setStatus('saving');
try {
const res = await fetch(`/api/admin/posts?slug=${encodeURIComponent(slug)}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error(String(res.status));
router.refresh();
window.location.reload();
} catch {
setStatus('error');
}
}
return (
<div className="flex flex-col gap-6">
{/* Toolbar */}
<div className="sticky top-0 z-10 -mx-6 flex flex-wrap items-center justify-between gap-3 border-b border-white/8 bg-base-900/80 px-6 py-3 backdrop-blur sm:-mx-8 sm:px-8">
<div className="flex items-center gap-1 rounded-full border border-white/10 bg-white/[0.02] p-1">
<TabBtn active={tab === 'meta'} onClick={() => setTab('meta')}>Meta</TabBtn>
<TabBtn active={tab === 'fa'} onClick={() => setTab('fa')}>FA · فارسی</TabBtn>
<TabBtn active={tab === 'en'} onClick={() => setTab('en')}>EN · English</TabBtn>
</div>
<div className="flex items-center gap-2">
{status === 'saved' && <span className="text-sm text-emerald">Saved </span>}
{status === 'error' && <span className="text-sm text-magenta">Save failed</span>}
{overridden && (
<button
type="button"
onClick={reset}
className="rounded-lg border border-white/10 px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/[0.05]"
>
Reset to default
</button>
)}
<button
type="button"
onClick={save}
disabled={status === 'saving'}
className="rounded-lg bg-electric px-4 py-2 text-sm font-semibold text-base-900 transition-opacity hover:opacity-90 disabled:opacity-50"
>
{status === 'saving' ? 'Saving…' : 'Save changes'}
</button>
</div>
</div>
{tab === 'meta' && (
<div className="flex flex-col gap-5 pb-24">
<p className="rounded-lg border border-white/8 bg-white/[0.02] p-3 text-xs text-slate-400">
Editing <span className="font-mono text-electric">{slug}</span>. Accent must be one of:
electric, violet, magenta, emerald, cyan. The card title/excerpt live under the
<span className="font-mono"> Journal</span> section.
</p>
<JsonForm
value={{ date: data.date, accent: data.accent }}
onChange={(nv) => {
const o = nv as { date: JsonValue; accent: JsonValue };
setData((d) => ({ ...d, date: o.date, accent: o.accent }));
}}
/>
</div>
)}
{tab === 'fa' && (
<div dir="rtl" className="pb-24">
<JsonForm
key="fa"
value={data.fa}
onChange={(nv) => setData((d) => ({ ...d, fa: nv }))}
/>
</div>
)}
{tab === 'en' && (
<div dir="ltr" className="pb-24">
<JsonForm
key="en"
value={data.en}
onChange={(nv) => setData((d) => ({ ...d, en: nv }))}
/>
</div>
)}
</div>
);
}
function TabBtn({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={
'rounded-full px-4 py-1.5 text-sm font-medium transition-colors ' +
(active ? 'bg-electric text-base-900' : 'text-slate-300 hover:text-white')
}
>
{children}
</button>
);
}
-123
View File
@@ -1,123 +0,0 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { JsonForm, type JsonValue } from './JsonForm';
type Status = 'idle' | 'saving' | 'saved' | 'error';
export function SectionEditor({
sectionKey,
title,
initial,
isOverridden,
}: {
sectionKey: string;
title: string;
initial: { fa: JsonValue; en: JsonValue };
isOverridden: boolean;
}) {
const router = useRouter();
const [data, setData] = useState<{ fa: JsonValue; en: JsonValue }>(initial);
const [tab, setTab] = useState<'fa' | 'en'>('fa');
const [status, setStatus] = useState<Status>('idle');
const [overridden, setOverridden] = useState(isOverridden);
async function save() {
setStatus('saving');
try {
const res = await fetch('/api/admin/section', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ key: sectionKey, data }),
});
if (!res.ok) throw new Error(String(res.status));
setStatus('saved');
setOverridden(true);
router.refresh();
setTimeout(() => setStatus('idle'), 2500);
} catch {
setStatus('error');
}
}
async function reset() {
if (!confirm('Revert this section to its built-in default? Your edits will be removed.')) return;
setStatus('saving');
try {
const res = await fetch(`/api/admin/section?key=${encodeURIComponent(sectionKey)}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error(String(res.status));
router.refresh();
window.location.reload();
} catch {
setStatus('error');
}
}
return (
<div className="flex flex-col gap-6">
{/* Toolbar */}
<div className="sticky top-0 z-10 -mx-6 flex flex-wrap items-center justify-between gap-3 border-b border-white/8 bg-base-900/80 px-6 py-3 backdrop-blur sm:-mx-8 sm:px-8">
<div className="flex items-center gap-1 rounded-full border border-white/10 bg-white/[0.02] p-1">
<TabBtn active={tab === 'fa'} onClick={() => setTab('fa')}>FA · فارسی</TabBtn>
<TabBtn active={tab === 'en'} onClick={() => setTab('en')}>EN · English</TabBtn>
</div>
<div className="flex items-center gap-2">
{status === 'saved' && <span className="text-sm text-emerald">Saved </span>}
{status === 'error' && <span className="text-sm text-magenta">Save failed</span>}
{overridden && (
<button
type="button"
onClick={reset}
className="rounded-lg border border-white/10 px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/[0.05]"
>
Reset to default
</button>
)}
<button
type="button"
onClick={save}
disabled={status === 'saving'}
className="rounded-lg bg-electric px-4 py-2 text-sm font-semibold text-base-900 transition-opacity hover:opacity-90 disabled:opacity-50"
>
{status === 'saving' ? 'Saving…' : 'Save changes'}
</button>
</div>
</div>
{/* The form for the active locale. FA renders RTL. */}
<div dir={tab === 'fa' ? 'rtl' : 'ltr'} className="pb-24">
<JsonForm
key={tab}
value={data[tab]}
onChange={(nv) => setData((d) => ({ ...d, [tab]: nv }))}
/>
</div>
</div>
);
}
function TabBtn({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={
'rounded-full px-4 py-1.5 text-sm font-medium transition-colors ' +
(active ? 'bg-electric text-base-900' : 'text-slate-300 hover:text-white')
}
>
{children}
</button>
);
}
-178
View File
@@ -1,178 +0,0 @@
'use client';
import Link from 'next/link';
import { motion } from 'framer-motion';
import { useLocale } from '@/lib/i18n/locale-context';
import type { PostContent, Block } from '@/lib/content/posts';
import { cn } from '@/lib/utils';
const FA_DIGITS = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'] as const;
const toFa = (s: string | number) =>
s.toString().replace(/\d/g, (d) => FA_DIGITS[Number(d)]);
type Meta = { title: string; category: string; readTime: number };
const ACCENT_TEXT: Record<PostContent['accent'], string> = {
electric: 'text-electric',
violet: 'text-violet',
magenta: 'text-magenta',
emerald: 'text-emerald',
cyan: 'text-cyan',
};
const ACCENT_BORDER: Record<PostContent['accent'], string> = {
electric: 'border-electric/30 bg-electric/5 text-electric',
violet: 'border-violet/30 bg-violet/5 text-violet',
magenta: 'border-magenta/30 bg-magenta/5 text-magenta',
emerald: 'border-emerald/30 bg-emerald/5 text-emerald',
cyan: 'border-cyan/30 bg-cyan/5 text-cyan',
};
export function BlogArticle({
meta,
content,
}: {
meta: { fa: Meta; en: Meta };
content: PostContent;
}) {
const { t, locale } = useLocale();
const m = meta[locale];
const body = content[locale];
const dir = locale === 'fa' ? 'rtl' : 'ltr';
const dateLabel = new Intl.DateTimeFormat(
locale === 'fa' ? 'fa-IR' : 'en-US',
{ year: 'numeric', month: 'long', day: 'numeric' },
).format(new Date(content.date));
return (
<article dir={dir} className="relative px-5 py-32 sm:px-8">
{/* Cover glow */}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-80 bg-radial-aurora opacity-60"
/>
<div className="mx-auto max-w-3xl">
<Link
href="/#blog"
className="label-mono inline-flex items-center gap-2 text-slate-400 transition-colors hover:text-electric"
>
<span className={locale === 'fa' ? 'rotate-180' : ''}></span>
{t.nav.blog}
</Link>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
>
<div className="mt-7 flex flex-wrap items-center gap-3">
<span
className={cn(
'rounded-full border px-2.5 py-0.5 font-mono text-[0.65rem] uppercase tracking-wider',
ACCENT_BORDER[content.accent],
)}
>
{m.category}
</span>
<span className="font-mono text-[0.7rem] text-slate-500">
{dateLabel}
</span>
<span className="font-mono text-[0.7rem] text-slate-500">
{locale === 'fa' ? toFa(m.readTime) : m.readTime}{' '}
{t.blog.readTimeSuffix}
</span>
</div>
<h1
className={cn(
'mt-5 font-display text-[clamp(2rem,4.5vw,3.2rem)] font-extrabold leading-[1.08] tracking-tight text-white',
locale === 'fa' && 'font-fa',
)}
>
{m.title}
</h1>
<p className="mt-6 text-balance text-[clamp(1.05rem,1.8vw,1.3rem)] leading-relaxed text-slate-300">
{body.lead}
</p>
</motion.div>
{/* Body */}
<div className="mt-12 flex flex-col gap-6">
{body.blocks.map((block, i) => (
<BlockView key={i} block={block} accent={content.accent} />
))}
</div>
{/* CTA */}
<div className="mt-16 border-t border-white/5 pt-10">
<p className="text-slate-400">
{locale === 'fa'
? 'این موضوع به سیستم شما مربوط است؟ بیایید درباره‌اش صحبت کنیم.'
: 'Is this relevant to your system? Lets talk it through.'}
</p>
<div className="mt-5 flex flex-wrap gap-3">
<Link href="/#contact" className="btn-primary">
{t.hero.ctaPrimary}
</Link>
<Link href="/#blog" className="btn-ghost">
{t.nav.blog}
</Link>
</div>
</div>
</div>
</article>
);
}
function BlockView({
block,
accent,
}: {
block: Block;
accent: PostContent['accent'];
}) {
switch (block.k) {
case 'h2':
return (
<h2 className="mt-4 font-display text-[clamp(1.3rem,2.4vw,1.7rem)] font-semibold leading-snug text-white">
{block.t}
</h2>
);
case 'p':
return (
<p className="text-[1.02rem] leading-[1.85] text-slate-300">
{block.t}
</p>
);
case 'ul':
return (
<ul className="flex flex-col gap-2.5">
{block.items.map((it, i) => (
<li key={i} className="flex gap-3 text-[1.02rem] leading-relaxed text-slate-300">
<span className={cn('mt-2 h-1.5 w-1.5 shrink-0 rounded-full', `bg-current ${ACCENT_TEXT[accent]}`)} />
<span>{it}</span>
</li>
))}
</ul>
);
case 'quote':
return (
<blockquote
className={cn(
'my-2 border-s-2 ps-5 text-[1.1rem] font-medium italic leading-relaxed text-slate-200',
accent === 'magenta' ? 'border-magenta' : 'border-electric',
)}
>
{block.t}
</blockquote>
);
case 'code':
return (
<pre className="overflow-x-auto rounded-xl border border-white/10 bg-base-900/80 p-4 font-mono text-[0.85rem] leading-relaxed text-slate-200">
<code>{block.t}</code>
</pre>
);
}
}
-188
View File
@@ -1,188 +0,0 @@
'use client';
import { motion } from 'framer-motion';
import { useLocale } from '@/lib/i18n/locale-context';
import { cn } from '@/lib/utils';
import { ParticleCanvas } from './ParticleCanvas';
import { Typewriter } from './Typewriter';
import { Counter } from '@/components/ui/Counter';
const fadeUp = (delay = 0) => ({
initial: { opacity: 0, y: 28 },
animate: { opacity: 1, y: 0 },
transition: { duration: 0.7, ease: [0.22, 1, 0.36, 1], delay },
});
export function Hero() {
const { t, locale } = useLocale();
return (
<section
id="top"
className={cn(
'relative isolate overflow-hidden',
// Full-screen on desktop, generous on mobile — leaves room for hero
// metrics without forcing a scroll on first paint at 1080p.
'min-h-[100svh] pt-28 pb-20 sm:pt-32',
)}
>
{/* Particle network background */}
<div className="pointer-events-none absolute inset-0 -z-10">
<ParticleCanvas />
{/* Edge fade so particles don't fight section seams */}
<div
aria-hidden
className="absolute inset-x-0 bottom-0 h-40 bg-gradient-to-b from-transparent to-base"
/>
</div>
<div className="mx-auto flex max-w-7xl flex-col items-center px-5 text-center sm:px-8">
{/* Availability chip */}
<motion.div {...fadeUp(0)} className="mb-7">
<span className="chip">
<span className="relative inline-flex h-2 w-2">
<span className="absolute inset-0 animate-pulse-dot rounded-full bg-emerald" />
<span className="relative inline-block h-2 w-2 rounded-full bg-emerald" />
</span>
{t.hero.availability}
</span>
</motion.div>
{/* Eyebrow */}
<motion.p
{...fadeUp(0.08)}
className="label-mono mb-6 inline-flex items-center gap-3 text-[clamp(0.65rem,1vw,0.75rem)]"
>
<span className="h-px w-10 bg-electric/60" aria-hidden />
{t.hero.eyebrow}
<span className="h-px w-10 bg-electric/60" aria-hidden />
</motion.p>
{/* Name */}
<motion.h1
{...fadeUp(0.15)}
className={cn(
'font-display text-balance text-[clamp(2.4rem,7vw,5.4rem)] font-extrabold leading-[1.02] tracking-tight text-white',
locale === 'fa' && 'font-fa',
)}
>
{t.hero.name}
</motion.h1>
{/* Headline */}
<motion.p
{...fadeUp(0.25)}
className={cn(
'mt-5 max-w-4xl text-balance text-[clamp(1.15rem,2.2vw,1.75rem)] font-medium leading-[1.25] text-slate-200',
)}
>
{t.hero.headlineLead}{' '}
<span className="gradient-text font-semibold">
{t.hero.headlineAccent}
</span>{' '}
{t.hero.headlineTrail}
</motion.p>
{/* Role typewriter */}
<motion.div
{...fadeUp(0.35)}
className="mt-5 flex items-center gap-3 font-mono text-[clamp(0.9rem,1.4vw,1.05rem)] uppercase tracking-[0.15em] text-slate-400"
>
<span className="h-px w-6 bg-slate-700" aria-hidden />
<Typewriter words={t.hero.roles} />
<span className="h-px w-6 bg-slate-700" aria-hidden />
</motion.div>
{/* Sub */}
<motion.p
{...fadeUp(0.42)}
className="mt-7 max-w-2xl text-balance text-[clamp(0.95rem,1.4vw,1.08rem)] leading-relaxed text-slate-400"
>
{t.hero.sub}
</motion.p>
{/* CTAs */}
<motion.div
{...fadeUp(0.5)}
className="mt-9 flex flex-wrap items-center justify-center gap-3"
>
<a href="#contact" className="btn-primary">
{t.hero.ctaPrimary}
<Arrow locale={locale} />
</a>
<a href="#services" className="btn-ghost">
{t.hero.ctaSecondary}
</a>
</motion.div>
{/* Metrics */}
<motion.div
{...fadeUp(0.6)}
className="mt-16 grid w-full max-w-4xl grid-cols-2 gap-4 sm:grid-cols-4"
>
{t.hero.metrics.map((m, i) => (
<div
key={m.label}
className="glass relative overflow-hidden px-5 py-5 text-start"
>
<span
aria-hidden
className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-electric/50 to-transparent"
/>
<div
className={cn(
'font-display text-[clamp(1.6rem,3vw,2.25rem)] font-bold leading-none',
// Cycle the accent colors across the 4 tiles
[
'text-electric',
'text-violet',
'text-magenta',
'text-emerald',
][i % 4],
)}
>
<Counter value={m.value} locale={locale} />
</div>
<div className="mt-2 text-[0.78rem] leading-snug text-slate-400">
{m.label}
</div>
</div>
))}
</motion.div>
{/* Scroll cue */}
<motion.a
href="#services"
{...fadeUp(0.75)}
aria-label={t.hero.scroll}
className="mt-14 inline-flex flex-col items-center gap-2 text-slate-500 transition-colors hover:text-slate-200"
>
<span className="label-mono">{t.hero.scroll}</span>
<span className="relative block h-9 w-5 rounded-full border border-slate-700">
<span className="absolute left-1/2 top-1.5 inline-block h-1.5 w-0.5 -translate-x-1/2 animate-float-y rounded-full bg-electric" />
</span>
</motion.a>
</div>
</section>
);
}
function Arrow({ locale }: { locale: 'fa' | 'en' }) {
return (
<svg
viewBox="0 0 24 24"
width="16"
height="16"
fill="none"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
className={locale === 'fa' ? 'rotate-180' : ''}
aria-hidden
>
<path d="M5 12 H19" />
<path d="M13 6 L19 12 L13 18" />
</svg>
);
}
-175
View File
@@ -1,175 +0,0 @@
'use client';
import { useEffect, useRef } from 'react';
/**
* Lightweight 2D hex-grid particle network.
* - Nodes drift slowly, repelled by the cursor.
* - Edges drawn between nearby nodes form a connection mesh.
* - Pauses when the tab is hidden or the section scrolls offscreen.
*/
export function ParticleCanvas() {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = ref.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const DPR = Math.min(window.devicePixelRatio || 1, 2);
let width = 0;
let height = 0;
let raf = 0;
let running = true;
const mouse = { x: -9999, y: -9999, active: false };
type Node = { x: number; y: number; vx: number; vy: number; r: number; hue: number };
let nodes: Node[] = [];
const COLORS = [
{ r: 56, g: 189, b: 248 }, // electric
{ r: 129, g: 140, b: 248 }, // violet
{ r: 232, g: 121, b: 249 }, // magenta
{ r: 34, g: 211, b: 238 }, // cyan
];
const seed = () => {
const area = width * height;
const density = window.matchMedia('(max-width: 640px)').matches ? 14000 : 9000;
const count = Math.min(140, Math.max(40, Math.floor(area / density)));
nodes = Array.from({ length: count }, () => ({
x: Math.random() * width,
y: Math.random() * height,
vx: (Math.random() - 0.5) * 0.18,
vy: (Math.random() - 0.5) * 0.18,
r: 0.8 + Math.random() * 1.6,
hue: Math.floor(Math.random() * COLORS.length),
}));
};
const resize = () => {
const rect = canvas.getBoundingClientRect();
width = rect.width;
height = rect.height;
canvas.width = Math.floor(width * DPR);
canvas.height = Math.floor(height * DPR);
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
seed();
};
const onMove = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
mouse.x = e.clientX - rect.left;
mouse.y = e.clientY - rect.top;
mouse.active = true;
};
const onLeave = () => {
mouse.active = false;
mouse.x = -9999;
mouse.y = -9999;
};
const onVisibility = () => {
running = !document.hidden;
if (running && !raf) raf = requestAnimationFrame(tick);
};
const tick = () => {
raf = 0;
if (!running) return;
ctx.clearRect(0, 0, width, height);
// Drift + cursor repel
for (const n of nodes) {
n.x += n.vx;
n.y += n.vy;
// Wrap around edges
if (n.x < -10) n.x = width + 10;
else if (n.x > width + 10) n.x = -10;
if (n.y < -10) n.y = height + 10;
else if (n.y > height + 10) n.y = -10;
if (mouse.active) {
const dx = n.x - mouse.x;
const dy = n.y - mouse.y;
const d2 = dx * dx + dy * dy;
const R = 140;
if (d2 < R * R && d2 > 0.01) {
const d = Math.sqrt(d2);
const force = (R - d) / R;
n.x += (dx / d) * force * 2.4;
n.y += (dy / d) * force * 2.4;
}
}
}
// Edges
const LINK_DIST = 130;
for (let i = 0; i < nodes.length; i++) {
const a = nodes[i];
for (let j = i + 1; j < nodes.length; j++) {
const b = nodes[j];
const dx = a.x - b.x;
const dy = a.y - b.y;
const d2 = dx * dx + dy * dy;
if (d2 < LINK_DIST * LINK_DIST) {
const d = Math.sqrt(d2);
const alpha = (1 - d / LINK_DIST) * 0.35;
const ca = COLORS[a.hue];
const cb = COLORS[b.hue];
const grad = ctx.createLinearGradient(a.x, a.y, b.x, b.y);
grad.addColorStop(0, `rgba(${ca.r},${ca.g},${ca.b},${alpha})`);
grad.addColorStop(1, `rgba(${cb.r},${cb.g},${cb.b},${alpha})`);
ctx.strokeStyle = grad;
ctx.lineWidth = 0.7;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
}
}
// Nodes
for (const n of nodes) {
const c = COLORS[n.hue];
ctx.beginPath();
ctx.fillStyle = `rgba(${c.r},${c.g},${c.b},0.85)`;
ctx.shadowBlur = 8;
ctx.shadowColor = `rgba(${c.r},${c.g},${c.b},0.55)`;
ctx.arc(n.x, n.y, n.r, 0, Math.PI * 2);
ctx.fill();
}
ctx.shadowBlur = 0;
raf = requestAnimationFrame(tick);
};
resize();
raf = requestAnimationFrame(tick);
window.addEventListener('resize', resize);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseleave', onLeave);
document.addEventListener('visibilitychange', onVisibility);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener('resize', resize);
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseleave', onLeave);
document.removeEventListener('visibilitychange', onVisibility);
};
}, []);
return (
<canvas
ref={ref}
aria-hidden
className="absolute inset-0 h-full w-full"
style={{ background: 'transparent' }}
/>
);
}
-69
View File
@@ -1,69 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
type Props = {
words: readonly string[];
typeSpeed?: number;
eraseSpeed?: number;
holdMs?: number;
};
/**
* Cycles through a list of phrases — types one in, holds, erases, advances.
* Resets cleanly when `words` reference changes (e.g. locale switch).
*/
export function Typewriter({
words,
typeSpeed = 70,
eraseSpeed = 40,
holdMs = 1600,
}: Props) {
const [index, setIndex] = useState(0);
const [text, setText] = useState('');
const [phase, setPhase] = useState<'typing' | 'holding' | 'erasing'>('typing');
// Reset state when the words array identity changes (locale switch).
useEffect(() => {
setIndex(0);
setText('');
setPhase('typing');
}, [words]);
useEffect(() => {
if (!words.length) return;
const target = words[index % words.length];
if (phase === 'typing') {
if (text === target) {
const t = setTimeout(() => setPhase('erasing'), holdMs);
return () => clearTimeout(t);
}
const t = setTimeout(
() => setText(target.slice(0, text.length + 1)),
typeSpeed,
);
return () => clearTimeout(t);
}
if (phase === 'erasing') {
if (text === '') {
setIndex((i) => (i + 1) % words.length);
setPhase('typing');
return;
}
const t = setTimeout(() => setText(text.slice(0, -1)), eraseSpeed);
return () => clearTimeout(t);
}
}, [text, phase, index, words, typeSpeed, eraseSpeed, holdMs]);
return (
<span className="inline-flex items-baseline gap-0.5" aria-live="polite">
<span className="gradient-text">{text}</span>
<span
aria-hidden
className="inline-block w-[2px] self-stretch translate-y-[2px] bg-electric animate-caret-blink"
/>
</span>
);
}
-51
View File
@@ -1,51 +0,0 @@
'use client';
import { useLocale } from '@/lib/i18n/locale-context';
import { cn } from '@/lib/utils';
export function LanguageToggle({ compact = false }: { compact?: boolean }) {
const { locale, setLocale } = useLocale();
return (
<div
className={cn(
'relative flex items-center rounded-full border border-white/10 bg-white/[0.02] p-0.5',
compact ? 'text-[0.65rem]' : 'text-xs',
)}
role="group"
aria-label="Language"
>
<button
type="button"
onClick={() => setLocale('fa')}
className={cn(
'relative z-10 rounded-full px-3 py-1 font-mono uppercase tracking-widest transition-colors',
locale === 'fa' ? 'text-base-900' : 'text-slate-400 hover:text-slate-200',
)}
aria-pressed={locale === 'fa'}
>
FA
</button>
<button
type="button"
onClick={() => setLocale('en')}
className={cn(
'relative z-10 rounded-full px-3 py-1 font-mono uppercase tracking-widest transition-colors',
locale === 'en' ? 'text-base-900' : 'text-slate-400 hover:text-slate-200',
)}
aria-pressed={locale === 'en'}
>
EN
</button>
<span
aria-hidden
className={cn(
'absolute top-0.5 bottom-0.5 w-[calc(50%-2px)] rounded-full bg-brand-gradient transition-[inset-inline-start] duration-300 ease-out',
)}
style={{
insetInlineStart: locale === 'fa' ? '2px' : 'calc(50% + 0px)',
}}
/>
</div>
);
}
-164
View File
@@ -1,164 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { motion } from 'framer-motion';
import { useLocale } from '@/lib/i18n/locale-context';
import { LanguageToggle } from './LanguageToggle';
import { cn } from '@/lib/utils';
export function Navbar() {
const { t, locale } = useLocale();
const [scrolled, setScrolled] = useState(false);
const [open, setOpen] = useState(false);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 12);
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
const links = [
{ href: '#services', label: t.nav.services },
{ href: '#stack', label: t.nav.stack },
{ href: '#expertise', label: t.nav.expertise },
{ href: '#portfolio', label: t.nav.portfolio },
{ href: '#blog', label: t.nav.blog },
{ href: '#contact', label: t.nav.contact },
];
return (
<motion.header
initial={{ y: -24, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
className={cn(
'fixed inset-x-0 top-0 z-40 transition-colors duration-300',
scrolled
? 'border-b border-white/5 bg-base-900/70 backdrop-blur-xl'
: 'border-b border-transparent',
)}
>
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-5 sm:px-8">
{/* Logo */}
<Link href="/" aria-label="Soroush Asadi" className="group flex items-center gap-2.5">
<Image
src="/logo-mark.svg"
alt=""
width={32}
height={32}
priority
className="transition-transform duration-300 group-hover:rotate-[8deg]"
/>
<span className="hidden sm:inline-flex flex-col leading-tight">
<span
className={cn(
'text-[0.95rem] font-semibold tracking-wide text-slate-100',
locale === 'fa' ? 'font-fa' : 'font-en',
)}
>
{locale === 'fa' ? 'سروش اسعدی' : 'Soroush Asadi'}
</span>
<span className="font-mono text-[0.6rem] uppercase tracking-[0.22em] text-slate-500">
AI · Architecture
</span>
</span>
</Link>
{/* Center nav */}
<nav
className="hidden items-center gap-1 rounded-full border border-white/5 bg-white/[0.02] px-2 py-1.5 md:flex"
aria-label="primary"
>
{links.map((l) => (
<a
key={l.href}
href={l.href}
className="rounded-full px-3 py-1.5 text-[0.82rem] text-slate-300 transition-colors hover:bg-white/[0.04] hover:text-white"
>
{l.label}
</a>
))}
</nav>
{/* Right cluster */}
<div className="flex items-center gap-3">
<LanguageToggle />
<a href="#contact" className="hidden sm:inline-flex btn-primary text-[0.82rem] !px-4 !py-2">
{t.nav.book}
<ArrowIcon locale={locale} />
</a>
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="md:hidden inline-flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-white/[0.02] text-slate-200"
aria-label="Toggle menu"
aria-expanded={open}
>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
{open ? (
<>
<path d="M6 6 L18 18" />
<path d="M18 6 L6 18" />
</>
) : (
<>
<path d="M4 7 H20" />
<path d="M4 12 H20" />
<path d="M4 17 H20" />
</>
)}
</svg>
</button>
</div>
</div>
{/* Mobile dropdown */}
{open && (
<div className="md:hidden border-t border-white/5 bg-base-900/95 px-5 py-4 backdrop-blur-xl">
<nav className="grid gap-1" aria-label="mobile">
{links.map((l) => (
<a
key={l.href}
href={l.href}
onClick={() => setOpen(false)}
className="rounded-lg px-3 py-2 text-sm text-slate-300 hover:bg-white/[0.04] hover:text-white"
>
{l.label}
</a>
))}
<a
href="#contact"
onClick={() => setOpen(false)}
className="mt-2 btn-primary justify-center"
>
{t.nav.book}
</a>
</nav>
</div>
)}
</motion.header>
);
}
function ArrowIcon({ locale }: { locale: 'fa' | 'en' }) {
return (
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
className={locale === 'fa' ? 'rotate-180' : ''}
aria-hidden
>
<path d="M5 12 H19" />
<path d="M13 6 L19 12 L13 18" />
</svg>
);
}
-108
View File
@@ -1,108 +0,0 @@
'use client';
import Link from 'next/link';
import { motion } from 'framer-motion';
import { useLocale } from '@/lib/i18n/locale-context';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { cn } from '@/lib/utils';
const FA_DIGITS = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'] as const;
const toFa = (n: number) =>
n.toString().replace(/\d/g, (d) => FA_DIGITS[Number(d)]);
const CATEGORY_COLOR: Record<string, string> = {
LLM: 'text-magenta border-magenta/30 bg-magenta/5',
Automation: 'text-violet border-violet/30 bg-violet/5',
'Google Stack': 'text-cyan border-cyan/30 bg-cyan/5',
Infra: 'text-emerald border-emerald/30 bg-emerald/5',
Mobile: 'text-electric border-electric/30 bg-electric/5',
Strategy: 'text-electric border-electric/30 bg-electric/5',
};
export function Blog() {
const { t, locale } = useLocale();
return (
<section id="blog" className="relative px-5 py-28 sm:px-8">
<div className="mx-auto max-w-7xl">
<SectionHeader
eyebrow={t.blog.eyebrow}
title={t.blog.title}
sub={t.blog.sub}
/>
<div className="mt-14 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{t.blog.items.map((post, i) => (
<motion.article
key={post.slug}
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{
duration: 0.55,
ease: [0.22, 1, 0.36, 1],
delay: 0.04 * i,
}}
className="glass group relative flex flex-col p-6 transition-shadow duration-300 hover:shadow-glow-electric"
>
<div className="flex items-center justify-between">
<span
className={cn(
'rounded-full border px-2.5 py-0.5 font-mono text-[0.65rem] uppercase tracking-wider',
CATEGORY_COLOR[post.category] ?? 'text-slate-300 border-white/10 bg-white/[0.03]',
)}
>
{post.category}
</span>
<span className="font-mono text-[0.7rem] text-slate-500">
{locale === 'fa' ? toFa(post.readTime) : post.readTime}{' '}
{t.blog.readTimeSuffix}
</span>
</div>
<h3
className={cn(
'mt-5 font-display text-[1.05rem] font-semibold leading-snug text-white transition-colors group-hover:text-electric',
locale === 'fa' && 'font-fa',
)}
>
<Link href={`/blog/${post.slug}`} className="after:absolute after:inset-0">
{post.title}
</Link>
</h3>
<p className="mt-3 grow text-[0.92rem] leading-relaxed text-slate-400">
{post.excerpt}
</p>
<span className="mt-5 inline-flex items-center gap-1.5 font-mono text-[0.72rem] uppercase tracking-wider text-electric">
{t.blog.readMore}
<Arrow locale={locale} />
</span>
</motion.article>
))}
</div>
</div>
</section>
);
}
function Arrow({ locale }: { locale: 'fa' | 'en' }) {
return (
<svg
viewBox="0 0 24 24"
width="12"
height="12"
fill="none"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
className={locale === 'fa' ? 'rotate-180' : ''}
aria-hidden
>
<path d="M5 12 H19" />
<path d="M13 6 L19 12 L13 18" />
</svg>
);
}
-171
View File
@@ -1,171 +0,0 @@
'use client';
import { useState } from 'react';
import { motion } from 'framer-motion';
import { useLocale } from '@/lib/i18n/locale-context';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { SERVICE_IDS } from '@/lib/i18n/dictionaries';
import { cn } from '@/lib/utils';
type Status = 'idle' | 'sending' | 'sent' | 'error';
export function Contact() {
const { t, locale } = useLocale();
const [status, setStatus] = useState<Status>('idle');
const [error, setError] = useState<string | null>(null);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setStatus('sending');
setError(null);
const form = e.currentTarget;
const data = Object.fromEntries(new FormData(form).entries());
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ ...data, locale }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body?.error ?? `HTTP ${res.status}`);
}
setStatus('sent');
form.reset();
} catch (err) {
setStatus('error');
setError(err instanceof Error ? err.message : 'Unknown error');
}
}
return (
<section id="contact" className="relative px-5 py-28 sm:px-8">
<div className="mx-auto max-w-5xl">
<SectionHeader
align="center"
eyebrow={t.contact.eyebrow}
title={t.contact.title}
sub={t.contact.sub}
/>
<motion.form
onSubmit={onSubmit}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
className="glass mt-14 grid grid-cols-1 gap-5 p-7 sm:grid-cols-2 sm:p-9"
noValidate
>
<Field label={t.contact.fields.name} htmlFor="name">
<input
id="name"
name="name"
type="text"
required
placeholder={t.contact.placeholders.name}
className={inputCls}
/>
</Field>
<Field label={t.contact.fields.company} htmlFor="company">
<input
id="company"
name="company"
type="text"
placeholder={t.contact.placeholders.company}
className={inputCls}
/>
</Field>
<Field label={t.contact.fields.service} htmlFor="service">
<select id="service" name="service" defaultValue="" className={inputCls} required>
<option value="" disabled>
</option>
{t.services.items.map((s, i) => (
<option key={SERVICE_IDS[i]} value={SERVICE_IDS[i]}>
{s.title}
</option>
))}
</select>
</Field>
<Field label={t.contact.fields.budget} htmlFor="budget">
<select id="budget" name="budget" defaultValue="" className={inputCls} required>
<option value="" disabled>
</option>
{t.contact.budgets.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
</Field>
<Field
label={t.contact.fields.message}
htmlFor="message"
className="sm:col-span-2"
>
<textarea
id="message"
name="message"
rows={5}
required
placeholder={t.contact.placeholders.message}
className={cn(inputCls, 'resize-y')}
/>
</Field>
<div className="sm:col-span-2 flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="font-mono text-[0.72rem] uppercase tracking-wider text-slate-500">
{t.contact.note}
</p>
<button
type="submit"
disabled={status === 'sending'}
className="btn-primary disabled:cursor-not-allowed disabled:opacity-60"
>
{status === 'sending' ? '…' : t.contact.submit}
</button>
</div>
{status === 'sent' && (
<p className="sm:col-span-2 rounded-lg border border-emerald/30 bg-emerald/5 px-4 py-3 text-sm text-emerald">
{locale === 'fa' ? 'پیام شما ارسال شد.' : 'Your message was sent.'}
</p>
)}
{status === 'error' && (
<p className="sm:col-span-2 rounded-lg border border-magenta/30 bg-magenta/5 px-4 py-3 text-sm text-magenta">
{locale === 'fa' ? 'خطا در ارسال:' : 'Send failed:'} {error}
</p>
)}
</motion.form>
</div>
</section>
);
}
const inputCls =
'w-full rounded-xl border border-white/10 bg-base-800/60 px-4 py-3 text-sm text-slate-100 placeholder:text-slate-500 outline-none transition-colors focus:border-electric/60 focus:bg-base-800';
function Field({
label,
htmlFor,
className,
children,
}: {
label: string;
htmlFor: string;
className?: string;
children: React.ReactNode;
}) {
return (
<label htmlFor={htmlFor} className={cn('flex flex-col gap-2', className)}>
<span className="label-mono">{label}</span>
{children}
</label>
);
}
-194
View File
@@ -1,194 +0,0 @@
'use client';
import { motion } from 'framer-motion';
import { useLocale } from '@/lib/i18n/locale-context';
import { SectionHeader } from '@/components/ui/SectionHeader';
/**
* Animated RAG pipeline: ingest → embed → retrieve → rerank → generate.
*
* The diagram itself is always laid out left-to-right (dir="ltr") regardless of
* page locale — a data pipeline reads forward in both languages — while the
* labels/descriptions come from the localized dictionary. The flowing dashes
* are pure SVG (animated stroke-dashoffset), so there is no per-frame JS.
*/
type Accent = 'electric' | 'violet' | 'cyan' | 'magenta' | 'emerald';
const ACCENT_HEX: Record<Accent, string> = {
electric: '#38bdf8',
violet: '#818cf8',
cyan: '#22d3ee',
magenta: '#e879f9',
emerald: '#34d399',
};
// Literal class maps so Tailwind's JIT scanner can see every variant.
const ACCENT_TEXT: Record<Accent, string> = {
electric: 'text-electric',
violet: 'text-violet',
cyan: 'text-cyan',
magenta: 'text-magenta',
emerald: 'text-emerald',
};
const ACCENT_BORDER: Record<Accent, string> = {
electric: 'border-electric/40',
violet: 'border-violet/40',
cyan: 'border-cyan/40',
magenta: 'border-magenta/40',
emerald: 'border-emerald/40',
};
const ACCENT_HOVER_SHADOW: Record<Accent, string> = {
electric: 'hover:shadow-[0_0_30px_-12px_#38bdf8]',
violet: 'hover:shadow-[0_0_30px_-12px_#818cf8]',
cyan: 'hover:shadow-[0_0_30px_-12px_#22d3ee]',
magenta: 'hover:shadow-[0_0_30px_-12px_#e879f9]',
emerald: 'hover:shadow-[0_0_30px_-12px_#34d399]',
};
function asAccent(value: string | undefined): Accent {
return value === 'violet' ||
value === 'cyan' ||
value === 'magenta' ||
value === 'emerald' ||
value === 'electric'
? value
: 'electric';
}
export function DataFlow() {
const { t } = useLocale();
const data = t.dataflow;
const nodes = data.nodes;
return (
<section id="dataflow" className="relative px-5 py-28 sm:px-8">
<div className="mx-auto max-w-7xl">
<SectionHeader eyebrow={data.eyebrow} title={data.title} sub={data.sub} />
{/* Diagram canvas — fixed LTR reading order. */}
<div dir="ltr" className="relative mt-14">
{/* SVG connectors sit behind the cards on md+ (horizontal flow). */}
<svg
aria-hidden
viewBox="0 0 1000 120"
preserveAspectRatio="none"
className="pointer-events-none absolute inset-x-0 top-1/2 hidden h-28 -translate-y-1/2 md:block"
>
<defs>
<linearGradient id="flow-line" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="#38bdf8" />
<stop offset="25%" stopColor="#818cf8" />
<stop offset="50%" stopColor="#22d3ee" />
<stop offset="75%" stopColor="#e879f9" />
<stop offset="100%" stopColor="#34d399" />
</linearGradient>
</defs>
{/* Static base rail */}
<line
x1="40"
y1="60"
x2="960"
y2="60"
stroke="url(#flow-line)"
strokeWidth="1.5"
strokeOpacity="0.28"
/>
{/* Animated travelling packets */}
<line
x1="40"
y1="60"
x2="960"
y2="60"
stroke="url(#flow-line)"
strokeWidth="2.5"
strokeLinecap="round"
strokeDasharray="6 60"
className="animate-flow-dash"
/>
</svg>
<ol className="relative grid grid-cols-1 gap-5 sm:grid-cols-2 md:grid-cols-5 md:gap-3">
{nodes.map((node, i) => {
const accent = asAccent(node.accent);
return (
<motion.li
key={node.id}
initial={{ opacity: 0, y: 22 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{
duration: 0.5,
ease: [0.22, 1, 0.36, 1],
delay: 0.08 * i,
}}
className="relative"
>
<div
className={`glass group relative flex h-full flex-col gap-3 rounded-2xl border ${ACCENT_BORDER[accent]} bg-white/[0.02] p-5 transition-shadow duration-500 ${ACCENT_HOVER_SHADOW[accent]}`}
>
{/* Step index + pulsing node dot */}
<div className="flex items-center justify-between">
<span className="font-mono text-[0.7rem] text-slate-500">
{String(i + 1).padStart(2, '0')}
</span>
<span className="relative flex h-2.5 w-2.5">
<span
className="absolute inline-flex h-full w-full animate-ping rounded-full opacity-60"
style={{ backgroundColor: ACCENT_HEX[accent] }}
/>
<span
className="relative inline-flex h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: ACCENT_HEX[accent] }}
/>
</span>
</div>
<h3
className={`font-display text-lg font-semibold ${ACCENT_TEXT[accent]}`}
>
{node.label}
</h3>
<p className="text-sm leading-relaxed text-slate-400">
{node.desc}
</p>
</div>
{/* Arrow connector for stacked (mobile / sm) layouts */}
{i < nodes.length - 1 && (
<span
aria-hidden
className="absolute left-1/2 top-full z-10 -translate-x-1/2 text-slate-600 sm:hidden"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path
d="M12 4v16m0 0l6-6m-6 6l-6-6"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
)}
</motion.li>
);
})}
</ol>
{data.caption && (
<motion.p
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.4 }}
className="mt-10 text-center font-mono text-[0.72rem] uppercase tracking-[0.18em] text-slate-500"
>
{data.caption}
</motion.p>
)}
</div>
</div>
</section>
);
}
-102
View File
@@ -1,102 +0,0 @@
'use client';
import { motion, useInView } from 'framer-motion';
import { useRef } from 'react';
import { useLocale } from '@/lib/i18n/locale-context';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { Counter } from '@/components/ui/Counter';
import { cn } from '@/lib/utils';
const FA_DIGITS = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'] as const;
const toFa = (s: string) =>
s.replace(/\d/g, (d) => FA_DIGITS[Number(d)]);
export function Expertise() {
const { t, locale } = useLocale();
const barsRef = useRef<HTMLDivElement>(null);
const inView = useInView(barsRef, { once: true, margin: '-80px' });
return (
<section id="expertise" className="relative px-5 py-28 sm:px-8">
<div className="mx-auto max-w-7xl">
<SectionHeader
eyebrow={t.expertise.eyebrow}
title={t.expertise.title}
sub={t.expertise.sub}
/>
<div className="mt-14 grid grid-cols-1 gap-10 lg:grid-cols-2">
{/* Metric tiles */}
<div className="grid grid-cols-2 gap-4 self-start">
{t.hero.metrics.map((m, i) => (
<motion.div
key={m.label}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{
duration: 0.5,
ease: [0.22, 1, 0.36, 1],
delay: 0.05 * i,
}}
className="glass relative overflow-hidden p-6"
>
<span
aria-hidden
className={cn(
'absolute inset-x-0 top-0 h-px',
'bg-gradient-to-r from-transparent via-electric/60 to-transparent',
)}
/>
<div
className={cn(
'font-display text-[clamp(1.8rem,3.5vw,2.6rem)] font-bold leading-none',
['text-electric', 'text-violet', 'text-magenta', 'text-emerald'][i % 4],
)}
>
<Counter value={m.value} locale={locale} />
</div>
<div className="mt-3 text-sm leading-snug text-slate-400">
{m.label}
</div>
</motion.div>
))}
</div>
{/* Skill bars */}
<div ref={barsRef} className="glass relative p-7 sm:p-8">
<span
aria-hidden
className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-magenta/60 to-transparent"
/>
<ul className="flex flex-col gap-6">
{t.expertise.bars.map((b, i) => (
<li key={b.label}>
<div className="mb-2 flex items-baseline justify-between text-sm">
<span className="text-slate-200">{b.label}</span>
<span className="font-mono text-xs text-slate-400">
{locale === 'fa' ? toFa(b.value.toString()) + '٪' : `${b.value}%`}
</span>
</div>
<div className="relative h-1.5 overflow-hidden rounded-full bg-white/[0.05]">
<motion.div
initial={{ width: 0 }}
animate={inView ? { width: `${b.value}%` } : { width: 0 }}
transition={{
duration: 1.2,
ease: [0.22, 1, 0.36, 1],
delay: 0.08 * i,
}}
className="absolute inset-y-0 start-0 rounded-full bg-brand-gradient"
style={{ backgroundSize: '200% 200%' }}
/>
</div>
</li>
))}
</ul>
</div>
</div>
</div>
</section>
);
}
-29
View File
@@ -1,29 +0,0 @@
'use client';
import Image from 'next/image';
import { useLocale } from '@/lib/i18n/locale-context';
export function Footer() {
const { t, locale } = useLocale();
return (
<footer className="relative border-t border-white/5 bg-base-900/40 px-5 py-12 sm:px-8">
<div className="mx-auto flex max-w-7xl flex-col gap-6 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Image src="/logo-mark.svg" alt="" width={28} height={28} />
<div className="flex flex-col leading-tight">
<span className="text-sm font-semibold text-slate-100">
{locale === 'fa' ? 'سروش اسعدی' : 'Soroush Asadi'}
</span>
<span className="font-mono text-[0.65rem] uppercase tracking-[0.2em] text-slate-500">
{t.footer.tagline}
</span>
</div>
</div>
<span className="font-mono text-[0.7rem] text-slate-500">
{t.footer.rights}
</span>
</div>
</footer>
);
}
-407
View File
@@ -1,407 +0,0 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { useLocale } from '@/lib/i18n/locale-context';
import { SectionHeader } from '@/components/ui/SectionHeader';
import type { Dict } from '@/lib/i18n/dictionaries';
import { cn } from '@/lib/utils';
type Item = Dict['portfolio']['items'][number];
type Accent = 'electric' | 'violet' | 'magenta' | 'emerald' | 'cyan';
const ACCENT_TEXT: Record<Accent, string> = {
electric: 'text-electric',
violet: 'text-violet',
magenta: 'text-magenta',
emerald: 'text-emerald',
cyan: 'text-cyan',
};
const ACCENT_BORDER: Record<Accent, string> = {
electric: 'border-electric/30 bg-electric/5 text-electric',
violet: 'border-violet/30 bg-violet/5 text-violet',
magenta: 'border-magenta/30 bg-magenta/5 text-magenta',
emerald: 'border-emerald/30 bg-emerald/5 text-emerald',
cyan: 'border-cyan/30 bg-cyan/5 text-cyan',
};
const ACCENT_RING: Record<Accent, string> = {
electric: 'hover:ring-electric/40',
violet: 'hover:ring-violet/40',
magenta: 'hover:ring-magenta/40',
emerald: 'hover:ring-emerald/40',
cyan: 'hover:ring-cyan/40',
};
// Full literal classes so Tailwind's JIT scanner picks them up — runtime
// string concatenation (`group-hover:${...}`) would never be detected.
const ACCENT_GROUP_HOVER: Record<Accent, string> = {
electric: 'group-hover:text-electric',
violet: 'group-hover:text-violet',
magenta: 'group-hover:text-magenta',
emerald: 'group-hover:text-emerald',
cyan: 'group-hover:text-cyan',
};
export function Portfolio() {
const { t, locale } = useLocale();
const items = t.portfolio.items as readonly Item[];
const [openId, setOpenId] = useState<string | null>(null);
const active = useMemo(
() => items.find((p) => p.id === openId) ?? null,
[items, openId],
);
return (
<section id="portfolio" className="relative px-5 py-28 sm:px-8">
<div className="mx-auto max-w-7xl">
<SectionHeader
eyebrow={t.portfolio.eyebrow}
title={t.portfolio.title}
sub={t.portfolio.sub}
/>
<div className="mt-14 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{items.map((item, i) => {
const accent = item.accent as Accent;
return (
<motion.button
key={item.id}
type="button"
onClick={() => setOpenId(item.id)}
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{
duration: 0.55,
ease: [0.22, 1, 0.36, 1],
delay: 0.04 * i,
}}
className={cn(
'group relative flex flex-col overflow-hidden rounded-2xl border border-white/8 bg-white/[0.02] text-start ring-1 ring-transparent transition-all duration-300 hover:-translate-y-1',
ACCENT_RING[accent],
)}
>
{/* Cover */}
<div className="relative aspect-[16/10] overflow-hidden">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.cover}
alt={item.title}
loading="lazy"
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
<div className="absolute inset-0 bg-gradient-to-t from-base-900/90 via-base-900/10 to-transparent" />
<div className="absolute inset-x-0 bottom-0 flex items-end justify-between gap-3 p-4">
<span
className={cn(
'rounded-full border px-2.5 py-0.5 font-mono text-[0.62rem] uppercase tracking-wider backdrop-blur-sm',
ACCENT_BORDER[accent],
)}
>
{item.role}
</span>
<span className="font-mono text-[0.7rem] text-slate-300">
{item.year}
</span>
</div>
</div>
{/* Body */}
<div className="flex grow flex-col p-5">
<h3
className={cn(
'font-display text-[1.05rem] font-semibold leading-snug text-white transition-colors',
ACCENT_GROUP_HOVER[accent],
locale === 'fa' && 'font-fa',
)}
>
{item.title}
</h3>
<p className="mt-2 line-clamp-2 grow text-[0.9rem] leading-relaxed text-slate-400">
{item.summary}
</p>
<div className="mt-4 flex flex-wrap gap-1.5">
{item.tags.slice(0, 4).map((tag) => (
<span
key={tag}
className="rounded-md border border-white/8 bg-white/[0.03] px-2 py-0.5 font-mono text-[0.62rem] text-slate-400"
>
{tag}
</span>
))}
</div>
<span
className={cn(
'mt-5 inline-flex items-center gap-1.5 font-mono text-[0.7rem] uppercase tracking-wider',
ACCENT_TEXT[accent],
)}
>
{t.portfolio.labels.view}
<Arrow locale={locale} />
</span>
</div>
</motion.button>
);
})}
</div>
</div>
<AnimatePresence>
{active && (
<Lightbox
key={active.id}
item={active}
labels={t.portfolio.labels}
locale={locale}
onClose={() => setOpenId(null)}
/>
)}
</AnimatePresence>
</section>
);
}
function Lightbox({
item,
labels,
locale,
onClose,
}: {
item: Item;
labels: Dict['portfolio']['labels'];
locale: 'fa' | 'en';
onClose: () => void;
}) {
const accent = item.accent as Accent;
const images = useMemo(() => [item.cover, ...item.gallery], [item]);
const [idx, setIdx] = useState(0);
const go = useCallback(
(dir: number) => setIdx((i) => (i + dir + images.length) % images.length),
[images.length],
);
// Keyboard navigation + scroll lock while the lightbox is open.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
else if (e.key === 'ArrowRight') go(locale === 'fa' ? -1 : 1);
else if (e.key === 'ArrowLeft') go(locale === 'fa' ? 1 : -1);
};
document.addEventListener('keydown', onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = prevOverflow;
};
}, [go, locale, onClose]);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25 }}
onClick={onClose}
className="fixed inset-0 z-[60] flex items-center justify-center bg-base-900/85 p-4 backdrop-blur-md sm:p-8"
dir={locale === 'fa' ? 'rtl' : 'ltr'}
role="dialog"
aria-modal="true"
aria-label={item.title}
>
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 16 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97, y: 10 }}
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
onClick={(e) => e.stopPropagation()}
className="grid max-h-full w-full max-w-5xl grid-rows-[auto] overflow-hidden rounded-3xl border border-white/10 bg-base-900/95 shadow-2xl md:grid-cols-[1.4fr_1fr]"
>
{/* Gallery viewer */}
<div className="relative flex flex-col bg-black/30">
<div className="relative aspect-[16/10] w-full overflow-hidden">
<AnimatePresence mode="wait">
{/* eslint-disable-next-line @next/next/no-img-element */}
<motion.img
key={images[idx]}
src={images[idx]}
alt={`${item.title}${idx + 1}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="h-full w-full object-cover"
/>
</AnimatePresence>
{images.length > 1 && (
<>
<NavButton side="prev" locale={locale} onClick={() => go(locale === 'fa' ? 1 : -1)} label={labels.prev} />
<NavButton side="next" locale={locale} onClick={() => go(locale === 'fa' ? -1 : 1)} label={labels.next} />
</>
)}
</div>
{/* Thumbnails */}
<div className="flex gap-2 overflow-x-auto p-3">
{images.map((src, i) => (
<button
key={src}
type="button"
onClick={() => setIdx(i)}
aria-label={`${labels.gallery} ${i + 1}`}
className={cn(
'relative h-12 w-20 shrink-0 overflow-hidden rounded-lg border transition-all',
i === idx
? cn('border-2', ACCENT_BORDER[accent])
: 'border-white/10 opacity-60 hover:opacity-100',
)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={src} alt="" className="h-full w-full object-cover" />
</button>
))}
</div>
</div>
{/* Meta panel */}
<div className="flex flex-col gap-5 overflow-y-auto p-6 sm:p-7">
<div className="flex items-start justify-between gap-3">
<span
className={cn(
'rounded-full border px-2.5 py-0.5 font-mono text-[0.62rem] uppercase tracking-wider',
ACCENT_BORDER[accent],
)}
>
{item.client}
</span>
<button
type="button"
onClick={onClose}
aria-label={labels.close}
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/10 bg-white/[0.03] text-slate-300 transition-colors hover:bg-white/[0.07] hover:text-white"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M6 6 L18 18" />
<path d="M18 6 L6 18" />
</svg>
</button>
</div>
<h3
className={cn(
'font-display text-[1.45rem] font-bold leading-tight text-white',
locale === 'fa' && 'font-fa',
)}
>
{item.title}
</h3>
<p className="text-[0.95rem] leading-relaxed text-slate-300">
{item.summary}
</p>
{/* Metrics */}
<div className="grid grid-cols-3 gap-3">
{item.metrics.map((mt) => (
<div
key={mt.label}
className="rounded-xl border border-white/8 bg-white/[0.02] p-3 text-center"
>
<div className={cn('font-display text-lg font-bold', ACCENT_TEXT[accent])}>
{mt.value}
</div>
<div className="mt-0.5 text-[0.65rem] leading-tight text-slate-500">
{mt.label}
</div>
</div>
))}
</div>
<dl className="grid grid-cols-2 gap-x-4 gap-y-3 border-t border-white/5 pt-5 text-sm">
<Field label={labels.role} value={item.role} />
<Field label={labels.year} value={item.year} />
<Field label={labels.client} value={item.client} />
</dl>
<div>
<span className="label-mono text-slate-500">{labels.stack}</span>
<div className="mt-2 flex flex-wrap gap-1.5">
{item.tags.map((tag) => (
<span
key={tag}
className="rounded-md border border-white/8 bg-white/[0.03] px-2 py-0.5 font-mono text-[0.66rem] text-slate-300"
>
{tag}
</span>
))}
</div>
</div>
</div>
</motion.div>
</motion.div>
);
}
function Field({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="font-mono text-[0.6rem] uppercase tracking-wider text-slate-500">
{label}
</dt>
<dd className="mt-1 text-slate-200">{value}</dd>
</div>
);
}
function NavButton({
side,
locale,
onClick,
label,
}: {
side: 'prev' | 'next';
locale: 'fa' | 'en';
onClick: () => void;
label: string;
}) {
// Visually pin to the left/right edge regardless of text direction.
const edge = side === 'prev' ? 'left-3' : 'right-3';
const pointLeft = side === 'prev';
return (
<button
type="button"
onClick={onClick}
aria-label={label}
className={cn(
'absolute top-1/2 -translate-y-1/2 inline-flex h-9 w-9 items-center justify-center rounded-full border border-white/15 bg-base-900/70 text-slate-200 backdrop-blur transition-colors hover:bg-base-900/90 hover:text-white',
edge,
)}
>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" className={pointLeft ? '' : 'rotate-180'}>
<path d="M15 6 L9 12 L15 18" />
</svg>
</button>
);
}
function Arrow({ locale }: { locale: 'fa' | 'en' }) {
return (
<svg
viewBox="0 0 24 24"
width="12"
height="12"
fill="none"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
className={locale === 'fa' ? 'rotate-180' : ''}
aria-hidden
>
<path d="M5 12 H19" />
<path d="M13 6 L19 12 L13 18" />
</svg>
);
}
-88
View File
@@ -1,88 +0,0 @@
import { cn } from '@/lib/utils';
export type ServiceIconKind =
| 'strategy'
| 'automation'
| 'llm-rag'
| 'architecture'
| 'mobile'
| 'google-stack';
type Props = {
kind: ServiceIconKind;
className?: string;
};
/**
* Custom line icons — one per service. Stroke uses currentColor so the
* parent's text color drives the accent.
*/
export function ServiceIcon({ kind, className }: Props) {
const base = cn('shrink-0', className);
switch (kind) {
case 'strategy':
return (
<svg viewBox="0 0 32 32" className={base} fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="16" cy="16" r="3" />
<circle cx="16" cy="16" r="9" />
<circle cx="16" cy="16" r="13.5" strokeOpacity="0.4" />
<path d="M16 3 V7" />
<path d="M16 25 V29" />
<path d="M3 16 H7" />
<path d="M25 16 H29" />
<path d="M16 16 L23.5 8.5" strokeWidth="2" />
</svg>
);
case 'automation':
return (
<svg viewBox="0 0 32 32" className={base} fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<rect x="5" y="6" width="9" height="6" rx="1.5" />
<rect x="18" y="6" width="9" height="6" rx="1.5" />
<rect x="5" y="20" width="9" height="6" rx="1.5" />
<rect x="18" y="20" width="9" height="6" rx="1.5" />
<path d="M14 9 H18" />
<path d="M9.5 12 V20" />
<path d="M22.5 12 V20" />
<path d="M14 23 H18" />
</svg>
);
case 'llm-rag':
return (
<svg viewBox="0 0 32 32" className={base} fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M16 4 C9 4 5 9 5 14 c0 3 1.4 5.4 3.5 7 V25 l3-2 a13 13 0 0 0 4.5 1 c7 0 11-5 11-10 S23 4 16 4 Z" />
<circle cx="11.5" cy="14" r="1.2" fill="currentColor" />
<circle cx="16" cy="14" r="1.2" fill="currentColor" />
<circle cx="20.5" cy="14" r="1.2" fill="currentColor" />
</svg>
);
case 'architecture':
return (
<svg viewBox="0 0 32 32" className={base} fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M16 4 L27 9.5 L16 15 L5 9.5 Z" />
<path d="M5 16 L16 21.5 L27 16" />
<path d="M5 22.5 L16 28 L27 22.5" strokeOpacity="0.6" />
</svg>
);
case 'mobile':
return (
<svg viewBox="0 0 32 32" className={base} fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<rect x="9" y="3" width="14" height="26" rx="3" />
<path d="M14 7 H18" />
<circle cx="16" cy="24.5" r="1" fill="currentColor" />
<path d="M12 13 L20 13" />
<path d="M12 17 L17 17" />
<path d="M12 21 L19 21" strokeOpacity="0.6" />
</svg>
);
case 'google-stack':
return (
<svg viewBox="0 0 32 32" className={base} fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M16 4 L28 11 V21 L16 28 L4 21 V11 Z" />
<path d="M16 4 V28" strokeOpacity="0.5" />
<path d="M4 11 L28 11" strokeOpacity="0.5" />
<path d="M4 21 L28 21" strokeOpacity="0.5" />
<circle cx="16" cy="16" r="2.5" fill="currentColor" />
</svg>
);
}
}
-221
View File
@@ -1,221 +0,0 @@
'use client';
import { useRef } from 'react';
import { motion, useMotionTemplate, useMotionValue } from 'framer-motion';
import { useLocale } from '@/lib/i18n/locale-context';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { cn } from '@/lib/utils';
import { ServiceIcon, type ServiceIconKind } from './ServiceIcon';
const FA_DIGITS = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'] as const;
function num(n: number, locale: 'fa' | 'en') {
const str = n.toString().padStart(2, '0');
return locale === 'fa'
? str.replace(/\d/g, (d) => FA_DIGITS[Number(d)])
: str;
}
const COLOR_MAP: Record<
string,
{ text: string; ring: string; glow: string; chip: string }
> = {
electric: {
text: 'text-electric',
ring: 'group-hover:border-electric/50',
glow: 'group-hover:shadow-glow-electric',
chip: 'border-electric/30 bg-electric/5 text-electric/90',
},
violet: {
text: 'text-violet',
ring: 'group-hover:border-violet/50',
glow: 'group-hover:shadow-glow-violet',
chip: 'border-violet/30 bg-violet/5 text-violet/90',
},
magenta: {
text: 'text-magenta',
ring: 'group-hover:border-magenta/50',
glow: 'group-hover:shadow-glow-magenta',
chip: 'border-magenta/30 bg-magenta/5 text-magenta/90',
},
emerald: {
text: 'text-emerald',
ring: 'group-hover:border-emerald/50',
glow: 'group-hover:shadow-glow-emerald',
chip: 'border-emerald/30 bg-emerald/5 text-emerald/90',
},
cyan: {
text: 'text-cyan',
ring: 'group-hover:border-cyan/50',
glow: 'group-hover:shadow-glow-electric',
chip: 'border-cyan/30 bg-cyan/5 text-cyan/90',
},
};
export function Services() {
const { t, locale } = useLocale();
return (
<section id="services" className="relative px-5 py-28 sm:px-8">
<div className="mx-auto max-w-7xl">
<SectionHeader
eyebrow={t.services.eyebrow}
title={t.services.title}
sub={t.services.sub}
/>
<div className="mt-14 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{t.services.items.map((item, i) => (
<ServiceCard
key={item.id}
index={i}
numLabel={num(i + 1, locale)}
title={item.title}
description={item.description}
tags={item.tags}
color={item.color}
iconKind={item.id as ServiceIconKind}
href={`/services/${item.id}`}
locale={locale}
/>
))}
</div>
</div>
</section>
);
}
function ServiceCard({
index,
numLabel,
title,
description,
tags,
color,
iconKind,
href,
locale,
}: {
index: number;
numLabel: string;
title: string;
description: string;
tags: readonly string[];
color: string;
iconKind: ServiceIconKind;
href: string;
locale: 'fa' | 'en';
}) {
const ref = useRef<HTMLDivElement>(null);
const mx = useMotionValue(50);
const my = useMotionValue(50);
const rotateX = useMotionValue(0);
const rotateY = useMotionValue(0);
// Subtle 3D tilt on pointer move — keeps the card "alive" without
// forcing GPU work when the cursor isn't over it.
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width;
const y = (e.clientY - r.top) / r.height;
mx.set(x * 100);
my.set(y * 100);
rotateY.set((x - 0.5) * 8);
rotateX.set((0.5 - y) * 8);
};
const onPointerLeave = () => {
rotateX.set(0);
rotateY.set(0);
};
const spotlight = useMotionTemplate`radial-gradient(220px circle at ${mx}% ${my}%, rgba(255,255,255,0.08), transparent 60%)`;
const c = COLOR_MAP[color] ?? COLOR_MAP.electric;
return (
<motion.article
ref={ref}
onPointerMove={onPointerMove}
onPointerLeave={onPointerLeave}
style={{
rotateX,
rotateY,
transformStyle: 'preserve-3d',
transformPerspective: 1000,
}}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{
duration: 0.6,
ease: [0.22, 1, 0.36, 1],
delay: 0.05 * index,
}}
className={cn(
'group relative isolate overflow-hidden p-6 sm:p-7',
'glass transition-all duration-300',
c.ring,
c.glow,
)}
>
{/* Spotlight */}
<motion.div
aria-hidden
style={{ background: spotlight }}
className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
/>
{/* Number + icon row */}
<div className="relative flex items-start justify-between">
<span
className={cn(
'font-mono text-[0.78rem] tracking-[0.18em] text-slate-500',
locale === 'fa' && 'fa-nums',
)}
>
{numLabel}
</span>
<span className={cn('transition-colors duration-300', c.text)}>
<ServiceIcon kind={iconKind} className="h-7 w-7" />
</span>
</div>
{/* Title */}
<h3
className={cn(
'relative mt-6 font-display text-[clamp(1.15rem,1.8vw,1.4rem)] font-semibold leading-snug text-white',
locale === 'fa' && 'font-fa',
)}
>
{title}
</h3>
{/* Description */}
<p className="relative mt-3 text-[0.94rem] leading-relaxed text-slate-400">
{description}
</p>
{/* Tags */}
<div className="relative mt-5 flex flex-wrap gap-1.5">
{tags.map((tag) => (
<span
key={tag}
className={cn(
'rounded-full border px-2.5 py-0.5 font-mono text-[0.65rem] uppercase tracking-wider',
c.chip,
)}
>
{tag}
</span>
))}
</div>
{/* Hairline */}
<span
aria-hidden
className="absolute inset-x-6 bottom-0 h-px bg-gradient-to-r from-transparent via-white/10 to-transparent"
/>
</motion.article>
);
}
-124
View File
@@ -1,124 +0,0 @@
'use client';
import dynamic from 'next/dynamic';
import { motion } from 'framer-motion';
import { useLocale } from '@/lib/i18n/locale-context';
import { SectionHeader } from '@/components/ui/SectionHeader';
import type { StackNode } from './StackCanvas';
// Category accent palette (index-aligned). Hex feeds the WebGL sprites; the
// literal Tailwind maps below keep the JIT scanner happy for the legend.
const ACCENTS = ['electric', 'violet', 'magenta', 'cyan'] as const;
type Accent = (typeof ACCENTS)[number];
const ACCENT_HEX: Record<Accent, string> = {
electric: '#38bdf8',
violet: '#818cf8',
magenta: '#e879f9',
cyan: '#22d3ee',
};
const ACCENT_TEXT: Record<Accent, string> = {
electric: 'text-electric',
violet: 'text-violet',
magenta: 'text-magenta',
cyan: 'text-cyan',
};
const ACCENT_BORDER: Record<Accent, string> = {
electric: 'border-electric/30',
violet: 'border-violet/30',
magenta: 'border-magenta/30',
cyan: 'border-cyan/30',
};
// The globe is client-only WebGL: never SSR it. While the chunk loads we show
// a calm placeholder so layout doesn't jump.
const StackCanvas = dynamic(
() => import('./StackCanvas').then((m) => m.StackCanvas),
{
ssr: false,
loading: () => (
<div className="flex h-[400px] w-full items-center justify-center sm:h-[460px] lg:h-[520px]">
<span className="h-24 w-24 animate-pulse rounded-full bg-gradient-to-br from-electric/20 to-violet/20 blur-xl" />
</div>
),
},
);
export function Stack() {
const { t, locale } = useLocale();
// Flatten every tool into a colored node for the constellation.
const nodes: StackNode[] = t.stack.categories.flatMap((cat, i) => {
const hex = ACCENT_HEX[ACCENTS[i % ACCENTS.length]];
return cat.items.map((label) => ({ label, color: hex }));
});
return (
<section id="stack" className="relative overflow-hidden px-5 py-28 sm:px-8">
<div className="mx-auto max-w-7xl">
<SectionHeader
eyebrow={t.stack.eyebrow}
title={t.stack.title}
sub={t.stack.sub}
/>
<div className="mt-10 grid grid-cols-1 items-center gap-8 lg:grid-cols-2">
{/* 3D constellation */}
<motion.div
initial={{ opacity: 0, scale: 0.94 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true, margin: '-60px' }}
transition={{ duration: 0.7, ease: [0.22, 1, 0.36, 1] }}
className="relative order-1 lg:order-none"
>
<StackCanvas nodes={nodes} />
<p className="pointer-events-none mt-2 text-center font-mono text-[0.66rem] uppercase tracking-[0.18em] text-slate-600">
{locale === 'fa' ? 'بکشید برای چرخش · نشانگر برای نام' : 'Drag to spin · hover for name'}
</p>
</motion.div>
{/* Category legend */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{t.stack.categories.map((cat, i) => {
const accent = ACCENTS[i % ACCENTS.length];
return (
<motion.div
key={cat.id}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{
duration: 0.5,
ease: [0.22, 1, 0.36, 1],
delay: 0.06 * i,
}}
className={`glass relative border ${ACCENT_BORDER[accent]} p-5`}
>
<div className="flex items-center gap-2">
<span
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: ACCENT_HEX[accent] }}
/>
<span className={`label-mono ${ACCENT_TEXT[accent]}`}>
{cat.label}
</span>
</div>
<ul className="mt-4 flex flex-wrap gap-2">
{cat.items.map((item) => (
<li
key={item}
className="rounded-full border border-white/10 px-2.5 py-1 font-mono text-[0.7rem] tracking-wide text-slate-300"
>
{item}
</li>
))}
</ul>
</motion.div>
);
})}
</div>
</div>
</div>
</section>
);
}
-259
View File
@@ -1,259 +0,0 @@
'use client';
import { useEffect, useRef } from 'react';
import * as THREE from 'three';
export type StackNode = { label: string; color: string };
/**
* An interactive 3D constellation of the tech stack. Every tool is a glowing
* dot positioned on a Fibonacci sphere and tinted by its category color. The
* globe auto-rotates, can be dragged to spin, and reveals a tooltip with the
* tool name when a dot is hovered (raycast). Everything is torn down on unmount
* — RAF, GL context, geometries, materials, textures, and listeners.
*/
export function StackCanvas({ nodes }: { nodes: StackNode[] }) {
const mountRef = useRef<HTMLDivElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const mount = mountRef.current;
const tooltip = tooltipRef.current;
if (!mount || !tooltip || nodes.length === 0) return;
const prefersReduced = window.matchMedia(
'(prefers-reduced-motion: reduce)',
).matches;
// --- Sizing -------------------------------------------------------------
let width = mount.clientWidth || 600;
let height = mount.clientHeight || 460;
// --- Renderer -----------------------------------------------------------
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(width, height);
renderer.setClearColor(0x000000, 0);
mount.appendChild(renderer.domElement);
renderer.domElement.style.touchAction = 'pan-y';
renderer.domElement.style.cursor = 'grab';
// --- Scene / camera -----------------------------------------------------
const scene = new THREE.Scene();
const R = 2.6;
const dist = 6.6;
const camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 100);
camera.position.set(0, 0, dist);
const group = new THREE.Group();
scene.add(group);
// --- Wireframe backdrop globe ------------------------------------------
const wireGeo = new THREE.IcosahedronGeometry(R, 2);
const wire = new THREE.LineSegments(
new THREE.WireframeGeometry(wireGeo),
new THREE.LineBasicMaterial({
color: 0x38bdf8,
transparent: true,
opacity: 0.08,
}),
);
wireGeo.dispose();
group.add(wire);
// --- Glow sprite texture (shared) --------------------------------------
const glowCanvas = document.createElement('canvas');
glowCanvas.width = glowCanvas.height = 64;
const gctx = glowCanvas.getContext('2d')!;
const grad = gctx.createRadialGradient(32, 32, 0, 32, 32, 32);
grad.addColorStop(0, 'rgba(255,255,255,1)');
grad.addColorStop(0.25, 'rgba(255,255,255,0.85)');
grad.addColorStop(1, 'rgba(255,255,255,0)');
gctx.fillStyle = grad;
gctx.fillRect(0, 0, 64, 64);
const glowTex = new THREE.CanvasTexture(glowCanvas);
// --- Nodes as sprites on a Fibonacci sphere ----------------------------
const golden = Math.PI * (3 - Math.sqrt(5));
const sprites: THREE.Sprite[] = [];
const materials: THREE.SpriteMaterial[] = [];
const n = nodes.length;
nodes.forEach((node, i) => {
const y = 1 - (i / Math.max(1, n - 1)) * 2;
const r = Math.sqrt(Math.max(0, 1 - y * y));
const theta = i * golden;
const pos = new THREE.Vector3(
Math.cos(theta) * r,
y,
Math.sin(theta) * r,
).multiplyScalar(R);
const mat = new THREE.SpriteMaterial({
map: glowTex,
color: new THREE.Color(node.color),
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
const sprite = new THREE.Sprite(mat);
sprite.position.copy(pos);
sprite.scale.setScalar(0.5);
sprite.userData = { label: node.label, color: node.color, base: 0.5 };
group.add(sprite);
sprites.push(sprite);
materials.push(mat);
});
// --- Interaction state --------------------------------------------------
let dragging = false;
let lastX = 0;
let lastY = 0;
let velX = 0;
let velY = 0;
const auto = prefersReduced ? 0 : 0.0018;
let hovered: THREE.Sprite | null = null;
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
let pointerInside = false;
const onPointerDown = (e: PointerEvent) => {
dragging = true;
lastX = e.clientX;
lastY = e.clientY;
renderer.domElement.setPointerCapture(e.pointerId);
renderer.domElement.style.cursor = 'grabbing';
};
const onPointerMove = (e: PointerEvent) => {
const rect = renderer.domElement.getBoundingClientRect();
pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
pointerInside = true;
if (dragging) {
const dx = e.clientX - lastX;
const dy = e.clientY - lastY;
lastX = e.clientX;
lastY = e.clientY;
velY = dx * 0.005;
velX = dy * 0.005;
}
};
const onPointerUp = (e: PointerEvent) => {
dragging = false;
try {
renderer.domElement.releasePointerCapture(e.pointerId);
} catch {
/* noop */
}
renderer.domElement.style.cursor = 'grab';
};
const onPointerLeave = () => {
pointerInside = false;
};
renderer.domElement.addEventListener('pointerdown', onPointerDown);
renderer.domElement.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
renderer.domElement.addEventListener('pointerleave', onPointerLeave);
// --- Resize -------------------------------------------------------------
const ro = new ResizeObserver(() => {
width = mount.clientWidth || width;
height = mount.clientHeight || height;
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height);
});
ro.observe(mount);
// --- Render loop --------------------------------------------------------
let raf = 0;
const tmp = new THREE.Vector3();
const tick = () => {
raf = requestAnimationFrame(tick);
// Rotation: apply velocity + gentle auto-spin, with decay when idle.
if (!dragging) {
velY *= 0.94;
velX *= 0.94;
}
group.rotation.y += velY + auto;
group.rotation.x += velX;
group.rotation.x = Math.max(-0.6, Math.min(0.6, group.rotation.x));
group.updateMatrixWorld();
// Hover raycast (only when not dragging and pointer is inside).
if (pointerInside && !dragging) {
raycaster.setFromCamera(pointer, camera);
const hits = raycaster.intersectObjects(sprites, false);
const next = (hits[0]?.object as THREE.Sprite) ?? null;
if (next !== hovered) {
hovered = next;
}
} else if (!pointerInside) {
hovered = null;
}
// Scale + tooltip for the hovered sprite.
for (const s of sprites) {
const target = s === hovered ? 0.85 : 0.5;
const cur = s.scale.x;
s.scale.setScalar(cur + (target - cur) * 0.2);
}
if (hovered) {
hovered.getWorldPosition(tmp);
tmp.project(camera);
const sx = (tmp.x * 0.5 + 0.5) * width;
const sy = (-tmp.y * 0.5 + 0.5) * height;
const data = hovered.userData as { label: string; color: string };
tooltip.textContent = data.label;
tooltip.style.transform = `translate(${sx}px, ${sy}px) translate(-50%, -160%)`;
tooltip.style.borderColor = data.color;
tooltip.style.color = data.color;
tooltip.style.opacity = '1';
renderer.domElement.style.cursor = 'pointer';
} else {
tooltip.style.opacity = '0';
if (!dragging) renderer.domElement.style.cursor = 'grab';
}
renderer.render(scene, camera);
};
tick();
// --- Teardown -----------------------------------------------------------
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
renderer.domElement.removeEventListener('pointerdown', onPointerDown);
renderer.domElement.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
renderer.domElement.removeEventListener('pointerleave', onPointerLeave);
materials.forEach((m) => m.dispose());
glowTex.dispose();
wire.geometry.dispose();
(wire.material as THREE.Material).dispose();
renderer.dispose();
if (renderer.domElement.parentNode === mount) {
mount.removeChild(renderer.domElement);
}
};
}, [nodes]);
return (
<div
ref={mountRef}
className="relative h-[400px] w-full select-none sm:h-[460px] lg:h-[520px]"
>
<div
ref={tooltipRef}
className="pointer-events-none absolute left-0 top-0 z-10 whitespace-nowrap rounded-full border bg-base-900/80 px-3 py-1 font-mono text-[0.72rem] tracking-wide backdrop-blur transition-opacity duration-150"
style={{ opacity: 0 }}
/>
</div>
);
}
-99
View File
@@ -1,99 +0,0 @@
'use client';
import { useEffect, useRef, useState } from 'react';
const FA_DIGITS = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'] as const;
function toAscii(str: string) {
return str.replace(/[۰-۹]/g, (d) =>
String(FA_DIGITS.indexOf(d as (typeof FA_DIGITS)[number])),
);
}
function toFa(n: number) {
return n.toString().replace(/\d/g, (d) => FA_DIGITS[Number(d)]);
}
/**
* Parses a metric string like "18+", "۱۲ms", "99%", "۹۹٪" into a numeric
* target plus a trailing suffix that survives the count animation.
*/
function parse(value: string) {
const ascii = toAscii(value);
const match = ascii.match(/^(\d+(?:\.\d+)?)(.*)$/);
if (!match) return { target: 0, suffix: value, decimals: 0 };
const target = parseFloat(match[1]);
const decimals = match[1].includes('.') ? match[1].split('.')[1].length : 0;
return { target, suffix: match[2], decimals };
}
const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
type Props = {
/** Final string, e.g. "18+", "۱۲ms", "99%" */
value: string;
/** Locale controls digit script in the rendered output */
locale: 'fa' | 'en';
/** Animation duration in ms */
duration?: number;
className?: string;
};
export function Counter({ value, locale, duration = 1600, className }: Props) {
const { target, suffix, decimals } = parse(value);
const [display, setDisplay] = useState(0);
const elRef = useRef<HTMLSpanElement>(null);
const started = useRef(false);
useEffect(() => {
const el = elRef.current;
if (!el) return;
const start = () => {
if (started.current) return;
started.current = true;
const t0 = performance.now();
const tick = (now: number) => {
const p = Math.min(1, (now - t0) / duration);
const eased = easeOutCubic(p);
setDisplay(target * eased);
if (p < 1) requestAnimationFrame(tick);
else setDisplay(target);
};
requestAnimationFrame(tick);
};
if (typeof IntersectionObserver === 'undefined') {
start();
return;
}
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting) {
start();
io.disconnect();
break;
}
}
},
{ threshold: 0.4 },
);
io.observe(el);
return () => io.disconnect();
}, [target, duration]);
const formatted = decimals
? display.toFixed(decimals)
: Math.round(display).toString();
const rendered = locale === 'fa' ? toFa(Number(formatted)) : formatted;
const sfx = locale === 'fa' ? suffix : toAscii(suffix);
return (
<span ref={elRef} className={className}>
{rendered}
{sfx}
</span>
);
}
-147
View File
@@ -1,147 +0,0 @@
'use client';
import { useEffect, useRef, useState } from 'react';
const HOVER_SELECTOR =
'a, button, [role="button"], input, textarea, select, summary, [data-cursor-hover]';
export function CustomCursor() {
const dotRef = useRef<HTMLDivElement>(null);
const ringRef = useRef<HTMLDivElement>(null);
const [enabled, setEnabled] = useState(false);
useEffect(() => {
// Only enable on desktop pointers (>= 900px and fine pointer)
const mq = window.matchMedia('(min-width: 900px) and (pointer: fine)');
const apply = () => {
const on = mq.matches;
setEnabled(on);
document.documentElement.classList.toggle('has-cursor', on);
};
apply();
mq.addEventListener('change', apply);
return () => mq.removeEventListener('change', apply);
}, []);
useEffect(() => {
if (!enabled) return;
let dotX = window.innerWidth / 2;
let dotY = window.innerHeight / 2;
let ringX = dotX;
let ringY = dotY;
let raf = 0;
const onMove = (e: MouseEvent) => {
dotX = e.clientX;
dotY = e.clientY;
};
const tick = () => {
// Ring lags the dot — trailing effect.
ringX += (dotX - ringX) * 0.18;
ringY += (dotY - ringY) * 0.18;
if (dotRef.current) {
dotRef.current.style.transform = `translate3d(${dotX}px, ${dotY}px, 0) translate(-50%, -50%)`;
}
if (ringRef.current) {
ringRef.current.style.transform = `translate3d(${ringX}px, ${ringY}px, 0) translate(-50%, -50%)`;
}
raf = requestAnimationFrame(tick);
};
const onOver = (e: MouseEvent) => {
const target = e.target as HTMLElement | null;
const isHover = !!target?.closest(HOVER_SELECTOR);
ringRef.current?.classList.toggle('cursor-ring--hover', isHover);
dotRef.current?.classList.toggle('cursor-dot--hover', isHover);
};
const onDown = () => ringRef.current?.classList.add('cursor-ring--down');
const onUp = () => ringRef.current?.classList.remove('cursor-ring--down');
const onLeave = () => {
ringRef.current?.classList.add('cursor--hidden');
dotRef.current?.classList.add('cursor--hidden');
};
const onEnter = () => {
ringRef.current?.classList.remove('cursor--hidden');
dotRef.current?.classList.remove('cursor--hidden');
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseover', onOver);
window.addEventListener('mousedown', onDown);
window.addEventListener('mouseup', onUp);
document.addEventListener('mouseleave', onLeave);
document.addEventListener('mouseenter', onEnter);
raf = requestAnimationFrame(tick);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseover', onOver);
window.removeEventListener('mousedown', onDown);
window.removeEventListener('mouseup', onUp);
document.removeEventListener('mouseleave', onLeave);
document.removeEventListener('mouseenter', onEnter);
cancelAnimationFrame(raf);
};
}, [enabled]);
if (!enabled) return null;
return (
<>
<style jsx global>{`
.cursor-dot,
.cursor-ring {
position: fixed;
left: 0;
top: 0;
pointer-events: none;
z-index: 9999;
will-change: transform;
transition:
width 0.25s ease,
height 0.25s ease,
background 0.25s ease,
border-color 0.25s ease,
opacity 0.25s ease;
}
.cursor-dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: #38bdf8;
box-shadow: 0 0 14px rgba(56, 189, 248, 0.8);
mix-blend-mode: screen;
}
.cursor-ring {
width: 36px;
height: 36px;
border-radius: 999px;
border: 1.5px solid rgba(56, 189, 248, 0.55);
}
.cursor-dot--hover {
background: #e879f9;
box-shadow: 0 0 18px rgba(232, 121, 249, 0.85);
}
.cursor-ring--hover {
width: 56px;
height: 56px;
border-color: rgba(232, 121, 249, 0.7);
background: rgba(232, 121, 249, 0.05);
}
.cursor-ring--down {
width: 30px;
height: 30px;
border-color: rgba(255, 255, 255, 0.8);
}
.cursor--hidden {
opacity: 0;
}
`}</style>
<div ref={ringRef} className="cursor-ring" aria-hidden />
<div ref={dotRef} className="cursor-dot" aria-hidden />
</>
);
}
-50
View File
@@ -1,50 +0,0 @@
'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>
);
}
+9 -9
View File
@@ -6,30 +6,30 @@ services:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/repository/docker-group/node:20-slim} DOTNET_IMAGE: ${DOTNET_IMAGE:-mcr.microsoft.com/dotnet/aspnet:10.0-alpine}
NPM_TOKEN: ${NPM_TOKEN:-} SDK_IMAGE: ${SDK_IMAGE:-mcr.microsoft.com/dotnet/sdk:10.0-alpine}
image: soroushasadi-site:latest image: soroushasadi-site:latest
container_name: soroushasadi-site container_name: soroushasadi-site
restart: unless-stopped restart: unless-stopped
# Host port 3020 → container 3000. Port 3000 on the host is Gitea
# (git.soroushasadi.com proxies to :3000) — NEVER publish on 3000.
ports: ports:
- "3000:3000" - "3020:3000"
environment: environment:
NODE_ENV: production ASPNETCORE_ENVIRONMENT: Production
DATA_DIR: /data DataDir: /data
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD} ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD}
ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET:?set ADMIN_SESSION_SECRET}
RESEND_API_KEY: ${RESEND_API_KEY:-} RESEND_API_KEY: ${RESEND_API_KEY:-}
CONTACT_INBOX: ${CONTACT_INBOX:-} CONTACT_INBOX: ${CONTACT_INBOX:-}
CONTACT_FROM: ${CONTACT_FROM:-} CONTACT_FROM: ${CONTACT_FROM:-}
volumes: volumes:
- cms-data:/data - cms-data:/data
healthcheck: healthcheck:
test: ["CMD", "node", "-e", test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/ || exit 1"]
"fetch('http://127.0.0.1:3000/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
start_period: 30s start_period: 20s
volumes: volumes:
cms-data: cms-data:
-112
View File
@@ -1,112 +0,0 @@
/**
* 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());
}
-57
View File
@@ -1,57 +0,0 @@
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] };
}
-45
View File
@@ -1,45 +0,0 @@
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);
}
-294
View File
@@ -1,294 +0,0 @@
/**
* 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 030: 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 3060: 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 6090: 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);
-31
View File
@@ -1,31 +0,0 @@
/**
* 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 };
}
-105
View File
@@ -1,105 +0,0 @@
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 };
-855
View File
@@ -1,855 +0,0 @@
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 1218 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;
-95
View File
@@ -1,95 +0,0 @@
'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;
}
-6
View File
@@ -1,6 +0,0 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
-39
View File
@@ -1,39 +0,0 @@
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*'],
};
-16
View File
@@ -1,16 +0,0 @@
/** @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;

Some files were not shown because too many files have changed in this diff Show More