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>
This commit is contained in:
+11
-11
@@ -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
|
||||||
|
|||||||
@@ -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/node:20-alpine
|
|
||||||
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 .
|
|
||||||
|
|||||||
@@ -32,45 +32,33 @@ 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/node:20-alpine
|
|
||||||
run: |
|
|
||||||
docker compose build
|
|
||||||
|
|
||||||
- name: Deploy
|
- name: Deploy
|
||||||
run: |
|
run: docker compose up -d --remove-orphans
|
||||||
docker compose up -d --remove-orphans
|
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|||||||
+22
-35
@@ -1,39 +1,26 @@
|
|||||||
# dependencies
|
# .NET
|
||||||
node_modules
|
bin/
|
||||||
.pnp
|
obj/
|
||||||
.pnp.js
|
*.user
|
||||||
|
.vs/
|
||||||
|
appsettings.*.local.json
|
||||||
|
|
||||||
# next.js
|
# CMS data (SQLite DB + uploads live in the Docker volume)
|
||||||
.next/
|
|
||||||
out/
|
|
||||||
build/
|
|
||||||
|
|
||||||
# production
|
|
||||||
dist/
|
|
||||||
|
|
||||||
# typescript
|
|
||||||
*.tsbuildinfo
|
|
||||||
next-env.d.ts
|
|
||||||
|
|
||||||
# misc
|
|
||||||
.DS_Store
|
|
||||||
*.pem
|
|
||||||
|
|
||||||
# debug
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
|
|
||||||
# env
|
|
||||||
.env*.local
|
|
||||||
.env
|
|
||||||
|
|
||||||
# vercel
|
|
||||||
.vercel
|
|
||||||
|
|
||||||
# CMS data (SQLite DB + uploaded media live in the mounted volume)
|
|
||||||
/data
|
/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/
|
||||||
|
|||||||
@@ -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=http://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
|
|
||||||
+25
-51
@@ -1,64 +1,38 @@
|
|||||||
ARG NODE_IMAGE=mirror.soroushasadi.com/node:20-alpine
|
ARG DOTNET_IMAGE=mcr.microsoft.com/dotnet/aspnet:10.0-alpine
|
||||||
|
ARG SDK_IMAGE=mcr.microsoft.com/dotnet/sdk:10.0-alpine
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ── Build ─────────────────────────────────────────────────────────────────────
|
||||||
# 1. Builder — installs deps (including native better-sqlite3 compilation)
|
FROM ${SDK_IMAGE} AS build
|
||||||
# then produces the standalone Next.js server bundle.
|
WORKDIR /src
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
FROM ${NODE_IMAGE} AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# libc6-compat: needed by Next.js SWC binaries on Alpine.
|
COPY SoroushAsadi.Web.csproj ./
|
||||||
# python3 / make / g++: needed to compile better-sqlite3 native addon.
|
RUN dotnet restore --runtime linux-musl-x64
|
||||||
RUN apk add --no-cache libc6-compat python3 make g++ ca-certificates
|
|
||||||
|
|
||||||
ARG NPM_TOKEN=""
|
|
||||||
COPY package.json package-lock.json .npmrc ./
|
|
||||||
RUN if [ -n "$NPM_TOKEN" ]; then \
|
|
||||||
echo "//mirror.soroushasadi.com/repository/npm-group/:_authToken=${NPM_TOKEN}" >> .npmrc ; \
|
|
||||||
fi \
|
|
||||||
&& npm ci \
|
|
||||||
&& echo "=== post-install check ===" \
|
|
||||||
&& (test -d node_modules/next \
|
|
||||||
&& echo "OK: node_modules/next found: $(cat node_modules/next/package.json | grep '\"version\"' | head -1)" \
|
|
||||||
|| (echo "FATAL: node_modules/next is missing after npm ci" && exit 1))
|
|
||||||
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
|
||||||
COPY . .
|
COPY . .
|
||||||
# Diagnose what's in .bin after install, then invoke next directly via node
|
RUN dotnet publish SoroushAsadi.Web.csproj \
|
||||||
# to bypass any PATH / symlink resolution issues with `npm run`.
|
--no-restore \
|
||||||
RUN ls node_modules/.bin/next 2>&1 || echo "WARN: next not in .bin" ; \
|
--runtime linux-musl-x64 \
|
||||||
node node_modules/next/dist/bin/next build
|
--self-contained false \
|
||||||
|
-c Release \
|
||||||
|
-o /app/publish
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ── Runtime ───────────────────────────────────────────────────────────────────
|
||||||
# 2. Runner — minimal image. Standalone server + static assets only.
|
FROM ${DOTNET_IMAGE} AS runner
|
||||||
# Content DB + uploads live in /data (mounted volume).
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
FROM ${NODE_IMAGE} AS runner
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apk add --no-cache libc6-compat ca-certificates
|
RUN apk add --no-cache ca-certificates \
|
||||||
|
&& addgroup -g 1001 dotnet \
|
||||||
|
&& adduser -u 1001 -G dotnet -h /home/dotnet -D dotnet
|
||||||
|
|
||||||
ENV NODE_ENV=production \
|
COPY --from=build /app/publish ./
|
||||||
NEXT_TELEMETRY_DISABLED=1 \
|
|
||||||
PORT=3000 \
|
|
||||||
HOSTNAME=0.0.0.0 \
|
|
||||||
DATA_DIR=/data
|
|
||||||
|
|
||||||
RUN addgroup -g 1001 nodejs && adduser -u 1001 -G nodejs -h /home/nextjs -D nextjs
|
ENV ASPNETCORE_ENVIRONMENT=Production \
|
||||||
|
ASPNETCORE_URLS=http://+:3000 \
|
||||||
|
DataDir=/data
|
||||||
|
|
||||||
COPY --from=builder /app/.next/standalone ./
|
RUN mkdir -p /data/uploads && chown -R dotnet:dotnet /data /app
|
||||||
COPY --from=builder /app/.next/static ./.next/static
|
USER dotnet
|
||||||
COPY --from=builder /app/public ./public
|
|
||||||
|
|
||||||
# Native addon compiled in builder on Alpine/musl — copy explicitly as a
|
|
||||||
# safety net in case file tracing misses the .node binary.
|
|
||||||
COPY --from=builder /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3
|
|
||||||
COPY --from=builder /app/node_modules/bindings ./node_modules/bindings
|
|
||||||
COPY --from=builder /app/node_modules/file-uri-to-path ./node_modules/file-uri-to-path
|
|
||||||
|
|
||||||
RUN mkdir -p /data/uploads && chown -R nextjs:nodejs /data /app
|
|
||||||
USER nextjs
|
|
||||||
|
|
||||||
VOLUME ["/data"]
|
VOLUME ["/data"]
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["node", "server.js"]
|
ENTRYPOINT ["dotnet", "SoroushAsadi.Web.dll"]
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
@page "/Admin/Logout"
|
||||||
|
@model SoroushAsadi.Pages.Admin.LogoutModel
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
@page "/blog"
|
||||||
|
@model SoroushAsadi.Pages.Blog.BlogIndexModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = Model.IsFa ? "بلاگ — سروش اسعدی" : "Blog — Soroush Asadi";
|
||||||
|
var fa = Model.IsFa;
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="min-h-screen pt-28 pb-20 px-5 sm:px-8">
|
||||||
|
<div class="mx-auto max-w-7xl">
|
||||||
|
<div class="section-header mb-14">
|
||||||
|
<div class="eyebrow mb-4"><span class="chip">@(fa ? "بلاگ" : "Journal")</span></div>
|
||||||
|
<h1 class="font-display text-4xl font-extrabold text-white @(fa ? "font-fa" : "")">
|
||||||
|
@(fa ? "یادداشتهای مهندسی" : "Engineering notes")
|
||||||
|
</h1>
|
||||||
|
<p class="mt-4 text-slate-400">@(fa ? "یافتهها از پروژههای واقعی — نه ترجمهی مقاله، نه فهرست hype." : "Findings from real engagements — not translated articles, not hype lists.")</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
@foreach (var post in Model.Posts)
|
||||||
|
{
|
||||||
|
<a href="/blog/@post.Slug" class="group glass block p-6 transition-all duration-300 hover:-translate-y-1 hover:border-electric/40 reveal">
|
||||||
|
<span class="label-mono text-electric mb-3 block">@post.Category</span>
|
||||||
|
<h2 class="font-display font-semibold leading-snug text-white group-hover:text-electric transition-colors @(fa ? "font-fa" : "")"
|
||||||
|
style="font-size:clamp(1rem,1.4vw,1.15rem)">@post.Title</h2>
|
||||||
|
<p class="mt-3 text-[.88rem] leading-relaxed text-slate-400 line-clamp-3">@post.Excerpt</p>
|
||||||
|
<div class="mt-4 flex items-center justify-between">
|
||||||
|
<span class="label-mono">@post.ReadTime @(fa ? "دقیقه" : "min") @(fa ? "مطالعه" : "read")</span>
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" class="text-electric @(fa ? "rotate-180" : "")"><path d="M5 12H19"/><path d="M13 6L19 12L13 18"/></svg>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -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 sharing، و request hedging برای سرویسدهی پایدار.",14),
|
||||||
|
new("flutter-on-device-ai","Mobile","هوش مصنوعی on-device در Flutter","استفاده از Gemini Nano و LiteRT برای استنتاج آفلاین در اپلیکیشنهای موبایل.",9),
|
||||||
|
new("enterprise-ai-roadmap","Strategy","نقشه راه هوش مصنوعی سازمانی در ۹۰ روز","چارچوبی که برای CTOها میسازم — از کشف موارد کاربری تا اولین استقرار تولید.",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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
@page "/blog/{slug}"
|
||||||
|
@model SoroushAsadi.Pages.Blog.PostModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = Model.Title + " — Soroush Asadi";
|
||||||
|
var fa = Model.IsFa;
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="min-h-screen pt-28 pb-20 px-5 sm:px-8">
|
||||||
|
<div class="mx-auto max-w-3xl">
|
||||||
|
<a href="/blog" class="label-mono mb-8 inline-flex items-center gap-2 text-slate-400 hover:text-white transition-colors @(fa ? "flex-row-reverse" : "")">
|
||||||
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" 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-slate-400">@(fa ? "مقاله پیدا نشد." : "Post not found.")</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="mb-6">
|
||||||
|
<span class="label-mono text-electric">@Model.Category</span>
|
||||||
|
<h1 class="mt-3 font-display text-3xl font-extrabold leading-tight text-white @(fa ? "font-fa" : "")">@Model.Title</h1>
|
||||||
|
<p class="mt-2 label-mono text-slate-500">@Model.ReadTime @(fa ? "دقیقه مطالعه" : "min read")</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article class="prose-custom glass p-8">
|
||||||
|
@Html.Raw(Model.BodyHtml)
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<style>
|
||||||
|
.prose-custom { color:#cbd5e1; line-height:1.8; }
|
||||||
|
.prose-custom h2,.prose-custom h3 { font-family:'Syne',sans-serif; font-weight:700; color:#fff; margin:2rem 0 .75rem; }
|
||||||
|
.prose-custom p { margin-bottom:1.25rem; }
|
||||||
|
.prose-custom code { font-family:'SpaceMono',monospace; background:rgba(56,189,248,.08); border:1px solid rgba(56,189,248,.2); border-radius:.35rem; padding:.15em .45em; font-size:.85em; color:#38bdf8; }
|
||||||
|
.prose-custom pre { background:#050a1a; border:1px solid rgba(255,255,255,.06); border-radius:.75rem; padding:1.25rem; overflow-x:auto; margin:1.5rem 0; }
|
||||||
|
.prose-custom pre code { background:none; border:none; padding:0; color:#e2e8f0; }
|
||||||
|
.prose-custom ul,.prose-custom ol { padding-inline-start:1.5rem; margin-bottom:1.25rem; }
|
||||||
|
.prose-custom li { margin-bottom:.4rem; }
|
||||||
|
.prose-custom blockquote { border-inline-start:3px solid #38bdf8; padding-inline-start:1rem; color:#94a3b8; margin:1.5rem 0; }
|
||||||
|
.prose-custom strong { color:#e2e8f0; }
|
||||||
|
.prose-custom a { color:#38bdf8; text-decoration:underline; text-underline-offset:.2em; }
|
||||||
|
</style>
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
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)
|
||||||
|
private static readonly Dictionary<string, (string Cat, string TitleEn, string TitleFa, int RT, string Body)> _defaults = new()
|
||||||
|
{
|
||||||
|
["rag-eval-framework"] = ("LLM", "A RAG evaluation framework that holds up in production", "چارچوب ارزیابی RAG که در تولید کار میکند", 8, DefaultBodies.RagEval),
|
||||||
|
["agentic-n8n-patterns"] = ("Automation", "Agentic patterns with n8n for the enterprise", "الگوهای عاملمحور با n8n برای سازمان", 11, DefaultBodies.N8nPatterns),
|
||||||
|
["vertex-cost-control"] = ("Google Stack", "Vertex AI cost control at scale", "کنترل هزینه روی Vertex AI در مقیاس بالا", 6, DefaultBodies.VertexCost),
|
||||||
|
["k8s-llm-inference"] = ("Infra", "Sub-50ms LLM inference on Kubernetes", "استنتاج LLM روی Kubernetes با تأخیر زیر ۵۰ ms",14, DefaultBodies.K8sInference),
|
||||||
|
["flutter-on-device-ai"] = ("Mobile", "On-device AI in Flutter", "هوش مصنوعی on-device در Flutter", 9, DefaultBodies.FlutterAI),
|
||||||
|
["enterprise-ai-roadmap"] = ("Strategy", "A 90-day enterprise AI roadmap", "نقشه راه هوش مصنوعی سازمانی در ۹۰ روز", 7, DefaultBodies.EnterpriseRoadmap),
|
||||||
|
};
|
||||||
|
|
||||||
|
public void OnGet()
|
||||||
|
{
|
||||||
|
if (!_defaults.TryGetValue(Slug, out var def)) { PostNotFound = true; return; }
|
||||||
|
|
||||||
|
// Check for DB override (stored under "posts" key as slug→{body,...})
|
||||||
|
var overrides = content.GetPostOverrides();
|
||||||
|
string body = def.Body;
|
||||||
|
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 { '&' => "&", '<' => "<", '>' => ">", _ => s[i].ToString() });
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
private static string Esc(string s) => s.Replace("&","&").Replace("<","<").Replace(">",">");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default article bodies (Markdown).
|
||||||
|
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 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 VertexCost = """
|
||||||
|
## Anti-pattern 1: calling Gemini Ultra for everything
|
||||||
|
|
||||||
|
Gemini Ultra (or GPT-4-class models) costs 10–30× 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 ~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 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 — 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–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× 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 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 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–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 2×2: **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–5. Set a threshold (e.g., ≥4.0 average) before the sprint begins. If the prototype clears it, proceed to production hardening. If it doesn't, 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–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.
|
||||||
|
""";
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
@page "/contact"
|
||||||
|
@model SoroushAsadi.Pages.ContactModel
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,550 @@
|
|||||||
|
@page
|
||||||
|
@model SoroushAsadi.Pages.IndexModel
|
||||||
|
@{
|
||||||
|
var fa = Model.IsFa;
|
||||||
|
var locale = Model.Locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ─── HERO ─────────────────────────────────────────────────────────── -->
|
||||||
|
<section id="top" class="relative isolate overflow-hidden min-h-[100svh] pt-28 pb-20 sm:pt-32">
|
||||||
|
<!-- Particle canvas -->
|
||||||
|
<div class="pointer-events-none absolute inset-0 -z-10">
|
||||||
|
<canvas id="particle-canvas" class="h-full w-full opacity-60"></canvas>
|
||||||
|
<div aria-hidden class="absolute inset-x-0 bottom-0 h-40 bg-gradient-to-b from-transparent to-base"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Aurora background -->
|
||||||
|
<div aria-hidden class="pointer-events-none absolute inset-0 -z-20"
|
||||||
|
style="background:radial-gradient(ellipse 80% 50% at 50% -20%,rgba(56,189,248,.18),transparent 60%),
|
||||||
|
radial-gradient(ellipse 60% 40% at 80% 10%,rgba(232,121,249,.10),transparent 60%),
|
||||||
|
radial-gradient(ellipse 60% 40% at 10% 30%,rgba(129,140,248,.10),transparent 60%)"></div>
|
||||||
|
|
||||||
|
<div class="mx-auto flex max-w-7xl flex-col items-center px-5 text-center sm:px-8">
|
||||||
|
|
||||||
|
<!-- Availability chip -->
|
||||||
|
<div class="mb-7 reveal">
|
||||||
|
<span class="chip">
|
||||||
|
<span class="relative inline-flex h-2 w-2">
|
||||||
|
<span class="absolute inset-0 animate-pulse-dot rounded-full bg-emerald"></span>
|
||||||
|
<span class="relative inline-block h-2 w-2 rounded-full bg-emerald"></span>
|
||||||
|
</span>
|
||||||
|
@(fa ? "پذیرش پروژههای منتخب فصل سوم ۲۰۲۶" : "Available for select Q3 2026 engagements")
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Eyebrow -->
|
||||||
|
<p class="label-mono mb-6 inline-flex items-center gap-3 reveal" style="transition-delay:.08s">
|
||||||
|
<span class="h-px w-10 bg-electric/60" aria-hidden></span>
|
||||||
|
@(fa ? "مهندس هوش مصنوعی · مشاور · معمار راهکار" : "AI Engineer · Consultant · Solution Architect")
|
||||||
|
<span class="h-px w-10 bg-electric/60" aria-hidden></span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Name -->
|
||||||
|
<h1 class="font-display text-balance font-extrabold leading-[1.02] tracking-tight text-white reveal @(fa ? "font-fa" : "")"
|
||||||
|
style="font-size:clamp(2.4rem,7vw,5.4rem);transition-delay:.15s">
|
||||||
|
@(fa ? "سروش اسعدی" : "Soroush Asadi")
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<!-- Headline -->
|
||||||
|
<p class="mt-5 max-w-4xl text-balance font-medium leading-[1.25] text-slate-200 reveal"
|
||||||
|
style="font-size:clamp(1.15rem,2.2vw,1.75rem);transition-delay:.25s">
|
||||||
|
@(fa ? "طراحی سامانههای" : "Architecting")
|
||||||
|
<span class="gradient-text font-semibold">@(fa ? "هوش مصنوعی" : "production-grade AI")</span>
|
||||||
|
@(fa ? "در مقیاس سازمانی." : "for the enterprise.")
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Typewriter -->
|
||||||
|
<div class="mt-5 flex items-center gap-3 font-mono uppercase tracking-[.15em] text-slate-400 reveal"
|
||||||
|
style="font-size:clamp(.9rem,1.4vw,1.05rem);transition-delay:.35s">
|
||||||
|
<span class="h-px w-6 bg-slate-700" aria-hidden></span>
|
||||||
|
<span id="typewriter"
|
||||||
|
data-words='@(fa
|
||||||
|
? "[\"راهبرد هوش مصنوعی\",\"مهندسی LLM و RAG\",\"معماری راهکار\",\"اتوماسیون عاملمحور\",\"استک گوگل کلود\"]"
|
||||||
|
: "[\"AI Strategy\",\"LLM & RAG Engineering\",\"Solution Architecture\",\"Agentic Automation\",\"Google Cloud Stack\"]")'></span>
|
||||||
|
<span class="inline-block w-px h-[1em] bg-electric animate-caret-blink" aria-hidden></span>
|
||||||
|
<span class="h-px w-6 bg-slate-700" aria-hidden></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sub -->
|
||||||
|
<p class="mt-7 max-w-2xl text-balance leading-relaxed text-slate-400 reveal"
|
||||||
|
style="font-size:clamp(.95rem,1.4vw,1.08rem);transition-delay:.42s">
|
||||||
|
@(fa
|
||||||
|
? "از راهبرد تا تولید — ساخت پایپلاینهای LLM، عاملهای خودکار، و معماریهای ابری که در میلیونها رویداد در روز پایدار میمانند."
|
||||||
|
: "From strategy to deployment — building LLM pipelines, autonomous agents, and cloud architectures that hold up at millions of events per day.")
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- CTAs -->
|
||||||
|
<div class="mt-9 flex flex-wrap items-center justify-center gap-3 reveal" style="transition-delay:.5s">
|
||||||
|
<a href="#contact" class="btn-primary">
|
||||||
|
@(fa ? "رزرو جلسه مشاوره" : "Book a consultation")
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" class="@(fa ? "rotate-180" : "")" aria-hidden="true"><path d="M5 12H19"/><path d="M13 6L19 12L13 18"/></svg>
|
||||||
|
</a>
|
||||||
|
<a href="#services" class="btn-ghost">@(fa ? "مشاهده خدمات" : "View services")</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Metrics -->
|
||||||
|
<div class="mt-16 grid w-full max-w-4xl grid-cols-2 gap-4 sm:grid-cols-4 reveal" style="transition-delay:.6s">
|
||||||
|
@{
|
||||||
|
var metrics = fa
|
||||||
|
? new[]{ ("۱۸+","مدل هوش مصنوعی مستقر","text-electric"), ("۴۰+","میکروسرویس تولید","text-violet"), ("۱۲ms","تأخیر استنتاج","text-magenta"), ("۹۹٪","پایداری SLA","text-emerald") }
|
||||||
|
: new[]{ ("18+","AI models in production","text-electric"), ("40+","microservices shipped","text-violet"), ("12ms","inference latency","text-magenta"), ("99%","SLA uptime","text-emerald") };
|
||||||
|
}
|
||||||
|
@foreach (var (val, label, color) in metrics)
|
||||||
|
{
|
||||||
|
<div class="glass relative overflow-hidden px-5 py-5 text-start">
|
||||||
|
<span aria-hidden class="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-electric/50 to-transparent"></span>
|
||||||
|
<div class="font-display font-bold leading-none @color" style="font-size:clamp(1.6rem,3vw,2.25rem)">@val</div>
|
||||||
|
<div class="mt-2 text-[.78rem] leading-snug text-slate-400">@label</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scroll cue -->
|
||||||
|
<a href="#services" class="mt-14 inline-flex flex-col items-center gap-2 text-slate-500 transition-colors hover:text-slate-200 reveal" style="transition-delay:.75s" aria-label="@(fa ? "اسکرول" : "Scroll")">
|
||||||
|
<span class="label-mono">@(fa ? "اسکرول" : "Scroll")</span>
|
||||||
|
<span class="relative block h-9 w-5 rounded-full border border-slate-700">
|
||||||
|
<span class="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>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ─── SERVICES ─────────────────────────────────────────────────────── -->
|
||||||
|
<section id="services" class="relative px-5 py-28 sm:px-8">
|
||||||
|
<div class="mx-auto max-w-7xl">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="eyebrow"><span class="chip">@(fa ? "خدمات" : "Services")</span></div>
|
||||||
|
<h2>@(fa ? "شش حوزه تخصصی" : "Six areas of practice")</h2>
|
||||||
|
<p>@(fa ? "از اولین جلسهی راهبرد تا استقرار تولید — یک شریک مهندسی برای کل چرخهی عمر هوش مصنوعی شما." : "From the first strategy session to production rollout — one engineering partner for the full AI lifecycle.")</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
@{
|
||||||
|
var services = fa ? new[]{
|
||||||
|
("strategy","راهبرد و نقشه راه هوش مصنوعی","ارزیابی بلوغ سازمانی، شناسایی موارد کاربری با بیشترین بازده، و طراحی نقشه راه ۱۲–۱۸ ماهه با KPIهای روشن.","electric",new[]{"Discovery","ROI Mapping","Roadmap"}),
|
||||||
|
("automation","اتوماسیون هوش مصنوعی","ساخت عاملهای خودکار و گردشکارهای n8n که فرایندهای دستی را به سامانههای قابل ممیزی تبدیل میکنند.","violet",new[]{"n8n","Agents","Workflows"}),
|
||||||
|
("llm-rag","مهندسی LLM و RAG","طراحی pipelineهای RAG با پایگاههای برداری، evaluation framework، و سرویسدهی با تأخیر زیر ۵۰ میلیثانیه.","magenta",new[]{"RAG","Vector DB","Eval"}),
|
||||||
|
("architecture","معماری راهکار","طراحی سامانههای توزیعشده روی Kubernetes با میکروسرویسها، event streaming، و الگوهای پایداری در مقیاس بالا.","emerald",new[]{"K8s","Microservices","Event-Driven"}),
|
||||||
|
("mobile","اپلیکیشنهای موبایل هوش مصنوعی","برنامههای Flutter، Swift و Kotlin با on-device inference، استریم LLM و تجربهی کاربری بومی.","electric",new[]{"Flutter","Swift","Kotlin"}),
|
||||||
|
("google-stack","تخصص استک گوگل","استقرار روی Vertex AI، GKE و Gemini با بهینهسازی هزینه و الگوهای امنیتی سطح enterprise.","cyan",new[]{"Vertex AI","GKE","Gemini"}),
|
||||||
|
} : new[]{
|
||||||
|
("strategy","AI Strategy & Roadmap","Maturity assessment, highest-ROI use-case discovery, and a 12–18 month roadmap with measurable KPIs.","electric",new[]{"Discovery","ROI Mapping","Roadmap"}),
|
||||||
|
("automation","AI Automation","Autonomous agents and n8n workflows that turn manual processes into auditable, observable systems.","violet",new[]{"n8n","Agents","Workflows"}),
|
||||||
|
("llm-rag","LLM & RAG Engineering","Production RAG pipelines with vector stores, evaluation frameworks, and sub-50ms serving.","magenta",new[]{"RAG","Vector DB","Eval"}),
|
||||||
|
("architecture","Solution Architecture","Distributed systems on Kubernetes — microservices, event streaming, and resilience patterns at scale.","emerald",new[]{"K8s","Microservices","Event-Driven"}),
|
||||||
|
("mobile","Mobile AI Apps","Flutter, Swift, and Kotlin apps with on-device inference, streaming LLM UX, and native polish.","electric",new[]{"Flutter","Swift","Kotlin"}),
|
||||||
|
("google-stack","Google Stack Specialist","Vertex AI, GKE, and Gemini deployments with cost optimization and enterprise security patterns.","cyan",new[]{"Vertex AI","GKE","Gemini"}),
|
||||||
|
};
|
||||||
|
int si = 0;
|
||||||
|
}
|
||||||
|
@foreach (var (id, title, desc, color, tags) in services)
|
||||||
|
{
|
||||||
|
var (ringCls, glowCls, textCls, chipCls) = color switch {
|
||||||
|
"violet" => ("group-hover:border-violet/50", "group-hover:shadow-glow-violet", "text-violet", "border-violet/30 bg-violet/5 text-violet/90"),
|
||||||
|
"magenta" => ("group-hover:border-magenta/50", "group-hover:shadow-glow-magenta", "text-magenta", "border-magenta/30 bg-magenta/5 text-magenta/90"),
|
||||||
|
"emerald" => ("group-hover:border-emerald/50", "group-hover:shadow-glow-emerald", "text-emerald", "border-emerald/30 bg-emerald/5 text-emerald/90"),
|
||||||
|
"cyan" => ("group-hover:border-cyan/50", "group-hover:shadow-glow-electric","text-cyan", "border-cyan/30 bg-cyan/5 text-cyan/90"),
|
||||||
|
_ => ("group-hover:border-electric/50","group-hover:shadow-glow-electric","text-electric","border-electric/30 bg-electric/5 text-electric/90"),
|
||||||
|
};
|
||||||
|
<article class="group relative isolate overflow-hidden p-6 sm:p-7 glass service-card reveal @ringCls @glowCls" style="transition-delay:@(si * 50)ms">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<span class="label-mono">@((si + 1).ToString("D2"))</span>
|
||||||
|
<span class="@textCls">
|
||||||
|
@Html.Raw(ServiceIcon(id))
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="mt-6 font-display font-semibold leading-snug text-white @(fa ? "font-fa" : "")" style="font-size:clamp(1.15rem,1.8vw,1.4rem)">@title</h3>
|
||||||
|
<p class="mt-3 text-[.94rem] leading-relaxed text-slate-400">@desc</p>
|
||||||
|
<div class="mt-5 flex flex-wrap gap-1.5">
|
||||||
|
@foreach (var tag in tags)
|
||||||
|
{
|
||||||
|
<span class="rounded-full border px-2.5 py-0.5 font-mono text-[.65rem] uppercase tracking-wider @chipCls">@tag</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<span aria-hidden class="absolute inset-x-6 bottom-0 h-px bg-gradient-to-r from-transparent via-white/10 to-transparent"></span>
|
||||||
|
</article>
|
||||||
|
si++;
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ─── DATA FLOW ────────────────────────────────────────────────────── -->
|
||||||
|
<section id="dataflow" class="relative overflow-hidden px-5 py-28 sm:px-8">
|
||||||
|
<div class="mx-auto max-w-5xl">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="eyebrow"><span class="chip">@(fa ? "پایپلاین" : "Pipeline")</span></div>
|
||||||
|
<h2>@(fa ? "از سند خام تا پاسخ قابل اتکا" : "From raw document to trustworthy answer")</h2>
|
||||||
|
<p>@(fa ? "مسیری که هر پرسش در یک سامانهی RAG تولیدی طی میکند — هر مرحله قابل اندازهگیری، قابل ممیزی و بهینهشده برای تأخیر." : "The path every query takes through a production RAG system — each stage measurable, auditable, and tuned for latency.")</p>
|
||||||
|
</div>
|
||||||
|
<!-- Flow diagram -->
|
||||||
|
<div class="reveal mt-10 overflow-x-auto">
|
||||||
|
<div class="flex min-w-max items-center gap-0 mx-auto w-fit">
|
||||||
|
@{
|
||||||
|
var nodes = fa ? new[]{
|
||||||
|
("ingest","دریافت","نرمالسازی، قطعهبندی و پاکسازی اسناد منبع","electric"),
|
||||||
|
("embed","برداریسازی","تولید embedding و نمایهسازی در پایگاه برداری","violet"),
|
||||||
|
("retrieve","بازیابی","جستجوی ترکیبی معنایی و کلیدواژهای","cyan"),
|
||||||
|
("rerank","بازرتبهبندی","مرتبسازی مجدد نامزدها با cross-encoder","magenta"),
|
||||||
|
("generate","تولید","پاسخ مستند با ارجاع به منبع","emerald"),
|
||||||
|
} : new[]{
|
||||||
|
("ingest","Ingest","Normalize, chunk, and clean source documents","electric"),
|
||||||
|
("embed","Embed","Generate embeddings and index in vector store","violet"),
|
||||||
|
("retrieve","Retrieve","Hybrid semantic + keyword search","cyan"),
|
||||||
|
("rerank","Rerank","Re-order candidates with a cross-encoder","magenta"),
|
||||||
|
("generate","Generate","Grounded answer with source citations","emerald"),
|
||||||
|
};
|
||||||
|
var colorMap2 = new Dictionary<string,(string border, string text, string bg)>{
|
||||||
|
["electric"] = ("border-electric/40","text-electric","bg-electric/10"),
|
||||||
|
["violet"] = ("border-violet/40", "text-violet", "bg-violet/10"),
|
||||||
|
["cyan"] = ("border-cyan/40", "text-cyan", "bg-cyan/10"),
|
||||||
|
["magenta"] = ("border-magenta/40", "text-magenta", "bg-magenta/10"),
|
||||||
|
["emerald"] = ("border-emerald/40", "text-emerald", "bg-emerald/10"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@for (int ni = 0; ni < nodes.Length; ni++)
|
||||||
|
{
|
||||||
|
var (nid, nlabel, ndesc, naccent) = nodes[ni];
|
||||||
|
var (nborder, ntext, nbg) = colorMap2[naccent];
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<div class="glass @nborder @nbg flex flex-col items-center gap-2 rounded-2xl border p-5 text-center w-40">
|
||||||
|
<span class="label-mono @ntext">@nlabel</span>
|
||||||
|
<p class="text-[.72rem] leading-snug text-slate-400">@ndesc</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
if (ni < nodes.Length - 1)
|
||||||
|
{
|
||||||
|
<div class="flex items-center px-2">
|
||||||
|
<svg width="40" height="16" viewBox="0 0 40 16" fill="none" aria-hidden="true">
|
||||||
|
<line x1="0" y1="8" x2="32" y2="8" stroke="rgba(56,189,248,0.4)" stroke-width="1.5" stroke-dasharray="4 3">
|
||||||
|
<animate attributeName="stroke-dashoffset" values="0;-14" dur="1.1s" repeatCount="indefinite"/>
|
||||||
|
</line>
|
||||||
|
<polygon points="32,4 40,8 32,12" fill="rgba(56,189,248,0.6)"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-8 text-center label-mono text-slate-500 reveal">@(fa ? "تأخیر سرتاسری زیر ۵۰ میلیثانیه · هر مرحله مشاهدهپذیر" : "Sub-50ms end-to-end · every stage observable")</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ─── STACK ─────────────────────────────────────────────────────────── -->
|
||||||
|
<section id="stack" class="relative px-5 py-28 sm:px-8">
|
||||||
|
<div class="mx-auto max-w-7xl">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="eyebrow"><span class="chip">@(fa ? "استک" : "Stack")</span></div>
|
||||||
|
<h2>@(fa ? "ابزارهای روزانه" : "Daily tooling")</h2>
|
||||||
|
<p>@(fa ? "هر چه ساخته میشود از این پایهها بیرون میآید — انتخابشده برای عمر طولانی، نه ترند روز." : "Everything I ship sits on this foundation — chosen for longevity, not hype cycles.")</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4 reveal">
|
||||||
|
@{
|
||||||
|
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"}),
|
||||||
|
};
|
||||||
|
string[] catColors = ["text-electric","text-violet","text-emerald","text-magenta"];
|
||||||
|
int ci2 = 0;
|
||||||
|
}
|
||||||
|
@foreach (var (catLabel, items) in cats)
|
||||||
|
{
|
||||||
|
<div class="glass p-6">
|
||||||
|
<h3 class="font-display font-semibold text-white mb-4 @catColors[ci2]">@catLabel</h3>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
@foreach (var item in items)
|
||||||
|
{
|
||||||
|
<li class="flex items-center gap-2 text-[.9rem] text-slate-300">
|
||||||
|
<span class="h-1 w-1 rounded-full @catColors[ci2].Replace("text-","bg-")" aria-hidden></span>
|
||||||
|
@item
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
ci2++;
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ─── EXPERTISE ────────────────────────────────────────────────────── -->
|
||||||
|
<section id="expertise" class="relative px-5 py-28 sm:px-8">
|
||||||
|
<div class="mx-auto max-w-3xl">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="eyebrow"><span class="chip">@(fa ? "تخصص" : "Expertise")</span></div>
|
||||||
|
<h2>@(fa ? "اعدادی که اهمیت دارند" : "The numbers that matter")</h2>
|
||||||
|
<p>@(fa ? "سامانههایی که در میلیونها رویداد در روز پایدار میمانند — اینها معیارهایی هستند که اندازه میگیریم." : "Systems that survive millions of events per day — these are the metrics I optimize for.")</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-6 reveal">
|
||||||
|
@{
|
||||||
|
var bars = fa ? new[]{
|
||||||
|
("مهندسی LLM و RAG", 95),
|
||||||
|
("معماری ابری و Kubernetes", 92),
|
||||||
|
("سیستمهای عاملمحور و اتوماسیون", 90),
|
||||||
|
("استک گوگل کلود (Vertex / GKE)", 88),
|
||||||
|
("موبایل بومی و cross-platform", 82),
|
||||||
|
} : new[]{
|
||||||
|
("LLM & RAG engineering", 95),
|
||||||
|
("Cloud architecture & Kubernetes", 92),
|
||||||
|
("Agentic systems & automation", 90),
|
||||||
|
("Google Cloud stack (Vertex / GKE)", 88),
|
||||||
|
("Native + cross-platform mobile", 82),
|
||||||
|
};
|
||||||
|
string[] barColors = ["bg-electric","bg-violet","bg-cyan","bg-magenta","bg-emerald"];
|
||||||
|
int bi = 0;
|
||||||
|
}
|
||||||
|
@foreach (var (blabel, bval) in bars)
|
||||||
|
{
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<span class="text-[.9rem] text-slate-300">@blabel</span>
|
||||||
|
<span class="label-mono text-slate-400">@bval%</span>
|
||||||
|
</div>
|
||||||
|
<div class="bar-track">
|
||||||
|
<div class="bar-fill @barColors[bi]" data-w="@bval%" style="width:0%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
bi++;
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ─── PORTFOLIO ────────────────────────────────────────────────────── -->
|
||||||
|
<section id="portfolio" class="relative px-5 py-28 sm:px-8">
|
||||||
|
<div class="mx-auto max-w-7xl">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="eyebrow"><span class="chip">@(fa ? "نمونهکارها" : "Selected work")</span></div>
|
||||||
|
<h2>@(fa ? "سامانههایی که در تولید کار میکنند" : "Systems that run in production")</h2>
|
||||||
|
<p>@(fa ? "گزیدهای از پروژههای واقعی. روی هر کارت بزنید تا جزئیات معماری را ببینید." : "A selection of real engagements. Tap any card for the gallery and architecture details.")</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
@{
|
||||||
|
var projects = fa ? new[]{
|
||||||
|
("atlas-rag","اطلس — پلتفرم RAG سازمانی","بانک ردیفاول","مهندس ارشد هوش مصنوعی","۲۰۲۵","دستیار دانش روی بیش از ۴ میلیون سند داخلی؛ بازیابی ترکیبی با pgvector و reranker.","electric",new[]{"RAG","pgvector","Vertex AI","Eval"},new[]{("۴M+","سند نمایهشده"),("۳۸ms","تأخیر p95"),("۹۲٪","دقت پاسخ")},"/portfolio/atlas-rag/cover.svg",new[]{"/portfolio/atlas-rag/01.svg","/portfolio/atlas-rag/02.svg","/portfolio/atlas-rag/03.svg"}),
|
||||||
|
("sentinel-agents","Sentinel — اتوماسیون Ops عاملمحور","SaaS scale-up","معمار راهکار","۲۰۲۵","پاسخ خودکار به حوادث با ترکیب n8n و LangGraph — عاملهای قابل ممیزی که alert تریاژ میکنند.","violet",new[]{"n8n","LangGraph","Agents"},new[]{("۷۰٪","کاهش MTTR"),("۲۴/۷","پوشش on-call"),("۱۵۰+","جریان خودکار")},"/portfolio/sentinel-agents/cover.svg",new[]{"/portfolio/sentinel-agents/01.svg","/portfolio/sentinel-agents/02.svg","/portfolio/sentinel-agents/03.svg"}),
|
||||||
|
("vertex-vision","Vertex Vision — استنتاج بینایی بلادرنگ","زنجیره خردهفروشی","مهندس هوش مصنوعی","۲۰۲۴","استنتاج بینایی بلادرنگ روی GKE با Triton و Vertex AI برای تحلیل قفسه و جریان مشتری.","cyan",new[]{"Vertex AI","GKE","Triton"},new[]{("۱.۲B","استنتاج ماهانه"),("۳۰۰+","فروشگاه"),("۶۰٪","کاهش هزینه GPU")},"/portfolio/vertex-vision/cover.svg",new[]{"/portfolio/vertex-vision/01.svg","/portfolio/vertex-vision/02.svg","/portfolio/vertex-vision/03.svg"}),
|
||||||
|
("mirage-mobile","Mirage — مجموعه هوش مصنوعی on-device","محصول مصرفی","رهبر موبایل + هوش مصنوعی","۲۰۲۴","اپلیکیشن Flutter با استنتاج کاملاً آفلاین با Gemini Nano و LiteRT.","magenta",new[]{"Flutter","Gemini Nano","LiteRT"},new[]{("۰","وابستگی شبکه"),("<80ms","پاسخ"),("۴.۸★","امتیاز کاربران")},"/portfolio/mirage-mobile/cover.svg",new[]{"/portfolio/mirage-mobile/01.svg","/portfolio/mirage-mobile/02.svg","/portfolio/mirage-mobile/03.svg"}),
|
||||||
|
("flux-stream","Flux — مش داده رویدادمحور","پلتفرم لجستیک","معمار پلتفرم","۲۰۲۳","ستون استریمینگ روی Kafka و NATS روی Kubernetes — ۴۰+ میکروسرویس با الگوهای پایداری.","emerald",new[]{"Kafka","NATS","Kubernetes","Go"},new[]{("۴۰+","میکروسرویس"),("۲M/s","رویداد در ثانیه"),("۹۹.۹٪","uptime")},"/portfolio/flux-stream/cover.svg",new[]{"/portfolio/flux-stream/01.svg","/portfolio/flux-stream/02.svg","/portfolio/flux-stream/03.svg"}),
|
||||||
|
("oracle-forecast","Oracle — موتور پیشبینی تقاضا","زنجیره تامین","مهندس ML","۲۰۲۳","پایپلاین پیشبینی سری زمانی روی BigQuery و dbt با بازآموزی خودکار.","electric",new[]{"Forecasting","BigQuery","dbt","MLOps"},new[]{("۲۳٪","کاهش ضایعات"),("۸۹٪","دقت پیشبینی"),("روزانه","بازآموزی")},"/portfolio/oracle-forecast/cover.svg",new[]{"/portfolio/oracle-forecast/01.svg","/portfolio/oracle-forecast/02.svg","/portfolio/oracle-forecast/03.svg"}),
|
||||||
|
} : new[]{
|
||||||
|
("atlas-rag","Atlas — Enterprise RAG Platform","Tier-1 bank","Lead AI Engineer","2025","A knowledge assistant over 4M+ internal documents — hybrid retrieval with pgvector and a reranker, sub-40ms serving on Vertex AI.","electric",new[]{"RAG","pgvector","Vertex AI","Eval"},new[]{("4M+","docs indexed"),("38ms","p95 latency"),("92%","answer accuracy")},"/portfolio/atlas-rag/cover.svg",new[]{"/portfolio/atlas-rag/01.svg","/portfolio/atlas-rag/02.svg","/portfolio/atlas-rag/03.svg"}),
|
||||||
|
("sentinel-agents","Sentinel — Agentic Ops Automation","SaaS scale-up","Solution Architect","2025","Autonomous incident response combining n8n and LangGraph — auditable agents that triage alerts and self-heal.","violet",new[]{"n8n","LangGraph","Agents"},new[]{("70%","MTTR reduction"),("24/7","on-call coverage"),("150+","automated flows")},"/portfolio/sentinel-agents/cover.svg",new[]{"/portfolio/sentinel-agents/01.svg","/portfolio/sentinel-agents/02.svg","/portfolio/sentinel-agents/03.svg"}),
|
||||||
|
("vertex-vision","Vertex Vision — Realtime Vision Inference","Retail chain","AI Engineer","2024","Real-time vision inference on GKE with Triton and Vertex AI for shelf analytics and customer flow across 300+ stores.","cyan",new[]{"Vertex AI","GKE","Triton"},new[]{("1.2B","inferences / mo"),("300+","stores"),("60%","GPU cost cut")},"/portfolio/vertex-vision/cover.svg",new[]{"/portfolio/vertex-vision/01.svg","/portfolio/vertex-vision/02.svg","/portfolio/vertex-vision/03.svg"}),
|
||||||
|
("mirage-mobile","Mirage — On-device AI Suite","Consumer product","Mobile + AI Lead","2024","A Flutter app with fully offline inference via Gemini Nano and LiteRT — streaming response UX with zero network dependency.","magenta",new[]{"Flutter","Gemini Nano","LiteRT"},new[]{("0","network deps"),("<80ms","response"),("4.8★","user rating")},"/portfolio/mirage-mobile/cover.svg",new[]{"/portfolio/mirage-mobile/01.svg","/portfolio/mirage-mobile/02.svg","/portfolio/mirage-mobile/03.svg"}),
|
||||||
|
("flux-stream","Flux — Event-Driven Data Mesh","Logistics platform","Platform Architect","2023","Streaming backbone on Kafka and NATS over Kubernetes — 40+ microservices with resilience patterns and exactly-once delivery.","emerald",new[]{"Kafka","NATS","Kubernetes","Go"},new[]{("40+","microservices"),("2M/s","events / sec"),("99.9%","uptime")},"/portfolio/flux-stream/cover.svg",new[]{"/portfolio/flux-stream/01.svg","/portfolio/flux-stream/02.svg","/portfolio/flux-stream/03.svg"}),
|
||||||
|
("oracle-forecast","Oracle — Demand Forecasting Engine","Supply chain","ML Engineer","2023","Time-series forecasting pipeline on BigQuery and dbt with automated retraining — reduced inventory waste significantly.","electric",new[]{"Forecasting","BigQuery","dbt","MLOps"},new[]{("23%","waste reduction"),("89%","forecast accuracy"),("daily","retraining")},"/portfolio/oracle-forecast/cover.svg",new[]{"/portfolio/oracle-forecast/01.svg","/portfolio/oracle-forecast/02.svg","/portfolio/oracle-forecast/03.svg"}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@foreach (var (pid, ptitle, pclient, prole, pyear, psummary, paccent, ptags, pmetrics, pcover, pgallery) in projects)
|
||||||
|
{
|
||||||
|
var (pborder, ptext) = paccent switch {
|
||||||
|
"violet" => ("border-violet/30", "text-violet"),
|
||||||
|
"cyan" => ("border-cyan/30", "text-cyan"),
|
||||||
|
"magenta" => ("border-magenta/30", "text-magenta"),
|
||||||
|
"emerald" => ("border-emerald/30", "text-emerald"),
|
||||||
|
_ => ("border-electric/30", "text-electric"),
|
||||||
|
};
|
||||||
|
var galleryJson = System.Text.Json.JsonSerializer.Serialize(pgallery);
|
||||||
|
<div class="glass cursor-pointer select-none p-6 transition-all duration-300 hover:-translate-y-1 reveal @pborder"
|
||||||
|
data-portfolio-card
|
||||||
|
tabindex="0"
|
||||||
|
data-title="@ptitle"
|
||||||
|
data-summary="@psummary"
|
||||||
|
data-gallery="@galleryJson"
|
||||||
|
role="button"
|
||||||
|
aria-label="@ptitle">
|
||||||
|
<div class="mb-4 aspect-video w-full overflow-hidden rounded-xl bg-base-700">
|
||||||
|
<img src="@pcover" alt="@ptitle" class="h-full w-full object-cover" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 flex flex-wrap gap-1.5">
|
||||||
|
@foreach (var tag in ptags)
|
||||||
|
{
|
||||||
|
<span class="rounded-full border @pborder px-2 py-0.5 font-mono text-[.62rem] uppercase tracking-wider @ptext/80">@tag</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<h3 class="font-display font-semibold text-white @(fa ? "font-fa" : "")" style="font-size:clamp(1rem,1.5vw,1.2rem)">@ptitle</h3>
|
||||||
|
<p class="mt-1 text-[.82rem] text-slate-400">@pclient · @pyear</p>
|
||||||
|
<div class="mt-4 grid grid-cols-3 gap-3 border-t border-white/5 pt-4">
|
||||||
|
@foreach (var (mv, ml) in pmetrics)
|
||||||
|
{
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="font-display font-bold @ptext text-lg">@mv</div>
|
||||||
|
<div class="text-[.68rem] text-slate-500">@ml</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Portfolio modal -->
|
||||||
|
<div id="portfolio-modal" class="fixed inset-0 z-[100] hidden" role="dialog" aria-modal="true">
|
||||||
|
<div id="modal-overlay" class="absolute inset-0 bg-black/80 backdrop-blur-sm"></div>
|
||||||
|
<div class="relative z-10 flex h-full items-center justify-center p-4">
|
||||||
|
<div class="glass w-full max-w-3xl max-h-[90vh] overflow-auto rounded-2xl p-6">
|
||||||
|
<div class="mb-4 flex items-start justify-between gap-4">
|
||||||
|
<h2 id="modal-title" class="font-display text-xl font-bold text-white"></h2>
|
||||||
|
<button id="modal-close" class="text-slate-400 hover:text-white transition-colors p-1" aria-label="Close">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4 aspect-video overflow-hidden rounded-xl bg-base-700">
|
||||||
|
<img id="modal-img" src="" alt="" class="h-full w-full object-contain" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-4 flex justify-between gap-2">
|
||||||
|
<button id="modal-prev" class="btn-ghost text-sm py-2 px-4 disabled:opacity-30">@(fa ? "قبلی" : "Previous")</button>
|
||||||
|
<button id="modal-next" class="btn-ghost text-sm py-2 px-4 disabled:opacity-30">@(fa ? "بعدی" : "Next")</button>
|
||||||
|
</div>
|
||||||
|
<p id="modal-body" class="text-sm leading-relaxed text-slate-400"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── BLOG ──────────────────────────────────────────────────────────── -->
|
||||||
|
<section id="blog" class="relative px-5 py-28 sm:px-8">
|
||||||
|
<div class="mx-auto max-w-7xl">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="eyebrow"><span class="chip">@(fa ? "بلاگ" : "Journal")</span></div>
|
||||||
|
<h2>@(fa ? "یادداشتهای مهندسی" : "Engineering notes")</h2>
|
||||||
|
<p>@(fa ? "یافتهها از پروژههای واقعی — نه ترجمهی مقاله، نه فهرست hype." : "Findings from real engagements — not translated articles, not hype lists.")</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
@{
|
||||||
|
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 sharing، و request hedging برای سرویسدهی پایدار.",14),
|
||||||
|
("flutter-on-device-ai","Mobile","هوش مصنوعی on-device در Flutter","استفاده از Gemini Nano و LiteRT برای استنتاج آفلاین در اپلیکیشنهای موبایل.",9),
|
||||||
|
("enterprise-ai-roadmap","Strategy","نقشه راه هوش مصنوعی سازمانی در ۹۰ روز","چارچوبی که برای CTOها میسازم — از کشف موارد کاربری تا اولین استقرار تولید.",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 glass block p-6 transition-all duration-300 hover:-translate-y-1 hover:border-electric/40 reveal">
|
||||||
|
<span class="label-mono text-electric mb-3 block">@cat</span>
|
||||||
|
<h3 class="font-display font-semibold leading-snug text-white group-hover:text-electric transition-colors @(fa ? "font-fa" : "")" style="font-size:clamp(1rem,1.4vw,1.15rem)">@btitle</h3>
|
||||||
|
<p class="mt-3 text-[.88rem] leading-relaxed text-slate-400 line-clamp-3">@excerpt</p>
|
||||||
|
<div class="mt-4 flex items-center justify-between">
|
||||||
|
<span class="label-mono">@readTime @(fa ? "دقیقه" : "min") @(fa ? "ادامه" : "read")</span>
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" class="text-electric @(fa ? "rotate-180" : "")"><path d="M5 12H19"/><path d="M13 6L19 12L13 18"/></svg>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ─── CONTACT ───────────────────────────────────────────────────────── -->
|
||||||
|
<section id="contact" class="relative px-5 py-28 sm:px-8">
|
||||||
|
<div class="mx-auto max-w-2xl">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="eyebrow"><span class="chip">@(fa ? "تماس" : "Contact")</span></div>
|
||||||
|
<h2>@(fa ? "رزرو یک جلسه ۳۰ دقیقهای" : "Book a 30-minute call")</h2>
|
||||||
|
<p>@(fa ? "بدون هزینه، بدون تعهد. موارد کاربردی، محدودیتها و گام بعدی را با هم بررسی میکنیم." : "No cost, no commitment. We map the use case, the constraints, and the next step together.")</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="contact-form"
|
||||||
|
class="glass p-8 space-y-5"
|
||||||
|
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="label-mono mb-2 block" for="name">@(fa ? "نام" : "Name")</label>
|
||||||
|
<input id="name" name="name" type="text" required placeholder="@(fa ? "نام و نام خانوادگی" : "Full name")" class="w-full rounded-xl border border-white/10 bg-white/[.03] px-4 py-3 text-sm text-white placeholder-slate-500 outline-none focus:border-electric/60 transition-colors" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label-mono mb-2 block" for="company">@(fa ? "سازمان" : "Company")</label>
|
||||||
|
<input id="company" name="company" type="text" placeholder="@(fa ? "نام سازمان" : "Organization")" class="w-full rounded-xl border border-white/10 bg-white/[.03] px-4 py-3 text-sm text-white placeholder-slate-500 outline-none focus:border-electric/60 transition-colors" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="label-mono mb-2 block" for="service">@(fa ? "خدمت" : "Service")</label>
|
||||||
|
<select id="service" name="service" required class="w-full rounded-xl border border-white/10 bg-base-800 px-4 py-3 text-sm text-white outline-none focus:border-electric/60 transition-colors">
|
||||||
|
<option value="" disabled selected>—</option>
|
||||||
|
@if (fa)
|
||||||
|
{
|
||||||
|
<option value="strategy">راهبرد و نقشه راه</option>
|
||||||
|
<option value="automation">اتوماسیون هوش مصنوعی</option>
|
||||||
|
<option value="llm-rag">مهندسی LLM و RAG</option>
|
||||||
|
<option value="architecture">معماری راهکار</option>
|
||||||
|
<option value="mobile">موبایل</option>
|
||||||
|
<option value="google-stack">استک گوگل</option>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<option value="strategy">AI Strategy & Roadmap</option>
|
||||||
|
<option value="automation">AI Automation</option>
|
||||||
|
<option value="llm-rag">LLM & RAG Engineering</option>
|
||||||
|
<option value="architecture">Solution Architecture</option>
|
||||||
|
<option value="mobile">Mobile AI Apps</option>
|
||||||
|
<option value="google-stack">Google Stack</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label-mono mb-2 block" for="budget">@(fa ? "بودجه (تقریبی)" : "Budget (rough)")</label>
|
||||||
|
<select id="budget" name="budget" required class="w-full rounded-xl border border-white/10 bg-base-800 px-4 py-3 text-sm text-white outline-none focus:border-electric/60 transition-colors">
|
||||||
|
<option value="" disabled selected>—</option>
|
||||||
|
<option>Under $10k</option>
|
||||||
|
<option>$10k–$50k</option>
|
||||||
|
<option>$50k–$200k</option>
|
||||||
|
<option>$200k+</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label-mono mb-2 block" for="message">@(fa ? "پیام" : "Message")</label>
|
||||||
|
<textarea id="message" name="message" required rows="4" placeholder="@(fa ? "هدف، بازه زمانی، موانع فعلی…" : "Goal, timeline, current blockers…")" class="w-full rounded-xl border border-white/10 bg-white/[.03] px-4 py-3 text-sm text-white placeholder-slate-500 outline-none focus:border-electric/60 transition-colors resize-none"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn-primary w-full justify-center">
|
||||||
|
@(fa ? "ارسال درخواست" : "Send request")
|
||||||
|
</button>
|
||||||
|
<p id="contact-status" class="text-center text-sm text-slate-500">@(fa ? "معمولاً ظرف ۲۴ ساعت کاری پاسخ میدهم." : "Typical reply within 24 working hours.")</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ─── FOOTER ────────────────────────────────────────────────────────── -->
|
||||||
|
<footer class="border-t border-white/5 px-5 py-10 sm:px-8">
|
||||||
|
<div class="mx-auto flex max-w-7xl flex-col items-center gap-3 text-center">
|
||||||
|
<img src="/logo-mark.svg" alt="" width="24" height="24" />
|
||||||
|
<p class="label-mono">@(fa ? "طراحیشده در تهران · ساختهشده برای سازمانها" : "Designed in Tehran · Built for the enterprise")</p>
|
||||||
|
<p class="text-[.78rem] text-slate-600">© 2026 Soroush Asadi. @(fa ? "تمام حقوق محفوظ است." : "All rights reserved.")</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
@functions {
|
||||||
|
static string ServiceIcon(string id) => id switch {
|
||||||
|
"strategy" => """<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" 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="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" 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="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" 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="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" 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="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><rect x="5" y="2" width="14" height="20" rx="2"/><path d="M12 18h.01"/></svg>""",
|
||||||
|
"google-stack" => """<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>""",
|
||||||
|
_ => """<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="10"/></svg>""",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
@page "/locale"
|
||||||
|
@model SoroushAsadi.Pages.LocalePageModel
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<!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>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: { extend: {
|
||||||
|
colors: { base:{DEFAULT:'#020510',800:'#050a1a'}, electric:'#38bdf8', violet:'#818cf8', magenta:'#e879f9', emerald:'#34d399' },
|
||||||
|
fontFamily: { sans:['Syne','system-ui','sans-serif'], mono:['SpaceMono','monospace'] }
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<link rel="stylesheet" href="/css/site.css" />
|
||||||
|
</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>
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
@{
|
||||||
|
var locale = (string)(ViewData["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 — AI Engineer, Consultant, Solution Architect");
|
||||||
|
}
|
||||||
|
<!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، اتوماسیون عاملمحور، زیرساخت ابری و استک گوگل."
|
||||||
|
: "Designing and deploying enterprise-grade AI systems — strategy, LLM & RAG, agentic automation, cloud infrastructure, and Google Stack.")" />
|
||||||
|
|
||||||
|
<!-- Fonts -->
|
||||||
|
<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; }
|
||||||
|
@@font-face { font-family:'VazirmatnLat'; src:url('/fonts/Vazirmatn-Latin.woff2') format('woff2'); font-display:swap; }
|
||||||
|
@@font-face { font-family:'SpaceMono'; src:url('/fonts/SpaceMono-Regular.woff2') format('woff2'); font-weight:400; font-display:swap; }
|
||||||
|
@@font-face { font-family:'SpaceMono'; src:url('/fonts/SpaceMono-Bold.woff2') format('woff2'); font-weight:700; font-display:swap; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- Tailwind CDN (play) + custom config -->
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
base: { DEFAULT:'#020510', 800:'#050a1a', 700:'#0a1224', 600:'#0f1b33' },
|
||||||
|
electric:'#38bdf8',
|
||||||
|
violet: '#818cf8',
|
||||||
|
magenta: '#e879f9',
|
||||||
|
emerald: '#34d399',
|
||||||
|
cyan: '#22d3ee',
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Syne','Vazirmatn','VazirmatnLat','system-ui','sans-serif'],
|
||||||
|
display: ['Syne','Vazirmatn','sans-serif'],
|
||||||
|
fa: ['Vazirmatn','VazirmatnLat','sans-serif'],
|
||||||
|
mono: ['SpaceMono','ui-monospace','monospace'],
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
'pulse-dot': {'0%,100%':{opacity:'1',transform:'scale(1)'},'50%':{opacity:'.6',transform:'scale(1.4)'}},
|
||||||
|
'gradient-pan': {'0%,100%':{backgroundPosition:'0% 50%'},'50%':{backgroundPosition:'100% 50%'}},
|
||||||
|
'caret-blink': {'0%,49%':{opacity:'1'},'50%,100%':{opacity:'0'}},
|
||||||
|
'float-y': {'0%,100%':{transform:'translateY(0)'},'50%':{transform:'translateY(-6px)'}},
|
||||||
|
'flow-dash': {'0%':{strokeDashoffset:'0'},'100%':{strokeDashoffset:'-66'}},
|
||||||
|
'fade-up': {'0%':{opacity:'0',transform:'translateY(24px)'},'100%':{opacity:'1',transform:'translateY(0)'}},
|
||||||
|
'bar-grow': {'0%':{width:'0%'},'100%':{width:'var(--bar-w)'}},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'pulse-dot': 'pulse-dot 1.8s ease-in-out infinite',
|
||||||
|
'caret-blink':'caret-blink 1s steps(2) infinite',
|
||||||
|
'float-y': 'float-y 4s ease-in-out infinite',
|
||||||
|
'flow-dash': 'flow-dash 1.1s linear infinite',
|
||||||
|
'fade-up': 'fade-up .7s cubic-bezier(.22,1,.36,1) forwards',
|
||||||
|
'bar-grow': 'bar-grow 1.2s cubic-bezier(.22,1,.36,1) forwards',
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
'glow-electric':'0 0 40px -8px rgba(56,189,248,.55)',
|
||||||
|
'glow-magenta': '0 0 40px -8px rgba(232,121,249,.55)',
|
||||||
|
'glow-violet': '0 0 40px -8px rgba(129,140,248,.55)',
|
||||||
|
'glow-emerald': '0 0 40px -8px rgba(52,211,153,.55)',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Site CSS -->
|
||||||
|
<link rel="stylesheet" href="/css/site.css" />
|
||||||
|
<link rel="icon" href="/logo-mark.svg" type="image/svg+xml" />
|
||||||
|
</head>
|
||||||
|
<body class="bg-base text-slate-200 antialiased">
|
||||||
|
|
||||||
|
<!-- Custom cursor (desktop only) -->
|
||||||
|
<div id="cursor" class="pointer-events-none fixed z-[9999] hidden h-5 w-5 -translate-x-1/2 -translate-y-1/2 rounded-full border border-electric/70 transition-transform duration-100 lg:block" aria-hidden="true"></div>
|
||||||
|
<div id="cursor-dot" class="pointer-events-none fixed z-[9999] hidden h-1.5 w-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-electric lg:block" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<!-- Navbar -->
|
||||||
|
<header id="navbar" class="fixed inset-x-0 top-0 z-50 transition-all duration-300">
|
||||||
|
<div class="mx-auto flex max-w-7xl items-center justify-between px-5 py-4 sm:px-8">
|
||||||
|
<!-- Logo -->
|
||||||
|
<a href="/#top" class="flex items-center gap-2.5" aria-label="Home">
|
||||||
|
<img src="/logo-mark.svg" alt="" width="28" height="28" class="h-7 w-7" />
|
||||||
|
<span class="font-display font-bold text-white @(isRtl ? "font-fa" : "")">
|
||||||
|
@(locale == "fa" ? "سروش اسعدی" : "Soroush Asadi")
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Desktop nav -->
|
||||||
|
<nav class="hidden items-center gap-6 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>
|
||||||
|
<a href="/#contact" 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="nav-link">Contact</a>
|
||||||
|
}
|
||||||
|
<a href="/#contact" class="btn-primary 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="label-mono text-slate-400 hover:text-white transition-colors">
|
||||||
|
@(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="Menu">
|
||||||
|
<span class="block h-0.5 w-5 bg-slate-300 transition-all"></span>
|
||||||
|
<span class="block h-0.5 w-5 bg-slate-300 transition-all"></span>
|
||||||
|
<span class="block h-0.5 w-5 bg-slate-300 transition-all"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile drawer -->
|
||||||
|
<div id="mobile-menu" class="hidden border-t border-white/5 bg-base-800/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="label-mono text-slate-400">
|
||||||
|
@(locale == "fa" ? "Switch to English" : "تغییر به فارسی")
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
@RenderBody()
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Scripts -->
|
||||||
|
<script src="/js/app.js" defer></script>
|
||||||
|
@await RenderSectionAsync("Scripts", required: false)
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
@using SoroushAsadi
|
||||||
|
@using SoroushAsadi.Services
|
||||||
|
@using System.Text.Json.Nodes
|
||||||
|
@namespace SoroushAsadi.Pages
|
||||||
|
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@{
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SoroushAsadi.Data;
|
||||||
|
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}");
|
||||||
|
app.UseStaticFiles();
|
||||||
|
|
||||||
|
// 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();
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using SoroushAsadi.Data;
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ?? "");
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<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>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>;
|
|
||||||
}
|
|
||||||
@@ -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 },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -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, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
let body: ContactPayload;
|
|
||||||
try {
|
|
||||||
body = (await req.json()) as ContactPayload;
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const k of required) {
|
|
||||||
if (!body[k] || String(body[k]).trim().length < 2) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `Missing field: ${k}` },
|
|
||||||
{ status: 422 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiKey = process.env.RESEND_API_KEY;
|
|
||||||
const inbox = process.env.CONTACT_INBOX;
|
|
||||||
const from = process.env.CONTACT_FROM;
|
|
||||||
|
|
||||||
// Graceful no-op for local dev so the form UX can be validated
|
|
||||||
// without forcing a Resend key. Production should set these.
|
|
||||||
if (!apiKey || !inbox || !from) {
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Email service is not configured' },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
console.info('[contact] received (no Resend key — logging only):', body);
|
|
||||||
return NextResponse.json({ ok: true, dev: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const resend = new Resend(apiKey);
|
|
||||||
const subject = `New consultation request — ${body.name}`;
|
|
||||||
const html = `
|
|
||||||
<div style="font-family: ui-sans-serif, system-ui, sans-serif; line-height: 1.55;">
|
|
||||||
<h2 style="margin: 0 0 12px;">New consultation request</h2>
|
|
||||||
<table style="border-collapse: collapse;">
|
|
||||||
<tr><td style="padding: 4px 12px 4px 0; color:#475569;">Name</td><td>${escape(body.name!)}</td></tr>
|
|
||||||
<tr><td style="padding: 4px 12px 4px 0; color:#475569;">Company</td><td>${escape(body.company ?? '')}</td></tr>
|
|
||||||
<tr><td style="padding: 4px 12px 4px 0; color:#475569;">Service</td><td>${escape(body.service!)}</td></tr>
|
|
||||||
<tr><td style="padding: 4px 12px 4px 0; color:#475569;">Budget</td><td>${escape(body.budget!)}</td></tr>
|
|
||||||
<tr><td style="padding: 4px 12px 4px 0; color:#475569;">Locale</td><td>${escape(body.locale ?? '')}</td></tr>
|
|
||||||
</table>
|
|
||||||
<h3 style="margin: 20px 0 6px;">Message</h3>
|
|
||||||
<p style="white-space: pre-wrap; background:#f8fafc; padding:12px; border-radius:8px;">${escape(body.message!)}</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { error } = await resend.emails.send({
|
|
||||||
from,
|
|
||||||
to: inbox,
|
|
||||||
subject,
|
|
||||||
html,
|
|
||||||
replyTo: body.company ? `${body.name} <${body.company}>` : undefined,
|
|
||||||
});
|
|
||||||
if (error) {
|
|
||||||
console.error('[contact] resend error', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Email service rejected the request' },
|
|
||||||
{ status: 502 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[contact] send failed', err);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Email service unreachable' },
|
|
||||||
{ status: 502 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 |
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Warning",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"DataDir": "/data"
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
"DataDir": "data"
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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? Let’s talk it through.'}
|
|
||||||
</p>
|
|
||||||
<div className="mt-5 flex flex-wrap gap-3">
|
|
||||||
<Link href="/#contact" className="btn-primary">
|
|
||||||
{t.hero.ctaPrimary}
|
|
||||||
</Link>
|
|
||||||
<Link href="/#blog" className="btn-ghost">
|
|
||||||
{t.nav.blog}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BlockView({
|
|
||||||
block,
|
|
||||||
accent,
|
|
||||||
}: {
|
|
||||||
block: Block;
|
|
||||||
accent: PostContent['accent'];
|
|
||||||
}) {
|
|
||||||
switch (block.k) {
|
|
||||||
case 'h2':
|
|
||||||
return (
|
|
||||||
<h2 className="mt-4 font-display text-[clamp(1.3rem,2.4vw,1.7rem)] font-semibold leading-snug text-white">
|
|
||||||
{block.t}
|
|
||||||
</h2>
|
|
||||||
);
|
|
||||||
case 'p':
|
|
||||||
return (
|
|
||||||
<p className="text-[1.02rem] leading-[1.85] text-slate-300">
|
|
||||||
{block.t}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
case 'ul':
|
|
||||||
return (
|
|
||||||
<ul className="flex flex-col gap-2.5">
|
|
||||||
{block.items.map((it, i) => (
|
|
||||||
<li key={i} className="flex gap-3 text-[1.02rem] leading-relaxed text-slate-300">
|
|
||||||
<span className={cn('mt-2 h-1.5 w-1.5 shrink-0 rounded-full', `bg-current ${ACCENT_TEXT[accent]}`)} />
|
|
||||||
<span>{it}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
case 'quote':
|
|
||||||
return (
|
|
||||||
<blockquote
|
|
||||||
className={cn(
|
|
||||||
'my-2 border-s-2 ps-5 text-[1.1rem] font-medium italic leading-relaxed text-slate-200',
|
|
||||||
accent === 'magenta' ? 'border-magenta' : 'border-electric',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{block.t}
|
|
||||||
</blockquote>
|
|
||||||
);
|
|
||||||
case 'code':
|
|
||||||
return (
|
|
||||||
<pre className="overflow-x-auto rounded-xl border border-white/10 bg-base-900/80 p-4 font-mono text-[0.85rem] leading-relaxed text-slate-200">
|
|
||||||
<code>{block.t}</code>
|
|
||||||
</pre>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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' }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+10
-12
@@ -6,30 +6,28 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/node:20-alpine}
|
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
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000: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:
|
||||||
|
|||||||
@@ -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());
|
|
||||||
}
|
|
||||||
@@ -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] };
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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 0–30: discovery, not deck-building' },
|
|
||||||
{ k: 'p', t: 'The first month is spent finding the use cases that are both valuable and feasible. I interview the people doing the work, map the data that actually exists (not the data the org wishes it had), and score candidates on impact versus effort. The output is a shortlist of three, not a 40-slide strategy.' },
|
|
||||||
{ k: 'h2', t: 'Days 30–60: one thin slice to production' },
|
|
||||||
{ k: 'p', t: 'We pick the single highest-leverage use case and ship it end-to-end for a small group of real users. Not a pilot in a sandbox — a thin slice in production, with monitoring, evaluation, and a rollback path. The goal is to learn what breaks when reality arrives.' },
|
|
||||||
{ k: 'ul', items: [
|
|
||||||
'Define success metrics before writing code.',
|
|
||||||
'Instrument cost and quality from request one.',
|
|
||||||
'Ship behind a flag to a controlled cohort.',
|
|
||||||
] },
|
|
||||||
{ k: 'h2', t: 'Days 60–90: harden and templatize' },
|
|
||||||
{ k: 'p', t: 'With one real workload live, the last month turns the bespoke build into a repeatable pattern: shared eval harness, a reference architecture, and the platform pieces the next three use cases will reuse. The second project should take half the time of the first.' },
|
|
||||||
{ k: 'quote', t: 'A roadmap is not a list of features. It is the order in which you reduce uncertainty.' },
|
|
||||||
{ k: 'h2', t: 'What kills roadmaps' },
|
|
||||||
{ k: 'p', t: 'Boiling the ocean, optimizing a model nobody uses, and treating AI as a research project instead of a product. The antidote to all three is the same: get one real thing in front of real users fast, then let what you learn redraw the map.' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
fa: {
|
|
||||||
lead: 'بیشتر ابتکارهای هوش مصنوعی سازمانی در شکاف میان دستور هیئتمدیره و یک قابلیت منتشرشده میمیرند. این نقشهی راه ۹۰روزهای است که برای عبور از آن میسازم — از کشف تا اولین استقرار تولید.',
|
|
||||||
blocks: [
|
|
||||||
{ k: 'h2', t: 'روز ۰ تا ۳۰: کشف، نه ساختن اسلاید' },
|
|
||||||
{ k: 'p', t: 'ماه اول صرف یافتن موارد کاربریای میشود که هم ارزشمند و هم شدنیاند. با کسانی که کار را انجام میدهند مصاحبه میکنم، دادهای را که واقعاً وجود دارد نگاشت میکنم (نه دادهای که سازمان آرزویش را دارد) و گزینهها را بر اساس اثر در برابر تلاش امتیاز میدهم. خروجی، فهرست کوتاهی از سه مورد است، نه یک راهبرد ۴۰اسلایدی.' },
|
|
||||||
{ k: 'h2', t: 'روز ۳۰ تا ۶۰: یک برش نازک تا تولید' },
|
|
||||||
{ k: 'p', t: 'تکمورد با بیشترین اهرم را برمیگزینیم و آن را سرتاسری برای گروه کوچکی از کاربران واقعی منتشر میکنیم. نه یک pilot در sandbox — یک برش نازک در تولید، با پایش، ارزیابی و مسیر بازگشت. هدف، یادگرفتن چیزی است که وقتی واقعیت میرسد میشکند.' },
|
|
||||||
{ k: 'ul', items: [
|
|
||||||
'معیارهای موفقیت را پیش از نوشتن کد تعریف کنید.',
|
|
||||||
'هزینه و کیفیت را از همان درخواست اول ابزارگذاری کنید.',
|
|
||||||
'پشت یک flag برای یک گروه کنترلشده منتشر کنید.',
|
|
||||||
] },
|
|
||||||
{ k: 'h2', t: 'روز ۶۰ تا ۹۰: تثبیت و قالبسازی' },
|
|
||||||
{ k: 'p', t: 'با یک بار کاری واقعی در حال اجرا، ماه آخر ساخت سفارشی را به الگویی تکرارپذیر تبدیل میکند: harness ارزیابی مشترک، یک معماری مرجع و قطعات پلتفرمی که سه مورد بعدی بازاستفاده خواهند کرد. پروژهی دوم باید نصف زمان اولی را ببرد.' },
|
|
||||||
{ k: 'quote', t: 'نقشهی راه فهرستی از قابلیتها نیست. ترتیبی است که در آن عدمقطعیت را کاهش میدهید.' },
|
|
||||||
{ k: 'h2', t: 'چه چیزی نقشهی راه را میکشد' },
|
|
||||||
{ k: 'p', t: 'جوشاندن اقیانوس، بهینهسازی مدلی که کسی استفاده نمیکند و رفتار با هوش مصنوعی بهمثابهی پروژهی پژوهشی بهجای محصول. پادزهر هر سه یکی است: یک چیز واقعی را سریع جلوی کاربران واقعی بگذارید، سپس بگذارید آنچه میآموزید نقشه را دوباره بکشد.' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const POST_SLUGS = Object.keys(POSTS);
|
|
||||||
@@ -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
@@ -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 };
|
|
||||||
@@ -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 12–18 month roadmap with measurable KPIs.',
|
|
||||||
tags: ['Discovery', 'ROI Mapping', 'Roadmap'],
|
|
||||||
color: 'electric',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'automation',
|
|
||||||
title: 'AI Automation',
|
|
||||||
description:
|
|
||||||
'Autonomous agents and n8n workflows that turn manual processes into auditable, observable systems.',
|
|
||||||
tags: ['n8n', 'Agents', 'Workflows'],
|
|
||||||
color: 'violet',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'llm-rag',
|
|
||||||
title: 'LLM & RAG Engineering',
|
|
||||||
description:
|
|
||||||
'Production RAG pipelines with vector stores, evaluation frameworks, and sub-50ms serving.',
|
|
||||||
tags: ['RAG', 'Vector DB', 'Eval'],
|
|
||||||
color: 'magenta',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'architecture',
|
|
||||||
title: 'Solution Architecture',
|
|
||||||
description:
|
|
||||||
'Distributed systems on Kubernetes — microservices, event streaming, and resilience patterns at scale.',
|
|
||||||
tags: ['K8s', 'Microservices', 'Event-Driven'],
|
|
||||||
color: 'emerald',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'mobile',
|
|
||||||
title: 'Mobile AI Apps',
|
|
||||||
description:
|
|
||||||
'Flutter, Swift, and Kotlin apps with on-device inference, streaming LLM UX, and native polish.',
|
|
||||||
tags: ['Flutter', 'Swift', 'Kotlin'],
|
|
||||||
color: 'electric',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'google-stack',
|
|
||||||
title: 'Google Stack Specialist',
|
|
||||||
description:
|
|
||||||
'Vertex AI, GKE, and Gemini deployments with cost optimization and enterprise security patterns.',
|
|
||||||
tags: ['Vertex AI', 'GKE', 'Gemini'],
|
|
||||||
color: 'cyan',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
dataflow: {
|
|
||||||
eyebrow: 'Pipeline',
|
|
||||||
title: 'From raw document to trustworthy answer',
|
|
||||||
sub:
|
|
||||||
'The path every query takes through a production RAG system — each stage measurable, auditable, and tuned for latency.',
|
|
||||||
caption: 'Sub-50ms end-to-end · every stage observable',
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
id: 'ingest',
|
|
||||||
label: 'Ingest',
|
|
||||||
desc: 'Normalize, chunk, and clean source documents',
|
|
||||||
accent: 'electric',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'embed',
|
|
||||||
label: 'Embed',
|
|
||||||
desc: 'Generate embeddings and index in the vector store',
|
|
||||||
accent: 'violet',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'retrieve',
|
|
||||||
label: 'Retrieve',
|
|
||||||
desc: 'Hybrid semantic + keyword search',
|
|
||||||
accent: 'cyan',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'rerank',
|
|
||||||
label: 'Rerank',
|
|
||||||
desc: 'Re-order candidates with a cross-encoder',
|
|
||||||
accent: 'magenta',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'generate',
|
|
||||||
label: 'Generate',
|
|
||||||
desc: 'Grounded answer with source citations',
|
|
||||||
accent: 'emerald',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
stack: {
|
|
||||||
eyebrow: 'Stack',
|
|
||||||
title: 'Daily tooling',
|
|
||||||
sub:
|
|
||||||
'Everything I ship sits on this foundation — chosen for longevity, not hype cycles.',
|
|
||||||
categories: [
|
|
||||||
{
|
|
||||||
id: 'languages',
|
|
||||||
label: 'Languages',
|
|
||||||
items: ['Python', 'TypeScript', 'Go', 'Rust', 'SQL'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'mobile',
|
|
||||||
label: 'Mobile',
|
|
||||||
items: ['Flutter', 'Swift / SwiftUI', 'Kotlin', 'React Native'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'infra',
|
|
||||||
label: 'Infrastructure',
|
|
||||||
items: ['Kubernetes', 'Terraform', 'Postgres', 'Redis', 'Kafka', 'NATS'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'ai',
|
|
||||||
label: 'AI / ML',
|
|
||||||
items: ['Vertex AI', 'Gemini', 'OpenAI', 'Anthropic', 'LangGraph', 'Pinecone', 'pgvector'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
expertise: {
|
|
||||||
eyebrow: 'Expertise',
|
|
||||||
title: 'The numbers that matter',
|
|
||||||
sub:
|
|
||||||
'Systems that survive millions of events per day — these are the metrics I optimize for.',
|
|
||||||
bars: [
|
|
||||||
{ label: 'LLM & RAG engineering', value: 95 },
|
|
||||||
{ label: 'Cloud architecture & Kubernetes', value: 92 },
|
|
||||||
{ label: 'Agentic systems & automation', value: 90 },
|
|
||||||
{ label: 'Google Cloud stack (Vertex / GKE)', value: 88 },
|
|
||||||
{ label: 'Native + cross-platform mobile', value: 82 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
blog: {
|
|
||||||
eyebrow: 'Journal',
|
|
||||||
title: 'Engineering notes',
|
|
||||||
sub:
|
|
||||||
'Findings from real engagements — not translated articles, not hype lists.',
|
|
||||||
readMore: 'Read',
|
|
||||||
readTimeSuffix: 'min',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
slug: 'rag-eval-framework',
|
|
||||||
category: 'LLM',
|
|
||||||
title: 'A RAG evaluation framework that holds up in production',
|
|
||||||
excerpt:
|
|
||||||
'Why BLEU and ROUGE fall short for RAG, and the metrics that actually drive decisions in real projects.',
|
|
||||||
readTime: 8,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: 'agentic-n8n-patterns',
|
|
||||||
category: 'Automation',
|
|
||||||
title: 'Agentic patterns with n8n for the enterprise',
|
|
||||||
excerpt:
|
|
||||||
'How to combine n8n with LangGraph to build auditable, debuggable autonomous workflows.',
|
|
||||||
readTime: 11,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: 'vertex-cost-control',
|
|
||||||
category: 'Google Stack',
|
|
||||||
title: 'Vertex AI cost control at scale',
|
|
||||||
excerpt:
|
|
||||||
'Three anti-patterns I see in 80% of Vertex projects — and how we cut 60% of monthly spend.',
|
|
||||||
readTime: 6,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: 'k8s-llm-inference',
|
|
||||||
category: 'Infra',
|
|
||||||
title: 'Sub-50ms LLM inference on Kubernetes',
|
|
||||||
excerpt:
|
|
||||||
'Deployment pattern with KEDA, GPU sharing, and request hedging for stable serving.',
|
|
||||||
readTime: 14,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: 'flutter-on-device-ai',
|
|
||||||
category: 'Mobile',
|
|
||||||
title: 'On-device AI in Flutter',
|
|
||||||
excerpt:
|
|
||||||
'Using Gemini Nano and LiteRT for offline inference inside mobile apps.',
|
|
||||||
readTime: 9,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: 'enterprise-ai-roadmap',
|
|
||||||
category: 'Strategy',
|
|
||||||
title: 'A 90-day enterprise AI roadmap',
|
|
||||||
excerpt:
|
|
||||||
'The framework I build for CTOs — from use-case discovery to first production deployment.',
|
|
||||||
readTime: 7,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
portfolio: {
|
|
||||||
eyebrow: 'Selected work',
|
|
||||||
title: 'Systems that run in production',
|
|
||||||
sub:
|
|
||||||
'A selection of real engagements — from RAG pipelines to event-driven data meshes. Tap any card for the gallery and the architecture behind it.',
|
|
||||||
labels: {
|
|
||||||
role: 'Role',
|
|
||||||
year: 'Year',
|
|
||||||
client: 'Client',
|
|
||||||
stack: 'Stack',
|
|
||||||
view: 'View project',
|
|
||||||
gallery: 'Gallery',
|
|
||||||
close: 'Close',
|
|
||||||
next: 'Next',
|
|
||||||
prev: 'Previous',
|
|
||||||
},
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 'atlas-rag',
|
|
||||||
title: 'Atlas — Enterprise RAG Platform',
|
|
||||||
client: 'Tier-1 bank',
|
|
||||||
year: '2025',
|
|
||||||
role: 'Lead AI Engineer',
|
|
||||||
summary:
|
|
||||||
'A knowledge assistant over 4M+ internal documents — hybrid retrieval with pgvector and a reranker, a bespoke evaluation harness, and sub-40ms serving on Vertex AI.',
|
|
||||||
accent: 'electric',
|
|
||||||
tags: ['RAG', 'pgvector', 'Vertex AI', 'Eval'],
|
|
||||||
metrics: [
|
|
||||||
{ value: '4M+', label: 'docs indexed' },
|
|
||||||
{ value: '38ms', label: 'p95 latency' },
|
|
||||||
{ value: '92%', label: 'answer accuracy' },
|
|
||||||
],
|
|
||||||
cover: '/portfolio/atlas-rag/cover.svg',
|
|
||||||
gallery: [
|
|
||||||
'/portfolio/atlas-rag/01.svg',
|
|
||||||
'/portfolio/atlas-rag/02.svg',
|
|
||||||
'/portfolio/atlas-rag/03.svg',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'sentinel-agents',
|
|
||||||
title: 'Sentinel — Agentic Ops Automation',
|
|
||||||
client: 'SaaS scale-up',
|
|
||||||
year: '2025',
|
|
||||||
role: 'Solution Architect',
|
|
||||||
summary:
|
|
||||||
'Autonomous incident response combining n8n and LangGraph — auditable agents that triage alerts, find root cause, and self-heal where it is safe to do so.',
|
|
||||||
accent: 'violet',
|
|
||||||
tags: ['n8n', 'LangGraph', 'Agents', 'Observability'],
|
|
||||||
metrics: [
|
|
||||||
{ value: '70%', label: 'MTTR reduction' },
|
|
||||||
{ value: '24/7', label: 'on-call coverage' },
|
|
||||||
{ value: '150+', label: 'automated flows' },
|
|
||||||
],
|
|
||||||
cover: '/portfolio/sentinel-agents/cover.svg',
|
|
||||||
gallery: [
|
|
||||||
'/portfolio/sentinel-agents/01.svg',
|
|
||||||
'/portfolio/sentinel-agents/02.svg',
|
|
||||||
'/portfolio/sentinel-agents/03.svg',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'vertex-vision',
|
|
||||||
title: 'Vertex Vision — Realtime Vision Inference',
|
|
||||||
client: 'Retail chain',
|
|
||||||
year: '2024',
|
|
||||||
role: 'AI Engineer',
|
|
||||||
summary:
|
|
||||||
'Real-time vision inference on GKE with Triton and Vertex AI for shelf analytics and customer flow across hundreds of stores, autoscaled off a work queue.',
|
|
||||||
accent: 'cyan',
|
|
||||||
tags: ['Vertex AI', 'GKE', 'Triton', 'Computer Vision'],
|
|
||||||
metrics: [
|
|
||||||
{ value: '1.2B', label: 'inferences / mo' },
|
|
||||||
{ value: '300+', label: 'stores' },
|
|
||||||
{ value: '60%', label: 'GPU cost cut' },
|
|
||||||
],
|
|
||||||
cover: '/portfolio/vertex-vision/cover.svg',
|
|
||||||
gallery: [
|
|
||||||
'/portfolio/vertex-vision/01.svg',
|
|
||||||
'/portfolio/vertex-vision/02.svg',
|
|
||||||
'/portfolio/vertex-vision/03.svg',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'mirage-mobile',
|
|
||||||
title: 'Mirage — On-device AI Suite',
|
|
||||||
client: 'Consumer product',
|
|
||||||
year: '2024',
|
|
||||||
role: 'Mobile + AI Lead',
|
|
||||||
summary:
|
|
||||||
'A Flutter app with fully offline inference via Gemini Nano and LiteRT — streaming response UX with zero network dependency and privacy preserved end to end.',
|
|
||||||
accent: 'magenta',
|
|
||||||
tags: ['Flutter', 'Gemini Nano', 'LiteRT', 'On-device'],
|
|
||||||
metrics: [
|
|
||||||
{ value: '0', label: 'network deps' },
|
|
||||||
{ value: '<80ms', label: 'response' },
|
|
||||||
{ value: '4.8★', label: 'user rating' },
|
|
||||||
],
|
|
||||||
cover: '/portfolio/mirage-mobile/cover.svg',
|
|
||||||
gallery: [
|
|
||||||
'/portfolio/mirage-mobile/01.svg',
|
|
||||||
'/portfolio/mirage-mobile/02.svg',
|
|
||||||
'/portfolio/mirage-mobile/03.svg',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'flux-stream',
|
|
||||||
title: 'Flux — Event-Driven Data Mesh',
|
|
||||||
client: 'Logistics platform',
|
|
||||||
year: '2023',
|
|
||||||
role: 'Platform Architect',
|
|
||||||
summary:
|
|
||||||
'A streaming backbone on Kafka and NATS over Kubernetes — 40+ microservices with resilience patterns, end-to-end tracing, and exactly-once delivery.',
|
|
||||||
accent: 'emerald',
|
|
||||||
tags: ['Kafka', 'NATS', 'Kubernetes', 'Go'],
|
|
||||||
metrics: [
|
|
||||||
{ value: '40+', label: 'microservices' },
|
|
||||||
{ value: '2M/s', label: 'events / sec' },
|
|
||||||
{ value: '99.9%', label: 'uptime' },
|
|
||||||
],
|
|
||||||
cover: '/portfolio/flux-stream/cover.svg',
|
|
||||||
gallery: [
|
|
||||||
'/portfolio/flux-stream/01.svg',
|
|
||||||
'/portfolio/flux-stream/02.svg',
|
|
||||||
'/portfolio/flux-stream/03.svg',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'oracle-forecast',
|
|
||||||
title: 'Oracle — Demand Forecasting Engine',
|
|
||||||
client: 'Supply chain',
|
|
||||||
year: '2023',
|
|
||||||
role: 'ML Engineer',
|
|
||||||
summary:
|
|
||||||
'A time-series forecasting pipeline on BigQuery and dbt with automated retraining — sharply reduced inventory waste and improved supply planning accuracy.',
|
|
||||||
accent: 'electric',
|
|
||||||
tags: ['Forecasting', 'BigQuery', 'dbt', 'MLOps'],
|
|
||||||
metrics: [
|
|
||||||
{ value: '23%', label: 'waste reduction' },
|
|
||||||
{ value: '89%', label: 'forecast accuracy' },
|
|
||||||
{ value: 'daily', label: 'retraining' },
|
|
||||||
],
|
|
||||||
cover: '/portfolio/oracle-forecast/cover.svg',
|
|
||||||
gallery: [
|
|
||||||
'/portfolio/oracle-forecast/01.svg',
|
|
||||||
'/portfolio/oracle-forecast/02.svg',
|
|
||||||
'/portfolio/oracle-forecast/03.svg',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
contact: {
|
|
||||||
eyebrow: 'Contact',
|
|
||||||
title: 'Book a 30-minute call',
|
|
||||||
sub:
|
|
||||||
'No cost, no commitment. We map the use case, the constraints, and the next step together.',
|
|
||||||
fields: {
|
|
||||||
name: 'Name',
|
|
||||||
company: 'Company',
|
|
||||||
service: 'Service',
|
|
||||||
budget: 'Budget (rough)',
|
|
||||||
message: 'Message',
|
|
||||||
},
|
|
||||||
placeholders: {
|
|
||||||
name: 'Full name',
|
|
||||||
company: 'Organization',
|
|
||||||
message: 'Goal, timeline, current blockers…',
|
|
||||||
},
|
|
||||||
budgets: ['Under $10k', '$10k–$50k', '$50k–$200k', '$200k+'],
|
|
||||||
submit: 'Send request',
|
|
||||||
note: 'Typical reply within 24 working hours.',
|
|
||||||
},
|
|
||||||
footer: {
|
|
||||||
tagline: 'Designed in Tehran · Built for the enterprise',
|
|
||||||
rights: '© 2026 Soroush Asadi. All rights reserved.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type Dict = typeof dict.en;
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { clsx, type ClassValue } from 'clsx';
|
|
||||||
import { twMerge } from 'tailwind-merge';
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs));
|
|
||||||
}
|
|
||||||
@@ -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*'],
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
Generated
-7165
File diff suppressed because it is too large
Load Diff
@@ -1,37 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "soroush-asadi-site",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"dev": "next dev",
|
|
||||||
"build": "next build",
|
|
||||||
"start": "next start",
|
|
||||||
"lint": "next lint",
|
|
||||||
"typecheck": "tsc --noEmit"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"better-sqlite3": "^11.5.0",
|
|
||||||
"clsx": "2.1.1",
|
|
||||||
"framer-motion": "11.11.17",
|
|
||||||
"lenis": "1.1.18",
|
|
||||||
"next": "14.2.18",
|
|
||||||
"react": "18.3.1",
|
|
||||||
"react-dom": "18.3.1",
|
|
||||||
"resend": "4.0.1",
|
|
||||||
"tailwind-merge": "2.5.5",
|
|
||||||
"three": "0.170.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/better-sqlite3": "^7.6.11",
|
|
||||||
"@types/node": "22.10.1",
|
|
||||||
"@types/react": "18.3.13",
|
|
||||||
"@types/react-dom": "18.3.1",
|
|
||||||
"@types/three": "0.170.0",
|
|
||||||
"autoprefixer": "10.4.20",
|
|
||||||
"eslint": "8.57.1",
|
|
||||||
"eslint-config-next": "14.2.18",
|
|
||||||
"postcss": "8.4.49",
|
|
||||||
"tailwindcss": "3.4.16",
|
|
||||||
"typescript": "5.7.2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user