Compare commits
18 Commits
e5094b53ff
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c759851ce | |||
| 7a7542d77b | |||
| 154e06ef54 | |||
| ffba74a727 | |||
| 8896740895 | |||
| 93f7873dd1 | |||
| 255e8d25e5 | |||
| 29b5f07ebf | |||
| b721f01e14 | |||
| 33efeac98f | |||
| cfff934bdd | |||
| dd882287df | |||
| 5cc9ed976c | |||
| 97bd2a12df | |||
| a55c75b928 | |||
| fcc476e432 | |||
| e79d2d6108 | |||
| 059c67bf76 |
@@ -40,13 +40,13 @@ jobs:
|
||||
- name: Build Container
|
||||
run: docker compose build
|
||||
|
||||
- name: Stop Existing Container
|
||||
run: |
|
||||
docker stop soroushasadi-site 2>/dev/null || true
|
||||
docker rm soroushasadi-site 2>/dev/null || true
|
||||
|
||||
- name: Deploy
|
||||
run: docker compose up -d --remove-orphans
|
||||
# Compose recreates ONLY our own container (container_name:
|
||||
# soroushasadi-site, project: soroushasadi). It must never touch
|
||||
# other stacks. Do NOT add any step that removes containers by
|
||||
# published port — port 3000 is Gitea and 5xxx/3xxx belong to
|
||||
# other apps on this host.
|
||||
run: docker compose up -d
|
||||
|
||||
- name: Wait For Health Check
|
||||
run: |
|
||||
|
||||
@@ -5,6 +5,10 @@ obj/
|
||||
.vs/
|
||||
appsettings.*.local.json
|
||||
|
||||
# Stale Next.js build output (project migrated to .NET)
|
||||
.next/
|
||||
node_modules/
|
||||
|
||||
# CMS data (SQLite DB + uploads live in the Docker volume)
|
||||
/data
|
||||
|
||||
|
||||
+15
-16
@@ -1,31 +1,30 @@
|
||||
@page "/blog"
|
||||
@model SoroushAsadi.Pages.Blog.BlogIndexModel
|
||||
@{
|
||||
ViewData["Title"] = Model.IsFa ? "بلاگ — سروش اسعدی" : "Blog — Soroush Asadi";
|
||||
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" : "")">
|
||||
<div class="px-5 pt-28 pb-24 sm:px-8 sm:pt-32">
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<div class="sec-head">
|
||||
<h1 class="@(fa ? "font-fa" : "")" style="font-size:clamp(2rem,4vw,2.75rem)">
|
||||
@(fa ? "یادداشتهای مهندسی" : "Engineering notes")
|
||||
</h1>
|
||||
<p class="mt-4 text-slate-400">@(fa ? "یافتهها از پروژههای واقعی — نه ترجمهی مقاله، نه فهرست hype." : "Findings from real engagements — not translated articles, not hype lists.")</p>
|
||||
<p class="lede mt-4">@(fa ? "درسهایی از پروژههای واقعی. نه ترجمهی مقاله، نه شعار توخالی." : "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">
|
||||
<div class="border-b border-zinc-200">
|
||||
@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>
|
||||
<a href="/blog/@post.Slug" class="group reveal grid grid-cols-1 gap-2 border-t border-zinc-200 py-6 sm:grid-cols-[8rem_1fr] sm:gap-8">
|
||||
<div class="flex items-baseline justify-between sm:flex-col sm:gap-1">
|
||||
<span class="kicker">@post.Category</span>
|
||||
<span class="text-[.78rem] text-zinc-500">@post.ReadTime @(fa ? "دقیقه" : "min")</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-[1.1rem] font-semibold transition-colors group-hover:text-accent @(fa ? "font-fa" : "")">@post.Title</h2>
|
||||
<p class="mt-1.5 text-[.9rem] leading-relaxed text-zinc-600">@post.Excerpt</p>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
|
||||
@@ -10,19 +10,19 @@ public class BlogIndexModel : BasePageModel
|
||||
{
|
||||
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("rag-eval-framework","LLM","چارچوب ارزیابی RAG که در عمل جواب میدهد","چرا BLEU و ROUGE برای RAG کافی نیستند، و معیارهایی که واقعاً به تصمیم کمک میکنند.",8),
|
||||
new("agentic-n8n-patterns","Automation","الگوهای عاملمحور با n8n برای سازمان","چطور n8n را با LangGraph ترکیب کنیم تا گردشکارهای خودکار و قابلردیابی بسازیم.",11),
|
||||
new("vertex-cost-control","Google Stack","کنترل هزینه روی Vertex AI در مقیاس بالا","سه اشتباه رایج که در بیشتر پروژههای Vertex میبینم، و اینکه چطور ۶۰٪ هزینه را کم کردیم.",6),
|
||||
new("k8s-llm-inference","Infra","اجرای LLM روی Kubernetes با تأخیر زیر ۵۰ میلیثانیه","الگوی استقرار با KEDA، اشتراک GPU و request hedging برای سرویسدهی پایدار.",14),
|
||||
new("flutter-on-device-ai","Mobile","هوش مصنوعی روی دستگاه در Flutter","استفاده از Gemini Nano و LiteRT برای پردازش آفلاین در اپهای موبایل.",9),
|
||||
new("enterprise-ai-roadmap","Strategy","نقشهی راه هوش مصنوعی سازمانی در ۹۰ روز","چارچوبی که برای مدیران فنی میچینم؛ از پیدا کردن بهترین ایده تا اولین اجرای واقعی.",7),
|
||||
} : new BlogPost[]{
|
||||
new("rag-eval-framework","LLM","A RAG evaluation framework that holds up in production","Why BLEU and ROUGE fall short for RAG, and the metrics that actually drive decisions in real projects.",8),
|
||||
new("agentic-n8n-patterns","Automation","Agentic patterns with n8n for the enterprise","How to combine n8n with LangGraph to build auditable, debuggable autonomous workflows.",11),
|
||||
new("vertex-cost-control","Google Stack","Vertex AI cost control at scale","Three anti-patterns I see in 80% of Vertex projects — and how we cut 60% of monthly spend.",6),
|
||||
new("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),
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+12
-28
@@ -1,48 +1,32 @@
|
||||
@page "/blog/{slug}"
|
||||
@model SoroushAsadi.Pages.Blog.PostModel
|
||||
@{
|
||||
ViewData["Title"] = Model.Title + " — Soroush Asadi";
|
||||
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>
|
||||
<div class="px-5 pt-28 pb-24 sm:px-8 sm:pt-32">
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<a href="/blog" class="mb-10 inline-flex items-center gap-2 text-sm text-zinc-500 transition-colors hover:text-zinc-900 @(fa ? "flex-row-reverse" : "")">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" class="@(fa ? "" : "rotate-180")"><path d="M5 12H19"/><path d="M13 6L19 12L13 18"/></svg>
|
||||
@(fa ? "بازگشت به بلاگ" : "Back to blog")
|
||||
</a>
|
||||
|
||||
@if (Model.PostNotFound)
|
||||
{
|
||||
<p class="text-slate-400">@(fa ? "مقاله پیدا نشد." : "Post not found.")</p>
|
||||
<p class="text-zinc-600">@(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>
|
||||
<header class="mb-8">
|
||||
<span class="kicker">@Model.Category</span>
|
||||
<h1 class="mt-3 @(fa ? "font-fa" : "")" style="font-size:clamp(1.8rem,4vw,2.5rem)">@Model.Title</h1>
|
||||
<p class="mt-3 text-sm text-zinc-500">@Model.ReadTime @(fa ? "دقیقه مطالعه" : "min read")</p>
|
||||
</header>
|
||||
|
||||
<article class="prose-custom glass p-8">
|
||||
<article class="prose-custom">
|
||||
@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>
|
||||
}
|
||||
|
||||
+167
-38
@@ -14,24 +14,25 @@ public class PostModel(ContentService content) : BasePageModel
|
||||
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()
|
||||
// Default bodies (Markdown-lite, rendered server-side). Body is locale-aware.
|
||||
private static readonly Dictionary<string, (string Cat, string TitleEn, string TitleFa, int RT, string BodyEn, string BodyFa)> _defaults = new()
|
||||
{
|
||||
["rag-eval-framework"] = ("LLM", "A RAG evaluation framework that holds up in production", "چارچوب ارزیابی RAG که در تولید کار میکند", 8, DefaultBodies.RagEval),
|
||||
["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),
|
||||
["rag-eval-framework"] = ("LLM", "A RAG evaluation framework that holds up in production", "چارچوب ارزیابی RAG که در عمل جواب میدهد", 8, DefaultBodies.RagEval, DefaultBodies.RagEvalFa),
|
||||
["agentic-n8n-patterns"] = ("Automation", "Agentic patterns with n8n for the enterprise", "الگوهای عاملمحور با n8n برای سازمان", 11, DefaultBodies.N8nPatterns, DefaultBodies.N8nPatternsFa),
|
||||
["vertex-cost-control"] = ("Google Stack", "Vertex AI cost control at scale", "کنترل هزینه روی Vertex AI در مقیاس بالا", 6, DefaultBodies.VertexCost, DefaultBodies.VertexCostFa),
|
||||
["k8s-llm-inference"] = ("Infra", "Sub-50ms LLM inference on Kubernetes", "اجرای LLM روی Kubernetes با تأخیر زیر ۵۰ میلیثانیه", 14, DefaultBodies.K8sInference, DefaultBodies.K8sInferenceFa),
|
||||
["flutter-on-device-ai"] = ("Mobile", "On-device AI in Flutter", "هوش مصنوعی روی دستگاه در Flutter", 9, DefaultBodies.FlutterAI, DefaultBodies.FlutterAIFa),
|
||||
["enterprise-ai-roadmap"] = ("Strategy", "A 90-day enterprise AI roadmap", "نقشهی راه هوش مصنوعی سازمانی در ۹۰ روز", 7, DefaultBodies.EnterpriseRoadmap, DefaultBodies.EnterpriseRoadmapFa),
|
||||
};
|
||||
|
||||
public void OnGet()
|
||||
{
|
||||
if (!_defaults.TryGetValue(Slug, out var def)) { PostNotFound = true; return; }
|
||||
|
||||
string body = IsFa ? def.BodyFa : def.BodyEn;
|
||||
|
||||
// Check for DB override (stored under "posts" key as slug→{body,...})
|
||||
var overrides = content.GetPostOverrides();
|
||||
string body = def.Body;
|
||||
if (overrides.TryGetValue(Slug, out var node) && node["body"]?.GetValue<string>() is { } dbBody)
|
||||
body = dbBody;
|
||||
|
||||
@@ -83,13 +84,13 @@ public class PostModel(ContentService content) : BasePageModel
|
||||
private static string Esc(string s) => s.Replace("&","&").Replace("<","<").Replace(">",">");
|
||||
}
|
||||
|
||||
/// Default article bodies (Markdown).
|
||||
/// Default article bodies (Markdown). EN + FA per post.
|
||||
internal static class DefaultBodies
|
||||
{
|
||||
public const string RagEval = """
|
||||
## Why standard metrics fail for RAG
|
||||
|
||||
BLEU and ROUGE measure n-gram overlap against a reference answer. In a RAG system, there is often no single correct reference — a question about company policy may have dozens of valid phrasings. High BLEU does not mean the system cited the right source; low BLEU does not mean it was wrong.
|
||||
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
|
||||
|
||||
@@ -97,55 +98,117 @@ BLEU and ROUGE measure n-gram overlap against a reference answer. In a RAG syste
|
||||
|
||||
**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.
|
||||
**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.
|
||||
Start with a **golden dataset**: 100-200 question/answer pairs that domain experts have verified. Run your pipeline against them nightly. Track the three metrics above over time. A drop in Faithfulness after a model upgrade is a red flag; a drop in Context Precision after a chunking change means your retrieval is degrading.
|
||||
|
||||
The harness does not have to be complex. A spreadsheet with automatic scoring via the OpenAI or Anthropic API is enough to start catching regressions before they reach production.
|
||||
""";
|
||||
|
||||
public const string RagEvalFa = """
|
||||
## چرا معیارهای استاندارد برای RAG جواب نمیدهند
|
||||
|
||||
BLEU و ROUGE میزان همپوشانی n-gram را با یک پاسخ مرجع میسنجند. در یک سامانهی RAG معمولاً پاسخ مرجع واحدی وجود ندارد؛ یک پرسش دربارهی سیاستهای سازمان میتواند دهها بیان درست داشته باشد. BLEU بالا به این معنا نیست که سیستم به منبع درست ارجاع داده، و BLEU پایین هم به این معنا نیست که اشتباه کرده.
|
||||
|
||||
## سه معیاری که واقعاً مهماند
|
||||
|
||||
**وفاداری (Faithfulness)** میسنجد که آیا هر ادعای پاسخ تولیدشده را میتوان به یک قطعهی بازیابیشده ردیابی کرد. امتیاز وفاداری ۱.۰ یعنی مدل چیزی از خودش نساخته. ابزارهایی مثل RAGAS این را با یک داور LLM پیاده میکنند.
|
||||
|
||||
**دقت زمینه (Context Precision)** میپرسد: از میان قطعههای بازیابیشده، چند تا واقعاً به پرسش مربوط بودند؟ دقت پایین، پنجرهی زمینه را هدر میدهد و خطر توهم را بالا میبرد.
|
||||
|
||||
**مرتبطبودن پاسخ (Answer Relevancy)** بررسی میکند که پاسخ نهایی واقعاً به آنچه پرسیده شده جواب میدهد، نه اینکه فقط خوب به نظر برسد.
|
||||
|
||||
## ساختن یک بستر ارزیابی
|
||||
|
||||
با یک **دیتاست طلایی** شروع کنید: ۱۰۰ تا ۲۰۰ جفت پرسش و پاسخ که کارشناسان حوزه تأییدشان کردهاند. هر شب پایپلاین را روی آنها اجرا کنید و این سه معیار را در طول زمان دنبال کنید. افت وفاداری بعد از ارتقای مدل یک هشدار جدی است؛ افت دقت زمینه بعد از تغییر قطعهبندی یعنی بازیابیتان دارد بدتر میشود.
|
||||
|
||||
بستر ارزیابی لازم نیست پیچیده باشد. یک صفحهگسترده با امتیازدهی خودکار از طریق API اوپنایآی یا Anthropic، برای شروع و گرفتن افت کیفیت پیش از رسیدن به تولید کافی است.
|
||||
""";
|
||||
|
||||
public const string N8nPatterns = """
|
||||
## The problem with "just use n8n"
|
||||
|
||||
n8n is excellent for integrating SaaS tools. It becomes fragile when you try to use it as an agent orchestrator — long-running loops, conditional retries, and LLM calls that can fail in non-obvious ways.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
LLM calls fail. Build **retry with exponential backoff** into every LangGraph node that calls an LLM. Set a hard limit of 3 retries, then route to a dead-letter state that pages the on-call engineer. Never silently swallow errors in agentic pipelines. A swallowed error is an invisible outage.
|
||||
""";
|
||||
|
||||
public const string N8nPatternsFa = """
|
||||
## مشکلِ «فقط از n8n استفاده کن»
|
||||
|
||||
n8n برای اتصال ابزارهای SaaS عالی است. اما وقتی بخواهید از آن بهعنوان ارکستراتورِ عامل استفاده کنید شکننده میشود؛ حلقههای طولانی، تلاشهای مجدد شرطی، و فراخوانیهای LLM که میتوانند به شکلهای غیرمنتظره شکست بخورند.
|
||||
|
||||
## جدا کردن ارکستراسیون از یکپارچهسازی
|
||||
|
||||
الگویی که جواب میدهد: **n8n تریگرها و یکپارچهسازیها را مدیریت کند، LangGraph منطقِ عامل را**.
|
||||
|
||||
یک گردشکار n8n یک کانال Slack را زیر نظر میگیرد. وقتی پیامی با الگو مطابقت کرد، یک endpoint از LangGraph را با دادهی خام صدا میزند. LangGraph حلقهی استدلال چندمرحلهای را اجرا میکند، حالت را نگه میدارد و یک نتیجهی ساختارمند برمیگرداند. بعد n8n آن نتیجه را مسیردهی میکند؛ در Jira ثبت میکند، ایمیل میفرستد، یا یک ردیف پایگاهداده را بهروز میکند.
|
||||
|
||||
## قابلممیزی کردنِ عاملها
|
||||
|
||||
هر گذارِ حالت در LangGraph باید یک رویداد در یک لاگ ساختارمند ثبت کند. ما از یک جدول Postgres با ستونهای `run_id`، `step`، `input`، `output` و `timestamp` استفاده میکنیم. این جدول همان ردِ ممیزیای میشود که هم تیمهای انطباق و هم مهندسان کشیک به آن نیاز دارند.
|
||||
|
||||
برای هر کاری که برگشتپذیر نیست یک گرهی `human_in_the_loop` اضافه کنید؛ حذف رکورد، ارسال ایمیل بیرونی، تأیید پرداخت. این گره اجرا را متوقف میکند و در Slack پیام میگذارد؛ یک انسان تأیید یا رد میکند و اجرا ادامه پیدا میکند.
|
||||
|
||||
## مدیریت درستِ خطاها
|
||||
|
||||
فراخوانیهای LLM شکست میخورند. در هر گرهی LangGraph که LLM را صدا میزند **تلاش مجدد با backoff نمایی** بسازید. سقف سه بار تلاش بگذارید، بعد به یک حالت dead-letter مسیردهی کنید که مهندس کشیک را خبر کند. در پایپلاینهای عاملمحور هیچوقت خطا را بیصدا فرو نخورید؛ یک خطای فروخورده، یک قطعی نامرئی است.
|
||||
""";
|
||||
|
||||
public const string VertexCost = """
|
||||
## Anti-pattern 1: calling Gemini Ultra for everything
|
||||
|
||||
Gemini Ultra (or GPT-4-class models) costs 10–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.
|
||||
Gemini Ultra (or GPT-4-class models) costs 10 to 30 times more per token than smaller models. Many teams default to the most capable model because it "just works" during prototyping, then never re-evaluate.
|
||||
|
||||
**Fix**: build a **model router**. Classify each incoming request by complexity. Simple lookups, short summaries, and classification tasks go to Gemini Flash or Haiku. Only complex reasoning, multi-step synthesis, and long-context tasks go to Pro or Ultra. In most production systems, 60–80% of requests can be served by the cheaper tier.
|
||||
**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.
|
||||
**Fix**: cache any context that is static or changes infrequently: system prompts, retrieved document sets, few-shot examples. Cache hits cost about 10% of full input price.
|
||||
|
||||
## Anti-pattern 3: synchronous batch jobs
|
||||
|
||||
Teams run nightly document processing jobs synchronously — one document at a time, each blocked on the previous. This is slow and expensive because you pay for idle wait time between calls.
|
||||
Teams run nightly document processing jobs synchronously, one document at a time, each blocked on the previous. This is slow and expensive because you pay for idle wait time between calls.
|
||||
|
||||
**Fix**: use the Vertex AI batch prediction API for jobs over ~1,000 documents. Batch jobs run asynchronously, are eligible for spot discounts, and typically cost 50% less per token than online serving.
|
||||
""";
|
||||
|
||||
public const string VertexCostFa = """
|
||||
## ضدالگوی اول: صدا زدن Gemini Ultra برای همهچیز
|
||||
|
||||
Gemini Ultra (یا مدلهای همردهی GPT-4) به ازای هر توکن ۱۰ تا ۳۰ برابر گرانتر از مدلهای کوچکترند. خیلی از تیمها در مرحلهی نمونهسازی سراغ توانمندترین مدل میروند چون «همینجوری کار میکند» و بعد دیگر هیچوقت بازنگری نمیکنند.
|
||||
|
||||
**راهحل**: یک **مسیردهندهی مدل (model router)** بسازید. هر درخواست ورودی را بر اساس پیچیدگی دستهبندی کنید. جستوجوهای ساده، خلاصههای کوتاه و کارهای دستهبندی به Gemini Flash یا Haiku بروند. فقط استدلالهای پیچیده، ترکیب چندمرحلهای و کارهای با زمینهی طولانی به Pro یا Ultra. در بیشتر سامانههای تولیدی، ۶۰ تا ۸۰ درصد درخواستها را میشود با ردهی ارزانتر سرویس داد.
|
||||
|
||||
## ضدالگوی دوم: نبودِ کشِ زمینه
|
||||
|
||||
Vertex AI از کش کردن prompt پشتیبانی میکند (مثل API اَنتروپیک). یک system prompt دههزار توکنی که با هر درخواست و با نرخ ۳ دلار به ازای هر میلیون توکن فرستاده میشود، پیش از آنکه کاربر حتی یک کلمه تایپ کند، برای هر میلیون فراخوانی ۳۰ دلار خرج برمیدارد.
|
||||
|
||||
**راهحل**: هر زمینهای که ثابت است یا کم تغییر میکند را کش کنید؛ system promptها، مجموعهی اسناد بازیابیشده، نمونههای few-shot. هزینهی hit کش حدود ۱۰ درصد قیمت کامل ورودی است.
|
||||
|
||||
## ضدالگوی سوم: کارهای دستهای همگام
|
||||
|
||||
تیمها کارهای شبانهی پردازش سند را همگام اجرا میکنند؛ سند به سند، هرکدام منتظر قبلی. این کُند و گران است چون بابت زمان انتظارِ بیکار بین فراخوانیها هم پول میدهید.
|
||||
|
||||
**راهحل**: برای کارهای بالای حدود ۱۰۰۰ سند از batch prediction API در Vertex AI استفاده کنید. کارهای دستهای ناهمگام اجرا میشوند، واجد تخفیف spot هستند و معمولاً به ازای هر توکن ۵۰ درصد ارزانتر از سرویسدهی آنلاین تمام میشوند.
|
||||
""";
|
||||
|
||||
public const string K8sInference = """
|
||||
@@ -155,25 +218,47 @@ A single Kubernetes `Deployment` behind a `ClusterIP` `Service`, fronted by an I
|
||||
|
||||
## 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.
|
||||
HPA (Horizontal Pod Autoscaler) scales on CPU and memory. LLM inference is GPU-bound and queue-depth-bound, and neither maps to CPU utilization well.
|
||||
|
||||
KEDA (Kubernetes Event-Driven Autoscaling) scales on arbitrary metrics — queue depth, Pub/Sub lag, Redis list length. We publish inference request counts to a Redis stream; KEDA scales the model server pods when the stream depth exceeds a threshold. Scaling-up latency drops from minutes (cluster autoscaler cold start) to seconds (replica scale-up from 1 to N).
|
||||
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.
|
||||
For models that fit in 4 to 8 GB VRAM, full GPU dedication is wasteful. NVIDIA's time-slicing MIG (Multi-Instance GPU) lets multiple pods share one A100, each getting a guaranteed slice.
|
||||
|
||||
Configure `nvidia.com/gpu: 1` and set the time-slice profile to `1g.10gb`. A single A100 80GB can serve 8 concurrent model instances at 10 GB each — 8× the throughput per GPU.
|
||||
Configure `nvidia.com/gpu: 1` and set the time-slice profile to `1g.10gb`. A single A100 80GB can serve 8 concurrent model instances at 10 GB each, 8 times the throughput per GPU.
|
||||
|
||||
## Request hedging for tail latency
|
||||
|
||||
p50 latency is 12ms. p99 is 280ms. The tail is dominated by KV-cache misses and occasional GC pauses. **Hedged requests**: after 40ms, send a duplicate request to a second replica. Take whichever response arrives first; cancel the other. This cuts p99 from 280ms to ~45ms with only ~15% increase in total compute.
|
||||
""";
|
||||
|
||||
public const string K8sInferenceFa = """
|
||||
## معماری پایه
|
||||
|
||||
یک `Deployment` در Kubernetes پشتِ یک `Service` از نوع `ClusterIP` که یک Ingress جلویش قرار گرفته. تا حدود ۵۰ درخواست بر ثانیه برای یک مدل کوچک خوب کار میکند. اما وقتی ترافیک ناگهان بالا میرود، یا زمانبندی podهای GPU سه دقیقه طول میکشد، یا سرور مدل دو ثانیه cold-start دارد، از هم میپاشد.
|
||||
|
||||
## مقیاس خودکار با KEDA
|
||||
|
||||
HPA (مقیاسگذار افقی pod) بر اساس CPU و حافظه مقیاس میدهد. اما استنتاج LLM به GPU و عمق صف وابسته است و هیچکدام با مصرف CPU خوب نگاشت نمیشوند.
|
||||
|
||||
KEDA (مقیاس خودکار رویدادمحور Kubernetes) بر اساس هر معیار دلخواهی مقیاس میدهد؛ عمق صف، تأخیر Pub/Sub، طول لیست Redis. ما تعداد درخواستهای استنتاج را در یک stream در Redis منتشر میکنیم؛ KEDA وقتی عمق stream از یک آستانه عبور کند podهای سرور مدل را مقیاس میدهد. تأخیر مقیاسگرفتن از چند دقیقه (cold-start مقیاسگذار خوشه) به چند ثانیه (افزایش replica از ۱ به N) میرسد.
|
||||
|
||||
## اشتراک GPU با time-slicing
|
||||
|
||||
برای مدلهایی که در ۴ تا ۸ گیگابایت VRAM جا میشوند، اختصاص کاملِ GPU اسراف است. فناوری time-slicing و MIG انویدیا (GPU چنداینستنسه) اجازه میدهد چند pod یک A100 را به اشتراک بگذارند و هرکدام یک سهم تضمینشده بگیرند.
|
||||
|
||||
`nvidia.com/gpu: 1` را تنظیم کنید و پروفایل time-slice را روی `1g.10gb` بگذارید. یک A100 هشتادگیگابایتی میتواند ۸ اینستنس مدل را همزمان، هرکدام با ۱۰ گیگابایت، سرویس بدهد؛ یعنی ۸ برابرِ توان عبوری به ازای هر GPU.
|
||||
|
||||
## hedging درخواست برای تأخیر دنباله
|
||||
|
||||
تأخیر p50 برابر ۱۲ میلیثانیه است و p99 برابر ۲۸۰ میلیثانیه. دنباله را عمدتاً missهای KV-cache و مکثهای گاهبهگاه GC میسازند. **درخواستهای hedged**: بعد از ۴۰ میلیثانیه یک درخواست تکراری به replica دوم بفرستید. هر پاسخی زودتر رسید همان را بردارید و دیگری را لغو کنید. این کار p99 را از ۲۸۰ به حدود ۴۵ میلیثانیه میرساند، با تنها حدود ۱۵ درصد افزایش در کل محاسبات.
|
||||
""";
|
||||
|
||||
public const string FlutterAI = """
|
||||
## Why on-device inference matters
|
||||
|
||||
Cloud inference requires a network round-trip, exposes user data to a server, and fails in offline scenarios. For consumer apps — messaging, health, productivity — on-device inference is often a requirement, not a nice-to-have.
|
||||
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
|
||||
|
||||
@@ -183,34 +268,78 @@ LiteRT (formerly TensorFlow Lite) handles vision and custom small models. For cl
|
||||
|
||||
## 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.
|
||||
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.
|
||||
On-device inference heats the chip. Implement **thermal throttling**: check `DeviceInfo.thermalState` (iOS) or subscribe to the battery API on Android. Reduce `maxTokens` from 512 to 128 during sustained load. Schedule background inference tasks during charging. Users notice neither the throttling nor the scheduling. They notice when their phone gets too hot.
|
||||
""";
|
||||
|
||||
public const string FlutterAIFa = """
|
||||
## چرا استنتاج روی دستگاه مهم است
|
||||
|
||||
استنتاج ابری یک رفتوبرگشتِ شبکه میخواهد، دادهی کاربر را در معرض سرور میگذارد، و در حالت آفلاین شکست میخورد. برای اپهای مصرفی مثل پیامرسان، سلامت و بهرهوری، استنتاج روی دستگاه اغلب یک الزام است، نه یک امکانِ خوببهداشتن.
|
||||
|
||||
## Gemini Nano و LiteRT
|
||||
|
||||
Gemini Nano گوگل یک مدل ۱.۸ میلیارد پارامتری است که کوانتیزه شده تا روی NPUهای موبایل (واحدهای پردازش عصبی) اجرا شود. یکپارچهسازی با Flutter از پکیج `google_ai_dart_sdk` و `GeminiNanoModel` استفاده میکند و وقتی مدلِ روی دستگاه در دسترس نباشد به استنتاج ابری برمیگردد.
|
||||
|
||||
LiteRT (همان TensorFlow Lite سابق) بینایی و مدلهای کوچک سفارشی را مدیریت میکند. برای کارهای دستهبندی و embedding، یک مدل کوانتیزهی ۵۰ مگابایتی روی یک گوشی اندرویدی میانرده در کمتر از ۲۰ میلیثانیه اجرا میشود.
|
||||
|
||||
## تجربهی استریم بدون شبکه
|
||||
|
||||
نکتهی کلیدی: کاربرها پاسخ کمی کندتر را تحمل میکنند اگر ببینند متن توکنبهتوکن ظاهر میشود. حتی استنتاج روی دستگاه هم میتواند استریم کند. Dart SDK مربوط به Gemini Nano متد `generateContentStream` را در اختیار میگذارد. توکنها را مستقیم به یک `StreamBuilder` در Flutter بدهید تا فارغ از کل زمان تولید، حسی پاسخگو داشته باشید.
|
||||
|
||||
## مدیریت باتری و دما
|
||||
|
||||
استنتاج روی دستگاه تراشه را گرم میکند. **throttling حرارتی** پیاده کنید: `DeviceInfo.thermalState` را در iOS بررسی کنید یا در اندروید به API باتری گوش بدهید. زیر بار طولانی `maxTokens` را از ۵۱۲ به ۱۲۸ کم کنید. کارهای استنتاجِ پسزمینه را برای زمان شارژ زمانبندی کنید. کاربر نه throttling را میفهمد و نه زمانبندی را؛ فقط وقتی گوشیاش داغ شود متوجه میشود.
|
||||
""";
|
||||
|
||||
public const string EnterpriseRoadmap = """
|
||||
## Days 1–30: discovery
|
||||
## 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.
|
||||
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?
|
||||
Interview 8 to 12 stakeholders across business units. For each, ask: what manual task takes more than 2 hours per week? What decision do you make with incomplete information? What report do you wish existed but is too expensive to build?
|
||||
|
||||
Map the candidates on a 2×2: **impact** (revenue, cost, risk) vs **feasibility** (data quality, integration complexity, regulatory constraints). The top-right quadrant is your first sprint.
|
||||
Map the candidates on a 2x2: **impact** (revenue, cost, risk) vs **feasibility** (data quality, integration complexity, regulatory constraints). The top-right quadrant is your first sprint.
|
||||
|
||||
## Days 31–60: prototype and validate
|
||||
## 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**.
|
||||
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.
|
||||
Run a structured eval: 100 questions, domain expert scores each answer 1 to 5. Set a threshold (e.g. 4.0 average or higher) before the sprint begins. If the prototype clears it, proceed to production hardening. If it does not, investigate root cause (usually data quality or chunking strategy) before committing engineering resources.
|
||||
|
||||
## Days 61–90: first production deployment
|
||||
## 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.
|
||||
Scope the first deployment to a single team of 10 to 20 people. This limits blast radius and generates real usage data fast.
|
||||
|
||||
Instrument everything: latency, cost per query, thumbs-up/thumbs-down from users, faithfulness score from the automated harness. Review metrics weekly with the business owner. Adjust chunking, retrieval strategy, or model tier based on what the data shows — not intuition.
|
||||
Instrument everything: latency, cost per query, thumbs-up/thumbs-down from users, faithfulness score from the automated harness. Review metrics weekly with the business owner. Adjust chunking, retrieval strategy, or model tier based on what the data shows, not intuition.
|
||||
|
||||
At day 90, you have a live system, a tuned eval harness, and a clear picture of what the second use case should be. That is the foundation for a credible 12-month roadmap.
|
||||
""";
|
||||
|
||||
public const string EnterpriseRoadmapFa = """
|
||||
## روز ۱ تا ۳۰: کشف
|
||||
|
||||
گرانترین اشتباه در هوش مصنوعی سازمانی، سریعساختنِ چیز اشتباه است. کشف یک تشریفات نیست؛ خودِ کار است.
|
||||
|
||||
با ۸ تا ۱۲ ذینفع در واحدهای مختلفِ کسبوکار مصاحبه کنید. از هرکدام بپرسید: کدام کار دستی بیش از دو ساعت در هفته وقت میگیرد؟ کدام تصمیم را با اطلاعات ناقص میگیرید؟ کدام گزارش را آرزو دارید داشته باشید ولی ساختنش گران است؟
|
||||
|
||||
نامزدها را روی یک ماتریس ۲×۲ بچینید: **اثر** (درآمد، هزینه، ریسک) در برابر **امکانپذیری** (کیفیت داده، پیچیدگی یکپارچهسازی، محدودیتهای مقرراتی). ربعِ بالا-راست، اولین sprint شماست.
|
||||
|
||||
## روز ۳۱ تا ۶۰: نمونهی اولیه و اعتبارسنجی
|
||||
|
||||
یک مورد کاربری از ربعِ بالا-راست بردارید. در سه هفته یک نمونهی اولیه بسازید. نمونه لازم نیست در سطح تولید باشد؛ باید **توسط کارشناسان حوزه قابلآزمون** باشد.
|
||||
|
||||
یک ارزیابی ساختارمند اجرا کنید: ۱۰۰ پرسش، کارشناس حوزه به هر پاسخ از ۱ تا ۵ امتیاز میدهد. پیش از شروع sprint یک آستانه بگذارید (مثلاً میانگین ۴.۰ یا بالاتر). اگر نمونه از آن گذشت، به سمتِ سختسازی برای تولید بروید. اگر نگذشت، ریشه را پیدا کنید (معمولاً کیفیت داده یا راهبرد قطعهبندی) و بعد منابع مهندسی را متعهد کنید.
|
||||
|
||||
## روز ۶۱ تا ۹۰: اولین استقرار تولید
|
||||
|
||||
اولین استقرار را به یک تیم ۱۰ تا ۲۰ نفره محدود کنید. این کار شعاع آسیب را کم میکند و سریع دادهی استفادهی واقعی میسازد.
|
||||
|
||||
همهچیز را اندازه بگیرید: تأخیر، هزینه به ازای هر پرسش، بازخورد مثبت و منفی کاربرها، امتیاز وفاداری از بسترِ خودکار. هفتگی با صاحب کسبوکار معیارها را مرور کنید. قطعهبندی، راهبرد بازیابی یا ردهی مدل را بر اساس آنچه داده نشان میدهد تنظیم کنید، نه بر اساس حدس.
|
||||
|
||||
در روز ۹۰ یک سامانهی زنده، یک بسترِ ارزیابیِ تنظیمشده، و تصویری روشن از اینکه دومین مورد کاربری چه باید باشد دارید. این، پایهی یک نقشهی راهِ ۱۲ماههی معتبر است.
|
||||
""";
|
||||
}
|
||||
|
||||
+247
-398
@@ -5,162 +5,94 @@
|
||||
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>
|
||||
<!-- ─── HERO (bento) ─────────────────────────────────────────────────── -->
|
||||
<section id="top" class="px-5 pt-24 pb-12 sm:px-8 sm:pt-28">
|
||||
<div class="mx-auto max-w-6xl">
|
||||
<div class="bento">
|
||||
|
||||
<!-- 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>
|
||||
<!-- Name / anchor tile -->
|
||||
<div class="tile tile-dark span-2 row-2 reveal">
|
||||
<span class="kicker @(fa ? "font-fa" : "")" style="color:#a1a1aa">@(fa ? "مهندس نرمافزار و هوش مصنوعی" : "Software & AI Engineer")</span>
|
||||
<h1 class="mt-3 @(fa ? "font-fa" : "")" style="font-size:clamp(2.4rem,5.5vw,4rem)">@(fa ? "سروش اسعدی" : "Soroush Asadi")</h1>
|
||||
<p class="t-sub mt-3 text-[1rem] @(fa ? "font-fa" : "")">@(fa ? "معمار سیستم. از ایده تا اجرا." : "Solution architect. From idea to production.")</p>
|
||||
<div class="mt-auto flex flex-wrap gap-3 pt-8">
|
||||
<a href="#contact" class="btn">
|
||||
@(fa ? "رزرو جلسه" : "Book a call")
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" class="@(fa ? "rotate-180" : "")" aria-hidden="true"><path d="M5 12h14"/><path d="m13 6 6 6-6 6"/></svg>
|
||||
</a>
|
||||
<a href="#services" class="btn-ghost">@(fa ? "مشاهده خدمات" : "View services")</a>
|
||||
<a href="#portfolio" class="btn-ghost btn-on-dark">@(fa ? "نمونهکارها" : "View work")</a>
|
||||
</div>
|
||||
</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>
|
||||
}
|
||||
<!-- Value-prop tile -->
|
||||
<div class="tile span-2 reveal" style="transition-delay:.06s">
|
||||
<p class="mt-auto text-balance leading-snug text-zinc-800 @(fa ? "font-fa" : "")" style="font-size:clamp(1.15rem,2vw,1.5rem)">
|
||||
@(fa ? "نرمافزار، اپلیکیشنهای سازمانی و " : "I build software, enterprise apps, and ")<span class="accent-text font-semibold">@(fa ? "راهکارهای هوش مصنوعی" : "AI solutions")</span>@(fa ? " میسازم که در عمل و در مقیاس واقعی کار میکنند." : " that hold up in production, at real scale.")
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<!-- Availability tile -->
|
||||
<div class="tile tile-accent reveal" style="transition-delay:.12s">
|
||||
<span class="inline-flex items-center gap-2 text-[.82rem]" style="color:rgba(255,255,255,.9)"><span style="width:7px;height:7px;border-radius:99px;background:#fff;display:inline-block"></span>@(fa ? "وضعیت" : "Status")</span>
|
||||
<p class="mt-auto pt-6 text-[1.05rem] font-semibold @(fa ? "font-fa" : "")" style="color:#fff">@(fa ? "پذیرای پروژههای جدید" : "Open for new projects")</p>
|
||||
</div>
|
||||
|
||||
<!-- Social tile -->
|
||||
<div class="tile reveal" style="transition-delay:.18s">
|
||||
<span class="kicker @(fa ? "font-fa" : "")">@(fa ? "ارتباط" : "Connect")</span>
|
||||
<div class="mt-auto flex items-center gap-2.5 pt-6">
|
||||
<a class="social" href="https://www.linkedin.com/in/soroushdes/" target="_blank" rel="noopener" aria-label="LinkedIn"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5ZM3 9h4v12H3V9Zm6 0h3.8v1.64h.05c.53-1 1.83-2.06 3.76-2.06 4.02 0 4.76 2.65 4.76 6.1V21h-4v-5.4c0-1.29-.02-2.95-1.8-2.95-1.8 0-2.07 1.4-2.07 2.85V21H9V9Z"/></svg></a>
|
||||
<a class="social" href="https://www.instagram.com/soroushasadicom/" target="_blank" rel="noopener" aria-label="Instagram"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="5"/><circle cx="12" cy="12" r="4"/><circle cx="17.2" cy="6.8" r="1.1" fill="currentColor" stroke="none"/></svg></a>
|
||||
<a class="social" href="mailto:code.soroush@gmail.com" aria-label="Email"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></svg></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ─── SERVICES ─────────────────────────────────────────────────────── -->
|
||||
<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>
|
||||
<!-- ─── SERVICES (text-block grid) ───────────────────────────────────── -->
|
||||
<section id="services" class="px-5 py-24 sm:px-8 sm:py-28">
|
||||
<div class="mx-auto max-w-6xl">
|
||||
<div class="sec-head">
|
||||
<h2>@(fa ? "شش کاری که انجام میدهم" : "Six areas of practice")</h2>
|
||||
<p class="lede">@(fa ? "از همان جلسهی اول تا وقتی محصول روی پای خودش میایستد، کنارتان هستم؛ در تمام مسیر مهندسی و محصول." : "From the first idea to production rollout, one engineering partner across the whole product.")</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
|
||||
<div class="bento">
|
||||
@{
|
||||
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"}),
|
||||
("apps","اپلیکیشنهای وب و سازمانی","پلتفرمهای وب و SaaS از صفر تا صد: داشبورد، چندمستاجری، صورتحساب و پنل مدیریت، ساختهشده برای رشد.",new[]{"Web","SaaS","Dashboards"}),
|
||||
("mobile","اپلیکیشنهای موبایل","اپهای بومی و چندسکویی با Flutter، Swift و Kotlin، با حسی روان و نزدیک به تجربهی بومی.",new[]{"Flutter","Swift","Kotlin"}),
|
||||
("architecture","معماری راهکار و زیرساخت ابری","سیستمهای توزیعشده روی Kubernetes؛ میکروسرویس، استریم رویداد، و پایداری زیر بار سنگین.",new[]{"K8s","Microservices","Cloud"}),
|
||||
("llm-rag","راهکارهای هوش مصنوعی","قابلیتهای LLM و RAG، عاملها و اتوماسیون که داخل محصول واقعی کار میکنند، نه فقط دمو.",new[]{"LLM","RAG","Agents"}),
|
||||
("automation","اتوماسیون و یکپارچهسازی","ابزارهایتان را به هم وصل میکنم و کارهای دستی را حذف؛ با گردشکارهای n8n، API و وبهوک.",new[]{"n8n","APIs","Webhooks"}),
|
||||
("strategy","راهبرد و نقشهی راه","راهبرد فنی، بازبینی معماری، و نقشهی راهی روشن از ایده تا عرضه.",new[]{"Discovery","Architecture","Roadmap"}),
|
||||
} : new[]{
|
||||
("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"}),
|
||||
("apps","Web & enterprise apps","End-to-end web platforms and SaaS: dashboards, multi-tenant, billing, and admin, built to scale.",new[]{"Web","SaaS","Dashboards"}),
|
||||
("mobile","Mobile apps","Native and cross-platform apps with Flutter, Swift, and Kotlin, with a smooth native feel.",new[]{"Flutter","Swift","Kotlin"}),
|
||||
("architecture","Solution architecture & cloud","Distributed systems on Kubernetes: microservices, event streaming, and resilience at scale.",new[]{"K8s","Microservices","Cloud"}),
|
||||
("llm-rag","AI solutions","LLM and RAG features, agents, and automation built into real products, not just demos.",new[]{"LLM","RAG","Agents"}),
|
||||
("automation","Automation & integrations","Connect your tools and remove manual work with n8n workflows, APIs, and webhooks.",new[]{"n8n","APIs","Webhooks"}),
|
||||
("strategy","Strategy & roadmap","Technical strategy, architecture review, and a clear roadmap from idea to launch.",new[]{"Discovery","Architecture","Roadmap"}),
|
||||
};
|
||||
int si = 0;
|
||||
}
|
||||
@foreach (var (id, title, desc, color, tags) in services)
|
||||
@{ int si = 0; }
|
||||
@foreach (var (id, title, desc, 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"),
|
||||
var (spanCls, variant) = id switch {
|
||||
"apps" => ("span-2", "tile-tint"),
|
||||
"llm-rag" => ("span-2", "tile-dark"),
|
||||
_ => ("", ""),
|
||||
};
|
||||
<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>
|
||||
var descCls = variant == "tile-dark" ? "t-sub" : "text-zinc-600";
|
||||
<article class="tile reveal @spanCls @variant" style="transition-delay:@(si * 60)ms">
|
||||
<span class="tile-icon" aria-hidden="true">@Html.Raw(ServiceIcon(id))</span>
|
||||
<h3 class="mt-4 text-lg font-semibold @(fa ? "font-fa" : "")">@title</h3>
|
||||
<p class="mt-2.5 text-[.93rem] leading-relaxed @descCls">@desc</p>
|
||||
<div class="mt-auto flex flex-wrap gap-1.5 pt-5">
|
||||
@foreach (var tag in tags) { <span class="chip">@tag</span> }
|
||||
</div>
|
||||
<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++;
|
||||
}
|
||||
@@ -168,76 +100,54 @@
|
||||
</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>
|
||||
<!-- ─── PIPELINE (horizontal stepper) ────────────────────────────────── -->
|
||||
<section id="dataflow" class="px-5 py-24 sm:px-8 sm:py-28">
|
||||
<div class="mx-auto max-w-6xl">
|
||||
<div class="sec-head">
|
||||
<h2>@(fa ? "از سند خام تا پاسخ قابل اتکا" : "From raw document to a trustworthy answer")</h2>
|
||||
<p class="lede">@(fa ? "مسیری که هر پرسش در یک سامانهی RAG واقعی طی میکند. هر مرحله را میشود اندازه گرفت، دنبال کرد و برای سرعت بهتر کرد." : "The path every query takes through a production RAG system. Each stage is measurable, auditable, and tuned for latency.")</p>
|
||||
</div>
|
||||
<!-- Flow diagram -->
|
||||
<div class="reveal mt-10 overflow-x-auto">
|
||||
<div class="flex min-w-max items-center gap-0 mx-auto w-fit">
|
||||
|
||||
<ol class="grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-3 lg:grid-cols-5">
|
||||
@{
|
||||
var nodes = fa ? new[]{
|
||||
("ingest","دریافت","نرمالسازی، قطعهبندی و پاکسازی اسناد منبع","electric"),
|
||||
("embed","برداریسازی","تولید embedding و نمایهسازی در پایگاه برداری","violet"),
|
||||
("retrieve","بازیابی","جستجوی ترکیبی معنایی و کلیدواژهای","cyan"),
|
||||
("rerank","بازرتبهبندی","مرتبسازی مجدد نامزدها با cross-encoder","magenta"),
|
||||
("generate","تولید","پاسخ مستند با ارجاع به منبع","emerald"),
|
||||
("دریافت","نرمالسازی، تکهتکهکردن و پاکسازی سندهای منبع"),
|
||||
("برداریسازی","ساخت embedding و نمایهکردن در پایگاه برداری"),
|
||||
("بازیابی","جستجوی ترکیبی معنایی و کلیدواژهای"),
|
||||
("بازرتبهبندی","چیدن دوبارهی نتایج با cross-encoder"),
|
||||
("تولید","پاسخ مستند همراه با ذکر منبع"),
|
||||
} : 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"),
|
||||
("Ingest","Normalize, chunk, and clean source documents"),
|
||||
("Embed","Generate embeddings and index in the vector store"),
|
||||
("Retrieve","Hybrid semantic and keyword search"),
|
||||
("Rerank","Re-order candidates with a cross-encoder"),
|
||||
("Generate","Grounded answer with source citations"),
|
||||
};
|
||||
int stepN = 0;
|
||||
}
|
||||
@for (int ni = 0; ni < nodes.Length; ni++)
|
||||
@foreach (var (nlabel, ndesc) in nodes)
|
||||
{
|
||||
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>
|
||||
stepN++;
|
||||
<li class="reveal border-t border-zinc-200 pt-4" style="transition-delay:@((stepN-1) * 40)ms">
|
||||
<span class="font-display text-sm text-zinc-500">@stepN.ToString("D2")</span>
|
||||
<h3 class="mt-2 text-base font-semibold @(fa ? "font-fa" : "")">@nlabel</h3>
|
||||
<p class="mt-1.5 text-[.85rem] leading-relaxed text-zinc-600">@ndesc</p>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-8 text-center label-mono text-slate-500 reveal">@(fa ? "تأخیر سرتاسری زیر ۵۰ میلیثانیه · هر مرحله مشاهدهپذیر" : "Sub-50ms end-to-end · every stage observable")</p>
|
||||
</ol>
|
||||
<p class="mt-8 text-sm text-zinc-500">@(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>
|
||||
<!-- ─── STACK (grouped tag clusters) ─────────────────────────────────── -->
|
||||
<section id="stack" class="px-5 py-24 sm:px-8 sm:py-28">
|
||||
<div class="mx-auto max-w-6xl">
|
||||
<div class="sec-head">
|
||||
<h2>@(fa ? "ابزار روزمره" : "Daily tooling")</h2>
|
||||
<p class="lede">@(fa ? "هر چیزی که میسازم روی اینها بنا میشود؛ انتخابشان کردهام چون میمانند، نه چون مد روزند." : "Everything I ship sits on this foundation, chosen for longevity, not hype cycles.")</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4 reveal">
|
||||
|
||||
<div class="bento">
|
||||
@{
|
||||
var cats = fa ? new[]{
|
||||
("زبانها", new[]{"Python","TypeScript","Go","Rust","SQL"}),
|
||||
@@ -250,201 +160,153 @@
|
||||
("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;
|
||||
}
|
||||
@{ int ci = 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 class="tile reveal @(ci == 3 ? "tile-tint" : "")" style="transition-delay:@(ci * 60)ms">
|
||||
<h3 class="text-sm font-semibold @(fa ? "font-fa" : "")">@catLabel</h3>
|
||||
<div class="mt-4 flex flex-wrap gap-1.5">
|
||||
@foreach (var item in items) { <span class="chip">@item</span> }
|
||||
</div>
|
||||
ci2++;
|
||||
</div>
|
||||
ci++;
|
||||
}
|
||||
</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>
|
||||
<!-- ─── EXPERTISE (definition list) ──────────────────────────────────── -->
|
||||
<section id="expertise" class="px-5 py-24 sm:px-8 sm:py-28">
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<div class="sec-head">
|
||||
<h2>@(fa ? "جاهایی که عمیق شدهام" : "What I go deep on")</h2>
|
||||
<p class="lede">@(fa ? "سامانههایی که روزانه میلیونها رویداد را تاب میآورند. اینها همان چیزهاییاند که سالها رویشان کار کردهام." : "Systems that survive millions of events per day. These are the areas I optimize for.")</p>
|
||||
</div>
|
||||
<div class="space-y-6 reveal">
|
||||
|
||||
<dl>
|
||||
@{
|
||||
var bars = fa ? new[]{
|
||||
("مهندسی LLM و RAG", 95),
|
||||
("معماری ابری و Kubernetes", 92),
|
||||
("سیستمهای عاملمحور و اتوماسیون", 90),
|
||||
("استک گوگل کلود (Vertex / GKE)", 88),
|
||||
("موبایل بومی و cross-platform", 82),
|
||||
var areas = fa ? new[]{
|
||||
("معماری نرمافزار و سیستمهای توزیعشده","میکروسرویس، استریم رویداد و الگوهای پایداری زیر بار سنگین."),
|
||||
("اپلیکیشنهای وب و سازمانی","پلتفرمهای چندمستاجری، داشبورد و سیستمهای پرترافیک."),
|
||||
("راهکارهای هوش مصنوعی (LLM و RAG)","بازیابی، ارزیابی و تولید پاسخ مستند، داخل محصول واقعی."),
|
||||
("زیرساخت ابری و Kubernetes","استقرار، مقیاسپذیری خودکار و حواسجمعی روی هزینه."),
|
||||
("موبایل بومی و چندسکویی","Flutter، Swift و Kotlin برای اپهای روان و سریع."),
|
||||
} : new[]{
|
||||
("LLM & RAG engineering", 95),
|
||||
("Cloud architecture & Kubernetes", 92),
|
||||
("Agentic systems & automation", 90),
|
||||
("Google Cloud stack (Vertex / GKE)", 88),
|
||||
("Native + cross-platform mobile", 82),
|
||||
("Software architecture & distributed systems","Microservices, event streaming, and resilience patterns at scale."),
|
||||
("Web & enterprise applications","Multi-tenant platforms, dashboards, and high-traffic systems."),
|
||||
("AI solutions (LLM & RAG)","Retrieval, evals, and grounded generation, inside real products."),
|
||||
("Cloud infrastructure & Kubernetes","Deployment, autoscaling, and real cost discipline."),
|
||||
("Native & cross-platform mobile","Flutter, Swift, and Kotlin for smooth, fast apps."),
|
||||
};
|
||||
string[] barColors = ["bg-electric","bg-violet","bg-cyan","bg-magenta","bg-emerald"];
|
||||
int bi = 0;
|
||||
}
|
||||
@foreach (var (blabel, bval) in bars)
|
||||
@foreach (var (alabel, adesc) in areas)
|
||||
{
|
||||
<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 class="reveal grid grid-cols-1 gap-1 border-t border-zinc-200 py-5 sm:grid-cols-[1fr_1.5fr] sm:gap-8">
|
||||
<dt class="text-base font-semibold @(fa ? "font-fa" : "")">@alabel</dt>
|
||||
<dd class="text-[.95rem] leading-relaxed text-zinc-600">@adesc</dd>
|
||||
</div>
|
||||
<div class="bar-track">
|
||||
<div class="bar-fill @barColors[bi]" data-w="@bval%" style="width:0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
bi++;
|
||||
}
|
||||
</div>
|
||||
</dl>
|
||||
</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>
|
||||
<!-- ─── PORTFOLIO (card grid, typographic covers) ────────────────────── -->
|
||||
<section id="portfolio" class="px-5 py-24 sm:px-8 sm:py-28">
|
||||
<div class="mx-auto max-w-6xl">
|
||||
<div class="sec-head">
|
||||
<h2>@(fa ? "نمونهکارهای منتخب" : "Selected work")</h2>
|
||||
<p class="lede">@(fa ? "محصولاتی که خودم طراحی و ساختهام. روی هر کارت بزنید تا خودِ سایت را ببینید." : "Products I have designed and built. Tap any card to open the live site.")</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div class="bento">
|
||||
@{
|
||||
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"}),
|
||||
("hamkadr","همکادر","hamkadr.ir","بازاری که کادر درمان را برای شیفت و استخدام به بیمارستانها و کلینیکها وصل میکند؛ با پروفایل، فیلتر، تقویم هفتگی و اپ موبایل.",new[]{"Marketplace","Healthcare","Mobile"}),
|
||||
("meezi","میزی","meezi.ir","سامانهی یکپارچه برای کافه و رستوران: سفارش با QR، صندوق فروش، انبار، کارکنان و تحلیل فروش، روی زیرساخت داخلی.",new[]{"SaaS","POS","Analytics"}),
|
||||
("bargevasat","برگ وسط","bargevasat.ir","بازی آنلاین حکم بهصورت بلادرنگ مقابل بازیکنان واقعی یا رباتهای هوشمند؛ با لیگ، رتبهبندی، جایزهی روزانه و همگامسازی چنددستگاهه.",new[]{"Realtime","Multiplayer","Game"}),
|
||||
("flatrender","فلترندر","flatrender.ir","استودیوی هوش مصنوعی که بیش از ۱۲۰۰ قالب را در چند دقیقه به ویدیو و تصویر آمادهی انتشار تبدیل میکند؛ بر پایهی ثانیهی رندر. در نسخهی بتا.",new[]{"AI","Video","SaaS"}),
|
||||
} : new[]{
|
||||
("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"}),
|
||||
("hamkadr","Hamkadr","hamkadr.ir","A marketplace connecting healthcare staff with hospitals and clinics for shifts and hiring, with profiles, filters, weekly scheduling, and a mobile app.",new[]{"Marketplace","Healthcare","Mobile"}),
|
||||
("meezi","Meezi","meezi.ir","An all-in-one SaaS for cafes and restaurants: QR ordering, POS, inventory, staff, and sales analytics, hosted in Iran.",new[]{"SaaS","POS","Analytics"}),
|
||||
("bargevasat","Barge Vasat","bargevasat.ir","A real-time multiplayer Hokm card game against people or AI bots, with leagues, rankings, daily rewards, and cross-device play.",new[]{"Realtime","Multiplayer","Game"}),
|
||||
("flatrender","Flatrender","flatrender.ir","An AI studio that turns 1,200+ templates into platform-ready videos and images in minutes, billed by render-seconds. In beta.",new[]{"AI","Video","SaaS"}),
|
||||
};
|
||||
}
|
||||
@foreach (var (pid, ptitle, pclient, prole, pyear, psummary, paccent, ptags, pmetrics, pcover, pgallery) in projects)
|
||||
@{ int pi = 0; }
|
||||
@foreach (var (pid, pname, pdomain, pdesc, ptags) 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 initial = char.ToUpperInvariant(pid[0]);
|
||||
var (spanCls, coverBg, coverFg) = pi switch {
|
||||
0 => ("span-2 row-2", "#18181b", "#fafafa"),
|
||||
1 => ("span-2", "#2563eb", "#ffffff"),
|
||||
2 => ("", "#eff4ff", "#2563eb"),
|
||||
_ => ("", "#f4f4f5", "#a1a1aa"),
|
||||
};
|
||||
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" />
|
||||
<a href="https://@pdomain" target="_blank" rel="noopener" aria-label="@pname"
|
||||
class="group tile tile-link reveal @spanCls" style="padding:0;transition-delay:@(pi * 60)ms">
|
||||
<div class="pcover" style="background:@coverBg;@(pi == 0 ? "min-height:210px" : "min-height:104px")">
|
||||
<span class="font-display font-bold" style="font-size:@(pi == 0 ? "5rem" : "2.6rem");color:@coverFg">@initial</span>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col p-5">
|
||||
<div class="mb-3 flex flex-wrap gap-1.5">
|
||||
@foreach (var tag in ptags)
|
||||
{
|
||||
<span class="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>
|
||||
}
|
||||
@foreach (var tag in ptags) { <span class="chip">@tag</span> }
|
||||
</div>
|
||||
<h3 class="text-[1.1rem] font-semibold @(fa ? "font-fa" : "")">@pname</h3>
|
||||
<p class="mt-1 text-[.78rem] text-zinc-500" dir="ltr">@pdomain</p>
|
||||
<p class="mt-2.5 text-[.88rem] leading-relaxed text-zinc-600 @(pi == 0 ? "" : "line-clamp-3")">@pdesc</p>
|
||||
<span class="arrow-link mt-auto pt-4">
|
||||
@(fa ? "مشاهدهی سایت" : "Visit site")
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 17 17 7"/><path d="M8 7h9v9"/></svg>
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
pi++;
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 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>
|
||||
<!-- ─── BLOG (editorial list) ────────────────────────────────────────── -->
|
||||
<section id="blog" class="px-5 py-24 sm:px-8 sm:py-28">
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<div class="sec-head">
|
||||
<h2>@(fa ? "یادداشتهای مهندسی" : "Engineering notes")</h2>
|
||||
<p>@(fa ? "یافتهها از پروژههای واقعی — نه ترجمهی مقاله، نه فهرست hype." : "Findings from real engagements — not translated articles, not hype lists.")</p>
|
||||
<p class="lede">@(fa ? "درسهایی از پروژههای واقعی. نه ترجمهی مقاله، نه شعار توخالی." : "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">
|
||||
|
||||
<div class="border-b border-zinc-200">
|
||||
@{
|
||||
var posts = fa ? new[]{
|
||||
("rag-eval-framework","LLM","چارچوب ارزیابی RAG که در تولید کار میکند","چرا BLEU و ROUGE برای RAG ناکافیاند، و معیارهایی که در پروژههای واقعی تصمیم میسازند.",8),
|
||||
("agentic-n8n-patterns","Automation","الگوهای عاملمحور با n8n برای سازمان","چگونه n8n را با LangGraph ترکیب کنیم تا گردشکارهای قابل ممیزی بسازیم.",11),
|
||||
("vertex-cost-control","Google Stack","کنترل هزینه روی Vertex AI در مقیاس بالا","سه ضدالگو که در ۸۰٪ پروژههای Vertex میبینم، و چگونه ۶۰٪ هزینه را کاهش دادیم.",6),
|
||||
("k8s-llm-inference","Infra","استنتاج LLM روی Kubernetes با تأخیر زیر ۵۰ میلیثانیه","الگوی استقرار با KEDA، GPU sharing، و request hedging برای سرویسدهی پایدار.",14),
|
||||
("flutter-on-device-ai","Mobile","هوش مصنوعی on-device در Flutter","استفاده از Gemini Nano و LiteRT برای استنتاج آفلاین در اپلیکیشنهای موبایل.",9),
|
||||
("enterprise-ai-roadmap","Strategy","نقشه راه هوش مصنوعی سازمانی در ۹۰ روز","چارچوبی که برای CTOها میسازم — از کشف موارد کاربری تا اولین استقرار تولید.",7),
|
||||
("rag-eval-framework","LLM","چارچوب ارزیابی RAG که در عمل جواب میدهد","چرا BLEU و ROUGE برای RAG کافی نیستند، و معیارهایی که واقعاً به تصمیم کمک میکنند.",8),
|
||||
("agentic-n8n-patterns","Automation","الگوهای عاملمحور با n8n برای سازمان","چطور n8n را با LangGraph ترکیب کنیم تا گردشکارهای خودکار و قابلردیابی بسازیم.",11),
|
||||
("vertex-cost-control","Google Stack","کنترل هزینه روی Vertex AI در مقیاس بالا","سه اشتباه رایج که در بیشتر پروژههای Vertex میبینم، و اینکه چطور ۶۰٪ هزینه را کم کردیم.",6),
|
||||
("k8s-llm-inference","Infra","اجرای LLM روی Kubernetes با تأخیر زیر ۵۰ میلیثانیه","الگوی استقرار با KEDA، اشتراک GPU و request hedging برای سرویسدهی پایدار.",14),
|
||||
("flutter-on-device-ai","Mobile","هوش مصنوعی روی دستگاه در Flutter","استفاده از Gemini Nano و LiteRT برای پردازش آفلاین در اپهای موبایل.",9),
|
||||
("enterprise-ai-roadmap","Strategy","نقشهی راه هوش مصنوعی سازمانی در ۹۰ روز","چارچوبی که برای مدیران فنی میچینم؛ از پیدا کردن بهترین ایده تا اولین اجرای واقعی.",7),
|
||||
} : new[]{
|
||||
("rag-eval-framework","LLM","A RAG evaluation framework that holds up in production","Why BLEU and ROUGE fall short for RAG, and the metrics that actually drive decisions in real projects.",8),
|
||||
("agentic-n8n-patterns","Automation","Agentic patterns with n8n for the enterprise","How to combine n8n with LangGraph to build auditable, debuggable autonomous workflows.",11),
|
||||
("vertex-cost-control","Google Stack","Vertex AI cost control at scale","Three anti-patterns I see in 80% of Vertex projects — and how we cut 60% of monthly spend.",6),
|
||||
("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),
|
||||
("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>
|
||||
<a href="/blog/@slug" class="group reveal grid grid-cols-1 gap-2 border-t border-zinc-200 py-6 sm:grid-cols-[8rem_1fr] sm:gap-8">
|
||||
<div class="flex items-baseline justify-between sm:flex-col sm:gap-1">
|
||||
<span class="kicker">@cat</span>
|
||||
<span class="text-[.78rem] text-zinc-500">@readTime @(fa ? "دقیقه" : "min")</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-[1.1rem] font-semibold transition-colors group-hover:text-accent @(fa ? "font-fa" : "")">@btitle</h3>
|
||||
<p class="mt-1.5 text-[.9rem] leading-relaxed text-zinc-600">@excerpt</p>
|
||||
<span class="arrow-link mt-3">
|
||||
@(fa ? "خواندن" : "Read")
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="@(fa ? "rotate-180" : "")" aria-hidden="true"><path d="M5 12h14"/><path d="m13 6 6 6-6 6"/></svg>
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
@@ -452,99 +314,86 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ─── CONTACT ───────────────────────────────────────────────────────── -->
|
||||
<section id="contact" class="relative px-5 py-28 sm:px-8">
|
||||
<!-- ─── CONTACT ──────────────────────────────────────────────────────── -->
|
||||
<section id="contact" class="px-5 py-24 sm:px-8 sm:py-28">
|
||||
<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 class="sec-head">
|
||||
<h2>@(fa ? "رزرو یک جلسهی ۳۰ دقیقهای" : "Book a 30-minute call")</h2>
|
||||
<p class="lede">@(fa ? "بدون هزینه، بدون تعهد. با هم میبینیم چه میخواهید، چه محدودیتهایی هست، و قدم بعد چیست." : "No cost, no commitment. We map the use case, the constraints, and the next step together.")</p>
|
||||
</div>
|
||||
|
||||
<form id="contact-form"
|
||||
class="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.")">
|
||||
<form id="contact-form" class="card space-y-5 p-6 sm:p-8"
|
||||
data-success-msg="@(fa ? "پیام رسید! معمولاً ظرف ۲۴ ساعت کاری جواب میدهم." : "Sent. Typical reply within 24 working hours.")"
|
||||
data-error-msg="@(fa ? "یک مشکلی پیش آمد. لطفاً دوباره تلاش کنید." : "Something went wrong. Please try again.")">
|
||||
<input type="hidden" name="locale" value="@locale" />
|
||||
|
||||
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="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" />
|
||||
<label class="flabel" for="name">@(fa ? "نام" : "Name")<span class="text-red-600" aria-hidden="true"> *</span></label>
|
||||
<input id="name" name="name" type="text" required autocomplete="name" placeholder="@(fa ? "نام و نام خانوادگی" : "Full name")" class="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="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" />
|
||||
<label class="flabel" for="company">@(fa ? "سازمان" : "Company")</label>
|
||||
<input id="company" name="company" type="text" autocomplete="organization" placeholder="@(fa ? "نام سازمان" : "Organization")" class="field" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="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>
|
||||
<label class="flabel" for="service">@(fa ? "خدمت" : "Service")<span class="text-red-600" aria-hidden="true"> *</span></label>
|
||||
<select id="service" name="service" required class="field">
|
||||
<option value="" disabled selected>@(fa ? "انتخاب کنید" : "Select…")</option>
|
||||
@if (fa)
|
||||
{
|
||||
<option value="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>
|
||||
<option value="apps">اپلیکیشن وب و سازمانی</option>
|
||||
<option value="mobile">اپلیکیشن موبایل</option>
|
||||
<option value="architecture">معماری و زیرساخت ابری</option>
|
||||
<option value="ai">راهکار هوش مصنوعی</option>
|
||||
<option value="automation">اتوماسیون و یکپارچهسازی</option>
|
||||
<option value="strategy">راهبرد و نقشهی راه</option>
|
||||
}
|
||||
else
|
||||
{
|
||||
<option value="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>
|
||||
<option value="apps">Web & enterprise apps</option>
|
||||
<option value="mobile">Mobile apps</option>
|
||||
<option value="architecture">Solution architecture & cloud</option>
|
||||
<option value="ai">AI solutions</option>
|
||||
<option value="automation">Automation & integrations</option>
|
||||
<option value="strategy">Strategy & roadmap</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="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>
|
||||
<label class="flabel" for="budget">@(fa ? "بودجه (تقریبی)" : "Budget (rough)")<span class="text-red-600" aria-hidden="true"> *</span></label>
|
||||
<select id="budget" name="budget" required class="field">
|
||||
<option value="" disabled selected>@(fa ? "انتخاب کنید" : "Select…")</option>
|
||||
<option value="under-10k">@(fa ? "زیر ۱۰ هزار دلار" : "Under $10k")</option>
|
||||
<option value="10-50k">@(fa ? "۱۰ تا ۵۰ هزار دلار" : "$10k - $50k")</option>
|
||||
<option value="50-200k">@(fa ? "۵۰ تا ۲۰۰ هزار دلار" : "$50k - $200k")</option>
|
||||
<option value="200k-plus">@(fa ? "بیش از ۲۰۰ هزار دلار" : "$200k+")</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="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>
|
||||
<label class="flabel" for="message">@(fa ? "پیام" : "Message")<span class="text-red-600" aria-hidden="true"> *</span></label>
|
||||
<textarea id="message" name="message" required rows="4" placeholder="@(fa ? "هدف، بازهی زمانی، و چیزی که الان گیرتان انداخته…" : "Goal, timeline, current blockers…")" class="field resize-none"></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-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>
|
||||
<button type="submit" class="btn w-full">@(fa ? "ارسال پیام" : "Send request")</button>
|
||||
<p id="contact-status" role="status" aria-live="polite" class="mt-1 text-sm text-zinc-500">@(fa ? "معمولاً ظرف ۲۴ ساعت کاری جواب میدهم." : "Typical reply within 24 working hours.")</p>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ─── 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>""",
|
||||
"strategy" => """<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M9 20H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h4"/><polyline points="9,9 4,9"/><polyline points="9,12 4,12"/><polyline points="9,15 4,15"/><rect x="9" y="2" width="6" height="6"/><path d="M15 8h4a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2h-4"/></svg>""",
|
||||
"automation" => """<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M4.22 4.22l2.12 2.12M17.66 17.66l2.12 2.12M2 12h3M19 12h3M4.22 19.78l2.12-2.12M17.66 6.34l2.12-2.12"/></svg>""",
|
||||
"llm-rag" => """<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>""",
|
||||
"architecture" => """<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><rect x="2" y="3" width="6" height="6"/><rect x="16" y="3" width="6" height="6"/><rect x="9" y="15" width="6" height="6"/><path d="M5 9v3h14V9M12 12v3"/></svg>""",
|
||||
"mobile" => """<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><rect x="5" y="2" width="14" height="20" rx="2"/><path d="M12 18h.01"/></svg>""",
|
||||
"apps" => """<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18"/><path d="M7 6.5h.01"/></svg>""",
|
||||
_ => """<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/></svg>""",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,16 +8,9 @@
|
||||
@@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" />
|
||||
<!-- Tailwind: prebuilt + purged admin stylesheet (`npm run build`). No runtime CDN. -->
|
||||
<link rel="stylesheet" href="/css/tailwind-admin.css" asp-append-version="true" />
|
||||
<link rel="stylesheet" href="/css/site.css" asp-append-version="true" />
|
||||
</head>
|
||||
<body class="min-h-screen bg-base text-slate-200 antialiased">
|
||||
<div class="flex min-h-screen">
|
||||
|
||||
+69
-87
@@ -1,11 +1,12 @@
|
||||
@{
|
||||
var locale = (string)(ViewData["Locale"] ?? "fa");
|
||||
var fa = locale == "fa";
|
||||
var isRtl = locale == "fa";
|
||||
var dir = isRtl ? "rtl" : "ltr";
|
||||
var lang = locale == "fa" ? "fa" : "en";
|
||||
var title = (string?)ViewData["Title"] ?? (locale == "fa"
|
||||
? "سروش اسعدی — مهندس هوش مصنوعی، مشاور، معمار راهکار"
|
||||
: "Soroush Asadi — AI Engineer, Consultant, Solution Architect");
|
||||
? "سروش اسعدی - مهندس نرمافزار و هوش مصنوعی"
|
||||
: "Soroush Asadi - Software & AI Engineer");
|
||||
}
|
||||
<!doctype html>
|
||||
<html lang="@lang" dir="@dir">
|
||||
@@ -14,89 +15,42 @@
|
||||
<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.")" />
|
||||
? "نرمافزار، اپلیکیشنهای سازمانی و راهکارهای هوش مصنوعی میسازم: پلتفرمهای وب و موبایل، سیستمهای توزیعشده، زیرساخت ابری و قابلیتهای LLM و RAG که به تولید میرسند."
|
||||
: "I design and build software, enterprise apps, and AI solutions: web and mobile platforms, distributed systems, cloud, and LLM/RAG features that ship to production.")" />
|
||||
<meta name="theme-color" content="#fafafa" />
|
||||
|
||||
<!-- Fonts -->
|
||||
<!-- Fonts: Syne (display) + Vazirmatn (Persian). Body is system sans. -->
|
||||
<style>
|
||||
@@font-face { font-family:'Syne'; src:url('/fonts/Syne-Variable.woff2') format('woff2'); font-weight:100 900; font-display:swap; }
|
||||
@@font-face { font-family:'Vazirmatn'; src:url('/fonts/Vazirmatn-Arabic.woff2') format('woff2'); font-display:swap; }
|
||||
@@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" />
|
||||
<!-- Tailwind: prebuilt + purged stylesheet (`npm run build`). No runtime CDN. -->
|
||||
<link rel="stylesheet" href="/css/tailwind.css" asp-append-version="true" />
|
||||
<link rel="stylesheet" href="/css/site.css" asp-append-version="true" />
|
||||
<link rel="icon" href="/logo-mark.svg" type="image/svg+xml" asp-append-version="true" />
|
||||
</head>
|
||||
<body class="bg-base text-slate-200 antialiased">
|
||||
<body class="site 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>
|
||||
<!-- Reading progress (CSS scroll-driven) -->
|
||||
<div class="scroll-progress" aria-hidden="true"></div>
|
||||
|
||||
<!-- Sentinel for the navbar border (observed by IntersectionObserver) -->
|
||||
<div id="nav-sentinel" aria-hidden="true"></div>
|
||||
|
||||
<!-- Navbar -->
|
||||
<header id="navbar" class="fixed inset-x-0 top-0 z-50 transition-all duration-300">
|
||||
<div class="mx-auto flex max-w-7xl items-center justify-between px-5 py-4 sm:px-8">
|
||||
<header id="navbar" class="fixed inset-x-0 top-0 z-50">
|
||||
<div class="mx-auto flex max-w-6xl items-center justify-between px-5 py-3.5 sm:px-8">
|
||||
<!-- Logo -->
|
||||
<a href="/#top" class="flex items-center gap-2.5" aria-label="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" : "")">
|
||||
<a href="/#top" class="flex items-center gap-2.5" aria-label="@(locale == "fa" ? "خانه" : "Home")">
|
||||
<img src="/logo-mark.svg" alt="" width="26" height="26" class="h-[26px] w-[26px]" />
|
||||
<span class="font-display text-[15px] font-bold text-zinc-900 @(isRtl ? "font-fa" : "")">
|
||||
@(locale == "fa" ? "سروش اسعدی" : "Soroush Asadi")
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<!-- Desktop nav -->
|
||||
<nav class="hidden items-center gap-6 md:flex" aria-label="Main">
|
||||
<nav class="hidden items-center gap-7 md:flex" aria-label="Main">
|
||||
@if (locale == "fa")
|
||||
{
|
||||
<a href="/#services" class="nav-link">خدمات</a>
|
||||
@@ -104,7 +58,6 @@
|
||||
<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
|
||||
{
|
||||
@@ -113,31 +66,26 @@
|
||||
<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>
|
||||
<a href="/#contact" class="btn text-sm">@(locale == "fa" ? "رزرو جلسه" : "Book a call")</a>
|
||||
<!-- Locale toggle -->
|
||||
<form method="post" action="/locale">
|
||||
<input type="hidden" name="locale" value="@(locale == "fa" ? "en" : "fa")" />
|
||||
<input type="hidden" name="returnUrl" value="@Context.Request.Path@Context.Request.QueryString" />
|
||||
<button type="submit" class="label-mono text-slate-400 hover:text-white transition-colors">
|
||||
@(locale == "fa" ? "EN" : "FA")
|
||||
</button>
|
||||
<button type="submit" class="nav-link text-xs tracking-wide">@(locale == "fa" ? "EN" : "FA")</button>
|
||||
</form>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile menu button -->
|
||||
<button id="menu-btn" class="flex flex-col gap-1.5 p-2 md:hidden" aria-label="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 id="menu-btn" class="flex flex-col gap-1.5 p-2 md:hidden" aria-label="@(locale == "fa" ? "منو" : "Menu")" aria-expanded="false">
|
||||
<span class="block h-0.5 w-5 bg-zinc-800"></span>
|
||||
<span class="block h-0.5 w-5 bg-zinc-800"></span>
|
||||
<span class="block h-0.5 w-5 bg-zinc-800"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile drawer -->
|
||||
<div id="mobile-menu" class="hidden border-t border-white/5 bg-base-800/95 backdrop-blur-xl md:hidden">
|
||||
<div id="mobile-menu" class="hidden border-t border-zinc-200 bg-white/95 backdrop-blur-xl md:hidden">
|
||||
<nav class="flex flex-col gap-1 px-5 py-4">
|
||||
@if (locale == "fa")
|
||||
{
|
||||
@@ -160,9 +108,7 @@
|
||||
<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>
|
||||
<button type="submit" class="nav-link text-xs tracking-wide">@(locale == "fa" ? "Switch to English" : "تغییر به فارسی")</button>
|
||||
</form>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -172,8 +118,44 @@
|
||||
@RenderBody()
|
||||
</main>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="/js/app.js" defer></script>
|
||||
<!-- Footer (shared across all public pages) -->
|
||||
<footer class="site-footer mt-8 px-5 py-14 sm:px-8">
|
||||
<div class="mx-auto grid max-w-6xl grid-cols-1 gap-10 sm:grid-cols-2 lg:grid-cols-[1.5fr_1fr_1fr]">
|
||||
<div>
|
||||
<a href="/#top" class="flex items-center gap-2.5" aria-label="@(locale == "fa" ? "خانه" : "Home")">
|
||||
<img src="/logo-mark.svg" alt="" width="26" height="26" class="h-[26px] w-[26px]" />
|
||||
<span class="font-display text-[15px] font-bold text-zinc-900 @(isRtl ? "font-fa" : "")">@(locale == "fa" ? "سروش اسعدی" : "Soroush Asadi")</span>
|
||||
</a>
|
||||
<p class="mt-4 max-w-xs text-sm leading-relaxed text-zinc-600">@(fa ? "نرمافزار، اپلیکیشنهای سازمانی و راهکارهای هوش مصنوعی که در عمل کار میکنند." : "Software, enterprise apps, and AI solutions, engineered to last.")</p>
|
||||
<div class="mt-5 flex gap-2.5">
|
||||
<a class="social" href="https://www.linkedin.com/in/soroushdes/" target="_blank" rel="noopener" aria-label="LinkedIn"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5ZM3 9h4v12H3V9Zm6 0h3.8v1.64h.05c.53-1 1.83-2.06 3.76-2.06 4.02 0 4.76 2.65 4.76 6.1V21h-4v-5.4c0-1.29-.02-2.95-1.8-2.95-1.8 0-2.07 1.4-2.07 2.85V21H9V9Z"/></svg></a>
|
||||
<a class="social" href="https://www.instagram.com/soroushasadicom/" target="_blank" rel="noopener" aria-label="Instagram"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="5"/><circle cx="12" cy="12" r="4"/><circle cx="17.2" cy="6.8" r="1.1" fill="currentColor" stroke="none"/></svg></a>
|
||||
<a class="social" href="mailto:code.soroush@gmail.com" aria-label="Email"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></svg></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav aria-label="Footer" class="flex flex-col gap-2.5">
|
||||
<span class="kicker mb-1">@(fa ? "پیمایش" : "Navigate")</span>
|
||||
<a href="/#services" class="foot-link">@(fa ? "خدمات" : "Services")</a>
|
||||
<a href="/#stack" class="foot-link">@(fa ? "استک" : "Stack")</a>
|
||||
<a href="/#portfolio" class="foot-link">@(fa ? "نمونهکارها" : "Work")</a>
|
||||
<a href="/blog" class="foot-link">@(fa ? "بلاگ" : "Blog")</a>
|
||||
</nav>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<span class="kicker mb-1">@(fa ? "تماس" : "Get in touch")</span>
|
||||
<a href="/#contact" class="foot-link">@(fa ? "رزرو جلسه" : "Book a call")</a>
|
||||
<a href="mailto:code.soroush@gmail.com" class="foot-link">code.soroush@gmail.com</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto mt-12 flex max-w-6xl flex-col items-center gap-2 border-t border-zinc-200 pt-6 text-center sm:flex-row sm:justify-between sm:text-start">
|
||||
<p class="text-[.78rem] text-zinc-500">© 2026 Soroush Asadi. @(fa ? "تمام حقوق محفوظ است." : "All rights reserved.")</p>
|
||||
<p class="text-[.78rem] text-zinc-500">@(fa ? "ساختهشده با دقت در تهران." : "Built with care in Tehran.")</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="/js/app.js" defer asp-append-version="true"></script>
|
||||
@await RenderSectionAsync("Scripts", required: false)
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+23
-1
@@ -50,7 +50,29 @@ using (var scope = app.Services.CreateScope())
|
||||
}
|
||||
|
||||
app.UseStatusCodePagesWithReExecute("/Error/{0}");
|
||||
app.UseStaticFiles();
|
||||
|
||||
// HTML pages must never be cached by the browser or CDN, so a new deploy is
|
||||
// visible immediately. Static assets are fingerprinted via asp-append-version
|
||||
// (?v=hash), so they can be cached aggressively and bust automatically.
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
context.Response.OnStarting(() =>
|
||||
{
|
||||
if (context.Response.ContentType?.Contains("text/html", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
context.Response.Headers.CacheControl = "no-cache, no-store, must-revalidate";
|
||||
context.Response.Headers.Pragma = "no-cache";
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
await next();
|
||||
});
|
||||
|
||||
app.UseStaticFiles(new StaticFileOptions
|
||||
{
|
||||
OnPrepareResponse = ctx =>
|
||||
ctx.Context.Response.Headers.CacheControl = "public, max-age=31536000"
|
||||
});
|
||||
|
||||
// Serve uploaded files from /data/uploads under /uploads/*
|
||||
var uploadsPath = Path.Combine(dataDir, "uploads");
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<!-- Override the transitive SQLitePCLRaw 2.1.11 (GHSA-2m69-gcr7-jv3q, no 2.x patch)
|
||||
with the 3.0.x line, which is outside the vulnerable range (<= 2.1.11). -->
|
||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
+3
-1
@@ -11,8 +11,10 @@ services:
|
||||
image: soroushasadi-site:latest
|
||||
container_name: soroushasadi-site
|
||||
restart: unless-stopped
|
||||
# Host port 3020 → container 3000. Port 3000 on the host is Gitea
|
||||
# (git.soroushasadi.com proxies to :3000) — NEVER publish on 3000.
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "3020:3000"
|
||||
environment:
|
||||
ASPNETCORE_ENVIRONMENT: Production
|
||||
DataDir: /data
|
||||
|
||||
Generated
+1028
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "soroushasadi-web",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"description": "Tailwind CSS build for the SoroushAsadi public + admin UI (replaces the runtime Play CDN).",
|
||||
"scripts": {
|
||||
"build:css": "tailwindcss -c tailwind.config.js -i ./Styles/tailwind.css -o ./wwwroot/css/tailwind.css --minify",
|
||||
"build:css:admin": "tailwindcss -c tailwind.admin.config.js -i ./Styles/tailwind.css -o ./wwwroot/css/tailwind-admin.css --minify",
|
||||
"build": "npm run build:css && npm run build:css:admin",
|
||||
"watch:css": "tailwindcss -c tailwind.config.js -i ./Styles/tailwind.css -o ./wwwroot/css/tailwind.css --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tailwindcss": "^3.4.17"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
// Admin (dark) theme. Separate build so its flat custom colors do not collide
|
||||
// with the public site's use of built-in color scales (e.g. emerald-600).
|
||||
module.exports = {
|
||||
content: [
|
||||
'./Pages/Admin/**/*.cshtml',
|
||||
'./Pages/Shared/_AdminLayout.cshtml',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
base: { DEFAULT: '#020510', 800: '#050a1a' },
|
||||
electric: '#38bdf8',
|
||||
violet: '#818cf8',
|
||||
magenta: '#e879f9',
|
||||
emerald: '#34d399',
|
||||
},
|
||||
fontFamily: {
|
||||
display: ['Syne', 'system-ui', 'sans-serif'],
|
||||
sans: ['Syne', 'system-ui', 'sans-serif'],
|
||||
mono: ['SpaceMono', 'monospace'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
// Public site theme. Scoped away from the admin theme (which redefines
|
||||
// `emerald` as a flat color and would collide with the emerald-600 scale).
|
||||
module.exports = {
|
||||
content: [
|
||||
'./Pages/*.cshtml',
|
||||
'./Pages/Blog/**/*.cshtml',
|
||||
'./Pages/Shared/_Layout.cshtml',
|
||||
'./wwwroot/js/**/*.js',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
accent: '#2563eb',
|
||||
accentink: '#1d4ed8',
|
||||
},
|
||||
fontFamily: {
|
||||
display: ['Syne', 'system-ui', 'sans-serif'],
|
||||
fa: ['Vazirmatn', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
+230
-86
@@ -1,115 +1,257 @@
|
||||
/* ─── Design tokens ──────────────────────────────────────────────────── */
|
||||
/* ════════════════════════════════════════════════════════════════════════
|
||||
Minimal editorial theme, premium pass - light, single accent.
|
||||
Public styles scoped under `.site`; the dark admin (which shares this file
|
||||
via _AdminLayout) keeps its own dark classes at the bottom (.glass etc).
|
||||
Dials: VARIANCE 7 / MOTION 5 / DENSITY 3.
|
||||
════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
:root {
|
||||
--bg: #020510;
|
||||
--electric: #38bdf8;
|
||||
--violet: #818cf8;
|
||||
--magenta: #e879f9;
|
||||
--emerald: #34d399;
|
||||
--cyan: #22d3ee;
|
||||
--radius: 14px;
|
||||
color-scheme: dark;
|
||||
--bg: #fafafa;
|
||||
--surface: #ffffff;
|
||||
--text: #18181b; /* zinc-900 */
|
||||
--text-2: #52525b; /* zinc-600 */
|
||||
--text-3: #71717a; /* zinc-500 - meets WCAG AA 4.5:1 on the off-white bg */
|
||||
--line: #e4e4e7; /* zinc-200 */
|
||||
--line-strong: #d4d4d8; /* zinc-300 */
|
||||
--accent: #2563eb; /* blue-600 - the single accent */
|
||||
--accent-ink: #1d4ed8; /* blue-700 */
|
||||
--accent-weak: #eff4ff;
|
||||
--accent-line: #c7d2fe; /* indigo-200, for hover borders */
|
||||
--ok: #16a34a; /* availability indicator only */
|
||||
--radius: 8px;
|
||||
--nav-h: 4.25rem;
|
||||
}
|
||||
|
||||
html, body {
|
||||
background: var(--bg);
|
||||
font-feature-settings: 'ss01','cv11';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; background: var(--bg); }
|
||||
|
||||
::selection { background: #dbe5ff; color: #18181b; }
|
||||
|
||||
a:focus-visible, button:focus-visible,
|
||||
input:focus-visible, textarea:focus-visible, select:focus-visible {
|
||||
outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px;
|
||||
}
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
[dir='rtl'] body { font-family: 'Vazirmatn','VazirmatnLat','Syne',system-ui,sans-serif; }
|
||||
[dir='ltr'] body { font-family: 'Syne','Vazirmatn','VazirmatnLat',system-ui,sans-serif; }
|
||||
|
||||
::selection { background: rgba(56,189,248,.35); color: #f8fafc; }
|
||||
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-track { background: #050a1a; }
|
||||
::-webkit-scrollbar-thumb { background: linear-gradient(180deg,#38bdf8,#818cf8); border-radius:999px; border:2px solid #050a1a; }
|
||||
::-webkit-scrollbar-track { background: var(--bg); }
|
||||
::-webkit-scrollbar-thumb { background: var(--line-strong); border-radius: 999px; border: 3px solid var(--bg); }
|
||||
|
||||
/* ─── Component classes (used across Razor templates) ────────────────── */
|
||||
/* ─── Public base (scoped to .site) ──────────────────────────────────── */
|
||||
body.site {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Vazirmatn', sans-serif;
|
||||
font-size: 1rem; line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility;
|
||||
}
|
||||
[dir='rtl'] body.site { font-family: 'Vazirmatn', system-ui, sans-serif; }
|
||||
|
||||
.glass {
|
||||
background: linear-gradient(180deg,rgba(255,255,255,.04) 0%,rgba(255,255,255,.015) 100%);
|
||||
border: 1px solid rgba(56,189,248,.14);
|
||||
backdrop-filter: blur(14px);
|
||||
box-shadow: inset 0 1px 0 0 rgba(255,255,255,.06), 0 30px 60px -30px rgba(0,0,0,.6);
|
||||
border-radius: var(--radius);
|
||||
.site h1, .site h2, .site h3, .site h4 {
|
||||
font-family: 'Syne', system-ui, sans-serif;
|
||||
font-weight: 700; color: var(--text);
|
||||
letter-spacing: -0.02em; line-height: 1.12; text-wrap: balance;
|
||||
}
|
||||
[dir='rtl'] .site h1, [dir='rtl'] .site h2,
|
||||
[dir='rtl'] .site h3, [dir='rtl'] .site h4 {
|
||||
font-family: 'Vazirmatn', system-ui, sans-serif; letter-spacing: 0;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: .5rem;
|
||||
padding: .35rem .75rem; border-radius: 999px;
|
||||
border: 1px solid rgba(52,211,153,.25); background: rgba(52,211,153,.06);
|
||||
color: #a7f3d0;
|
||||
font-family: 'SpaceMono',ui-monospace,monospace;
|
||||
font-size: .72rem; letter-spacing: .04em; text-transform: uppercase;
|
||||
}
|
||||
/* anchor targets clear the fixed navbar */
|
||||
.site section[id] { scroll-margin-top: var(--nav-h); }
|
||||
|
||||
.label-mono {
|
||||
font-family: 'SpaceMono',ui-monospace,monospace;
|
||||
font-size: .7rem; letter-spacing: .16em; text-transform: uppercase; color: #94a3b8;
|
||||
}
|
||||
.accent-text { color: var(--accent); }
|
||||
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg,#38bdf8 0%,#818cf8 45%,#e879f9 100%);
|
||||
-webkit-background-clip: text; background-clip: text; color: transparent;
|
||||
background-size: 200% 200%; animation: gradient-pan 8s ease-in-out infinite;
|
||||
/* thin reading-progress bar (CSS scroll-driven; degrades to nothing) */
|
||||
.scroll-progress {
|
||||
position: fixed; inset: 0 0 auto 0; height: 2px; z-index: 60;
|
||||
background: var(--accent); transform-origin: left center; transform: scaleX(0);
|
||||
animation: sp-grow linear both; animation-timeline: scroll(root);
|
||||
}
|
||||
@keyframes gradient-pan { 0%,100%{background-position:0% 50%} 50%{background-position:100% 50%} }
|
||||
[dir='rtl'] .scroll-progress { transform-origin: right center; }
|
||||
@keyframes sp-grow { to { transform: scaleX(1); } }
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex; align-items: center; gap: .6rem;
|
||||
padding: .85rem 1.4rem; border-radius: 999px;
|
||||
font-weight: 600; color: #020510;
|
||||
background: linear-gradient(135deg,#38bdf8 0%,#818cf8 60%,#e879f9 100%);
|
||||
background-size: 200% 200%;
|
||||
transition: transform .25s ease, box-shadow .25s ease, background-position .6s ease;
|
||||
box-shadow: 0 12px 40px -12px rgba(56,189,248,.55);
|
||||
/* ─── Navigation ─────────────────────────────────────────────────────── */
|
||||
#navbar { transition: background .25s ease, border-color .25s ease; border-bottom: 1px solid transparent; }
|
||||
#navbar.scrolled { background: rgba(250,250,250,.82); backdrop-filter: blur(12px); border-bottom-color: var(--line); }
|
||||
|
||||
.nav-link { position: relative; font-size: .9rem; color: var(--text-2); transition: color .18s ease; }
|
||||
.nav-link:hover { color: var(--text); }
|
||||
.nav-link.active { color: var(--text); }
|
||||
.nav-link::after {
|
||||
content: ""; position: absolute; left: 0; right: 0; bottom: -6px; height: 1.5px;
|
||||
background: var(--text); transform: scaleX(0); transform-origin: left; transition: transform .25s ease;
|
||||
}
|
||||
.btn-primary:hover { transform: translateY(-1px); background-position: 100% 0; box-shadow: 0 18px 50px -10px rgba(232,121,249,.55); }
|
||||
[dir='rtl'] .nav-link::after { transform-origin: right; }
|
||||
.nav-link:hover::after, .nav-link.active::after { transform: scaleX(1); }
|
||||
|
||||
/* ─── Buttons (one shape: 8px radius, no pills, no glow) ──────────────── */
|
||||
.btn, .btn-primary {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: .5rem;
|
||||
padding: .72rem 1.2rem; border-radius: var(--radius);
|
||||
font-weight: 600; font-size: .92rem;
|
||||
background: var(--accent); color: #fff; border: 1px solid var(--accent);
|
||||
transition: background .18s ease, transform .12s ease, box-shadow .18s ease;
|
||||
}
|
||||
.btn:hover, .btn-primary:hover { background: var(--accent-ink); border-color: var(--accent-ink); box-shadow: 0 8px 24px -10px rgba(37,99,235,.5); }
|
||||
.btn:active, .btn-primary:active { transform: translateY(1px); }
|
||||
.btn svg { transition: transform .2s ease; }
|
||||
.btn:hover svg { transform: translateX(3px); }
|
||||
[dir='rtl'] .btn:hover svg { transform: translateX(-3px); }
|
||||
|
||||
.btn-ghost {
|
||||
display: inline-flex; align-items: center; gap: .6rem;
|
||||
padding: .8rem 1.35rem; border-radius: 999px;
|
||||
font-weight: 500; color: #e2e8f0;
|
||||
border: 1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.02);
|
||||
transition: border-color .25s ease, background .25s ease, transform .25s ease;
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: .5rem;
|
||||
padding: .72rem 1.2rem; border-radius: var(--radius);
|
||||
font-weight: 500; font-size: .92rem;
|
||||
color: var(--text); background: transparent; border: 1px solid var(--line-strong);
|
||||
transition: border-color .18s ease, background .18s ease, transform .12s ease;
|
||||
}
|
||||
.btn-ghost:hover { border-color: rgba(56,189,248,.6); background: rgba(56,189,248,.06); transform: translateY(-1px); }
|
||||
.btn-ghost:hover { border-color: var(--text); background: #fff; }
|
||||
.btn-ghost:active { transform: translateY(1px); }
|
||||
|
||||
.nav-link {
|
||||
font-size: .875rem; color: #94a3b8;
|
||||
transition: color .2s ease; text-decoration: none;
|
||||
/* pointer affordance + disabled semantics (ui-ux-pro-max: cursor-pointer, disabled-states) */
|
||||
.site button { cursor: pointer; }
|
||||
.site button:disabled { cursor: not-allowed; opacity: .55; }
|
||||
|
||||
/* ─── Availability status ────────────────────────────────────────────── */
|
||||
.status {
|
||||
display: inline-flex; align-items: center; gap: .5rem;
|
||||
font-size: .8rem; color: var(--text-2);
|
||||
border: 1px solid var(--line); background: #fff; border-radius: var(--radius); padding: .32rem .7rem;
|
||||
}
|
||||
.nav-link:hover { color: #e2e8f0; }
|
||||
.status .dot { position: relative; width: 7px; height: 7px; border-radius: 999px; background: var(--ok); }
|
||||
.status .dot::after { content: ""; position: absolute; inset: 0; border-radius: 999px; background: var(--ok); opacity: .55; animation: ping 1.9s cubic-bezier(0,0,.2,1) infinite; }
|
||||
@keyframes ping { 70%, 100% { transform: scale(2.4); opacity: 0; } }
|
||||
|
||||
#navbar.scrolled { background: rgba(2,5,16,.85); backdrop-filter: blur(20px); border-bottom: 1px solid rgba(255,255,255,.05); }
|
||||
/* ─── Social ─────────────────────────────────────────────────────────── */
|
||||
.social {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 44px; height: 44px; border: 1px solid var(--line-strong); border-radius: var(--radius);
|
||||
color: var(--text-2); transition: color .18s ease, border-color .18s ease, transform .18s ease;
|
||||
}
|
||||
.social:hover { color: var(--text); border-color: var(--text); transform: translateY(-2px); }
|
||||
.social svg { width: 18px; height: 18px; }
|
||||
|
||||
/* ─── Section header ─────────────────────────────────────────────── */
|
||||
.section-header { text-align: center; margin-bottom: 3.5rem; }
|
||||
.section-header .eyebrow { margin-bottom: 1rem; }
|
||||
.section-header h2 { font-family:'Syne',sans-serif; font-size:clamp(1.8rem,3.5vw,2.75rem); font-weight:800; color:#fff; margin-bottom:1rem; }
|
||||
[dir='rtl'] .section-header h2 { font-family:'Vazirmatn',sans-serif; }
|
||||
.section-header p { max-width:42rem; margin:0 auto; color:#94a3b8; font-size:1.0625rem; line-height:1.7; }
|
||||
/* ─── Type helpers ───────────────────────────────────────────────────── */
|
||||
.kicker { font-size: .76rem; letter-spacing: .14em; text-transform: uppercase; color: var(--text-3); font-weight: 500; }
|
||||
[dir='rtl'] .kicker { letter-spacing: .06em; }
|
||||
.lede { color: var(--text-2); font-size: 1.05rem; line-height: 1.7; max-width: 44rem; }
|
||||
|
||||
/* ─── Service cards ──────────────────────────────────────────────── */
|
||||
.service-card { transition: border-color .3s, box-shadow .3s, transform .3s; }
|
||||
.service-card:hover { transform: translateY(-2px); }
|
||||
.sec-head { margin-bottom: 3rem; max-width: 46rem; }
|
||||
.sec-head h2 { font-size: clamp(1.7rem, 3.2vw, 2.4rem); }
|
||||
.sec-head .lede { margin-top: .9rem; }
|
||||
|
||||
/* ─── Expertise bars ─────────────────────────────────────────────── */
|
||||
.bar-track { height: .375rem; background: rgba(255,255,255,.07); border-radius:999px; overflow:hidden; }
|
||||
.bar-fill { height: 100%; border-radius:999px; transform-origin:left; }
|
||||
[dir='rtl'] .bar-fill { transform-origin:right; }
|
||||
.arrow-link { display: inline-flex; align-items: center; gap: .4rem; color: var(--accent); font-weight: 500; font-size: .85rem; }
|
||||
.arrow-link svg { transition: transform .2s ease; }
|
||||
.arrow-link:hover svg, .group:hover .arrow-link svg { transform: translateX(3px); }
|
||||
[dir='rtl'] .arrow-link:hover svg, [dir='rtl'] .group:hover .arrow-link svg { transform: translateX(-3px); }
|
||||
|
||||
/* ─── Scroll-reveal ──────────────────────────────────────────────── */
|
||||
.reveal { opacity: 0; transform: translateY(24px); transition: opacity .7s cubic-bezier(.22,1,.36,1), transform .7s cubic-bezier(.22,1,.36,1); }
|
||||
/* ─── Neutral tag chip (one shape) ───────────────────────────────────── */
|
||||
.chip {
|
||||
display: inline-flex; align-items: center;
|
||||
border: 1px solid var(--line); border-radius: 6px;
|
||||
padding: .2rem .55rem; font-size: .72rem; color: var(--text-2);
|
||||
background: #fff; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ─── Service blocks (animated accent top-rule on hover) ─────────────── */
|
||||
.svc { position: relative; }
|
||||
.svc::before {
|
||||
content: ""; position: absolute; inset-block-start: -1px; inset-inline: 0; height: 2px;
|
||||
background: var(--accent); transform: scaleX(0); transform-origin: left; transition: transform .3s ease;
|
||||
}
|
||||
[dir='rtl'] .svc::before { transform-origin: right; }
|
||||
.svc:hover::before { transform: scaleX(1); }
|
||||
.svc:hover .svc-icon { color: var(--accent); }
|
||||
.svc-icon { transition: color .2s ease; }
|
||||
|
||||
/* ─── Surface card (portfolio, contact) ──────────────────────────────── */
|
||||
.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.card-link { transition: border-color .2s ease, transform .2s ease, box-shadow .2s ease; }
|
||||
.card-link:hover { border-color: var(--accent-line); transform: translateY(-3px); box-shadow: 0 16px 40px -24px rgba(24,24,27,.35); }
|
||||
.cover { overflow: hidden; }
|
||||
.cover > * { transition: transform .45s cubic-bezier(.22,1,.36,1); }
|
||||
.card-link:hover .cover > * { transform: scale(1.04); }
|
||||
|
||||
/* ─── Bento grid (Apple-style tiles, varied spans) ───────────────────── */
|
||||
.bento { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); grid-auto-rows: minmax(158px, auto); gap: 14px; }
|
||||
.tile {
|
||||
position: relative; display: flex; flex-direction: column;
|
||||
background: var(--surface); border: 1px solid var(--line); border-radius: 22px;
|
||||
padding: 1.5rem; overflow: hidden;
|
||||
transition: transform .22s cubic-bezier(.22,1,.36,1), box-shadow .22s ease, border-color .22s ease;
|
||||
}
|
||||
.tile-link { cursor: pointer; }
|
||||
.tile-link:hover { transform: translateY(-4px); box-shadow: 0 22px 48px -26px rgba(24,24,27,.4); border-color: var(--line-strong); }
|
||||
.span-2 { grid-column: span 2; } .span-3 { grid-column: span 3; } .span-4 { grid-column: span 4; }
|
||||
.row-2 { grid-row: span 2; }
|
||||
.tile-dark { background: #18181b; border-color: #18181b; }
|
||||
.tile-accent { background: var(--accent); border-color: var(--accent); }
|
||||
.tile-tint { background: var(--accent-weak); border-color: #dbe3ff; }
|
||||
.tile-icon { color: var(--accent); }
|
||||
.tile-dark .tile-icon, .tile-accent .tile-icon { color: #fff; }
|
||||
.tile-dark h1, .tile-dark h2, .tile-dark h3, .tile-accent h1, .tile-accent h2, .tile-accent h3 { color: #fafafa; }
|
||||
.t-sub { color: #d4d4d8; }
|
||||
.tile-accent .t-sub { color: rgba(255,255,255,.85); }
|
||||
.btn-on-dark { color: #fafafa; background: transparent; border-color: rgba(255,255,255,.28); }
|
||||
.btn-on-dark:hover { color: #fafafa; background: rgba(255,255,255,.08); border-color: #fafafa; }
|
||||
.pcover { display: flex; align-items: center; justify-content: center; }
|
||||
@media (max-width: 1023px) {
|
||||
.bento { grid-template-columns: repeat(2, minmax(0,1fr)); }
|
||||
.span-3, .span-4 { grid-column: span 2; }
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
.bento { grid-template-columns: 1fr; grid-auto-rows: auto; }
|
||||
.span-2, .span-3, .span-4 { grid-column: span 1; }
|
||||
.row-2 { grid-row: span 1; }
|
||||
}
|
||||
|
||||
/* ─── Form fields ────────────────────────────────────────────────────── */
|
||||
.flabel { display: block; font-size: .85rem; font-weight: 500; color: var(--text-2); margin-bottom: .4rem; }
|
||||
.field {
|
||||
width: 100%; font-family: inherit; font-size: .92rem; color: var(--text);
|
||||
background: #fff; border: 1px solid var(--line-strong); border-radius: var(--radius);
|
||||
padding: .7rem .85rem; transition: border-color .18s ease, box-shadow .18s ease;
|
||||
}
|
||||
.field::placeholder { color: var(--text-3); }
|
||||
.field:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
|
||||
|
||||
/* ─── Footer ─────────────────────────────────────────────────────────── */
|
||||
.site-footer { border-top: 1px solid var(--line); background: #fff; }
|
||||
.foot-link { color: var(--text-2); font-size: .9rem; transition: color .18s ease; }
|
||||
.foot-link:hover { color: var(--text); }
|
||||
|
||||
/* ─── Scroll reveal (MOTION 5: subtle, transform+opacity only) ───────── */
|
||||
.reveal { opacity: 0; transform: translateY(18px); transition: opacity .6s ease, transform .7s cubic-bezier(.22,1,.36,1); }
|
||||
.reveal.visible { opacity: 1; transform: none; }
|
||||
|
||||
/* ─── Portfolio modal ────────────────────────────────────────────── */
|
||||
#portfolio-modal { transition: opacity .25s ease; }
|
||||
#portfolio-modal.hidden { pointer-events: none; }
|
||||
/* ─── Blog prose ─────────────────────────────────────────────────────── */
|
||||
.prose-custom { color: var(--text-2); line-height: 1.8; font-size: 1.02rem; }
|
||||
.prose-custom h2, .prose-custom h3 { color: var(--text); font-family: 'Syne', system-ui, sans-serif; margin: 2rem 0 .6rem; }
|
||||
[dir='rtl'] .prose-custom h2, [dir='rtl'] .prose-custom h3 { font-family: 'Vazirmatn', system-ui, sans-serif; }
|
||||
.prose-custom p { margin-bottom: 1.2rem; }
|
||||
.prose-custom a { color: var(--accent); text-decoration: underline; text-underline-offset: .2em; }
|
||||
.prose-custom strong { color: var(--text); }
|
||||
.prose-custom code { font-family: ui-monospace, 'SFMono-Regular', Menlo, monospace; background: #f4f4f5; border: 1px solid var(--line); border-radius: 5px; padding: .12em .4em; font-size: .88em; color: #3f3f46; }
|
||||
.prose-custom pre { background: #f4f4f5; border: 1px solid var(--line); border-radius: 8px; padding: 1.1rem; overflow-x: auto; margin: 1.4rem 0; }
|
||||
.prose-custom pre code { background: none; border: 0; padding: 0; color: #27272a; }
|
||||
.prose-custom ul, .prose-custom ol { padding-inline-start: 1.4rem; margin-bottom: 1.2rem; }
|
||||
.prose-custom li { margin-bottom: .4rem; }
|
||||
.prose-custom blockquote { border-inline-start: 3px solid var(--accent); padding-inline-start: 1rem; color: var(--text-2); margin: 1.4rem 0; }
|
||||
|
||||
/* ─── Admin nav ──────────────────────────────────────────────────── */
|
||||
/* ════════════════════════════════════════════════════════════════════════
|
||||
Admin (dark) - preserved. _AdminLayout has its own dark Tailwind config;
|
||||
these classes keep working on its dark surface. Do not light-theme these.
|
||||
════════════════════════════════════════════════════════════════════════ */
|
||||
.glass {
|
||||
background: linear-gradient(180deg, rgba(255,255,255,.04), rgba(255,255,255,.015));
|
||||
border: 1px solid rgba(255,255,255,.10);
|
||||
border-radius: 14px;
|
||||
box-shadow: inset 0 1px 0 0 rgba(255,255,255,.05);
|
||||
}
|
||||
.label-mono {
|
||||
font-family: 'SpaceMono', ui-monospace, monospace;
|
||||
font-size: .7rem; letter-spacing: .16em; text-transform: uppercase; color: #94a3b8;
|
||||
}
|
||||
.admin-nav-link {
|
||||
display: block; padding: .5rem .75rem; border-radius: .5rem;
|
||||
color: #94a3b8; font-size: .875rem; text-decoration: none;
|
||||
@@ -118,10 +260,12 @@ html { scroll-behavior: smooth; }
|
||||
.admin-nav-link:hover { background: rgba(255,255,255,.05); color: #e2e8f0; }
|
||||
.admin-nav-link.active { background: rgba(56,189,248,.1); color: #38bdf8; }
|
||||
|
||||
/* ─── Reduced motion ─────────────────────────────────────────────── */
|
||||
/* ─── Reduced motion ─────────────────────────────────────────────────── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,*::before,*::after {
|
||||
*, *::before, *::after {
|
||||
animation-duration: .001ms !important; animation-iteration-count: 1 !important;
|
||||
transition-duration: .001ms !important; scroll-behavior: auto !important;
|
||||
}
|
||||
.reveal { opacity: 1 !important; transform: none !important; }
|
||||
.scroll-progress { display: none; }
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+60
-215
@@ -1,257 +1,102 @@
|
||||
/* ─── Custom cursor ───────────────────────────────────────────────────── */
|
||||
(function () {
|
||||
const ring = document.getElementById('cursor');
|
||||
const dot = document.getElementById('cursor-dot');
|
||||
if (!ring || !dot) return;
|
||||
let mx = 0, my = 0, rx = 0, ry = 0;
|
||||
document.addEventListener('mousemove', e => { mx = e.clientX; my = e.clientY; });
|
||||
document.addEventListener('mouseenter', () => { ring.style.opacity = '1'; dot.style.opacity = '1'; });
|
||||
document.addEventListener('mouseleave', () => { ring.style.opacity = '0'; dot.style.opacity = '0'; });
|
||||
(function raf() {
|
||||
rx += (mx - rx) * 0.12; ry += (my - ry) * 0.12;
|
||||
ring.style.left = rx + 'px'; ring.style.top = ry + 'px';
|
||||
dot.style.left = mx + 'px'; dot.style.top = my + 'px';
|
||||
requestAnimationFrame(raf);
|
||||
})();
|
||||
document.querySelectorAll('a,button,label,input,textarea,select').forEach(el => {
|
||||
el.addEventListener('mouseenter', () => ring.style.transform = 'translate(-50%,-50%) scale(1.6)');
|
||||
el.addEventListener('mouseleave', () => ring.style.transform = 'translate(-50%,-50%) scale(1)');
|
||||
});
|
||||
})();
|
||||
/* ════════════════════════════════════════════════════════════════════════
|
||||
Minimal interactions. MOTION_INTENSITY 3: hover + a single subtle reveal.
|
||||
No custom cursor, no particle canvas, no typewriter, no scroll listeners.
|
||||
════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ─── Navbar scroll shadow ────────────────────────────────────────────── */
|
||||
/* ─── Navbar border on scroll (IntersectionObserver, not a scroll listener) */
|
||||
(function () {
|
||||
const nav = document.getElementById('navbar');
|
||||
if (!nav) return;
|
||||
const update = () => nav.classList.toggle('scrolled', window.scrollY > 30);
|
||||
window.addEventListener('scroll', update, { passive: true });
|
||||
update();
|
||||
const sentinel = document.getElementById('nav-sentinel');
|
||||
if (!nav || !sentinel) return;
|
||||
const io = new IntersectionObserver(
|
||||
([entry]) => nav.classList.toggle('scrolled', !entry.isIntersecting),
|
||||
{ threshold: 0 }
|
||||
);
|
||||
io.observe(sentinel);
|
||||
})();
|
||||
|
||||
/* ─── Mobile menu toggle ──────────────────────────────────────────────── */
|
||||
/* ─── Scrollspy: highlight the nav link for the section in view ───────── */
|
||||
(function () {
|
||||
const links = Array.from(document.querySelectorAll('#navbar a[href*="#"]'));
|
||||
if (!links.length || !('IntersectionObserver' in window)) return;
|
||||
const map = {};
|
||||
links.forEach((a) => {
|
||||
const id = (a.getAttribute('href') || '').split('#')[1];
|
||||
if (id) (map[id] = map[id] || []).push(a);
|
||||
});
|
||||
const sections = Object.keys(map)
|
||||
.map((id) => document.getElementById(id))
|
||||
.filter(Boolean);
|
||||
if (!sections.length) return;
|
||||
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
entries.forEach((e) => {
|
||||
if (!e.isIntersecting) return;
|
||||
links.forEach((a) => a.classList.remove('active'));
|
||||
(map[e.target.id] || []).forEach((a) => a.classList.add('active'));
|
||||
});
|
||||
}, { rootMargin: '-45% 0px -50% 0px', threshold: 0 });
|
||||
sections.forEach((s) => io.observe(s));
|
||||
})();
|
||||
|
||||
/* ─── Mobile menu toggle ─────────────────────────────────────────────── */
|
||||
(function () {
|
||||
const btn = document.getElementById('menu-btn');
|
||||
const menu = document.getElementById('mobile-menu');
|
||||
if (!btn || !menu) return;
|
||||
btn.addEventListener('click', () => {
|
||||
const open = menu.classList.toggle('hidden');
|
||||
btn.setAttribute('aria-expanded', String(!open));
|
||||
const hidden = menu.classList.toggle('hidden');
|
||||
btn.setAttribute('aria-expanded', String(!hidden));
|
||||
});
|
||||
menu.querySelectorAll('a').forEach(a => a.addEventListener('click', () => menu.classList.add('hidden')));
|
||||
})();
|
||||
|
||||
/* ─── Scroll-reveal (Intersection Observer) ───────────────────────────── */
|
||||
/* ─── Scroll reveal (one subtle entry per element, then unobserve) ────── */
|
||||
(function () {
|
||||
const els = document.querySelectorAll('.reveal');
|
||||
if (!els.length) return;
|
||||
if (!('IntersectionObserver' in window)) {
|
||||
els.forEach(el => el.classList.add('visible'));
|
||||
return;
|
||||
}
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
entries.forEach(e => { if (e.isIntersecting) { e.target.classList.add('visible'); io.unobserve(e.target); } });
|
||||
}, { threshold: 0.12 });
|
||||
document.querySelectorAll('.reveal').forEach(el => io.observe(el));
|
||||
})();
|
||||
|
||||
/* ─── Particle canvas (hero background) ──────────────────────────────── */
|
||||
(function () {
|
||||
const canvas = document.getElementById('particle-canvas');
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
let W, H, particles = [], animId;
|
||||
|
||||
function resize() {
|
||||
W = canvas.width = canvas.offsetWidth;
|
||||
H = canvas.height = canvas.offsetHeight;
|
||||
}
|
||||
|
||||
function makeParticle() {
|
||||
return {
|
||||
x: Math.random() * W, y: Math.random() * H,
|
||||
vx: (Math.random() - 0.5) * 0.3, vy: (Math.random() - 0.5) * 0.3,
|
||||
r: Math.random() * 1.5 + 0.5,
|
||||
a: Math.random() * 0.5 + 0.1
|
||||
};
|
||||
}
|
||||
|
||||
function init() { particles = Array.from({ length: 90 }, makeParticle); }
|
||||
|
||||
function draw() {
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
const MAX_DIST = 120;
|
||||
for (let i = 0; i < particles.length; i++) {
|
||||
const p = particles[i];
|
||||
p.x += p.vx; p.y += p.vy;
|
||||
if (p.x < 0) p.x = W; if (p.x > W) p.x = 0;
|
||||
if (p.y < 0) p.y = H; if (p.y > H) p.y = 0;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = `rgba(56,189,248,${p.a})`;
|
||||
ctx.fill();
|
||||
|
||||
for (let j = i + 1; j < particles.length; j++) {
|
||||
const q = particles[j];
|
||||
const dx = p.x - q.x, dy = p.y - q.y;
|
||||
const d = Math.sqrt(dx * dx + dy * dy);
|
||||
if (d < MAX_DIST) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p.x, p.y); ctx.lineTo(q.x, q.y);
|
||||
ctx.strokeStyle = `rgba(56,189,248,${(1 - d / MAX_DIST) * 0.15})`;
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
animId = requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
resize(); init(); draw();
|
||||
window.addEventListener('resize', () => { resize(); });
|
||||
|
||||
// Pause when hidden
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) { cancelAnimationFrame(animId); } else { draw(); }
|
||||
});
|
||||
})();
|
||||
|
||||
/* ─── Typewriter ──────────────────────────────────────────────────────── */
|
||||
(function () {
|
||||
const el = document.getElementById('typewriter');
|
||||
if (!el) return;
|
||||
const words = JSON.parse(el.dataset.words || '[]');
|
||||
if (!words.length) return;
|
||||
let wi = 0, ci = 0, deleting = false;
|
||||
|
||||
function tick() {
|
||||
const word = words[wi];
|
||||
if (!deleting) {
|
||||
el.textContent = word.slice(0, ++ci);
|
||||
if (ci === word.length) { setTimeout(tick, 1800); deleting = true; return; }
|
||||
setTimeout(tick, 80);
|
||||
} else {
|
||||
el.textContent = word.slice(0, --ci);
|
||||
if (ci === 0) { deleting = false; wi = (wi + 1) % words.length; setTimeout(tick, 300); return; }
|
||||
setTimeout(tick, 40);
|
||||
}
|
||||
}
|
||||
tick();
|
||||
})();
|
||||
|
||||
/* ─── Animated counters ───────────────────────────────────────────────── */
|
||||
(function () {
|
||||
const io = new IntersectionObserver(entries => {
|
||||
entries.forEach(e => {
|
||||
if (!e.isIntersecting) return;
|
||||
const el = e.target;
|
||||
const target = parseInt(el.dataset.target || '0', 10);
|
||||
const duration = 1200;
|
||||
const start = performance.now();
|
||||
function step(now) {
|
||||
const t = Math.min((now - start) / duration, 1);
|
||||
const ease = 1 - Math.pow(1 - t, 3);
|
||||
el.textContent = el.dataset.prefix + Math.round(target * ease) + (el.dataset.suffix || '');
|
||||
if (t < 1) requestAnimationFrame(step);
|
||||
}
|
||||
requestAnimationFrame(step);
|
||||
io.unobserve(el);
|
||||
if (e.isIntersecting) { e.target.classList.add('visible'); io.unobserve(e.target); }
|
||||
});
|
||||
}, { threshold: 0.5 });
|
||||
document.querySelectorAll('.counter').forEach(el => io.observe(el));
|
||||
}, { threshold: 0.12, rootMargin: '0px 0px -8% 0px' });
|
||||
els.forEach(el => io.observe(el));
|
||||
})();
|
||||
|
||||
/* ─── Expertise bars (animate on scroll) ─────────────────────────────── */
|
||||
(function () {
|
||||
const io = new IntersectionObserver(entries => {
|
||||
entries.forEach(e => {
|
||||
if (!e.isIntersecting) return;
|
||||
const fill = e.target.querySelector('.bar-fill');
|
||||
if (fill) { fill.style.width = fill.dataset.w; }
|
||||
io.unobserve(e.target);
|
||||
});
|
||||
}, { threshold: 0.3 });
|
||||
document.querySelectorAll('.bar-track').forEach(el => io.observe(el));
|
||||
})();
|
||||
|
||||
/* ─── Portfolio modal ─────────────────────────────────────────────────── */
|
||||
(function () {
|
||||
const modal = document.getElementById('portfolio-modal');
|
||||
const overlay = document.getElementById('modal-overlay');
|
||||
if (!modal) return;
|
||||
|
||||
let images = [], idx = 0;
|
||||
|
||||
const imgEl = document.getElementById('modal-img');
|
||||
const titleEl = document.getElementById('modal-title');
|
||||
const bodyEl = document.getElementById('modal-body');
|
||||
const prevBtn = document.getElementById('modal-prev');
|
||||
const nextBtn = document.getElementById('modal-next');
|
||||
const closeBtn = document.getElementById('modal-close');
|
||||
|
||||
function showModal(card) {
|
||||
images = JSON.parse(card.dataset.gallery || '[]');
|
||||
idx = 0;
|
||||
if (titleEl) titleEl.textContent = card.dataset.title || '';
|
||||
if (bodyEl) bodyEl.innerHTML = card.dataset.summary || '';
|
||||
updateImg();
|
||||
modal.classList.remove('hidden');
|
||||
modal.style.opacity = '0';
|
||||
requestAnimationFrame(() => { modal.style.opacity = '1'; });
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function hideModal() {
|
||||
modal.style.opacity = '0';
|
||||
setTimeout(() => { modal.classList.add('hidden'); document.body.style.overflow = ''; }, 250);
|
||||
}
|
||||
|
||||
function updateImg() {
|
||||
if (!imgEl) return;
|
||||
imgEl.src = images[idx] || '';
|
||||
if (prevBtn) prevBtn.disabled = idx === 0;
|
||||
if (nextBtn) nextBtn.disabled = idx === images.length - 1;
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-portfolio-card]').forEach(card => {
|
||||
card.addEventListener('click', () => showModal(card));
|
||||
card.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); showModal(card); } });
|
||||
});
|
||||
|
||||
if (closeBtn) closeBtn.addEventListener('click', hideModal);
|
||||
if (overlay) overlay.addEventListener('click', hideModal);
|
||||
if (prevBtn) prevBtn.addEventListener('click', () => { if (idx > 0) { idx--; updateImg(); } });
|
||||
if (nextBtn) nextBtn.addEventListener('click', () => { if (idx < images.length - 1) { idx++; updateImg(); } });
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (modal.classList.contains('hidden')) return;
|
||||
if (e.key === 'Escape') hideModal();
|
||||
if (e.key === 'ArrowLeft') { if (idx > 0) { idx--; updateImg(); } }
|
||||
if (e.key === 'ArrowRight') { if (idx < images.length - 1) { idx++; updateImg(); } }
|
||||
});
|
||||
})();
|
||||
|
||||
/* ─── Contact form (AJAX) ─────────────────────────────────────────────── */
|
||||
/* ─── Contact form (AJAX, JSON → /contact) ───────────────────────────── */
|
||||
(function () {
|
||||
const form = document.getElementById('contact-form');
|
||||
if (!form) return;
|
||||
const status = document.getElementById('contact-status');
|
||||
|
||||
form.addEventListener('submit', async e => {
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const data = Object.fromEntries(new FormData(form));
|
||||
const btn = form.querySelector('[type="submit"]');
|
||||
btn.disabled = true;
|
||||
if (btn) btn.disabled = true;
|
||||
|
||||
const setStatus = (msg, cls) => { if (status) { status.textContent = msg; status.className = 'mt-1 text-sm ' + cls; } };
|
||||
|
||||
try {
|
||||
const res = await fetch('/contact', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (res.ok) {
|
||||
if (status) { status.textContent = form.dataset.successMsg || 'Sent!'; status.className = 'mt-3 text-sm text-emerald-400'; }
|
||||
setStatus(form.dataset.successMsg || 'Sent.', 'text-emerald-600');
|
||||
form.reset();
|
||||
} else {
|
||||
if (status) { status.textContent = form.dataset.errorMsg || 'Something went wrong.'; status.className = 'mt-3 text-sm text-red-400'; }
|
||||
setStatus(form.dataset.errorMsg || 'Something went wrong.', 'text-red-600');
|
||||
}
|
||||
} catch {
|
||||
if (status) { status.textContent = form.dataset.errorMsg || 'Network error.'; status.className = 'mt-3 text-sm text-red-400'; }
|
||||
setStatus(form.dataset.errorMsg || 'Network error.', 'text-red-600');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100" role="img" aria-label="Soroush Asadi">
|
||||
<rect width="100" height="100" rx="23" fill="#18181b"/>
|
||||
<path d="M70 33 C70 22 57 19 47 19 C33 19 28 28 28 36 C28 45 38 49 50 51 C62 53 72 57 72 66 C72 76 60 82 47 82 C35 82 29 78 27 70" fill="none" stroke="#fafafa" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="20" cy="78" r="7.5" fill="#2563eb"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 471 B |
Reference in New Issue
Block a user