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:
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user