Rewrite: Next.js → ASP.NET Core 10 Razor Pages
deploy / deploy (push) Failing after 1m21s

Full rewrite of the portfolio site from Next.js 14 to .NET 10:

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-01 07:46:56 +03:30
parent bcea9dc2f6
commit 1b3a8b493e
111 changed files with 2409 additions and 14062 deletions
+26
View File
@@ -0,0 +1,26 @@
@page "/Admin"
@model SoroushAsadi.Pages.Admin.AdminIndexModel
@{
Layout = "_AdminLayout";
ViewData["Title"] = "Dashboard";
}
<h1 class="font-display text-2xl font-bold text-white mb-8">Dashboard</h1>
<div class="grid grid-cols-1 gap-5 sm:grid-cols-3">
<a href="/Admin/Sections" class="glass block p-6 hover:border-electric/40 transition-colors">
<div class="text-3xl font-bold text-electric mb-1">@Model.SectionCount</div>
<div class="text-sm text-slate-400">Section overrides</div>
<div class="mt-2 text-xs text-slate-500">Edit bilingual section content</div>
</a>
<a href="/Admin/Posts" class="glass block p-6 hover:border-violet/40 transition-colors">
<div class="text-3xl font-bold text-violet mb-1">6</div>
<div class="text-sm text-slate-400">Blog posts</div>
<div class="mt-2 text-xs text-slate-500">Edit article bodies</div>
</a>
<div class="glass p-6">
<div class="text-3xl font-bold text-emerald mb-1">↑</div>
<div class="text-sm text-slate-400">Site status</div>
<div class="mt-2 text-xs text-slate-500"><a href="/" class="text-electric hover:underline">View live site →</a></div>
</div>
</div>
+11
View File
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Authorization;
using SoroushAsadi.Services;
namespace SoroushAsadi.Pages.Admin;
[Authorize]
public class AdminIndexModel(ContentService content) : Microsoft.AspNetCore.Mvc.RazorPages.PageModel
{
public int SectionCount { get; private set; }
public void OnGet() => SectionCount = content.GetSectionKeys().Count;
}
+29
View File
@@ -0,0 +1,29 @@
@page "/Admin/Login"
@model SoroushAsadi.Pages.Admin.LoginModel
@{
Layout = "_AdminLayout";
ViewData["Title"] = "Sign in";
}
<div class="flex min-h-[60vh] items-center justify-center">
<div class="w-full max-w-sm">
<div class="mb-8 text-center">
<img src="/logo-mark.svg" alt="" width="40" height="40" class="mx-auto mb-4" />
<h1 class="font-display text-2xl font-bold text-white">Admin sign in</h1>
</div>
@if (!string.IsNullOrEmpty(Model.Error))
{
<div class="mb-4 rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-400">@Model.Error</div>
}
<form method="post" class="glass p-6 space-y-4">
<div>
<label class="label-mono mb-2 block" for="password">Password</label>
<input id="password" name="password" type="password" required autofocus
class="w-full rounded-xl border border-white/10 bg-white/[.03] px-4 py-3 text-sm text-white outline-none focus:border-electric/60 transition-colors" />
</div>
<button type="submit" class="btn-primary w-full justify-center">Sign in</button>
</form>
</div>
</div>
+33
View File
@@ -0,0 +1,33 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using SoroushAsadi.Services;
namespace SoroushAsadi.Pages.Admin;
public class LoginModel(AuthService auth) : PageModel
{
public string Error { get; private set; } = "";
public void OnGet() { }
public async Task<IActionResult> OnPostAsync(string password, string returnUrl = "/Admin")
{
if (!auth.VerifyPassword(password))
{
Error = "Incorrect password.";
return Page();
}
var claims = new[] { new Claim(ClaimTypes.Name, "admin") };
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(identity));
if (!Url.IsLocalUrl(returnUrl)) returnUrl = "/Admin";
return LocalRedirect(returnUrl);
}
}
+2
View File
@@ -0,0 +1,2 @@
@page "/Admin/Logout"
@model SoroushAsadi.Pages.Admin.LogoutModel
+16
View File
@@ -0,0 +1,16 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace SoroushAsadi.Pages.Admin;
[IgnoreAntiforgeryToken]
public class LogoutModel : PageModel
{
public async Task<IActionResult> OnPostAsync()
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return RedirectToPage("/Admin/Login");
}
}
+35
View File
@@ -0,0 +1,35 @@
@page "/Admin/Posts/{slug}"
@model SoroushAsadi.Pages.Admin.Posts.PostEditModel
@{
Layout = "_AdminLayout";
ViewData["Title"] = "Edit post: " + Model.Slug;
}
<div class="mb-6 flex items-center gap-4">
<a href="/Admin/Posts" class="text-slate-400 hover:text-white transition-colors text-sm">← Posts</a>
<h1 class="font-display text-xl font-bold text-white">@Model.Slug</h1>
<a href="/blog/@Model.Slug" target="_blank" class="text-xs text-slate-400 hover:text-white transition-colors">View ↗</a>
</div>
@if (!string.IsNullOrEmpty(Model.Message))
{
<div class="mb-4 rounded-lg border border-emerald/30 bg-emerald/10 px-4 py-3 text-sm text-emerald">@Model.Message</div>
}
<form method="post" class="space-y-4">
<div>
<label class="label-mono mb-2 block">Body (Markdown)</label>
<p class="text-xs text-slate-500 mb-2">Supports: ## headings, **bold**, `code`, - list items, paragraphs</p>
<textarea name="body" rows="30"
class="w-full rounded-xl border border-white/10 bg-white/[.03] px-4 py-3 font-mono text-xs text-slate-200 outline-none focus:border-electric/60 transition-colors resize-y"
spellcheck="false">@Model.CurrentBody</textarea>
</div>
<div class="flex gap-3">
<button type="submit" class="btn-primary">Save</button>
@if (Model.HasOverride)
{
<button type="submit" name="reset" value="1" class="rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-2 text-sm text-red-400 hover:bg-red-500/20 transition-colors">Reset to default</button>
}
<a href="/Admin/Posts" class="btn-ghost">Cancel</a>
</div>
</form>
+71
View File
@@ -0,0 +1,71 @@
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using SoroushAsadi.Services;
namespace SoroushAsadi.Pages.Admin.Posts;
[Authorize]
public class PostEditModel(ContentService content) : PageModel
{
[BindProperty(SupportsGet = true)]
public string Slug { get; set; } = "";
public string CurrentBody { get; private set; } = "";
public string Message { get; private set; } = "";
public bool HasOverride { get; private set; }
// Known default bodies live in Blog/Post.cshtml.cs (DefaultBodies)
private static readonly Dictionary<string, string> _defaults = new()
{
["rag-eval-framework"] = SoroushAsadi.Pages.Blog.DefaultBodies.RagEval,
["agentic-n8n-patterns"] = SoroushAsadi.Pages.Blog.DefaultBodies.N8nPatterns,
["vertex-cost-control"] = SoroushAsadi.Pages.Blog.DefaultBodies.VertexCost,
["k8s-llm-inference"] = SoroushAsadi.Pages.Blog.DefaultBodies.K8sInference,
["flutter-on-device-ai"] = SoroushAsadi.Pages.Blog.DefaultBodies.FlutterAI,
["enterprise-ai-roadmap"] = SoroushAsadi.Pages.Blog.DefaultBodies.EnterpriseRoadmap,
};
public void OnGet()
{
var overrides = content.GetPostOverrides();
if (overrides.TryGetValue(Slug, out var node) && node["body"]?.GetValue<string>() is { } body)
{
CurrentBody = body;
HasOverride = true;
}
else
{
CurrentBody = _defaults.GetValueOrDefault(Slug, "");
}
}
public IActionResult OnPost(string body, string? reset)
{
if (reset == "1")
{
// Remove override — default body shows through
var existing = content.GetPostOverrides();
existing.Remove(Slug);
// Rebuild the posts JSON without this slug
var obj = new JsonObject();
foreach (var kv in existing) obj[kv.Key] = kv.Value.DeepClone();
content.SaveSection(ContentService.PostsKey, obj.ToJsonString());
Message = "Reset to default.";
HasOverride = false;
CurrentBody = _defaults.GetValueOrDefault(Slug, "");
return Page();
}
content.SavePost(Slug, new JsonObject { ["body"] = body });
HasOverride = true;
CurrentBody = body;
Message = "Saved.";
return Page();
}
}
// Re-export DefaultBodies from the Blog page so this page can use them
+29
View File
@@ -0,0 +1,29 @@
@page "/Admin/Posts"
@model SoroushAsadi.Pages.Admin.Posts.PostsIndexModel
@{
Layout = "_AdminLayout";
ViewData["Title"] = "Blog posts";
var slugs = new[]{ "rag-eval-framework","agentic-n8n-patterns","vertex-cost-control","k8s-llm-inference","flutter-on-device-ai","enterprise-ai-roadmap" };
}
<h1 class="font-display text-2xl font-bold text-white mb-8">Blog posts</h1>
<div class="space-y-2">
@foreach (var slug in slugs)
{
var hasOverride = Model.OverrideSlugs.Contains(slug);
<div class="glass flex items-center justify-between p-4">
<div class="flex items-center gap-3">
<span class="font-mono text-sm text-white">@slug</span>
@if (hasOverride)
{
<span class="rounded-full bg-violet/10 px-2 py-0.5 font-mono text-[.65rem] text-violet">customized</span>
}
</div>
<div class="flex gap-2">
<a href="/Admin/Posts/@slug" class="btn-ghost text-xs py-1.5 px-3">Edit</a>
<a href="/blog/@slug" target="_blank" class="text-xs text-slate-400 hover:text-white transition-colors self-center">View ↗</a>
</div>
</div>
}
</div>
+13
View File
@@ -0,0 +1,13 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
using SoroushAsadi.Services;
namespace SoroushAsadi.Pages.Admin.Posts;
[Authorize]
public class PostsIndexModel(ContentService content) : PageModel
{
public IReadOnlySet<string> OverrideSlugs { get; private set; } = new HashSet<string>();
public void OnGet() => OverrideSlugs = content.GetPostOverrides().Keys.ToHashSet();
}
+29
View File
@@ -0,0 +1,29 @@
@page "/Admin/Sections/{key}"
@model SoroushAsadi.Pages.Admin.Sections.SectionEditModel
@{
Layout = "_AdminLayout";
ViewData["Title"] = "Edit: " + Model.SectionKey;
}
<div class="mb-6 flex items-center gap-4">
<a href="/Admin/Sections" class="text-slate-400 hover:text-white transition-colors text-sm">← Sections</a>
<h1 class="font-display text-xl font-bold text-white">@Model.SectionKey</h1>
</div>
@if (!string.IsNullOrEmpty(Model.Message))
{
<div class="mb-4 rounded-lg border border-emerald/30 bg-emerald/10 px-4 py-3 text-sm text-emerald">@Model.Message</div>
}
<form method="post" class="space-y-4">
<div>
<label class="label-mono mb-2 block">JSON ({"fa": {...}, "en": {...}})</label>
<textarea name="json" rows="24"
class="w-full rounded-xl border border-white/10 bg-white/[.03] px-4 py-3 font-mono text-xs text-slate-200 outline-none focus:border-electric/60 transition-colors resize-y"
spellcheck="false">@Model.CurrentJson</textarea>
</div>
<div class="flex gap-3">
<button type="submit" class="btn-primary">Save</button>
<a href="/Admin/Sections" class="btn-ghost">Cancel</a>
</div>
</form>
+43
View File
@@ -0,0 +1,43 @@
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using SoroushAsadi.Services;
namespace SoroushAsadi.Pages.Admin.Sections;
[Authorize]
public class SectionEditModel(ContentService content) : PageModel
{
[BindProperty(SupportsGet = true)]
public string SectionKey { get; set; } = "";
public string CurrentJson { get; private set; } = "{\n \"fa\": {},\n \"en\": {}\n}";
public string Message { get; private set; } = "";
public void OnGet()
{
var node = content.GetSection(SectionKey);
if (node is not null)
CurrentJson = node.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
}
public IActionResult OnPost(string json)
{
try
{
// Validate JSON
JsonDocument.Parse(json);
content.SaveSection(SectionKey, json);
Message = "Saved.";
CurrentJson = json;
return Page();
}
catch (JsonException ex)
{
Message = $"Invalid JSON: {ex.Message}";
CurrentJson = json;
return Page();
}
}
}
+37
View File
@@ -0,0 +1,37 @@
@page "/Admin/Sections"
@model SoroushAsadi.Pages.Admin.Sections.SectionsIndexModel
@{
Layout = "_AdminLayout";
ViewData["Title"] = "Sections";
var all = new[]{ "hero","services","dataflow","stack","expertise","portfolio","blog","contact","footer" };
}
<div class="mb-6 flex items-center justify-between">
<h1 class="font-display text-2xl font-bold text-white">Sections</h1>
</div>
<div class="space-y-2">
@foreach (var key in all)
{
var hasOverride = Model.OverrideKeys.Contains(key);
<div class="glass flex items-center justify-between p-4">
<div class="flex items-center gap-3">
<span class="font-mono text-sm text-white">@key</span>
@if (hasOverride)
{
<span class="rounded-full bg-electric/10 px-2 py-0.5 font-mono text-[.65rem] text-electric">customized</span>
}
</div>
<div class="flex gap-2">
<a href="/Admin/Sections/@key" class="btn-ghost text-xs py-1.5 px-3">Edit</a>
@if (hasOverride)
{
<form method="post" asp-page-handler="Reset" class="inline">
<input type="hidden" name="key" value="@key" />
<button type="submit" class="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-1.5 text-xs text-red-400 hover:bg-red-500/20 transition-colors">Reset</button>
</form>
}
</div>
</div>
}
</div>
+19
View File
@@ -0,0 +1,19 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using SoroushAsadi.Services;
namespace SoroushAsadi.Pages.Admin.Sections;
[Authorize]
public class SectionsIndexModel(ContentService content) : PageModel
{
public IReadOnlySet<string> OverrideKeys { get; private set; } = new HashSet<string>();
public void OnGet() => OverrideKeys = content.GetSectionKeys().ToHashSet();
public IActionResult OnPostReset(string key)
{
content.DeleteSection(key);
return RedirectToPage();
}
}
+17
View File
@@ -0,0 +1,17 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace SoroushAsadi.Pages;
/// <summary>Base class that reads the locale cookie and exposes Locale + IsFa helpers.</summary>
public abstract class BasePageModel : PageModel
{
public string Locale { get; private set; } = "fa";
public bool IsFa => Locale == "fa";
public override void OnPageHandlerExecuting(Microsoft.AspNetCore.Mvc.Filters.PageHandlerExecutingContext context)
{
Locale = Request.Cookies["locale"] is "en" ? "en" : "fa";
ViewData["Locale"] = Locale;
base.OnPageHandlerExecuting(context);
}
}
+34
View File
@@ -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>
+28
View File
@@ -0,0 +1,28 @@
namespace SoroushAsadi.Pages.Blog;
public class BlogIndexModel : BasePageModel
{
public record BlogPost(string Slug, string Category, string Title, string Excerpt, int ReadTime);
public IReadOnlyList<BlogPost> Posts { get; private set; } = [];
public void OnGet()
{
var fa = IsFa;
Posts = fa ? new BlogPost[]{
new("rag-eval-framework","LLM","چارچوب ارزیابی RAG که در تولید کار می‌کند","چرا BLEU و ROUGE برای RAG ناکافی‌اند، و معیارهایی که در پروژه‌های واقعی تصمیم می‌سازند.",8),
new("agentic-n8n-patterns","Automation","الگوهای عامل‌محور با n8n برای سازمان","چگونه n8n را با LangGraph ترکیب کنیم تا گردش‌کارهای قابل ممیزی بسازیم.",11),
new("vertex-cost-control","Google Stack","کنترل هزینه روی Vertex AI در مقیاس بالا","سه ضدالگو که در ۸۰٪ پروژه‌های Vertex می‌بینم، و چگونه ۶۰٪ هزینه را کاهش دادیم.",6),
new("k8s-llm-inference","Infra","استنتاج LLM روی Kubernetes با تأخیر زیر ۵۰ میلی‌ثانیه","الگوی استقرار با KEDA، GPU 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),
};
}
}
+48
View File
@@ -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>
}
+216
View File
@@ -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 { '&' => "&amp;", '<' => "&lt;", '>' => "&gt;", _ => s[i].ToString() });
i++;
}
return sb.ToString();
}
private static string Esc(string s) => s.Replace("&","&amp;").Replace("<","&lt;").Replace(">","&gt;");
}
/// Default article bodies (Markdown).
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**: 100200 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 1030× 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, 6080% 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 48 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 130: discovery
The most expensive mistake in enterprise AI is building the wrong thing fast. Discovery is not a formality it is the work.
Interview 812 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 3160: 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 15. 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 6190: first production deployment
Scope the first deployment to a single team of 1020 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.
""";
}
+2
View File
@@ -0,0 +1,2 @@
@page "/contact"
@model SoroushAsadi.Pages.ContactModel
+30
View File
@@ -0,0 +1,30 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using SoroushAsadi.Services;
namespace SoroushAsadi.Pages;
/// <summary>POST /contact — JSON endpoint for the contact form.</summary>
[IgnoreAntiforgeryToken]
public class ContactModel(EmailService email) : PageModel
{
public record ContactBody(
string? Name, string? Company, string? Service,
string? Budget, string? Message, string? Locale);
public async Task<IActionResult> OnPostAsync([FromBody] ContactBody body)
{
if (string.IsNullOrWhiteSpace(body.Name) || body.Name.Length < 2 ||
string.IsNullOrWhiteSpace(body.Service) || body.Service.Length < 2 ||
string.IsNullOrWhiteSpace(body.Budget) || body.Budget.Length < 2 ||
string.IsNullOrWhiteSpace(body.Message) || body.Message.Length < 2)
return BadRequest(new { error = "Missing required fields" });
var err = await email.SendContactAsync(new EmailService.ContactForm(
body.Name!, body.Company ?? "", body.Service!, body.Budget!, body.Message!, body.Locale ?? "en"));
return err is null
? new JsonResult(new { ok = true })
: StatusCode(502, new { error = err });
}
}
+550
View File
@@ -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 1218 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>""",
};
}
+34
View File
@@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Mvc;
using SoroushAsadi.Services;
namespace SoroushAsadi.Pages;
[Microsoft.AspNetCore.Mvc.IgnoreAntiforgeryToken]
public class IndexModel : BasePageModel
{
public string BlogReadMore { get; private set; } = "Read";
public string BlogReadSuffix { get; private set; } = "min";
public void OnGet()
{
BlogReadMore = IsFa ? "ادامه" : "Read";
BlogReadSuffix = IsFa ? "دقیقه" : "min";
}
public async Task<IActionResult> OnPostContactAsync(
[FromServices] EmailService email,
string name, string company, string service,
string budget, string message)
{
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(service) ||
string.IsNullOrWhiteSpace(budget) || string.IsNullOrWhiteSpace(message))
return BadRequest(new { error = "Missing required fields" });
var err = await email.SendContactAsync(
new EmailService.ContactForm(name, company ?? "", service, budget, message, Locale));
return err is null
? new JsonResult(new { ok = true })
: StatusCode(502, new { error = err });
}
}
+2
View File
@@ -0,0 +1,2 @@
@page "/locale"
@model SoroushAsadi.Pages.LocalePageModel
+25
View File
@@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace SoroushAsadi.Pages;
/// <summary>POST /locale — sets the locale cookie and redirects back.</summary>
[IgnoreAntiforgeryToken]
public class LocalePageModel : PageModel
{
public IActionResult OnPost(string locale, string returnUrl = "/")
{
if (locale is not "fa" and not "en") locale = "fa";
Response.Cookies.Append("locale", locale, new CookieOptions
{
Expires = DateTimeOffset.UtcNow.AddYears(1),
HttpOnly = false,
SameSite = SameSiteMode.Lax,
Path = "/"
});
if (!Url.IsLocalUrl(returnUrl)) returnUrl = "/";
return LocalRedirect(returnUrl);
}
}
+56
View File
@@ -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>
+179
View File
@@ -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>
+5
View File
@@ -0,0 +1,5 @@
@using SoroushAsadi
@using SoroushAsadi.Services
@using System.Text.Json.Nodes
@namespace SoroushAsadi.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
+3
View File
@@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}