From 1b3a8b493e6186640855455efdf92b1ad94e7076 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Mon, 1 Jun 2026 07:46:56 +0330 Subject: [PATCH] =?UTF-8?q?Rewrite:=20Next.js=20=E2=86=92=20ASP.NET=20Core?= =?UTF-8?q?=2010=20Razor=20Pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full rewrite of the portfolio site from Next.js 14 to .NET 10: - ASP.NET Core 10 Razor Pages, no Node.js dependency - EF Core 10 + SQLite (same schema as before — data survives upgrade) - Cookie authentication (same single-password model) - Resend contact form via HttpClient - Bilingual FA/EN via locale cookie + BasePageModel - All UI ported to Razor Pages with Tailwind CDN + custom CSS - Vanilla JS: particles, typewriter, cursor, animations, portfolio modal - Dockerfile: SDK 10.0-alpine → aspnet 10.0-alpine (no npm/Node needed) - CI/CD: dropped NPM_TOKEN, ADMIN_SESSION_SECRET — pure dotnet publish Co-Authored-By: Claude Sonnet 4.6 --- .dockerignore | 22 +- .gitea/workflows/ci.yml | 8 +- .gitea/workflows/deploy.yml | 18 +- .gitignore | 57 +- .npmrc | 13 - Dockerfile | 76 +- Models/ContentSection.cs | 13 + Pages/Admin/Index.cshtml | 26 + Pages/Admin/Index.cshtml.cs | 11 + Pages/Admin/Login.cshtml | 29 + Pages/Admin/Login.cshtml.cs | 33 + Pages/Admin/Logout.cshtml | 2 + Pages/Admin/Logout.cshtml.cs | 16 + Pages/Admin/Posts/Edit.cshtml | 35 + Pages/Admin/Posts/Edit.cshtml.cs | 71 + Pages/Admin/Posts/Index.cshtml | 29 + Pages/Admin/Posts/Index.cshtml.cs | 13 + Pages/Admin/Sections/Edit.cshtml | 29 + Pages/Admin/Sections/Edit.cshtml.cs | 43 + Pages/Admin/Sections/Index.cshtml | 37 + Pages/Admin/Sections/Index.cshtml.cs | 19 + Pages/BasePageModel.cs | 17 + Pages/Blog/Index.cshtml | 34 + Pages/Blog/Index.cshtml.cs | 28 + Pages/Blog/Post.cshtml | 48 + Pages/Blog/Post.cshtml.cs | 216 + Pages/Contact.cshtml | 2 + Pages/Contact.cshtml.cs | 30 + Pages/Index.cshtml | 550 ++ Pages/Index.cshtml.cs | 34 + Pages/LocalePage.cshtml | 2 + Pages/LocalePage.cshtml.cs | 25 + Pages/Shared/_AdminLayout.cshtml | 56 + Pages/Shared/_Layout.cshtml | 179 + Pages/_ViewImports.cshtml | 5 + Pages/_ViewStart.cshtml | 3 + Program.cs | 68 + Services/AuthService.cs | 37 + Services/ContentService.cs | 107 + Services/EmailService.cs | 69 + SoroushAsadi.Web.csproj | 18 + app/(admin)/admin/login/page.tsx | 91 - app/(admin)/admin/page.tsx | 95 - app/(admin)/admin/posts/[slug]/page.tsx | 51 - app/(admin)/admin/posts/page.tsx | 74 - app/(admin)/admin/sections/[key]/page.tsx | 50 - app/(admin)/layout.tsx | 16 - app/(site)/blog/[slug]/page.tsx | 46 - app/(site)/layout.tsx | 38 - app/(site)/page.tsx | 25 - app/(site)/services/[slug]/page.tsx | 84 - app/api/admin/login/route.ts | 35 - app/api/admin/logout/route.ts | 16 - app/api/admin/posts/route.ts | 97 - app/api/admin/section/route.ts | 49 - app/api/admin/upload/route.ts | 44 - app/api/contact/route.ts | 99 - app/api/uploads/[...path]/route.ts | 45 - app/globals.css | 182 - app/icon.svg | 13 - app/layout.tsx | 96 - appsettings.Production.json | 9 + appsettings.json | 10 + components/admin/AdminShell.tsx | 156 - components/admin/JsonForm.tsx | 308 - components/admin/PostEditor.tsx | 165 - components/admin/SectionEditor.tsx | 123 - components/blog/BlogArticle.tsx | 178 - components/hero/Hero.tsx | 188 - components/hero/ParticleCanvas.tsx | 175 - components/hero/Typewriter.tsx | 69 - components/nav/LanguageToggle.tsx | 51 - components/nav/Navbar.tsx | 164 - components/sections/Blog.tsx | 108 - components/sections/Contact.tsx | 171 - components/sections/DataFlow.tsx | 194 - components/sections/Expertise.tsx | 102 - components/sections/Footer.tsx | 29 - components/sections/Portfolio.tsx | 407 - components/sections/ServiceIcon.tsx | 88 - components/sections/Services.tsx | 221 - components/sections/Stack.tsx | 124 - components/sections/StackCanvas.tsx | 259 - components/ui/Counter.tsx | 99 - components/ui/CustomCursor.tsx | 147 - components/ui/SectionHeader.tsx | 50 - docker-compose.yml | 22 +- lib/auth/session.ts | 112 - lib/content/load.ts | 57 - lib/content/posts-store.ts | 45 - lib/content/posts.ts | 294 - lib/content/sections.ts | 31 - lib/db/store.ts | 105 - lib/i18n/dictionaries.ts | 855 -- lib/i18n/locale-context.tsx | 95 - lib/utils.ts | 6 - middleware.ts | 39 - next.config.mjs | 16 - package-lock.json | 7165 ----------------- package.json | 37 - postcss.config.mjs | 6 - scripts/gen-portfolio-art.mjs | 117 - tailwind.config.ts | 93 - tsconfig.json | 23 - wwwroot/css/site.css | 127 + {app => wwwroot}/fonts/SpaceMono-Bold.woff2 | Bin .../fonts/SpaceMono-Regular.woff2 | Bin {app => wwwroot}/fonts/Syne-Variable.woff2 | Bin {app => wwwroot}/fonts/Vazirmatn-Arabic.woff2 | Bin {app => wwwroot}/fonts/Vazirmatn-Latin.woff2 | Bin wwwroot/js/app.js | 257 + 111 files changed, 2409 insertions(+), 14062 deletions(-) delete mode 100644 .npmrc create mode 100644 Models/ContentSection.cs create mode 100644 Pages/Admin/Index.cshtml create mode 100644 Pages/Admin/Index.cshtml.cs create mode 100644 Pages/Admin/Login.cshtml create mode 100644 Pages/Admin/Login.cshtml.cs create mode 100644 Pages/Admin/Logout.cshtml create mode 100644 Pages/Admin/Logout.cshtml.cs create mode 100644 Pages/Admin/Posts/Edit.cshtml create mode 100644 Pages/Admin/Posts/Edit.cshtml.cs create mode 100644 Pages/Admin/Posts/Index.cshtml create mode 100644 Pages/Admin/Posts/Index.cshtml.cs create mode 100644 Pages/Admin/Sections/Edit.cshtml create mode 100644 Pages/Admin/Sections/Edit.cshtml.cs create mode 100644 Pages/Admin/Sections/Index.cshtml create mode 100644 Pages/Admin/Sections/Index.cshtml.cs create mode 100644 Pages/BasePageModel.cs create mode 100644 Pages/Blog/Index.cshtml create mode 100644 Pages/Blog/Index.cshtml.cs create mode 100644 Pages/Blog/Post.cshtml create mode 100644 Pages/Blog/Post.cshtml.cs create mode 100644 Pages/Contact.cshtml create mode 100644 Pages/Contact.cshtml.cs create mode 100644 Pages/Index.cshtml create mode 100644 Pages/Index.cshtml.cs create mode 100644 Pages/LocalePage.cshtml create mode 100644 Pages/LocalePage.cshtml.cs create mode 100644 Pages/Shared/_AdminLayout.cshtml create mode 100644 Pages/Shared/_Layout.cshtml create mode 100644 Pages/_ViewImports.cshtml create mode 100644 Pages/_ViewStart.cshtml create mode 100644 Program.cs create mode 100644 Services/AuthService.cs create mode 100644 Services/ContentService.cs create mode 100644 Services/EmailService.cs create mode 100644 SoroushAsadi.Web.csproj delete mode 100644 app/(admin)/admin/login/page.tsx delete mode 100644 app/(admin)/admin/page.tsx delete mode 100644 app/(admin)/admin/posts/[slug]/page.tsx delete mode 100644 app/(admin)/admin/posts/page.tsx delete mode 100644 app/(admin)/admin/sections/[key]/page.tsx delete mode 100644 app/(admin)/layout.tsx delete mode 100644 app/(site)/blog/[slug]/page.tsx delete mode 100644 app/(site)/layout.tsx delete mode 100644 app/(site)/page.tsx delete mode 100644 app/(site)/services/[slug]/page.tsx delete mode 100644 app/api/admin/login/route.ts delete mode 100644 app/api/admin/logout/route.ts delete mode 100644 app/api/admin/posts/route.ts delete mode 100644 app/api/admin/section/route.ts delete mode 100644 app/api/admin/upload/route.ts delete mode 100644 app/api/contact/route.ts delete mode 100644 app/api/uploads/[...path]/route.ts delete mode 100644 app/globals.css delete mode 100644 app/icon.svg delete mode 100644 app/layout.tsx create mode 100644 appsettings.Production.json create mode 100644 appsettings.json delete mode 100644 components/admin/AdminShell.tsx delete mode 100644 components/admin/JsonForm.tsx delete mode 100644 components/admin/PostEditor.tsx delete mode 100644 components/admin/SectionEditor.tsx delete mode 100644 components/blog/BlogArticle.tsx delete mode 100644 components/hero/Hero.tsx delete mode 100644 components/hero/ParticleCanvas.tsx delete mode 100644 components/hero/Typewriter.tsx delete mode 100644 components/nav/LanguageToggle.tsx delete mode 100644 components/nav/Navbar.tsx delete mode 100644 components/sections/Blog.tsx delete mode 100644 components/sections/Contact.tsx delete mode 100644 components/sections/DataFlow.tsx delete mode 100644 components/sections/Expertise.tsx delete mode 100644 components/sections/Footer.tsx delete mode 100644 components/sections/Portfolio.tsx delete mode 100644 components/sections/ServiceIcon.tsx delete mode 100644 components/sections/Services.tsx delete mode 100644 components/sections/Stack.tsx delete mode 100644 components/sections/StackCanvas.tsx delete mode 100644 components/ui/Counter.tsx delete mode 100644 components/ui/CustomCursor.tsx delete mode 100644 components/ui/SectionHeader.tsx delete mode 100644 lib/auth/session.ts delete mode 100644 lib/content/load.ts delete mode 100644 lib/content/posts-store.ts delete mode 100644 lib/content/posts.ts delete mode 100644 lib/content/sections.ts delete mode 100644 lib/db/store.ts delete mode 100644 lib/i18n/dictionaries.ts delete mode 100644 lib/i18n/locale-context.tsx delete mode 100644 lib/utils.ts delete mode 100644 middleware.ts delete mode 100644 next.config.mjs delete mode 100644 package-lock.json delete mode 100644 package.json delete mode 100644 postcss.config.mjs delete mode 100644 scripts/gen-portfolio-art.mjs delete mode 100644 tailwind.config.ts delete mode 100644 tsconfig.json create mode 100644 wwwroot/css/site.css rename {app => wwwroot}/fonts/SpaceMono-Bold.woff2 (100%) rename {app => wwwroot}/fonts/SpaceMono-Regular.woff2 (100%) rename {app => wwwroot}/fonts/Syne-Variable.woff2 (100%) rename {app => wwwroot}/fonts/Vazirmatn-Arabic.woff2 (100%) rename {app => wwwroot}/fonts/Vazirmatn-Latin.woff2 (100%) create mode 100644 wwwroot/js/app.js diff --git a/.dockerignore b/.dockerignore index 50bf606..851f22d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,14 +1,14 @@ -node_modules -.next -.git -.gitea -data -npm-debug.log* -.env*.local +bin/ +obj/ +data/ +.git/ +.gitea/ +.claude/ +.vs/ +.vscode/ +.idea/ .env -.DS_Store -*.tsbuildinfo -README.md +.env.local +*.user Dockerfile -.dockerignore docker-compose.yml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 20d8780..79921d8 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -29,11 +29,5 @@ jobs: git checkout FETCH_HEAD - name: Docker Build Test - env: - NODE_IMAGE: mirror.soroushasadi.com/node:20-alpine - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: | - docker build \ - --build-arg NODE_IMAGE="$NODE_IMAGE" \ - --build-arg NPM_TOKEN="$NPM_TOKEN" \ - -t soroushasadi-site:test . \ No newline at end of file + docker build -t soroushasadi-site:test . diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 4651094..f940404 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -32,45 +32,33 @@ jobs: run: | cat > .env << EOF ADMIN_PASSWORD=${{ secrets.ADMIN_PASSWORD }} - ADMIN_SESSION_SECRET=${{ secrets.ADMIN_SESSION_SECRET }} RESEND_API_KEY=${{ secrets.RESEND_API_KEY }} CONTACT_INBOX=${{ secrets.CONTACT_INBOX }} CONTACT_FROM=${{ secrets.CONTACT_FROM }} - NPM_TOKEN=${{ secrets.NPM_TOKEN }} EOF - name: Build Container - env: - NODE_IMAGE: mirror.soroushasadi.com/node:20-alpine - run: | - docker compose build + run: docker compose build - name: Deploy - run: | - docker compose up -d --remove-orphans + run: docker compose up -d --remove-orphans - name: Wait For Health Check run: | for i in $(seq 1 30); do - STATUS=$(docker inspect \ --format='{{.State.Health.Status}}' \ soroushasadi-site 2>/dev/null) - echo "Status: $STATUS" - if [ "$STATUS" = "healthy" ]; then echo "Deployment successful" exit 0 fi - sleep 5 done - docker logs soroushasadi-site --tail 100 exit 1 - name: Cleanup if: success() - run: | - docker image prune -f \ No newline at end of file + run: docker image prune -f diff --git a/.gitignore b/.gitignore index 347cc55..995fd3c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,39 +1,26 @@ -# dependencies -node_modules -.pnp -.pnp.js +# .NET +bin/ +obj/ +*.user +.vs/ +appsettings.*.local.json -# next.js -.next/ -out/ -build/ - -# production -dist/ - -# typescript -*.tsbuildinfo -next-env.d.ts - -# misc -.DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# env -.env*.local -.env - -# vercel -.vercel - -# CMS data (SQLite DB + uploaded media live in the mounted volume) +# CMS data (SQLite DB + uploads live in the Docker volume) /data -# local tooling / agent state -.claude/ +# Environment +.env +.env.local +.env*.local +# IDE +.idea/ +.vscode/ + +# OS +.DS_Store +*.pem +Thumbs.db + +# Claude agent state +.claude/ diff --git a/.npmrc b/.npmrc deleted file mode 100644 index 1a2a6a1..0000000 --- a/.npmrc +++ /dev/null @@ -1,13 +0,0 @@ -# All npm traffic is proxied through the Nexus npm-group repository. npm rewrites -# the registry.npmjs.org hosts found in package-lock.json to this mirror at -# install time (default replace-registry-host=npmjs), so the committed lockfile -# is reused as-is — no regeneration needed. -registry=http://mirror.soroushasadi.com/repository/npm-group/ - -# Auth is never committed. CI and the Docker build append an `_authToken` line -# from the NPM_TOKEN secret at install time; for local installs put the token in -# your personal ~/.npmrc. See .gitea/workflows/*.yml. - -# Trim install noise and avoid extra round-trips to the public registry. -audit=false -fund=false diff --git a/Dockerfile b/Dockerfile index 535dce5..0f4e2a2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,64 +1,38 @@ -ARG NODE_IMAGE=mirror.soroushasadi.com/node:20-alpine +ARG DOTNET_IMAGE=mcr.microsoft.com/dotnet/aspnet:10.0-alpine +ARG SDK_IMAGE=mcr.microsoft.com/dotnet/sdk:10.0-alpine -# --------------------------------------------------------------------------- -# 1. Builder — installs deps (including native better-sqlite3 compilation) -# then produces the standalone Next.js server bundle. -# --------------------------------------------------------------------------- -FROM ${NODE_IMAGE} AS builder -WORKDIR /app +# ── Build ───────────────────────────────────────────────────────────────────── +FROM ${SDK_IMAGE} AS build +WORKDIR /src -# libc6-compat: needed by Next.js SWC binaries on Alpine. -# python3 / make / g++: needed to compile better-sqlite3 native addon. -RUN apk add --no-cache libc6-compat python3 make g++ ca-certificates +COPY SoroushAsadi.Web.csproj ./ +RUN dotnet restore --runtime linux-musl-x64 -ARG NPM_TOKEN="" -COPY package.json package-lock.json .npmrc ./ -RUN if [ -n "$NPM_TOKEN" ]; then \ - echo "//mirror.soroushasadi.com/repository/npm-group/:_authToken=${NPM_TOKEN}" >> .npmrc ; \ - fi \ - && npm ci \ - && echo "=== post-install check ===" \ - && (test -d node_modules/next \ - && echo "OK: node_modules/next found: $(cat node_modules/next/package.json | grep '\"version\"' | head -1)" \ - || (echo "FATAL: node_modules/next is missing after npm ci" && exit 1)) - -ENV NEXT_TELEMETRY_DISABLED=1 COPY . . -# Diagnose what's in .bin after install, then invoke next directly via node -# to bypass any PATH / symlink resolution issues with `npm run`. -RUN ls node_modules/.bin/next 2>&1 || echo "WARN: next not in .bin" ; \ - node node_modules/next/dist/bin/next build +RUN dotnet publish SoroushAsadi.Web.csproj \ + --no-restore \ + --runtime linux-musl-x64 \ + --self-contained false \ + -c Release \ + -o /app/publish -# --------------------------------------------------------------------------- -# 2. Runner — minimal image. Standalone server + static assets only. -# Content DB + uploads live in /data (mounted volume). -# --------------------------------------------------------------------------- -FROM ${NODE_IMAGE} AS runner +# ── Runtime ─────────────────────────────────────────────────────────────────── +FROM ${DOTNET_IMAGE} AS runner WORKDIR /app -RUN apk add --no-cache libc6-compat ca-certificates +RUN apk add --no-cache ca-certificates \ + && addgroup -g 1001 dotnet \ + && adduser -u 1001 -G dotnet -h /home/dotnet -D dotnet -ENV NODE_ENV=production \ - NEXT_TELEMETRY_DISABLED=1 \ - PORT=3000 \ - HOSTNAME=0.0.0.0 \ - DATA_DIR=/data +COPY --from=build /app/publish ./ -RUN addgroup -g 1001 nodejs && adduser -u 1001 -G nodejs -h /home/nextjs -D nextjs +ENV ASPNETCORE_ENVIRONMENT=Production \ + ASPNETCORE_URLS=http://+:3000 \ + DataDir=/data -COPY --from=builder /app/.next/standalone ./ -COPY --from=builder /app/.next/static ./.next/static -COPY --from=builder /app/public ./public - -# Native addon compiled in builder on Alpine/musl — copy explicitly as a -# safety net in case file tracing misses the .node binary. -COPY --from=builder /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3 -COPY --from=builder /app/node_modules/bindings ./node_modules/bindings -COPY --from=builder /app/node_modules/file-uri-to-path ./node_modules/file-uri-to-path - -RUN mkdir -p /data/uploads && chown -R nextjs:nodejs /data /app -USER nextjs +RUN mkdir -p /data/uploads && chown -R dotnet:dotnet /data /app +USER dotnet VOLUME ["/data"] EXPOSE 3000 -CMD ["node", "server.js"] +ENTRYPOINT ["dotnet", "SoroushAsadi.Web.dll"] diff --git a/Models/ContentSection.cs b/Models/ContentSection.cs new file mode 100644 index 0000000..ac8eb7b --- /dev/null +++ b/Models/ContentSection.cs @@ -0,0 +1,13 @@ +namespace SoroushAsadi.Models; + +/// +/// Maps to the existing `sections` SQLite table. +/// data column is a JSON string — either {"fa":…,"en":…} for bilingual sections, +/// or {"slug": PostContent, …} for the "posts" key. +/// +public class ContentSection +{ + public string Key { get; set; } = ""; + public string DataJson { get; set; } = "{}"; + public long UpdatedAt { get; set; } +} diff --git a/Pages/Admin/Index.cshtml b/Pages/Admin/Index.cshtml new file mode 100644 index 0000000..c82ea33 --- /dev/null +++ b/Pages/Admin/Index.cshtml @@ -0,0 +1,26 @@ +@page "/Admin" +@model SoroushAsadi.Pages.Admin.AdminIndexModel +@{ + Layout = "_AdminLayout"; + ViewData["Title"] = "Dashboard"; +} + +

Dashboard

+ + diff --git a/Pages/Admin/Index.cshtml.cs b/Pages/Admin/Index.cshtml.cs new file mode 100644 index 0000000..4b41dde --- /dev/null +++ b/Pages/Admin/Index.cshtml.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Authorization; +using SoroushAsadi.Services; + +namespace SoroushAsadi.Pages.Admin; + +[Authorize] +public class AdminIndexModel(ContentService content) : Microsoft.AspNetCore.Mvc.RazorPages.PageModel +{ + public int SectionCount { get; private set; } + public void OnGet() => SectionCount = content.GetSectionKeys().Count; +} diff --git a/Pages/Admin/Login.cshtml b/Pages/Admin/Login.cshtml new file mode 100644 index 0000000..442fe71 --- /dev/null +++ b/Pages/Admin/Login.cshtml @@ -0,0 +1,29 @@ +@page "/Admin/Login" +@model SoroushAsadi.Pages.Admin.LoginModel +@{ + Layout = "_AdminLayout"; + ViewData["Title"] = "Sign in"; +} + +
+
+
+ +

Admin sign in

+
+ + @if (!string.IsNullOrEmpty(Model.Error)) + { +
@Model.Error
+ } + +
+
+ + +
+ +
+
+
diff --git a/Pages/Admin/Login.cshtml.cs b/Pages/Admin/Login.cshtml.cs new file mode 100644 index 0000000..1d45469 --- /dev/null +++ b/Pages/Admin/Login.cshtml.cs @@ -0,0 +1,33 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using SoroushAsadi.Services; + +namespace SoroushAsadi.Pages.Admin; + +public class LoginModel(AuthService auth) : PageModel +{ + public string Error { get; private set; } = ""; + + public void OnGet() { } + + public async Task OnPostAsync(string password, string returnUrl = "/Admin") + { + if (!auth.VerifyPassword(password)) + { + Error = "Incorrect password."; + return Page(); + } + + var claims = new[] { new Claim(ClaimTypes.Name, "admin") }; + var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + await HttpContext.SignInAsync( + CookieAuthenticationDefaults.AuthenticationScheme, + new ClaimsPrincipal(identity)); + + if (!Url.IsLocalUrl(returnUrl)) returnUrl = "/Admin"; + return LocalRedirect(returnUrl); + } +} diff --git a/Pages/Admin/Logout.cshtml b/Pages/Admin/Logout.cshtml new file mode 100644 index 0000000..d076eff --- /dev/null +++ b/Pages/Admin/Logout.cshtml @@ -0,0 +1,2 @@ +@page "/Admin/Logout" +@model SoroushAsadi.Pages.Admin.LogoutModel diff --git a/Pages/Admin/Logout.cshtml.cs b/Pages/Admin/Logout.cshtml.cs new file mode 100644 index 0000000..e2ea95e --- /dev/null +++ b/Pages/Admin/Logout.cshtml.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace SoroushAsadi.Pages.Admin; + +[IgnoreAntiforgeryToken] +public class LogoutModel : PageModel +{ + public async Task OnPostAsync() + { + await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + return RedirectToPage("/Admin/Login"); + } +} diff --git a/Pages/Admin/Posts/Edit.cshtml b/Pages/Admin/Posts/Edit.cshtml new file mode 100644 index 0000000..f4324a1 --- /dev/null +++ b/Pages/Admin/Posts/Edit.cshtml @@ -0,0 +1,35 @@ +@page "/Admin/Posts/{slug}" +@model SoroushAsadi.Pages.Admin.Posts.PostEditModel +@{ + Layout = "_AdminLayout"; + ViewData["Title"] = "Edit post: " + Model.Slug; +} + +
+ ← Posts +

@Model.Slug

+ View ↗ +
+ +@if (!string.IsNullOrEmpty(Model.Message)) +{ +
@Model.Message
+} + +
+
+ +

Supports: ## headings, **bold**, `code`, - list items, paragraphs

+ +
+
+ + @if (Model.HasOverride) + { + + } + Cancel +
+
diff --git a/Pages/Admin/Posts/Edit.cshtml.cs b/Pages/Admin/Posts/Edit.cshtml.cs new file mode 100644 index 0000000..0747a99 --- /dev/null +++ b/Pages/Admin/Posts/Edit.cshtml.cs @@ -0,0 +1,71 @@ +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using SoroushAsadi.Services; + +namespace SoroushAsadi.Pages.Admin.Posts; + +[Authorize] +public class PostEditModel(ContentService content) : PageModel +{ + [BindProperty(SupportsGet = true)] + public string Slug { get; set; } = ""; + + public string CurrentBody { get; private set; } = ""; + public string Message { get; private set; } = ""; + public bool HasOverride { get; private set; } + + // Known default bodies live in Blog/Post.cshtml.cs (DefaultBodies) + private static readonly Dictionary _defaults = new() + { + ["rag-eval-framework"] = SoroushAsadi.Pages.Blog.DefaultBodies.RagEval, + ["agentic-n8n-patterns"] = SoroushAsadi.Pages.Blog.DefaultBodies.N8nPatterns, + ["vertex-cost-control"] = SoroushAsadi.Pages.Blog.DefaultBodies.VertexCost, + ["k8s-llm-inference"] = SoroushAsadi.Pages.Blog.DefaultBodies.K8sInference, + ["flutter-on-device-ai"] = SoroushAsadi.Pages.Blog.DefaultBodies.FlutterAI, + ["enterprise-ai-roadmap"] = SoroushAsadi.Pages.Blog.DefaultBodies.EnterpriseRoadmap, + }; + + public void OnGet() + { + var overrides = content.GetPostOverrides(); + if (overrides.TryGetValue(Slug, out var node) && node["body"]?.GetValue() is { } body) + { + CurrentBody = body; + HasOverride = true; + } + else + { + CurrentBody = _defaults.GetValueOrDefault(Slug, ""); + } + } + + public IActionResult OnPost(string body, string? reset) + { + if (reset == "1") + { + // Remove override — default body shows through + var existing = content.GetPostOverrides(); + existing.Remove(Slug); + // Rebuild the posts JSON without this slug + var obj = new JsonObject(); + foreach (var kv in existing) obj[kv.Key] = kv.Value.DeepClone(); + content.SaveSection(ContentService.PostsKey, obj.ToJsonString()); + + Message = "Reset to default."; + HasOverride = false; + CurrentBody = _defaults.GetValueOrDefault(Slug, ""); + return Page(); + } + + content.SavePost(Slug, new JsonObject { ["body"] = body }); + HasOverride = true; + CurrentBody = body; + Message = "Saved."; + return Page(); + } +} + +// Re-export DefaultBodies from the Blog page so this page can use them + diff --git a/Pages/Admin/Posts/Index.cshtml b/Pages/Admin/Posts/Index.cshtml new file mode 100644 index 0000000..e6ba265 --- /dev/null +++ b/Pages/Admin/Posts/Index.cshtml @@ -0,0 +1,29 @@ +@page "/Admin/Posts" +@model SoroushAsadi.Pages.Admin.Posts.PostsIndexModel +@{ + Layout = "_AdminLayout"; + ViewData["Title"] = "Blog posts"; + var slugs = new[]{ "rag-eval-framework","agentic-n8n-patterns","vertex-cost-control","k8s-llm-inference","flutter-on-device-ai","enterprise-ai-roadmap" }; +} + +

Blog posts

+ +
+ @foreach (var slug in slugs) + { + var hasOverride = Model.OverrideSlugs.Contains(slug); +
+
+ @slug + @if (hasOverride) + { + customized + } +
+
+ Edit + View ↗ +
+
+ } +
diff --git a/Pages/Admin/Posts/Index.cshtml.cs b/Pages/Admin/Posts/Index.cshtml.cs new file mode 100644 index 0000000..d72663d --- /dev/null +++ b/Pages/Admin/Posts/Index.cshtml.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.RazorPages; +using SoroushAsadi.Services; + +namespace SoroushAsadi.Pages.Admin.Posts; + +[Authorize] +public class PostsIndexModel(ContentService content) : PageModel +{ + public IReadOnlySet OverrideSlugs { get; private set; } = new HashSet(); + + public void OnGet() => OverrideSlugs = content.GetPostOverrides().Keys.ToHashSet(); +} diff --git a/Pages/Admin/Sections/Edit.cshtml b/Pages/Admin/Sections/Edit.cshtml new file mode 100644 index 0000000..99b1359 --- /dev/null +++ b/Pages/Admin/Sections/Edit.cshtml @@ -0,0 +1,29 @@ +@page "/Admin/Sections/{key}" +@model SoroushAsadi.Pages.Admin.Sections.SectionEditModel +@{ + Layout = "_AdminLayout"; + ViewData["Title"] = "Edit: " + Model.SectionKey; +} + +
+ ← Sections +

@Model.SectionKey

+
+ +@if (!string.IsNullOrEmpty(Model.Message)) +{ +
@Model.Message
+} + +
+
+ + +
+
+ + Cancel +
+
diff --git a/Pages/Admin/Sections/Edit.cshtml.cs b/Pages/Admin/Sections/Edit.cshtml.cs new file mode 100644 index 0000000..ea4444f --- /dev/null +++ b/Pages/Admin/Sections/Edit.cshtml.cs @@ -0,0 +1,43 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using SoroushAsadi.Services; + +namespace SoroushAsadi.Pages.Admin.Sections; + +[Authorize] +public class SectionEditModel(ContentService content) : PageModel +{ + [BindProperty(SupportsGet = true)] + public string SectionKey { get; set; } = ""; + + public string CurrentJson { get; private set; } = "{\n \"fa\": {},\n \"en\": {}\n}"; + public string Message { get; private set; } = ""; + + public void OnGet() + { + var node = content.GetSection(SectionKey); + if (node is not null) + CurrentJson = node.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); + } + + public IActionResult OnPost(string json) + { + try + { + // Validate JSON + JsonDocument.Parse(json); + content.SaveSection(SectionKey, json); + Message = "Saved."; + CurrentJson = json; + return Page(); + } + catch (JsonException ex) + { + Message = $"Invalid JSON: {ex.Message}"; + CurrentJson = json; + return Page(); + } + } +} diff --git a/Pages/Admin/Sections/Index.cshtml b/Pages/Admin/Sections/Index.cshtml new file mode 100644 index 0000000..b01805d --- /dev/null +++ b/Pages/Admin/Sections/Index.cshtml @@ -0,0 +1,37 @@ +@page "/Admin/Sections" +@model SoroushAsadi.Pages.Admin.Sections.SectionsIndexModel +@{ + Layout = "_AdminLayout"; + ViewData["Title"] = "Sections"; + var all = new[]{ "hero","services","dataflow","stack","expertise","portfolio","blog","contact","footer" }; +} + +
+

Sections

+
+ +
+ @foreach (var key in all) + { + var hasOverride = Model.OverrideKeys.Contains(key); +
+
+ @key + @if (hasOverride) + { + customized + } +
+
+ Edit + @if (hasOverride) + { +
+ + +
+ } +
+
+ } +
diff --git a/Pages/Admin/Sections/Index.cshtml.cs b/Pages/Admin/Sections/Index.cshtml.cs new file mode 100644 index 0000000..84e1fca --- /dev/null +++ b/Pages/Admin/Sections/Index.cshtml.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using SoroushAsadi.Services; + +namespace SoroushAsadi.Pages.Admin.Sections; + +[Authorize] +public class SectionsIndexModel(ContentService content) : PageModel +{ + public IReadOnlySet OverrideKeys { get; private set; } = new HashSet(); + public void OnGet() => OverrideKeys = content.GetSectionKeys().ToHashSet(); + + public IActionResult OnPostReset(string key) + { + content.DeleteSection(key); + return RedirectToPage(); + } +} diff --git a/Pages/BasePageModel.cs b/Pages/BasePageModel.cs new file mode 100644 index 0000000..c402abf --- /dev/null +++ b/Pages/BasePageModel.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace SoroushAsadi.Pages; + +/// Base class that reads the locale cookie and exposes Locale + IsFa helpers. +public abstract class BasePageModel : PageModel +{ + public string Locale { get; private set; } = "fa"; + public bool IsFa => Locale == "fa"; + + public override void OnPageHandlerExecuting(Microsoft.AspNetCore.Mvc.Filters.PageHandlerExecutingContext context) + { + Locale = Request.Cookies["locale"] is "en" ? "en" : "fa"; + ViewData["Locale"] = Locale; + base.OnPageHandlerExecuting(context); + } +} diff --git a/Pages/Blog/Index.cshtml b/Pages/Blog/Index.cshtml new file mode 100644 index 0000000..98a43e2 --- /dev/null +++ b/Pages/Blog/Index.cshtml @@ -0,0 +1,34 @@ +@page "/blog" +@model SoroushAsadi.Pages.Blog.BlogIndexModel +@{ + ViewData["Title"] = Model.IsFa ? "بلاگ — سروش اسعدی" : "Blog — Soroush Asadi"; + var fa = Model.IsFa; +} + +
+
+
+
@(fa ? "بلاگ" : "Journal")
+

+ @(fa ? "یادداشت‌های مهندسی" : "Engineering notes") +

+

@(fa ? "یافته‌ها از پروژه‌های واقعی — نه ترجمه‌ی مقاله، نه فهرست hype." : "Findings from real engagements — not translated articles, not hype lists.")

+
+ + +
+
diff --git a/Pages/Blog/Index.cshtml.cs b/Pages/Blog/Index.cshtml.cs new file mode 100644 index 0000000..27f556e --- /dev/null +++ b/Pages/Blog/Index.cshtml.cs @@ -0,0 +1,28 @@ +namespace SoroushAsadi.Pages.Blog; + +public class BlogIndexModel : BasePageModel +{ + public record BlogPost(string Slug, string Category, string Title, string Excerpt, int ReadTime); + + public IReadOnlyList Posts { get; private set; } = []; + + public void OnGet() + { + var fa = IsFa; + Posts = fa ? new BlogPost[]{ + new("rag-eval-framework","LLM","چارچوب ارزیابی RAG که در تولید کار می‌کند","چرا BLEU و ROUGE برای RAG ناکافی‌اند، و معیارهایی که در پروژه‌های واقعی تصمیم می‌سازند.",8), + new("agentic-n8n-patterns","Automation","الگوهای عامل‌محور با n8n برای سازمان","چگونه n8n را با LangGraph ترکیب کنیم تا گردش‌کارهای قابل ممیزی بسازیم.",11), + new("vertex-cost-control","Google Stack","کنترل هزینه روی Vertex AI در مقیاس بالا","سه ضدالگو که در ۸۰٪ پروژه‌های Vertex می‌بینم، و چگونه ۶۰٪ هزینه را کاهش دادیم.",6), + new("k8s-llm-inference","Infra","استنتاج LLM روی Kubernetes با تأخیر زیر ۵۰ میلی‌ثانیه","الگوی استقرار با KEDA، GPU sharing، و request hedging برای سرویس‌دهی پایدار.",14), + new("flutter-on-device-ai","Mobile","هوش مصنوعی on-device در Flutter","استفاده از Gemini Nano و LiteRT برای استنتاج آفلاین در اپلیکیشن‌های موبایل.",9), + new("enterprise-ai-roadmap","Strategy","نقشه راه هوش مصنوعی سازمانی در ۹۰ روز","چارچوبی که برای CTOها می‌سازم — از کشف موارد کاربری تا اولین استقرار تولید.",7), + } : new BlogPost[]{ + new("rag-eval-framework","LLM","A RAG evaluation framework that holds up in production","Why BLEU and ROUGE fall short for RAG, and the metrics that actually drive decisions in real projects.",8), + new("agentic-n8n-patterns","Automation","Agentic patterns with n8n for the enterprise","How to combine n8n with LangGraph to build auditable, debuggable autonomous workflows.",11), + new("vertex-cost-control","Google Stack","Vertex AI cost control at scale","Three anti-patterns I see in 80% of Vertex projects — and how we cut 60% of monthly spend.",6), + new("k8s-llm-inference","Infra","Sub-50ms LLM inference on Kubernetes","Deployment pattern with KEDA, GPU sharing, and request hedging for stable serving.",14), + new("flutter-on-device-ai","Mobile","On-device AI in Flutter","Using Gemini Nano and LiteRT for offline inference inside mobile apps.",9), + new("enterprise-ai-roadmap","Strategy","A 90-day enterprise AI roadmap","The framework I build for CTOs — from use-case discovery to first production deployment.",7), + }; + } +} diff --git a/Pages/Blog/Post.cshtml b/Pages/Blog/Post.cshtml new file mode 100644 index 0000000..c389741 --- /dev/null +++ b/Pages/Blog/Post.cshtml @@ -0,0 +1,48 @@ +@page "/blog/{slug}" +@model SoroushAsadi.Pages.Blog.PostModel +@{ + ViewData["Title"] = Model.Title + " — Soroush Asadi"; + var fa = Model.IsFa; +} + +
+
+ + + @(fa ? "بازگشت به بلاگ" : "Back to blog") + + + @if (Model.PostNotFound) + { +

@(fa ? "مقاله پیدا نشد." : "Post not found.")

+ } + else + { +
+ @Model.Category +

@Model.Title

+

@Model.ReadTime @(fa ? "دقیقه مطالعه" : "min read")

+
+ +
+ @Html.Raw(Model.BodyHtml) +
+ } +
+
+ +@section Scripts { + +} diff --git a/Pages/Blog/Post.cshtml.cs b/Pages/Blog/Post.cshtml.cs new file mode 100644 index 0000000..6b05b37 --- /dev/null +++ b/Pages/Blog/Post.cshtml.cs @@ -0,0 +1,216 @@ +using System.Text; +using SoroushAsadi.Services; + +namespace SoroushAsadi.Pages.Blog; + +public class PostModel(ContentService content) : BasePageModel +{ + [Microsoft.AspNetCore.Mvc.BindProperty(SupportsGet = true)] + public string Slug { get; set; } = ""; + + public string Title { get; private set; } = ""; + public string Category { get; private set; } = ""; + public int ReadTime { get; private set; } + public string BodyHtml { get; private set; } = ""; + public bool PostNotFound { get; private set; } + + // Default bodies (Markdown-lite, rendered server-side) + private static readonly Dictionary _defaults = new() + { + ["rag-eval-framework"] = ("LLM", "A RAG evaluation framework that holds up in production", "چارچوب ارزیابی RAG که در تولید کار می‌کند", 8, DefaultBodies.RagEval), + ["agentic-n8n-patterns"] = ("Automation", "Agentic patterns with n8n for the enterprise", "الگوهای عامل‌محور با n8n برای سازمان", 11, DefaultBodies.N8nPatterns), + ["vertex-cost-control"] = ("Google Stack", "Vertex AI cost control at scale", "کنترل هزینه روی Vertex AI در مقیاس بالا", 6, DefaultBodies.VertexCost), + ["k8s-llm-inference"] = ("Infra", "Sub-50ms LLM inference on Kubernetes", "استنتاج LLM روی Kubernetes با تأخیر زیر ۵۰ ms",14, DefaultBodies.K8sInference), + ["flutter-on-device-ai"] = ("Mobile", "On-device AI in Flutter", "هوش مصنوعی on-device در Flutter", 9, DefaultBodies.FlutterAI), + ["enterprise-ai-roadmap"] = ("Strategy", "A 90-day enterprise AI roadmap", "نقشه راه هوش مصنوعی سازمانی در ۹۰ روز", 7, DefaultBodies.EnterpriseRoadmap), + }; + + public void OnGet() + { + if (!_defaults.TryGetValue(Slug, out var def)) { PostNotFound = true; return; } + + // Check for DB override (stored under "posts" key as slug→{body,...}) + var overrides = content.GetPostOverrides(); + string body = def.Body; + if (overrides.TryGetValue(Slug, out var node) && node["body"]?.GetValue() is { } dbBody) + body = dbBody; + + Title = IsFa ? def.TitleFa : def.TitleEn; + Category = def.Cat; + ReadTime = def.RT; + BodyHtml = SimpleMarkdown(body); + } + + // Minimal Markdown → HTML (headings, bold, code, paragraphs) + private static string SimpleMarkdown(string md) + { + if (string.IsNullOrWhiteSpace(md)) return ""; + var sb = new StringBuilder(); + foreach (var rawLine in md.Split('\n')) + { + var line = rawLine.TrimEnd(); + if (line.StartsWith("## ")) { sb.Append($"

{Inline(line[3..])}

\n"); continue; } + if (line.StartsWith("### ")) { sb.Append($"

{Inline(line[4..])}

\n"); continue; } + if (line.StartsWith("- ")) { sb.Append($"
  • {Inline(line[2..])}
  • \n"); continue; } + if (string.IsNullOrWhiteSpace(line)) { sb.Append('\n'); continue; } + sb.Append($"

    {Inline(line)}

    \n"); + } + return sb.ToString(); + } + + private static string Inline(string s) + { + // **bold**, `code`, &, <, > + var sb = new StringBuilder(); + int i = 0; + while (i < s.Length) + { + if (i + 1 < s.Length && s[i] == '*' && s[i + 1] == '*') + { + int end = s.IndexOf("**", i + 2); + if (end >= 0) { sb.Append(""); sb.Append(Esc(s[(i + 2)..end])); sb.Append(""); i = end + 2; continue; } + } + if (s[i] == '`') + { + int end = s.IndexOf('`', i + 1); + if (end >= 0) { sb.Append(""); sb.Append(Esc(s[(i + 1)..end])); sb.Append(""); i = end + 1; continue; } + } + sb.Append(s[i] switch { '&' => "&", '<' => "<", '>' => ">", _ => s[i].ToString() }); + i++; + } + return sb.ToString(); + } + private static string Esc(string s) => s.Replace("&","&").Replace("<","<").Replace(">",">"); +} + +/// Default article bodies (Markdown). +internal static class DefaultBodies +{ + public const string RagEval = """ +## Why standard metrics fail for RAG + +BLEU and ROUGE measure n-gram overlap against a reference answer. In a RAG system, there is often no single correct reference — a question about company policy may have dozens of valid phrasings. High BLEU does not mean the system cited the right source; low BLEU does not mean it was wrong. + +## The three metrics that actually matter + +**Faithfulness** measures whether every claim in the generated answer can be traced back to a retrieved passage. A faithfulness score of 1.0 means the model invented nothing. Tools like RAGAS implement this with an LLM judge. + +**Context Precision** asks: of the passages retrieved, how many were actually relevant to the question? Low precision wastes context window and increases hallucination risk. + +**Answer Relevancy** checks whether the final response actually addresses what was asked — not just whether it sounds good. + +## Building an eval harness + +Start with a **golden dataset**: 100–200 question/answer pairs that domain experts have verified. Run your pipeline against them nightly. Track the three metrics above over time. A drop in Faithfulness after a model upgrade is a red flag; a drop in Context Precision after a chunking change means your retrieval is degrading. + +The harness does not have to be complex. A spreadsheet with automatic scoring via the OpenAI or Anthropic API is enough to start catching regressions before they reach production. +"""; + + public const string N8nPatterns = """ +## The problem with "just use n8n" + +n8n is excellent for integrating SaaS tools. It becomes fragile when you try to use it as an agent orchestrator — long-running loops, conditional retries, and LLM calls that can fail in non-obvious ways. + +## Separating orchestration from integration + +The pattern that works: **n8n handles triggers and integrations; LangGraph handles agent logic**. + +An n8n workflow watches a Slack channel. When a message matches a pattern, it calls a LangGraph endpoint with the raw payload. LangGraph runs the multi-step reasoning loop, maintains state, and returns a structured result. n8n takes that result and routes it — posts to Jira, sends an email, updates a database row. + +## Making agents auditable + +Every LangGraph state transition should emit an event to a structured log. We use a Postgres table with columns: `run_id`, `step`, `input`, `output`, `timestamp`. This table becomes the audit trail that compliance teams and on-call engineers both need. + +Add a `human_in_the_loop` node for any action that cannot be undone — deleting records, sending external emails, approving payments. The node pauses execution and posts to Slack; a human approves or rejects; execution resumes. + +## Handling failures gracefully + +LLM calls fail. Build **retry with exponential backoff** into every LangGraph node that calls an LLM. Set a hard limit of 3 retries, then route to a dead-letter state that pages the on-call engineer. Never silently swallow errors in agentic pipelines — a swallowed error is an invisible outage. +"""; + + public const string VertexCost = """ +## Anti-pattern 1: calling Gemini Ultra for everything + +Gemini Ultra (or GPT-4-class models) costs 10–30× more per token than smaller models. Many teams default to the most capable model because it "just works" during prototyping, then never re-evaluate. + +**Fix**: build a **model router**. Classify each incoming request by complexity. Simple lookups, short summaries, and classification tasks go to Gemini Flash or Haiku. Only complex reasoning, multi-step synthesis, and long-context tasks go to Pro or Ultra. In most production systems, 60–80% of requests can be served by the cheaper tier. + +## Anti-pattern 2: no context caching + +Vertex AI supports prompt caching (as does the Anthropic API). A system prompt that is 10k tokens, sent with every request at $3/M tokens, costs $30 for every million calls before the user has typed a single word. + +**Fix**: cache any context that is static or changes infrequently — system prompts, retrieved document sets, few-shot examples. Cache hits cost ~10% of full input price. + +## Anti-pattern 3: synchronous batch jobs + +Teams run nightly document processing jobs synchronously — one document at a time, each blocked on the previous. This is slow and expensive because you pay for idle wait time between calls. + +**Fix**: use the Vertex AI batch prediction API for jobs over ~1,000 documents. Batch jobs run asynchronously, are eligible for spot discounts, and typically cost 50% less per token than online serving. +"""; + + public const string K8sInference = """ +## The baseline architecture + +A single Kubernetes `Deployment` behind a `ClusterIP` `Service`, fronted by an Ingress. Works fine up to ~50 RPS for a small model. Falls apart when traffic spikes, when GPU pods take 3 minutes to schedule, or when the model server has a 2-second cold-start. + +## Autoscaling with KEDA + +HPA (Horizontal Pod Autoscaler) scales on CPU and memory. LLM inference is GPU-bound and queue-depth-bound — neither maps to CPU utilization well. + +KEDA (Kubernetes Event-Driven Autoscaling) scales on arbitrary metrics — queue depth, Pub/Sub lag, Redis list length. We publish inference request counts to a Redis stream; KEDA scales the model server pods when the stream depth exceeds a threshold. Scaling-up latency drops from minutes (cluster autoscaler cold start) to seconds (replica scale-up from 1 to N). + +## GPU sharing with time-slicing + +For models that fit in 4–8 GB VRAM, full GPU dedication is wasteful. NVIDIA's time-slicing MIG (Multi-Instance GPU) lets multiple pods share one A100, each getting a guaranteed slice. + +Configure `nvidia.com/gpu: 1` and set the time-slice profile to `1g.10gb`. A single A100 80GB can serve 8 concurrent model instances at 10 GB each — 8× the throughput per GPU. + +## Request hedging for tail latency + +p50 latency is 12ms. p99 is 280ms. The tail is dominated by KV-cache misses and occasional GC pauses. **Hedged requests**: after 40ms, send a duplicate request to a second replica. Take whichever response arrives first; cancel the other. This cuts p99 from 280ms to ~45ms with only ~15% increase in total compute. +"""; + + public const string FlutterAI = """ +## Why on-device inference matters + +Cloud inference requires a network round-trip, exposes user data to a server, and fails in offline scenarios. For consumer apps — messaging, health, productivity — on-device inference is often a requirement, not a nice-to-have. + +## Gemini Nano and LiteRT + +Google's Gemini Nano is a 1.8B parameter model quantized to run on mobile NPUs (Neural Processing Units). The Flutter integration uses the `google_ai_dart_sdk` package with `GeminiNanoModel`, falling back to cloud inference when the device model is unavailable. + +LiteRT (formerly TensorFlow Lite) handles vision and custom small models. For classification and embedding tasks, a 50MB quantized model runs in under 20ms on a mid-range Android device. + +## Streaming UX without a network + +The key insight: users tolerate slightly slower responses if they can see text appearing token by token. Even on-device inference can stream — Gemini Nano's Dart SDK exposes a `generateContentStream` method. Pipe tokens directly to a Flutter `StreamBuilder` for a responsive feel regardless of total generation time. + +## Battery and thermal management + +On-device inference heats the chip. Implement **thermal throttling**: check `DeviceInfo.thermalState` (iOS) or subscribe to the battery API on Android. Reduce `maxTokens` from 512 to 128 during sustained load. Schedule background inference tasks during charging. Users notice neither the throttling nor the scheduling — they notice when their phone gets too hot. +"""; + + public const string EnterpriseRoadmap = """ +## Days 1–30: discovery + +The most expensive mistake in enterprise AI is building the wrong thing fast. Discovery is not a formality — it is the work. + +Interview 8–12 stakeholders across business units. For each, ask: what manual task takes more than 2 hours per week? What decision do you make with incomplete information? What report do you wish existed but is too expensive to build? + +Map the candidates on a 2×2: **impact** (revenue, cost, risk) vs **feasibility** (data quality, integration complexity, regulatory constraints). The top-right quadrant is your first sprint. + +## Days 31–60: prototype and validate + +Pick one use case from the top-right. Build a prototype in 3 weeks. The prototype does not have to be production-grade — it has to be **testable by domain experts**. + +Run a structured eval: 100 questions, domain expert scores each answer 1–5. Set a threshold (e.g., ≥4.0 average) before the sprint begins. If the prototype clears it, proceed to production hardening. If it doesn't, investigate root cause — usually data quality or chunking strategy — before committing engineering resources. + +## Days 61–90: first production deployment + +Scope the first deployment to a single team of 10–20 people. This limits blast radius and generates real usage data fast. + +Instrument everything: latency, cost per query, thumbs-up/thumbs-down from users, faithfulness score from the automated harness. Review metrics weekly with the business owner. Adjust chunking, retrieval strategy, or model tier based on what the data shows — not intuition. + +At day 90, you have a live system, a tuned eval harness, and a clear picture of what the second use case should be. That is the foundation for a credible 12-month roadmap. +"""; +} diff --git a/Pages/Contact.cshtml b/Pages/Contact.cshtml new file mode 100644 index 0000000..1a54fbe --- /dev/null +++ b/Pages/Contact.cshtml @@ -0,0 +1,2 @@ +@page "/contact" +@model SoroushAsadi.Pages.ContactModel diff --git a/Pages/Contact.cshtml.cs b/Pages/Contact.cshtml.cs new file mode 100644 index 0000000..1aba762 --- /dev/null +++ b/Pages/Contact.cshtml.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using SoroushAsadi.Services; + +namespace SoroushAsadi.Pages; + +/// POST /contact — JSON endpoint for the contact form. +[IgnoreAntiforgeryToken] +public class ContactModel(EmailService email) : PageModel +{ + public record ContactBody( + string? Name, string? Company, string? Service, + string? Budget, string? Message, string? Locale); + + public async Task OnPostAsync([FromBody] ContactBody body) + { + if (string.IsNullOrWhiteSpace(body.Name) || body.Name.Length < 2 || + string.IsNullOrWhiteSpace(body.Service) || body.Service.Length < 2 || + string.IsNullOrWhiteSpace(body.Budget) || body.Budget.Length < 2 || + string.IsNullOrWhiteSpace(body.Message) || body.Message.Length < 2) + return BadRequest(new { error = "Missing required fields" }); + + var err = await email.SendContactAsync(new EmailService.ContactForm( + body.Name!, body.Company ?? "", body.Service!, body.Budget!, body.Message!, body.Locale ?? "en")); + + return err is null + ? new JsonResult(new { ok = true }) + : StatusCode(502, new { error = err }); + } +} diff --git a/Pages/Index.cshtml b/Pages/Index.cshtml new file mode 100644 index 0000000..7676f69 --- /dev/null +++ b/Pages/Index.cshtml @@ -0,0 +1,550 @@ +@page +@model SoroushAsadi.Pages.IndexModel +@{ + var fa = Model.IsFa; + var locale = Model.Locale; +} + + +
    + +
    + +
    +
    + + +
    + +
    + + +
    + + + + + + @(fa ? "پذیرش پروژه‌های منتخب فصل سوم ۲۰۲۶" : "Available for select Q3 2026 engagements") + +
    + + +

    + + @(fa ? "مهندس هوش مصنوعی · مشاور · معمار راهکار" : "AI Engineer · Consultant · Solution Architect") + +

    + + +

    + @(fa ? "سروش اسعدی" : "Soroush Asadi") +

    + + +

    + @(fa ? "طراحی سامانه‌های" : "Architecting") + @(fa ? "هوش مصنوعی" : "production-grade AI") + @(fa ? "در مقیاس سازمانی." : "for the enterprise.") +

    + + +
    + + + + +
    + + +

    + @(fa + ? "از راهبرد تا تولید — ساخت پایپ‌لاین‌های LLM، عامل‌های خودکار، و معماری‌های ابری که در میلیون‌ها رویداد در روز پایدار می‌مانند." + : "From strategy to deployment — building LLM pipelines, autonomous agents, and cloud architectures that hold up at millions of events per day.") +

    + + + + + +
    + @{ + 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) + { +
    + +
    @val
    +
    @label
    +
    + } +
    + + + + @(fa ? "اسکرول" : "Scroll") + + + + +
    +
    + + +
    +
    +
    +
    @(fa ? "خدمات" : "Services")
    +

    @(fa ? "شش حوزه تخصصی" : "Six areas of practice")

    +

    @(fa ? "از اولین جلسه‌ی راهبرد تا استقرار تولید — یک شریک مهندسی برای کل چرخه‌ی عمر هوش مصنوعی شما." : "From the first strategy session to production rollout — one engineering partner for the full AI lifecycle.")

    +
    +
    + @{ + var services = fa ? new[]{ + ("strategy","راهبرد و نقشه راه هوش مصنوعی","ارزیابی بلوغ سازمانی، شناسایی موارد کاربری با بیشترین بازده، و طراحی نقشه راه ۱۲–۱۸ ماهه با KPIهای روشن.","electric",new[]{"Discovery","ROI Mapping","Roadmap"}), + ("automation","اتوماسیون هوش مصنوعی","ساخت عامل‌های خودکار و گردش‌کارهای n8n که فرایندهای دستی را به سامانه‌های قابل ممیزی تبدیل می‌کنند.","violet",new[]{"n8n","Agents","Workflows"}), + ("llm-rag","مهندسی LLM و RAG","طراحی pipeline‌های RAG با پایگاه‌های برداری، evaluation framework، و سرویس‌دهی با تأخیر زیر ۵۰ میلی‌ثانیه.","magenta",new[]{"RAG","Vector DB","Eval"}), + ("architecture","معماری راهکار","طراحی سامانه‌های توزیع‌شده روی Kubernetes با میکروسرویس‌ها، event streaming، و الگوهای پایداری در مقیاس بالا.","emerald",new[]{"K8s","Microservices","Event-Driven"}), + ("mobile","اپلیکیشن‌های موبایل هوش مصنوعی","برنامه‌های Flutter، Swift و Kotlin با on-device inference، استریم LLM و تجربه‌ی کاربری بومی.","electric",new[]{"Flutter","Swift","Kotlin"}), + ("google-stack","تخصص استک گوگل","استقرار روی Vertex AI، GKE و Gemini با بهینه‌سازی هزینه و الگوهای امنیتی سطح enterprise.","cyan",new[]{"Vertex AI","GKE","Gemini"}), + } : new[]{ + ("strategy","AI Strategy & Roadmap","Maturity assessment, highest-ROI use-case discovery, and a 12–18 month roadmap with measurable KPIs.","electric",new[]{"Discovery","ROI Mapping","Roadmap"}), + ("automation","AI Automation","Autonomous agents and n8n workflows that turn manual processes into auditable, observable systems.","violet",new[]{"n8n","Agents","Workflows"}), + ("llm-rag","LLM & RAG Engineering","Production RAG pipelines with vector stores, evaluation frameworks, and sub-50ms serving.","magenta",new[]{"RAG","Vector DB","Eval"}), + ("architecture","Solution Architecture","Distributed systems on Kubernetes — microservices, event streaming, and resilience patterns at scale.","emerald",new[]{"K8s","Microservices","Event-Driven"}), + ("mobile","Mobile AI Apps","Flutter, Swift, and Kotlin apps with on-device inference, streaming LLM UX, and native polish.","electric",new[]{"Flutter","Swift","Kotlin"}), + ("google-stack","Google Stack Specialist","Vertex AI, GKE, and Gemini deployments with cost optimization and enterprise security patterns.","cyan",new[]{"Vertex AI","GKE","Gemini"}), + }; + int si = 0; + } + @foreach (var (id, title, desc, color, tags) in services) + { + var (ringCls, glowCls, textCls, chipCls) = color switch { + "violet" => ("group-hover:border-violet/50", "group-hover:shadow-glow-violet", "text-violet", "border-violet/30 bg-violet/5 text-violet/90"), + "magenta" => ("group-hover:border-magenta/50", "group-hover:shadow-glow-magenta", "text-magenta", "border-magenta/30 bg-magenta/5 text-magenta/90"), + "emerald" => ("group-hover:border-emerald/50", "group-hover:shadow-glow-emerald", "text-emerald", "border-emerald/30 bg-emerald/5 text-emerald/90"), + "cyan" => ("group-hover:border-cyan/50", "group-hover:shadow-glow-electric","text-cyan", "border-cyan/30 bg-cyan/5 text-cyan/90"), + _ => ("group-hover:border-electric/50","group-hover:shadow-glow-electric","text-electric","border-electric/30 bg-electric/5 text-electric/90"), + }; +
    +
    + @((si + 1).ToString("D2")) + + @Html.Raw(ServiceIcon(id)) + +
    +

    @title

    +

    @desc

    +
    + @foreach (var tag in tags) + { + @tag + } +
    + +
    + si++; + } +
    +
    +
    + + +
    +
    +
    +
    @(fa ? "پایپ‌لاین" : "Pipeline")
    +

    @(fa ? "از سند خام تا پاسخ قابل اتکا" : "From raw document to trustworthy answer")

    +

    @(fa ? "مسیری که هر پرسش در یک سامانه‌ی RAG تولیدی طی می‌کند — هر مرحله قابل اندازه‌گیری، قابل ممیزی و بهینه‌شده برای تأخیر." : "The path every query takes through a production RAG system — each stage measurable, auditable, and tuned for latency.")

    +
    + +
    +
    + @{ + var nodes = fa ? new[]{ + ("ingest","دریافت","نرمال‌سازی، قطعه‌بندی و پاک‌سازی اسناد منبع","electric"), + ("embed","برداری‌سازی","تولید embedding و نمایه‌سازی در پایگاه برداری","violet"), + ("retrieve","بازیابی","جستجوی ترکیبی معنایی و کلیدواژه‌ای","cyan"), + ("rerank","بازرتبه‌بندی","مرتب‌سازی مجدد نامزدها با cross-encoder","magenta"), + ("generate","تولید","پاسخ مستند با ارجاع به منبع","emerald"), + } : new[]{ + ("ingest","Ingest","Normalize, chunk, and clean source documents","electric"), + ("embed","Embed","Generate embeddings and index in vector store","violet"), + ("retrieve","Retrieve","Hybrid semantic + keyword search","cyan"), + ("rerank","Rerank","Re-order candidates with a cross-encoder","magenta"), + ("generate","Generate","Grounded answer with source citations","emerald"), + }; + var colorMap2 = new Dictionary{ + ["electric"] = ("border-electric/40","text-electric","bg-electric/10"), + ["violet"] = ("border-violet/40", "text-violet", "bg-violet/10"), + ["cyan"] = ("border-cyan/40", "text-cyan", "bg-cyan/10"), + ["magenta"] = ("border-magenta/40", "text-magenta", "bg-magenta/10"), + ["emerald"] = ("border-emerald/40", "text-emerald", "bg-emerald/10"), + }; + } + @for (int ni = 0; ni < nodes.Length; ni++) + { + var (nid, nlabel, ndesc, naccent) = nodes[ni]; + var (nborder, ntext, nbg) = colorMap2[naccent]; +
    +
    + @nlabel +

    @ndesc

    +
    +
    + if (ni < nodes.Length - 1) + { +
    + +
    + } + } +
    +
    +

    @(fa ? "تأخیر سرتاسری زیر ۵۰ میلی‌ثانیه · هر مرحله مشاهده‌پذیر" : "Sub-50ms end-to-end · every stage observable")

    +
    +
    + + +
    +
    +
    +
    @(fa ? "استک" : "Stack")
    +

    @(fa ? "ابزارهای روزانه" : "Daily tooling")

    +

    @(fa ? "هر چه ساخته می‌شود از این پایه‌ها بیرون می‌آید — انتخاب‌شده برای عمر طولانی، نه ترند روز." : "Everything I ship sits on this foundation — chosen for longevity, not hype cycles.")

    +
    +
    + @{ + var cats = fa ? new[]{ + ("زبان‌ها", new[]{"Python","TypeScript","Go","Rust","SQL"}), + ("موبایل", new[]{"Flutter","Swift / SwiftUI","Kotlin","React Native"}), + ("زیرساخت", new[]{"Kubernetes","Terraform","Postgres","Redis","Kafka","NATS"}), + ("هوش مصنوعی", new[]{"Vertex AI","Gemini","OpenAI","Anthropic","LangGraph","Pinecone","pgvector"}), + } : new[]{ + ("Languages", new[]{"Python","TypeScript","Go","Rust","SQL"}), + ("Mobile", new[]{"Flutter","Swift / SwiftUI","Kotlin","React Native"}), + ("Infrastructure",new[]{"Kubernetes","Terraform","Postgres","Redis","Kafka","NATS"}), + ("AI / ML", new[]{"Vertex AI","Gemini","OpenAI","Anthropic","LangGraph","Pinecone","pgvector"}), + }; + string[] catColors = ["text-electric","text-violet","text-emerald","text-magenta"]; + int ci2 = 0; + } + @foreach (var (catLabel, items) in cats) + { +
    +

    @catLabel

    +
      + @foreach (var item in items) + { +
    • + + @item +
    • + } +
    +
    + ci2++; + } +
    +
    +
    + + +
    +
    +
    +
    @(fa ? "تخصص" : "Expertise")
    +

    @(fa ? "اعدادی که اهمیت دارند" : "The numbers that matter")

    +

    @(fa ? "سامانه‌هایی که در میلیون‌ها رویداد در روز پایدار می‌مانند — این‌ها معیارهایی هستند که اندازه می‌گیریم." : "Systems that survive millions of events per day — these are the metrics I optimize for.")

    +
    +
    + @{ + var bars = fa ? new[]{ + ("مهندسی LLM و RAG", 95), + ("معماری ابری و Kubernetes", 92), + ("سیستم‌های عامل‌محور و اتوماسیون", 90), + ("استک گوگل کلود (Vertex / GKE)", 88), + ("موبایل بومی و cross-platform", 82), + } : new[]{ + ("LLM & RAG engineering", 95), + ("Cloud architecture & Kubernetes", 92), + ("Agentic systems & automation", 90), + ("Google Cloud stack (Vertex / GKE)", 88), + ("Native + cross-platform mobile", 82), + }; + string[] barColors = ["bg-electric","bg-violet","bg-cyan","bg-magenta","bg-emerald"]; + int bi = 0; + } + @foreach (var (blabel, bval) in bars) + { +
    +
    + @blabel + @bval% +
    +
    +
    +
    +
    + bi++; + } +
    +
    +
    + + +
    +
    +
    +
    @(fa ? "نمونه‌کارها" : "Selected work")
    +

    @(fa ? "سامانه‌هایی که در تولید کار می‌کنند" : "Systems that run in production")

    +

    @(fa ? "گزیده‌ای از پروژه‌های واقعی. روی هر کارت بزنید تا جزئیات معماری را ببینید." : "A selection of real engagements. Tap any card for the gallery and architecture details.")

    +
    + +
    + @{ + var projects = fa ? new[]{ + ("atlas-rag","اطلس — پلتفرم RAG سازمانی","بانک ردیف‌اول","مهندس ارشد هوش مصنوعی","۲۰۲۵","دستیار دانش روی بیش از ۴ میلیون سند داخلی؛ بازیابی ترکیبی با pgvector و reranker.","electric",new[]{"RAG","pgvector","Vertex AI","Eval"},new[]{("۴M+","سند نمایه‌شده"),("۳۸ms","تأخیر p95"),("۹۲٪","دقت پاسخ")},"/portfolio/atlas-rag/cover.svg",new[]{"/portfolio/atlas-rag/01.svg","/portfolio/atlas-rag/02.svg","/portfolio/atlas-rag/03.svg"}), + ("sentinel-agents","Sentinel — اتوماسیون Ops عامل‌محور","SaaS scale-up","معمار راهکار","۲۰۲۵","پاسخ خودکار به حوادث با ترکیب n8n و LangGraph — عامل‌های قابل ممیزی که alert تریاژ می‌کنند.","violet",new[]{"n8n","LangGraph","Agents"},new[]{("۷۰٪","کاهش MTTR"),("۲۴/۷","پوشش on-call"),("۱۵۰+","جریان خودکار")},"/portfolio/sentinel-agents/cover.svg",new[]{"/portfolio/sentinel-agents/01.svg","/portfolio/sentinel-agents/02.svg","/portfolio/sentinel-agents/03.svg"}), + ("vertex-vision","Vertex Vision — استنتاج بینایی بلادرنگ","زنجیره خرده‌فروشی","مهندس هوش مصنوعی","۲۰۲۴","استنتاج بینایی بلادرنگ روی GKE با Triton و Vertex AI برای تحلیل قفسه و جریان مشتری.","cyan",new[]{"Vertex AI","GKE","Triton"},new[]{("۱.۲B","استنتاج ماهانه"),("۳۰۰+","فروشگاه"),("۶۰٪","کاهش هزینه GPU")},"/portfolio/vertex-vision/cover.svg",new[]{"/portfolio/vertex-vision/01.svg","/portfolio/vertex-vision/02.svg","/portfolio/vertex-vision/03.svg"}), + ("mirage-mobile","Mirage — مجموعه هوش مصنوعی on-device","محصول مصرفی","رهبر موبایل + هوش مصنوعی","۲۰۲۴","اپلیکیشن Flutter با استنتاج کاملاً آفلاین با Gemini Nano و LiteRT.","magenta",new[]{"Flutter","Gemini Nano","LiteRT"},new[]{("۰","وابستگی شبکه"),("<80ms","پاسخ"),("۴.۸★","امتیاز کاربران")},"/portfolio/mirage-mobile/cover.svg",new[]{"/portfolio/mirage-mobile/01.svg","/portfolio/mirage-mobile/02.svg","/portfolio/mirage-mobile/03.svg"}), + ("flux-stream","Flux — مش داده رویدادمحور","پلتفرم لجستیک","معمار پلتفرم","۲۰۲۳","ستون استریمینگ روی Kafka و NATS روی Kubernetes — ۴۰+ میکروسرویس با الگوهای پایداری.","emerald",new[]{"Kafka","NATS","Kubernetes","Go"},new[]{("۴۰+","میکروسرویس"),("۲M/s","رویداد در ثانیه"),("۹۹.۹٪","uptime")},"/portfolio/flux-stream/cover.svg",new[]{"/portfolio/flux-stream/01.svg","/portfolio/flux-stream/02.svg","/portfolio/flux-stream/03.svg"}), + ("oracle-forecast","Oracle — موتور پیش‌بینی تقاضا","زنجیره تامین","مهندس ML","۲۰۲۳","پایپ‌لاین پیش‌بینی سری زمانی روی BigQuery و dbt با بازآموزی خودکار.","electric",new[]{"Forecasting","BigQuery","dbt","MLOps"},new[]{("۲۳٪","کاهش ضایعات"),("۸۹٪","دقت پیش‌بینی"),("روزانه","بازآموزی")},"/portfolio/oracle-forecast/cover.svg",new[]{"/portfolio/oracle-forecast/01.svg","/portfolio/oracle-forecast/02.svg","/portfolio/oracle-forecast/03.svg"}), + } : new[]{ + ("atlas-rag","Atlas — Enterprise RAG Platform","Tier-1 bank","Lead AI Engineer","2025","A knowledge assistant over 4M+ internal documents — hybrid retrieval with pgvector and a reranker, sub-40ms serving on Vertex AI.","electric",new[]{"RAG","pgvector","Vertex AI","Eval"},new[]{("4M+","docs indexed"),("38ms","p95 latency"),("92%","answer accuracy")},"/portfolio/atlas-rag/cover.svg",new[]{"/portfolio/atlas-rag/01.svg","/portfolio/atlas-rag/02.svg","/portfolio/atlas-rag/03.svg"}), + ("sentinel-agents","Sentinel — Agentic Ops Automation","SaaS scale-up","Solution Architect","2025","Autonomous incident response combining n8n and LangGraph — auditable agents that triage alerts and self-heal.","violet",new[]{"n8n","LangGraph","Agents"},new[]{("70%","MTTR reduction"),("24/7","on-call coverage"),("150+","automated flows")},"/portfolio/sentinel-agents/cover.svg",new[]{"/portfolio/sentinel-agents/01.svg","/portfolio/sentinel-agents/02.svg","/portfolio/sentinel-agents/03.svg"}), + ("vertex-vision","Vertex Vision — Realtime Vision Inference","Retail chain","AI Engineer","2024","Real-time vision inference on GKE with Triton and Vertex AI for shelf analytics and customer flow across 300+ stores.","cyan",new[]{"Vertex AI","GKE","Triton"},new[]{("1.2B","inferences / mo"),("300+","stores"),("60%","GPU cost cut")},"/portfolio/vertex-vision/cover.svg",new[]{"/portfolio/vertex-vision/01.svg","/portfolio/vertex-vision/02.svg","/portfolio/vertex-vision/03.svg"}), + ("mirage-mobile","Mirage — On-device AI Suite","Consumer product","Mobile + AI Lead","2024","A Flutter app with fully offline inference via Gemini Nano and LiteRT — streaming response UX with zero network dependency.","magenta",new[]{"Flutter","Gemini Nano","LiteRT"},new[]{("0","network deps"),("<80ms","response"),("4.8★","user rating")},"/portfolio/mirage-mobile/cover.svg",new[]{"/portfolio/mirage-mobile/01.svg","/portfolio/mirage-mobile/02.svg","/portfolio/mirage-mobile/03.svg"}), + ("flux-stream","Flux — Event-Driven Data Mesh","Logistics platform","Platform Architect","2023","Streaming backbone on Kafka and NATS over Kubernetes — 40+ microservices with resilience patterns and exactly-once delivery.","emerald",new[]{"Kafka","NATS","Kubernetes","Go"},new[]{("40+","microservices"),("2M/s","events / sec"),("99.9%","uptime")},"/portfolio/flux-stream/cover.svg",new[]{"/portfolio/flux-stream/01.svg","/portfolio/flux-stream/02.svg","/portfolio/flux-stream/03.svg"}), + ("oracle-forecast","Oracle — Demand Forecasting Engine","Supply chain","ML Engineer","2023","Time-series forecasting pipeline on BigQuery and dbt with automated retraining — reduced inventory waste significantly.","electric",new[]{"Forecasting","BigQuery","dbt","MLOps"},new[]{("23%","waste reduction"),("89%","forecast accuracy"),("daily","retraining")},"/portfolio/oracle-forecast/cover.svg",new[]{"/portfolio/oracle-forecast/01.svg","/portfolio/oracle-forecast/02.svg","/portfolio/oracle-forecast/03.svg"}), + }; + } + @foreach (var (pid, ptitle, pclient, prole, pyear, psummary, paccent, ptags, pmetrics, pcover, pgallery) in projects) + { + var (pborder, ptext) = paccent switch { + "violet" => ("border-violet/30", "text-violet"), + "cyan" => ("border-cyan/30", "text-cyan"), + "magenta" => ("border-magenta/30", "text-magenta"), + "emerald" => ("border-emerald/30", "text-emerald"), + _ => ("border-electric/30", "text-electric"), + }; + var galleryJson = System.Text.Json.JsonSerializer.Serialize(pgallery); +
    +
    + @ptitle +
    +
    + @foreach (var tag in ptags) + { + @tag + } +
    +

    @ptitle

    +

    @pclient · @pyear

    +
    + @foreach (var (mv, ml) in pmetrics) + { +
    +
    @mv
    +
    @ml
    +
    + } +
    +
    + } +
    +
    +
    + + + + + +
    +
    +
    +
    @(fa ? "بلاگ" : "Journal")
    +

    @(fa ? "یادداشت‌های مهندسی" : "Engineering notes")

    +

    @(fa ? "یافته‌ها از پروژه‌های واقعی — نه ترجمه‌ی مقاله، نه فهرست hype." : "Findings from real engagements — not translated articles, not hype lists.")

    +
    +
    + @{ + var posts = fa ? new[]{ + ("rag-eval-framework","LLM","چارچوب ارزیابی RAG که در تولید کار می‌کند","چرا BLEU و ROUGE برای RAG ناکافی‌اند، و معیارهایی که در پروژه‌های واقعی تصمیم می‌سازند.",8), + ("agentic-n8n-patterns","Automation","الگوهای عامل‌محور با n8n برای سازمان","چگونه n8n را با LangGraph ترکیب کنیم تا گردش‌کارهای قابل ممیزی بسازیم.",11), + ("vertex-cost-control","Google Stack","کنترل هزینه روی Vertex AI در مقیاس بالا","سه ضدالگو که در ۸۰٪ پروژه‌های Vertex می‌بینم، و چگونه ۶۰٪ هزینه را کاهش دادیم.",6), + ("k8s-llm-inference","Infra","استنتاج LLM روی Kubernetes با تأخیر زیر ۵۰ میلی‌ثانیه","الگوی استقرار با KEDA، GPU sharing، و request hedging برای سرویس‌دهی پایدار.",14), + ("flutter-on-device-ai","Mobile","هوش مصنوعی on-device در Flutter","استفاده از Gemini Nano و LiteRT برای استنتاج آفلاین در اپلیکیشن‌های موبایل.",9), + ("enterprise-ai-roadmap","Strategy","نقشه راه هوش مصنوعی سازمانی در ۹۰ روز","چارچوبی که برای CTOها می‌سازم — از کشف موارد کاربری تا اولین استقرار تولید.",7), + } : new[]{ + ("rag-eval-framework","LLM","A RAG evaluation framework that holds up in production","Why BLEU and ROUGE fall short for RAG, and the metrics that actually drive decisions in real projects.",8), + ("agentic-n8n-patterns","Automation","Agentic patterns with n8n for the enterprise","How to combine n8n with LangGraph to build auditable, debuggable autonomous workflows.",11), + ("vertex-cost-control","Google Stack","Vertex AI cost control at scale","Three anti-patterns I see in 80% of Vertex projects — and how we cut 60% of monthly spend.",6), + ("k8s-llm-inference","Infra","Sub-50ms LLM inference on Kubernetes","Deployment pattern with KEDA, GPU sharing, and request hedging for stable serving.",14), + ("flutter-on-device-ai","Mobile","On-device AI in Flutter","Using Gemini Nano and LiteRT for offline inference inside mobile apps.",9), + ("enterprise-ai-roadmap","Strategy","A 90-day enterprise AI roadmap","The framework I build for CTOs — from use-case discovery to first production deployment.",7), + }; + } + @foreach (var (slug, cat, btitle, excerpt, readTime) in posts) + { + + @cat +

    @btitle

    +

    @excerpt

    +
    + @readTime @(fa ? "دقیقه" : "min") @(fa ? "ادامه" : "read") + +
    +
    + } +
    +
    +
    + + +
    +
    +
    +
    @(fa ? "تماس" : "Contact")
    +

    @(fa ? "رزرو یک جلسه ۳۰ دقیقه‌ای" : "Book a 30-minute call")

    +

    @(fa ? "بدون هزینه، بدون تعهد. موارد کاربردی، محدودیت‌ها و گام بعدی را با هم بررسی می‌کنیم." : "No cost, no commitment. We map the use case, the constraints, and the next step together.")

    +
    + +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    + + +
    +
    + + +
    +
    + +
    + + +
    + + +

    @(fa ? "معمولاً ظرف ۲۴ ساعت کاری پاسخ می‌دهم." : "Typical reply within 24 working hours.")

    +
    +
    +
    + + +
    +
    + +

    @(fa ? "طراحی‌شده در تهران · ساخته‌شده برای سازمان‌ها" : "Designed in Tehran · Built for the enterprise")

    +

    © 2026 Soroush Asadi. @(fa ? "تمام حقوق محفوظ است." : "All rights reserved.")

    +
    +
    + +@functions { + static string ServiceIcon(string id) => id switch { + "strategy" => """""", + "automation" => """""", + "llm-rag" => """""", + "architecture" => """""", + "mobile" => """""", + "google-stack" => """""", + _ => """""", + }; +} diff --git a/Pages/Index.cshtml.cs b/Pages/Index.cshtml.cs new file mode 100644 index 0000000..ade9e7d --- /dev/null +++ b/Pages/Index.cshtml.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Mvc; +using SoroushAsadi.Services; + +namespace SoroushAsadi.Pages; + +[Microsoft.AspNetCore.Mvc.IgnoreAntiforgeryToken] +public class IndexModel : BasePageModel +{ + public string BlogReadMore { get; private set; } = "Read"; + public string BlogReadSuffix { get; private set; } = "min"; + + public void OnGet() + { + BlogReadMore = IsFa ? "ادامه" : "Read"; + BlogReadSuffix = IsFa ? "دقیقه" : "min"; + } + + public async Task OnPostContactAsync( + [FromServices] EmailService email, + string name, string company, string service, + string budget, string message) + { + if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(service) || + string.IsNullOrWhiteSpace(budget) || string.IsNullOrWhiteSpace(message)) + return BadRequest(new { error = "Missing required fields" }); + + var err = await email.SendContactAsync( + new EmailService.ContactForm(name, company ?? "", service, budget, message, Locale)); + + return err is null + ? new JsonResult(new { ok = true }) + : StatusCode(502, new { error = err }); + } +} diff --git a/Pages/LocalePage.cshtml b/Pages/LocalePage.cshtml new file mode 100644 index 0000000..062b2a6 --- /dev/null +++ b/Pages/LocalePage.cshtml @@ -0,0 +1,2 @@ +@page "/locale" +@model SoroushAsadi.Pages.LocalePageModel diff --git a/Pages/LocalePage.cshtml.cs b/Pages/LocalePage.cshtml.cs new file mode 100644 index 0000000..3028c93 --- /dev/null +++ b/Pages/LocalePage.cshtml.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace SoroushAsadi.Pages; + +/// POST /locale — sets the locale cookie and redirects back. +[IgnoreAntiforgeryToken] +public class LocalePageModel : PageModel +{ + public IActionResult OnPost(string locale, string returnUrl = "/") + { + if (locale is not "fa" and not "en") locale = "fa"; + + Response.Cookies.Append("locale", locale, new CookieOptions + { + Expires = DateTimeOffset.UtcNow.AddYears(1), + HttpOnly = false, + SameSite = SameSiteMode.Lax, + Path = "/" + }); + + if (!Url.IsLocalUrl(returnUrl)) returnUrl = "/"; + return LocalRedirect(returnUrl); + } +} diff --git a/Pages/Shared/_AdminLayout.cshtml b/Pages/Shared/_AdminLayout.cshtml new file mode 100644 index 0000000..c2fc70a --- /dev/null +++ b/Pages/Shared/_AdminLayout.cshtml @@ -0,0 +1,56 @@ + + + + + + @ViewData["Title"] — Admin + + + + + + +
    + + + + +
    +
    + CMS +
    + +
    +
    +
    + @RenderBody() +
    +
    +
    + + diff --git a/Pages/Shared/_Layout.cshtml b/Pages/Shared/_Layout.cshtml new file mode 100644 index 0000000..94a76f1 --- /dev/null +++ b/Pages/Shared/_Layout.cshtml @@ -0,0 +1,179 @@ +@{ + var locale = (string)(ViewData["Locale"] ?? "fa"); + var isRtl = locale == "fa"; + var dir = isRtl ? "rtl" : "ltr"; + var lang = locale == "fa" ? "fa" : "en"; + var title = (string?)ViewData["Title"] ?? (locale == "fa" + ? "سروش اسعدی — مهندس هوش مصنوعی، مشاور، معمار راهکار" + : "Soroush Asadi — AI Engineer, Consultant, Solution Architect"); +} + + + + + + @title + + + + + + + + + + + + + + + + + + + + + + +
    + @RenderBody() +
    + + + + @await RenderSectionAsync("Scripts", required: false) + + diff --git a/Pages/_ViewImports.cshtml b/Pages/_ViewImports.cshtml new file mode 100644 index 0000000..237d090 --- /dev/null +++ b/Pages/_ViewImports.cshtml @@ -0,0 +1,5 @@ +@using SoroushAsadi +@using SoroushAsadi.Services +@using System.Text.Json.Nodes +@namespace SoroushAsadi.Pages +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/Pages/_ViewStart.cshtml b/Pages/_ViewStart.cshtml new file mode 100644 index 0000000..820a2f6 --- /dev/null +++ b/Pages/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "_Layout"; +} diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..2eea0ae --- /dev/null +++ b/Program.cs @@ -0,0 +1,68 @@ +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.EntityFrameworkCore; +using SoroushAsadi.Data; +using SoroushAsadi.Services; + +var builder = WebApplication.CreateBuilder(args); + +// --- Razor Pages --- +builder.Services.AddRazorPages(); + +// --- Authentication (single-password cookie auth) --- +builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(opt => + { + opt.LoginPath = "/Admin/Login"; + opt.LogoutPath = "/Admin/Logout"; + opt.Cookie.Name = "sa_admin"; + opt.Cookie.HttpOnly = true; + opt.Cookie.SameSite = SameSiteMode.Lax; + opt.ExpireTimeSpan = TimeSpan.FromDays(7); + opt.SlidingExpiration = true; + }); +builder.Services.AddAuthorization(); + +// --- EF Core + SQLite --- +var dataDir = builder.Configuration["DataDir"] + ?? Path.Combine(builder.Environment.ContentRootPath, "data"); +Directory.CreateDirectory(dataDir); +Directory.CreateDirectory(Path.Combine(dataDir, "uploads")); + +builder.Services.AddDbContext(opt => + opt.UseSqlite($"Data Source={Path.Combine(dataDir, "cms.db")}")); + +// --- App services --- +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddHttpClient(); + +// --- Static file serving for /data/uploads --- +builder.Services.Configure(opt => { }); + +var app = builder.Build(); + +// Run EF migrations on startup (creates DB if missing) +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); +} + +app.UseStatusCodePagesWithReExecute("/Error/{0}"); +app.UseStaticFiles(); + +// Serve uploaded files from /data/uploads under /uploads/* +var uploadsPath = Path.Combine(dataDir, "uploads"); +app.UseStaticFiles(new StaticFileOptions +{ + FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(uploadsPath), + RequestPath = "/uploads" +}); + +app.UseRouting(); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapRazorPages(); + +app.Run(); diff --git a/Services/AuthService.cs b/Services/AuthService.cs new file mode 100644 index 0000000..96c2523 --- /dev/null +++ b/Services/AuthService.cs @@ -0,0 +1,37 @@ +using System.Security.Cryptography; +using System.Text; + +namespace SoroushAsadi.Services; + +/// Single-password authentication for the admin panel. +public class AuthService(IConfiguration config, IWebHostEnvironment env) +{ + private string? GetPassword() + { + var pw = config["ADMIN_PASSWORD"] ?? Environment.GetEnvironmentVariable("ADMIN_PASSWORD"); + if (!string.IsNullOrEmpty(pw)) return pw; + // Allow "admin" in Development only + return env.IsDevelopment() ? "admin" : null; + } + + /// True when the submitted password matches the configured one (constant-time). + public bool VerifyPassword(string input) + { + var expected = GetPassword(); + if (expected is null) return false; + + var a = SHA256Hash(input); + var b = SHA256Hash(expected); + return CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(a), + Encoding.UTF8.GetBytes(b)); + } + + public bool IsConfigured() => GetPassword() is not null; + + private static string SHA256Hash(string input) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input)); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } +} diff --git a/Services/ContentService.cs b/Services/ContentService.cs new file mode 100644 index 0000000..73010ce --- /dev/null +++ b/Services/ContentService.cs @@ -0,0 +1,107 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using SoroushAsadi.Data; + +namespace SoroushAsadi.Services; + +/// +/// Merges hardcoded default content with admin overrides stored in SQLite. +/// Sections are keyed by name ("hero", "services", etc.). +/// The stored JSON is {"fa": {...}, "en": {...}} for bilingual sections, +/// or a slug-keyed map for "posts". +/// +public class ContentService(AppDbContext db) +{ + private static readonly JsonSerializerOptions _json = + new() { PropertyNameCaseInsensitive = true }; + + // ── Public API ──────────────────────────────────────────────────────── + + /// Returns the merged content for a section as a JsonNode. + /// Callers get the locale-specific sub-object (e.g. node["en"]). + public JsonNode? GetSection(string key) + { + try + { + var row = db.ContentSections.Find(key); + if (row is null) return null; + return JsonNode.Parse(row.DataJson); + } + catch { return null; } + } + + /// Returns merged bilingual content for the given section + locale. + public JsonNode? GetSectionLocale(string key, string locale) + { + var node = GetSection(key); + return node?[locale]; + } + + /// Returns all section rows (for admin listing). + public IReadOnlyList GetSectionKeys() => + db.ContentSections.Select(s => s.Key).ToList(); + + /// Upserts a section's JSON data. + public void SaveSection(string key, string json) + { + var row = db.ContentSections.Find(key); + if (row is null) + { + row = new Models.ContentSection { Key = key }; + db.ContentSections.Add(row); + } + row.DataJson = json; + row.UpdatedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + db.SaveChanges(); + } + + /// Deletes a section override (restores built-in default). + public void DeleteSection(string key) + { + var row = db.ContentSections.Find(key); + if (row is not null) + { + db.ContentSections.Remove(row); + db.SaveChanges(); + } + } + + // ── Posts (stored under key "posts") ───────────────────────────────── + + public const string PostsKey = "posts"; + + /// Returns the slug→PostContent map from DB, or empty. + public Dictionary GetPostOverrides() + { + try + { + var row = db.ContentSections.Find(PostsKey); + if (row is null) return []; + var parsed = JsonNode.Parse(row.DataJson); + if (parsed is not JsonObject obj) return []; + return obj.ToDictionary(kv => kv.Key, kv => kv.Value!); + } + catch { return []; } + } + + /// Saves a single post override. + public void SavePost(string slug, JsonNode content) + { + var row = db.ContentSections.Find(PostsKey); + JsonObject obj; + if (row is null) + { + obj = []; + row = new Models.ContentSection { Key = PostsKey }; + db.ContentSections.Add(row); + } + else + { + obj = JsonNode.Parse(row.DataJson) as JsonObject ?? []; + } + obj[slug] = content.DeepClone(); + row.DataJson = obj.ToJsonString(); + row.UpdatedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + db.SaveChanges(); + } +} diff --git a/Services/EmailService.cs b/Services/EmailService.cs new file mode 100644 index 0000000..83a1628 --- /dev/null +++ b/Services/EmailService.cs @@ -0,0 +1,69 @@ +using System.Net.Http.Json; +using System.Text.Json; + +namespace SoroushAsadi.Services; + +public class EmailService(HttpClient http, IConfiguration config, ILogger logger) +{ + private string? ApiKey => config["RESEND_API_KEY"] ?? Environment.GetEnvironmentVariable("RESEND_API_KEY"); + private string? Inbox => config["CONTACT_INBOX"] ?? Environment.GetEnvironmentVariable("CONTACT_INBOX"); + private string? From => config["CONTACT_FROM"] ?? Environment.GetEnvironmentVariable("CONTACT_FROM"); + + public record ContactForm( + string Name, string Company, string Service, + string Budget, string Message, string Locale); + + /// null on success, error string on failure. + public async Task SendContactAsync(ContactForm form) + { + if (ApiKey is null || Inbox is null || From is null) + { + logger.LogInformation("[contact] received (no Resend key — logging only): {Name}", form.Name); + return null; // dev no-op + } + + var html = $""" +
    +

    New consultation request

    + + + + + + +
    Name{Esc(form.Name)}
    Company{Esc(form.Company)}
    Service{Esc(form.Service)}
    Budget{Esc(form.Budget)}
    Locale{Esc(form.Locale)}
    +

    Message

    +

    {Esc(form.Message)}

    +
    + """; + + using var req = new HttpRequestMessage(HttpMethod.Post, "https://api.resend.com/emails"); + req.Headers.Add("Authorization", $"Bearer {ApiKey}"); + req.Content = JsonContent.Create(new + { + from = From, + to = new[] { Inbox }, + subject = $"New consultation request — {form.Name}", + html + }); + + try + { + var res = await http.SendAsync(req); + if (!res.IsSuccessStatusCode) + { + var body = await res.Content.ReadAsStringAsync(); + logger.LogError("[contact] Resend error {Status}: {Body}", res.StatusCode, body); + return "Email service rejected the request."; + } + return null; + } + catch (Exception ex) + { + logger.LogError(ex, "[contact] Send failed"); + return "Email service unreachable."; + } + } + + private static string Esc(string? s) => System.Web.HttpUtility.HtmlEncode(s ?? ""); +} diff --git a/SoroushAsadi.Web.csproj b/SoroushAsadi.Web.csproj new file mode 100644 index 0000000..9d41700 --- /dev/null +++ b/SoroushAsadi.Web.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + SoroushAsadi + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + diff --git a/app/(admin)/admin/login/page.tsx b/app/(admin)/admin/login/page.tsx deleted file mode 100644 index 992b7f2..0000000 --- a/app/(admin)/admin/login/page.tsx +++ /dev/null @@ -1,91 +0,0 @@ -'use client'; - -import { Suspense, useState } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; - -function LoginInner() { - const router = useRouter(); - const params = useSearchParams(); - const from = params.get('from') || '/admin'; - const [password, setPassword] = useState(''); - const [error, setError] = useState(null); - const [busy, setBusy] = useState(false); - - async function submit(e: React.FormEvent) { - e.preventDefault(); - setBusy(true); - setError(null); - try { - const res = await fetch('/api/admin/login', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ password }), - }); - if (res.ok) { - router.replace(from); - router.refresh(); - } else { - setError('Incorrect password.'); - setBusy(false); - } - } catch { - setError('Something went wrong. Try again.'); - setBusy(false); - } - } - - return ( -
    -
    -
    -
    - - SA - -
    -

    Content CMS

    -

    - soroushasadi.ir -

    -
    -
    - - - setPassword(e.target.value)} - className="w-full rounded-lg border border-white/10 bg-base-900/60 px-3 py-2.5 text-sm text-slate-100 outline-none focus:border-electric/60" - placeholder="••••••••" - /> - - {error &&

    {error}

    } - - -
    -
    - ); -} - -export default function AdminLoginPage() { - return ( - - - - ); -} diff --git a/app/(admin)/admin/page.tsx b/app/(admin)/admin/page.tsx deleted file mode 100644 index 08fd86d..0000000 --- a/app/(admin)/admin/page.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import Link from 'next/link'; -import { AdminShell } from '@/components/admin/AdminShell'; -import { EDITABLE_SECTIONS } from '@/lib/content/sections'; -import { sectionStatus } from '@/lib/db/store'; -import { passwordConfigured } from '@/lib/auth/session'; - -export const dynamic = 'force-dynamic'; - -function timeAgo(ts: number): string { - const s = Math.floor((Date.now() - ts) / 1000); - if (s < 60) return 'just now'; - const m = Math.floor(s / 60); - if (m < 60) return `${m}m ago`; - const h = Math.floor(m / 60); - if (h < 24) return `${h}h ago`; - return `${Math.floor(h / 24)}d ago`; -} - -export default function AdminDashboard() { - const status = sectionStatus(); - const usingDefaultPassword = !process.env.ADMIN_PASSWORD && passwordConfigured(); - const edited = Object.keys(status).length; - - return ( - -
    -
    -

    Dashboard

    -

    - Edit every section of the site. {edited > 0 ? `${edited} section${edited > 1 ? 's' : ''} customized.` : 'All sections are at their defaults.'} -

    -
    - - {usingDefaultPassword && ( -
    - Heads up: no ADMIN_PASSWORD is set, so the dev default - (admin) is in use. Set one in your environment before going live. -
    - )} - -
    - {EDITABLE_SECTIONS.map((s) => { - const edited = status[s.key]; - return ( - -
    -

    - {s.label.en} - - {s.label.fa} - -

    - {edited ? ( - - edited · {timeAgo(edited)} - - ) : ( - - default - - )} -
    -

    {s.desc.en}

    - - ); - })} -
    - -
    - -
    -

    - Journal articles - مقالات -

    - - bodies → - -
    -

    - Edit the full bilingual body of each blog post (lead + content blocks). -

    - -
    -
    -
    - ); -} diff --git a/app/(admin)/admin/posts/[slug]/page.tsx b/app/(admin)/admin/posts/[slug]/page.tsx deleted file mode 100644 index 4260487..0000000 --- a/app/(admin)/admin/posts/[slug]/page.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { notFound } from 'next/navigation'; -import Link from 'next/link'; -import { AdminShell } from '@/components/admin/AdminShell'; -import { PostEditor } from '@/components/admin/PostEditor'; -import type { JsonValue } from '@/components/admin/JsonForm'; -import { loadContent } from '@/lib/content/load'; -import { loadPost, loadPostOverrides, isKnownSlug } from '@/lib/content/posts-store'; - -// Always render on demand so the editor mirrors current DB state. -export const dynamic = 'force-dynamic'; - -export default function AdminPostEditorPage({ params }: { params: { slug: string } }) { - const { slug } = params; - if (!isKnownSlug(slug)) notFound(); - - const post = loadPost(slug); - if (!post) notFound(); - - const overridden = slug in loadPostOverrides(); - const { en } = loadContent(); - const title = en.blog.items.find((p) => p.slug === slug)?.title ?? slug; - - return ( - -
    - - ← Journal articles - -

    {title}

    -

    - Edit the lead and body blocks for both languages, then save. Changes go live immediately. -

    - - -
    -
    - ); -} diff --git a/app/(admin)/admin/posts/page.tsx b/app/(admin)/admin/posts/page.tsx deleted file mode 100644 index bbbd7da..0000000 --- a/app/(admin)/admin/posts/page.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import Link from 'next/link'; -import { AdminShell } from '@/components/admin/AdminShell'; -import { loadContent } from '@/lib/content/load'; -import { loadAllPosts, loadPostOverrides } from '@/lib/content/posts-store'; - -// Always reflect live DB state in the editor list. -export const dynamic = 'force-dynamic'; - -export default function AdminPostsPage() { - const posts = loadAllPosts(); - const overrides = loadPostOverrides(); - const { en } = loadContent(); - const cardBySlug = new Map( - en.blog.items.map((p) => [p.slug, p]), - ); - const slugs = Object.keys(posts); - const editedCount = Object.keys(overrides).length; - - return ( - -
    -
    -

    Journal articles

    -

    - Edit the full bilingual body of each post.{' '} - {editedCount > 0 - ? `${editedCount} article${editedCount > 1 ? 's' : ''} customized.` - : 'All articles are at their defaults.'}{' '} - Titles, excerpts and read time live under the{' '} - - Journal - {' '} - section. -

    -
    - -
    - {slugs.map((slug) => { - const card = cardBySlug.get(slug); - const post = posts[slug]; - const edited = slug in overrides; - return ( - -
    -

    - {card?.title ?? slug} -

    - {edited ? ( - - edited - - ) : ( - - default - - )} -
    -
    - {card?.category ?? '—'} - · - {post.date} -
    - - ); - })} -
    -
    -
    - ); -} diff --git a/app/(admin)/admin/sections/[key]/page.tsx b/app/(admin)/admin/sections/[key]/page.tsx deleted file mode 100644 index 6c85cb4..0000000 --- a/app/(admin)/admin/sections/[key]/page.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { notFound } from 'next/navigation'; -import Link from 'next/link'; -import { AdminShell } from '@/components/admin/AdminShell'; -import { SectionEditor } from '@/components/admin/SectionEditor'; -import type { JsonValue } from '@/components/admin/JsonForm'; -import { isEditableKey, sectionLabel } from '@/lib/content/sections'; -import { loadSection } from '@/lib/content/load'; -import { getSection } from '@/lib/db/store'; - -// Always render on demand: the editor must reflect the current DB state, and -// generateStaticParams would otherwise bake build-time defaults into the page. -export const dynamic = 'force-dynamic'; - -export default function SectionEditorPage({ params }: { params: { key: string } }) { - const { key } = params; - if (!isEditableKey(key)) notFound(); - - const data = loadSection(key); - const label = sectionLabel(key); - const isOverridden = getSection(key) !== null; - - return ( - -
    - - ← Dashboard - -

    - {label.en} - - {label.fa} - -

    -

    - Edit both languages with the FA / EN tabs, then save. Changes go live immediately. -

    - - -
    -
    - ); -} diff --git a/app/(admin)/layout.tsx b/app/(admin)/layout.tsx deleted file mode 100644 index 389b45c..0000000 --- a/app/(admin)/layout.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type { Metadata } from 'next'; - -export const metadata: Metadata = { - title: 'Admin · Content CMS', - robots: { index: false, follow: false }, -}; - -// Admin chrome is independent of the public LocaleProvider/Navbar; the -// route-group split means these pages never inherit the marketing layout. -export default function AdminRootLayout({ - children, -}: { - children: React.ReactNode; -}) { - return
    {children}
    ; -} diff --git a/app/(site)/blog/[slug]/page.tsx b/app/(site)/blog/[slug]/page.tsx deleted file mode 100644 index a9f4b26..0000000 --- a/app/(site)/blog/[slug]/page.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { notFound } from 'next/navigation'; -import { loadContent } from '@/lib/content/load'; -import { loadPost } from '@/lib/content/posts-store'; -import { BlogArticle } from '@/components/blog/BlogArticle'; - -// Live content: bodies and card meta both come from the CMS-merged tree, so -// admin edits show immediately. (No generateStaticParams — render on demand.) -export const dynamic = 'force-dynamic'; - -type Params = { slug: string }; - -export function generateMetadata({ params }: { params: Params }) { - const { en } = loadContent(); - const post = en.blog.items.find((p) => p.slug === params.slug); - if (!post) return {}; - return { - title: post.title, - description: post.excerpt, - openGraph: { title: post.title, description: post.excerpt, type: 'article' }, - alternates: { - canonical: `/blog/${post.slug}`, - languages: { - 'fa-IR': `/blog/${post.slug}`, - 'en-US': `/blog/${post.slug}`, - }, - }, - }; -} - -export default function BlogPostPage({ params }: { params: Params }) { - const content = loadPost(params.slug); - const { en: enContent, fa: faContent } = loadContent(); - const en = enContent.blog.items.find((p) => p.slug === params.slug); - const fa = faContent.blog.items.find((p) => p.slug === params.slug); - if (!content || !en || !fa) notFound(); - - return ( - - ); -} diff --git a/app/(site)/layout.tsx b/app/(site)/layout.tsx deleted file mode 100644 index c4097cb..0000000 --- a/app/(site)/layout.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { LocaleProvider } from '@/lib/i18n/locale-context'; -import { loadContent } from '@/lib/content/load'; -import { Navbar } from '@/components/nav/Navbar'; -import { CustomCursor } from '@/components/ui/CustomCursor'; - -/** - * Public site shell. Reads the live content tree (dict defaults merged with - * any admin overrides) on every request so edits made in the panel appear - * immediately, then feeds it to the client-side LocaleProvider. - */ -export const dynamic = 'force-dynamic'; - -export default function SiteLayout({ children }: { children: React.ReactNode }) { - const content = loadContent(); - - return ( - - {/* Ambient backdrop */} -
    -
    - - -
    {children}
    - - ); -} diff --git a/app/(site)/page.tsx b/app/(site)/page.tsx deleted file mode 100644 index bbf5266..0000000 --- a/app/(site)/page.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Hero } from '@/components/hero/Hero'; -import { Services } from '@/components/sections/Services'; -import { DataFlow } from '@/components/sections/DataFlow'; -import { Stack } from '@/components/sections/Stack'; -import { Expertise } from '@/components/sections/Expertise'; -import { Portfolio } from '@/components/sections/Portfolio'; -import { Blog } from '@/components/sections/Blog'; -import { Contact } from '@/components/sections/Contact'; -import { Footer } from '@/components/sections/Footer'; - -export default function HomePage() { - return ( - <> - - - - - - - - -