From 52485a17c4d1cd41f0baf8ce285ce525cc3e4bb0 Mon Sep 17 00:00:00 2001 From: Nikolay Mamadaev Date: Wed, 7 Oct 2026 12:52:10 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=86=D0=B0=20/ai-dev/=20=E2=80=94=20AI-=D1=80=D0=B0=D0=B7?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D0=B0=20=D0=B8=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D1=8B,=20=D0=BA=D0=B0=D1=80?= =?UTF-8?q?=D1=82=D0=BE=D1=87=D0=BA=D0=B0=20=D0=B2=20=D0=90=D0=BA=D1=82?= =?UTF-8?q?=D0=B8=D0=B2=D0=BD=D0=BE=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/ai-dev/index.html | 653 +++++++++++++++++ public/assets/ai-dev/placeholder-01.webp | Bin 0 -> 594 bytes public/assets/ai-dev/placeholder-02.webp | Bin 0 -> 526 bytes public/assets/ai-dev/placeholder-03.webp | Bin 0 -> 594 bytes public/index.html | 398 ++++++----- public/sitemap.xml | 6 + scripts/build-pages.mjs | 867 ++++++++++++----------- scripts/gen-ai-dev-placeholders.mjs | 32 + src/pages/ai-dev.html | 351 +++++++++ src/partials/mini-projects-section.html | 398 ++++++----- 10 files changed, 1895 insertions(+), 810 deletions(-) create mode 100644 public/ai-dev/index.html create mode 100644 public/assets/ai-dev/placeholder-01.webp create mode 100644 public/assets/ai-dev/placeholder-02.webp create mode 100644 public/assets/ai-dev/placeholder-03.webp create mode 100644 scripts/gen-ai-dev-placeholders.mjs create mode 100644 src/pages/ai-dev.html diff --git a/public/ai-dev/index.html b/public/ai-dev/index.html new file mode 100644 index 0000000..eb95019 --- /dev/null +++ b/public/ai-dev/index.html @@ -0,0 +1,653 @@ + + + + + + + AI-разработка · Николай Мамадаев + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+
+ +
+
+ + +
+ + + +
+
+ +
+ + +
+
+

AI-разработка · идея, код, деплой

+

Делаю целые продукты

+

Telegram-боты, MCP-серверы, плагины и утилиты. Схема, код, деплой

+ +

+ Больше десяти лет я проектировал интерфейсы. Теперь довожу идеи до работающего + кода: бэкенд, база данных, деплой. Инфраструктура тоже моя часть работы. + В большинстве проектов код пишет ИИ-агент. Я задаю архитектуру, проверяю + результат и отвечаю за то, что продукт вообще работает. +

+ +

Что я делаю

+
    +
  • Продуктовая логика и API
  • +
  • Telegram-боты на aiogram 3
  • +
  • MCP-серверы
  • +
  • Плагины Figma и Obsidian
  • +
  • Автоматизация и агентные пайплайны
  • +
+ +

Проекты

+
    +
  1. +
    + +
    +
    +

    Personal Assistant

    +
    +

    Python · aiogram 3 · PostgreSQL

    +

    + Личный ассистент в Telegram: напоминания, планирование дел и работа с LLM + в одном диалоге. +

    + +
  2. +
  3. +
    + +
    +
    +

    Image Gen Bot

    +
    +

    Python · aiogram 3 · Redis · PostgreSQL

    +

    + Бот для генерации изображений: баланс, реферальная программа, админ-логи + в отдельные топики супергруппы. +

    + +
  4. +
  5. +
    + +
    +
    +

    MCP Image Server

    +
    +

    JavaScript · MCP · OpenRouter

    +

    + Собственный MCP-сервер для генерации картинок. Подключается к агентам + как инструмент. +

    + +
  6. +
  7. +
    + +
    +
    +

    AI assistant

    +
    +

    TypeScript · Figma Plugin API

    +

    + Плагин для Figma, который исполняет скрипты прямо в редакторе. + ИИ-ассистент для любого тарифа. +

    + +
  8. +
  9. +
    + +
    +
    +

    Card Columns

    +
    +

    TypeScript · Obsidian API

    +

    + Вью для заметок в стиле канбан-доски: группировка по колонкам с фильтрами + по тегам. +

    + +
  10. +
  11. +
    + +
    +
    +

    Hermes Desktop Plugins

    +
    +

    JavaScript · TypeScript · Electron

    +

    + Плагины для рабочего стола Hermes: чип баланса API, звук завершения задачи + и контроль трат. +

    + +
  12. +
  13. +
    + +
    +
    +

    Social Post Writer

    +
    +

    Python · aiogram 3 · SQLite

    +

    + Бот, который превращает тезисы в готовый пост под нужную площадку и тон, + учитывая голос автора. +

    + +
  14. +
  15. +
    + +
    +
    +

    AI Design Assistant

    +
    +

    HTML · CSS · GLSL · WebGL

    +

    + Этот сайт целиком: структура, дизайн-система, шейдеры, CI/CD. + Весь цикл создания вместе с ИИ-агентом. +

    + +
  16. +
+ +

Стек

+
    +
  • Python
  • +
  • TypeScript
  • +
  • JavaScript
  • +
  • Go
  • +
  • HTML/CSS
  • +
  • GLSL
  • +
  • aiogram 3
  • +
  • FastAPI
  • +
  • PostgreSQL
  • +
  • Redis
  • +
  • SQLite
  • +
  • Docker
  • +
  • Coolify
  • +
  • Gitea
  • +
  • MCP
  • +
+ + +

+ Код открыт: GitHub + и Gitea. + Сервисы, которые работают прямо сейчас, живут на + secrets-4-me. +

+
+
+ + \ No newline at end of file diff --git a/public/assets/ai-dev/placeholder-01.webp b/public/assets/ai-dev/placeholder-01.webp new file mode 100644 index 0000000000000000000000000000000000000000..03e0a4e6121cbe41d63b2dfa3ebb80f697990bc7 GIT binary patch literal 594 zcmWIYbaV4!Vqge&bqWXzuu!lAvIQ&|<}zwEFil{zo9eaLcj*#kB?S(K4MLrnc@N8@ zeHfT7-L&Md-Bcv^JLQS(x05HT-%g(J{kFQ|`)z*C_jBg7?;DjKOu=%Ef#Ki({o0?8 z_HBP&nr_RU-hWewAxt%9;}iPb#7Ww5X#53 M@L&ibAqF4-07Gy*A^-pY literal 0 HcmV?d00001 diff --git a/public/assets/ai-dev/placeholder-02.webp b/public/assets/ai-dev/placeholder-02.webp new file mode 100644 index 0000000000000000000000000000000000000000..99a82e72041c35de44970ffdce4fd0f2db66bb03 GIT binary patch literal 526 zcmWIYbaP{4Vqge&bqWXzuu%BL$iN_A$uO5utAS|(quo@m#lB0IC@U#&Fl-R&%*=aO zo+$0Wb@tZBq9VE9DNk&_ojg(fcJhSpx78KjZ}W4$pEIX@-%U&Y+D)U{d;&)%l4;?KvQk3S!Oj?O)|4_3*DIybE-2<78i Mcrb*J5CaeZ03h5u&Hw-a literal 0 HcmV?d00001 diff --git a/public/index.html b/public/index.html index bdc19b9..60847ba 100644 --- a/public/index.html +++ b/public/index.html @@ -818,197 +818,213 @@ -
- -
+
+ +
diff --git a/public/sitemap.xml b/public/sitemap.xml index 5448c23..ff2a894 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -1,6 +1,12 @@ + + https://mamadaevv.site/ai-dev/ + 2026-10-07 + + + https://mamadaevv.site/cases/ai-assistant-figma/ 2026-09-26 diff --git a/scripts/build-pages.mjs b/scripts/build-pages.mjs index f627e7b..58c08e3 100644 --- a/scripts/build-pages.mjs +++ b/scripts/build-pages.mjs @@ -1,428 +1,439 @@ -import fs from "node:fs"; -import path from "node:path"; - -const rootDir = process.cwd(); -const srcPagesDir = path.join(rootDir, "src", "pages"); -const srcPartialsDir = path.join(rootDir, "src", "partials"); -const srcI18nDir = path.join(rootDir, "src", "i18n"); -const outDir = path.join(rootDir, "public"); - -// Локали: дефолтная (ru) собирается в корень public/, остальные — в public//. -const LOCALES = ["ru", "en"]; -const DEFAULT_LOCALE = "ru"; - -// SVG-displacement больше не используется: всё стекло — через CSS blur. -// --- SEO --- -const SITE_ORIGIN = "https://mamadaevv.site"; -const OG_IMAGE_DEFAULT = `${SITE_ORIGIN}/assets/og/og-default.png`; -const AUTHOR_NAME_RU = "Николай Мамадаев"; -const AUTHOR_NAME_EN = "Nikolai Mamadaev"; -const SITE_NAME_RU = "Николай Мамадаев — продуктовый дизайнер"; -const SITE_NAME_EN = "Nikolai Mamadaev — Product Designer"; -// Служебные страницы-каталоги: закрыты от индексации. -const NOINDEX_PAGES = new Set([ - "cases/design-system", - "cases/template-elements-demo", - "cases/mini-projects-showcase", -]); - -// Страницы, у которых пока нет перевода: собираются только в дефолтную локаль, -// чтобы /en/ не отдавал русскоязычный дубль и не попадал в sitemap. -const DEFAULT_LOCALE_ONLY_PAGES = new Set(); - -// Своя og-картинка для страницы основного AI Server-кейса. -const PER_PAGE_OG_IMAGE = { - "cases/ai-platform-primo-rpa": `${SITE_ORIGIN}/assets/og/og-primo-star.png`, -}; - -const titleRe = /([\s\S]*?)<\/title>/i; -const descRe = /<meta\s+name="description"\s+content="([\s\S]*?)"\s*\/?>/i; - -function decodeEntities(str) { - return String(str) - .replace(/ /g, " ") - .replace(/&/g, "&") - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/</g, "<") - .replace(/>/g, ">"); -} - -function extractMeta(html) { - const t = html.match(titleRe); - const d = html.match(descRe); - return { - title: t ? decodeEntities(t[1]).trim() : "", - description: d ? decodeEntities(d[1]).trim() : "", - }; -} - -/** Путь страницы на сайте (без ведущего слэша, с закрывающим). */ -function sitePath(relNorm, loc) { - const base = relNorm - .replace(/\.html$/, "") - .replace(/(^|\/)index$/, "/"); - const norm = relNorm.startsWith("cases/") - ? `cases/${path.basename(relNorm, ".html")}/` - : base === "resume" ? "resume/" - : base === "cookie-policy" ? "cookie-policy/" - : base === "" || base === "/" ? "" - : `${base.replace(/\/$/, "")}/`; - return loc === DEFAULT_LOCALE ? norm : `${loc}/${norm}`; -} - -/** Канонический URL страницы. */ -function canonicalFor(relNorm, loc) { - const p = sitePath(relNorm, loc); - return `${SITE_ORIGIN}/${p}`; -} - -/** Дата изменения страницы (mtime исходника) в формате YYYY-MM-DD. */ -function lastModFor(relNorm) { - const src = path.join(srcPagesDir, relNorm); - const enSrc = src.replace(/\.html$/, ".en.html"); - let mtime = 0; - if (fs.existsSync(src)) mtime = fs.statSync(src).mtimeMs; - if (fs.existsSync(enSrc)) mtime = Math.max(mtime, fs.statSync(enSrc).mtimeMs); - return new Date(mtime || Date.now()).toISOString().slice(0, 10); -} - -/** JSON-LD для страницы: Person+WebSite на главной, CreativeWork на кейсах, BreadcrumbList везде. */ -function buildJsonLd(relNorm, loc, meta, canonical) { - const isRu = loc === DEFAULT_LOCALE; - const authorName = isRu ? AUTHOR_NAME_RU : AUTHOR_NAME_EN; - const siteName = isRu ? SITE_NAME_RU : SITE_NAME_EN; - const graph = []; - const personId = `${SITE_ORIGIN}/#person`; - const websiteId = `${SITE_ORIGIN}/#website`; - - const person = { - "@type": "Person", - "@id": personId, - name: authorName, - url: SITE_ORIGIN, - jobTitle: isRu ? "Продуктовый дизайнер" : "Product Designer", - description: isRu - ? "Продуктовый дизайнер с опытом более 10 лет. Проектирую цифровые продукты, интерфейсы и дизайн-системы." - : "Product designer with 10+ years of experience. Designing digital products, interfaces and design systems.", - knowsAbout: isRu - ? ["UX/UI-дизайн", "продуктовый дизайн", "дизайн-системы", "прототипирование", "Figma", "no-code"] - : ["UX/UI design", "product design", "design systems", "prototyping", "Figma", "no-code"], - sameAs: ["https://t.me/mamadaevv", "https://vk.com/mamadaevv", "https://github.com/mamadaevv"], - }; - graph.push(person); - - graph.push({ - "@type": "WebSite", - "@id": websiteId, - url: `${SITE_ORIGIN}/`, - name: siteName, - inLanguage: loc, - author: { "@id": personId }, - publisher: { "@id": personId }, - }); - - const isCase = relNorm.startsWith("cases/"); - const pageNode = { - "@type": isCase ? "CreativeWork" : "WebPage", - "@id": `${canonical}#page`, - url: canonical, - name: meta.title, - description: meta.description, - isPartOf: { "@id": websiteId }, - author: { "@id": personId }, - inLanguage: loc, - }; - if (isCase && !NOINDEX_PAGES.has(relNorm.replace(/\.html$/, ""))) { - pageNode.creator = { "@id": personId }; - pageNode.datePublished = lastModFor(relNorm); - pageNode.about = isRu - ? "UX/UI-дизайн и проектирование интерфейсов" - : "UX/UI design and interface design"; - } - graph.push(pageNode); - - // Хлебные крошки для вложенных страниц. - const parts = sitePath(relNorm, loc).split("/").filter(Boolean); - if (parts.length > 0) { - const crumbs = [{ "@type": "ListItem", position: 1, name: siteName, item: `${SITE_ORIGIN}/` }]; - let acc = SITE_ORIGIN; - let pos = 2; - for (const part of parts) { - if (part === loc) continue; - acc += `/${part}`; - crumbs.push({ - "@type": "ListItem", - position: pos++, - name: part === "cases" ? (isRu ? "Кейсы" : "Cases") : meta.title || part, - item: `${acc}/`, - }); - } - if (crumbs.length > 1) { - graph.push({ - "@type": "BreadcrumbList", - "@id": `${canonical}#breadcrumbs`, - itemListElement: crumbs, - }); - } - } - - return { - "@context": "https://schema.org", - "@graph": graph, - }; -} - -/** Генерация sitemap.xml с hreflang-альтернатами. */ -function buildSitemap(pageFiles) { - const urls = []; - const localeSuffixRe = new RegExp(`\\.(${LOCALES.join("|")})\\.html$`, "i"); - for (const pagePath of pageFiles) { - const relNorm = path.relative(srcPagesDir, pagePath).split(path.sep).join("/"); - const noSlug = relNorm.replace(/\.html$/, ""); - if (NOINDEX_PAGES.has(noSlug)) continue; - const lastmod = lastModFor(relNorm); - const locUrl = canonicalFor(relNorm, DEFAULT_LOCALE); - const buildLocales = DEFAULT_LOCALE_ONLY_PAGES.has(noSlug) ? [DEFAULT_LOCALE] : LOCALES; - const alternates = buildLocales.map( - (loc) => - ` <xhtml:link rel="alternate" hreflang="${loc}" href="${canonicalFor(relNorm, loc)}" />` - ); - urls.push( - ` <url>\n <loc>${locUrl}</loc>\n <lastmod>${lastmod}</lastmod>\n` + - ` <xhtml:link rel="alternate" hreflang="x-default" href="${locUrl}" />\n` + - alternates.join("\n") + - `\n </url>` - ); - } - return ( - `<?xml version="1.0" encoding="UTF-8"?>\n` + - `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"\n` + - ` xmlns:xhtml="http://www.w3.org/1999/xhtml">\n` + - urls.join("\n") + - `\n</urlset>\n` - ); -} - -const includeRe = /\{\{\>\s*([a-z0-9-]+)\s*\}\}/gi; -const varRe = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g; -const i18nRe = /\{\{\s*i18n:([\w.-]+)\s*\}\}/g; - -function readUtf8(filePath) { - return fs.readFileSync(filePath, "utf8"); -} - -function walkHtmlFiles(dir, acc = []) { - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - const abs = path.join(dir, entry.name); - if (entry.isDirectory()) { - walkHtmlFiles(abs, acc); - continue; - } - if (entry.isFile() && entry.name.endsWith(".html")) { - acc.push(abs); - } - } - return acc; -} - -function loadPartials() { - const partialMap = new Map(); - for (const filePath of walkHtmlFiles(srcPartialsDir)) { - const name = path.basename(filePath, ".html"); - partialMap.set(name, readUtf8(filePath)); - } - return partialMap; -} - -function loadI18n() { - const map = {}; - for (const loc of LOCALES) { - const file = path.join(srcI18nDir, `${loc}.json`); - map[loc] = fs.existsSync(file) ? JSON.parse(readUtf8(file)) : {}; - } - return map; -} - -function resolveI18n(key, dict, fallback) { - if (dict && Object.prototype.hasOwnProperty.call(dict, key)) return String(dict[key]); - if (fallback && Object.prototype.hasOwnProperty.call(fallback, key)) return String(fallback[key]); - return `{{i18n:${key}}}`; -} - -function renderTemplate(input, partials, vars, dict, fallback, loc, stack = []) { - const withIncludes = input.replace(includeRe, (_, name) => { - const key = String(name).trim(); - const partialKey = loc && loc !== DEFAULT_LOCALE && partials.has(`${key}.${loc}`) ? `${key}.${loc}` : key; - if (!partials.has(partialKey)) { - throw new Error(`Partial "${key}" not found`); - } - if (stack.includes(partialKey)) { - throw new Error(`Circular partial include: ${[...stack, partialKey].join(" -> ")}`); - } - const partialContent = partials.get(partialKey); - return renderTemplate(partialContent, partials, vars, dict, fallback, loc, [...stack, partialKey]); - }); - - const withI18n = withIncludes.replace(i18nRe, (_, key) => resolveI18n(String(key).trim(), dict, fallback)); - - return withI18n.replace(varRe, (_, name) => { - const key = String(name).trim(); - if (Object.prototype.hasOwnProperty.call(vars, key)) { - return String(vars[key]); - } - return `{{${key}}}`; - }); -} - -function ensureDir(dirPath) { - fs.mkdirSync(dirPath, { recursive: true }); -} - -/** На странице кейса убираем из HTML карусели карточку со ссылкой на текущий файл (href уже с подставленным casesPrefix). */ -function stripCurrentCasePortfolioCarouselItem(html, omitHrefBasename) { - if (!omitHrefBasename) return html; - const slug = omitHrefBasename.replace(/\.html$/, ""); - const hrefDouble = `href="${slug}/"`; - const hrefSingle = `href='${slug}/'`; - const itemClass = "projects-carousel__item"; - let search = 0; - while (true) { - const i = html.indexOf("<article", search); - if (i === -1) return html; - const tagEnd = html.indexOf(">", i); - if (tagEnd === -1) return html; - const openTag = html.slice(i, tagEnd + 1); - if (!openTag.includes(itemClass)) { - search = i + 9; - continue; - } - const contentStart = tagEnd + 1; - const closeIdx = html.indexOf("</article>", contentStart); - if (closeIdx === -1) return html; - const afterArticle = closeIdx + "</article>".length; - const block = html.slice(i, afterArticle); - if (block.includes(hrefDouble) || block.includes(hrefSingle)) { - return html.slice(0, i) + html.slice(afterArticle); - } - search = contentStart; - } -} - -/** Базовые переменны для страницы в дефолтной локали (как было до i18n). */ -function baseVars(relNorm, depth) { - if (relNorm.startsWith("cases/")) { - return { assetPrefix: "../../", casesPrefix: "../" }; - } - if (relNorm === "resume.html") { - return { assetPrefix: "../", casesPrefix: "" }; - } - if (relNorm === "cookie-policy.html") { - return { assetPrefix: "../", casesPrefix: "" }; - } - return { - assetPrefix: depth === 0 ? "" : "../".repeat(depth), - casesPrefix: depth === 0 ? "cases/" : "", - }; -} - -function build() { - const partials = loadPartials(); - const i18n = loadI18n(); - const defaultDict = i18n[DEFAULT_LOCALE] || {}; - const localeSuffixRe = new RegExp(`\\.(${LOCALES.join("|")})\\.html$`, "i"); - const pageFiles = walkHtmlFiles(srcPagesDir).filter((p) => !localeSuffixRe.test(p)); - - for (const pagePath of pageFiles) { - const rel = path.relative(srcPagesDir, pagePath); - const relNorm = rel.split(path.sep).join("/"); - const pageDirRel = path.dirname(rel); - const depth = pageDirRel === "." ? 0 : pageDirRel.split(path.sep).length; - const isCase = relNorm.startsWith("cases/"); - const isResume = relNorm === "resume.html"; - const isCookiePolicy = relNorm === "cookie-policy.html"; - const omitFile = isCase ? path.basename(pagePath) : null; - const noSlug = relNorm.replace(/\.html$/, ""); - - for (const loc of LOCALES) { - if (loc !== DEFAULT_LOCALE && DEFAULT_LOCALE_ONLY_PAGES.has(noSlug)) continue; - const dict = i18n[loc] || {}; - const isDefault = loc === DEFAULT_LOCALE; - const langPrefix = isDefault ? "" : `${loc}/`; - - // Переведённый исходник страницы для локали, если есть. - let sourcePath = pagePath; - if (!isDefault) { - const override = pagePath.replace(/\.html$/, `.${loc}.html`); - if (fs.existsSync(override)) sourcePath = override; - } - const source = readUtf8(sourcePath); - - // --- SEO-переменные для head-seo / json-ld --- - const isNoindex = NOINDEX_PAGES.has(noSlug); - const canonicalUrl = canonicalFor(relNorm, loc); - const metaSource = extractMeta(source); - const pageTitle = metaSource.title || (isDefault ? SITE_NAME_RU : SITE_NAME_EN); - const pageDesc = metaSource.description || pageTitle; - const isCasePage = relNorm.startsWith("cases/"); - - const vars = baseVars(relNorm, depth); - vars.canonicalUrl = canonicalUrl; - vars.canonicalRu = canonicalFor(relNorm, "ru"); - vars.canonicalEn = canonicalFor(relNorm, "en"); - vars.authorName = isDefault ? AUTHOR_NAME_RU : AUTHOR_NAME_EN; - vars.siteName = isDefault ? SITE_NAME_RU : SITE_NAME_EN; - vars.metaRobots = isNoindex ? "noindex, nofollow" : "index, follow"; - vars.themeColor = "#0f1115"; - vars.ogType = isCasePage ? "article" : "website"; - vars.ogLocale = isDefault ? "ru_RU" : "en_US"; - vars.ogLocaleAlt = isDefault ? "en_US" : "ru_RU"; - vars.ogTitle = pageTitle; - vars.ogDescription = pageDesc; - vars.ogImage = PER_PAGE_OG_IMAGE[noSlug] || OG_IMAGE_DEFAULT; - vars.ogImageAlt = pageTitle; - vars.jsonLd = JSON.stringify(buildJsonLd(relNorm, loc, { title: pageTitle, description: pageDesc }, canonicalUrl), null, 2); - - // Префикс до КОРНЯ ЛОКАЛИ (без en-сдвига) — для навигационных ссылок, - // чтобы они вели в ту же локаль (/en/), а не в корень сайта (/). - vars.pagePrefix = vars.assetPrefix; - // Для не-дефолтной локали всё содержимое уходит на уровень глубже (public/<lang>/), - // поэтому к assetPrefix добавляем один ../, а пустой casesPrefix — в ../cases/. - if (!isDefault) { - vars.assetPrefix = vars.assetPrefix === "" ? "../" : vars.assetPrefix + "../"; - if (vars.casesPrefix === "") vars.casesPrefix = "../cases/"; - } - vars.lang = loc; - - let outPath; - if (isCase) { - const slug = path.basename(pagePath, ".html"); - outPath = path.join(outDir, langPrefix, "cases", slug, "index.html"); - } else if (isResume) { - outPath = path.join(outDir, langPrefix, "resume", "index.html"); - } else if (isCookiePolicy) { - outPath = path.join(outDir, langPrefix, "cookie-policy", "index.html"); - } else { - outPath = path.join(outDir, langPrefix, rel); - } - - let rendered = renderTemplate(source, partials, vars, dict, defaultDict, loc); - // Подменяем язык в <html lang="..."> под текущую локаль. - rendered = rendered.replace(/<html\s+lang="ru"/, `<html lang="${loc}"`); - if (isCase) { - rendered = stripCurrentCasePortfolioCarouselItem(rendered, omitFile); - } - ensureDir(path.dirname(outPath)); - fs.writeFileSync(outPath, rendered, "utf8"); - } - } - - const sitemapXml = buildSitemap(pageFiles); - fs.writeFileSync(path.join(outDir, "sitemap.xml"), sitemapXml, "utf8"); - - console.log(`Built ${pageFiles.length} pages × ${LOCALES.length} locales into public/`); - console.log(`Generated sitemap.xml (${sitemapXml.split("<url>").length - 1} urls)`); -} - -build(); +import fs from "node:fs"; +import path from "node:path"; + +const rootDir = process.cwd(); +const srcPagesDir = path.join(rootDir, "src", "pages"); +const srcPartialsDir = path.join(rootDir, "src", "partials"); +const srcI18nDir = path.join(rootDir, "src", "i18n"); +const outDir = path.join(rootDir, "public"); + +// Локали: дефолтная (ru) собирается в корень public/, остальные — в public/<lang>/. +const LOCALES = ["ru", "en"]; +const DEFAULT_LOCALE = "ru"; + +// SVG-displacement больше не используется: всё стекло — через CSS blur. +// --- SEO --- +const SITE_ORIGIN = "https://mamadaevv.site"; +const OG_IMAGE_DEFAULT = `${SITE_ORIGIN}/assets/og/og-default.png`; +const AUTHOR_NAME_RU = "Николай Мамадаев"; +const AUTHOR_NAME_EN = "Nikolai Mamadaev"; +const SITE_NAME_RU = "Николай Мамадаев — продуктовый дизайнер"; +const SITE_NAME_EN = "Nikolai Mamadaev — Product Designer"; +// Служебные страницы-каталоги: закрыты от индексации. +const NOINDEX_PAGES = new Set([ + "cases/design-system", + "cases/template-elements-demo", + "cases/mini-projects-showcase", +]); + +// Страницы, у которых пока нет перевода: собираются только в дефолтную локаль, +// чтобы /en/ не отдавал русскоязычный дубль и не попадал в sitemap. +const DEFAULT_LOCALE_ONLY_PAGES = new Set(["ai-dev"]); + +// Своя og-картинка для страницы основного AI Server-кейса. +const PER_PAGE_OG_IMAGE = { + "cases/ai-platform-primo-rpa": `${SITE_ORIGIN}/assets/og/og-primo-star.png`, +}; + +const titleRe = /<title>([\s\S]*?)<\/title>/i; +const descRe = /<meta\s+name="description"\s+content="([\s\S]*?)"\s*\/?>/i; + +function decodeEntities(str) { + return String(str) + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, "<") + .replace(/>/g, ">"); +} + +function extractMeta(html) { + const t = html.match(titleRe); + const d = html.match(descRe); + return { + title: t ? decodeEntities(t[1]).trim() : "", + description: d ? decodeEntities(d[1]).trim() : "", + }; +} + +/** Путь страницы на сайте (без ведущего слэша, с закрывающим). */ +function sitePath(relNorm, loc) { + const base = relNorm + .replace(/\.html$/, "") + .replace(/(^|\/)index$/, "/"); + const norm = relNorm.startsWith("cases/") + ? `cases/${path.basename(relNorm, ".html")}/` + : base === "resume" ? "resume/" + : base === "cookie-policy" ? "cookie-policy/" + : base === "ai-dev" ? "ai-dev/" + : base === "" || base === "/" ? "" + : `${base.replace(/\/$/, "")}/`; + return loc === DEFAULT_LOCALE ? norm : `${loc}/${norm}`; +} + +/** Канонический URL страницы. */ +function canonicalFor(relNorm, loc) { + const p = sitePath(relNorm, loc); + return `${SITE_ORIGIN}/${p}`; +} + +/** Дата изменения страницы (mtime исходника) в формате YYYY-MM-DD. */ +function lastModFor(relNorm) { + const src = path.join(srcPagesDir, relNorm); + const enSrc = src.replace(/\.html$/, ".en.html"); + let mtime = 0; + if (fs.existsSync(src)) mtime = fs.statSync(src).mtimeMs; + if (fs.existsSync(enSrc)) mtime = Math.max(mtime, fs.statSync(enSrc).mtimeMs); + return new Date(mtime || Date.now()).toISOString().slice(0, 10); +} + +/** JSON-LD для страницы: Person+WebSite на главной, CreativeWork на кейсах, BreadcrumbList везде. */ +function buildJsonLd(relNorm, loc, meta, canonical) { + const isRu = loc === DEFAULT_LOCALE; + const authorName = isRu ? AUTHOR_NAME_RU : AUTHOR_NAME_EN; + const siteName = isRu ? SITE_NAME_RU : SITE_NAME_EN; + const graph = []; + const personId = `${SITE_ORIGIN}/#person`; + const websiteId = `${SITE_ORIGIN}/#website`; + + const person = { + "@type": "Person", + "@id": personId, + name: authorName, + url: SITE_ORIGIN, + jobTitle: isRu ? "Продуктовый дизайнер" : "Product Designer", + description: isRu + ? "Продуктовый дизайнер с опытом более 10 лет. Проектирую цифровые продукты, интерфейсы и дизайн-системы." + : "Product designer with 10+ years of experience. Designing digital products, interfaces and design systems.", + knowsAbout: isRu + ? ["UX/UI-дизайн", "продуктовый дизайн", "дизайн-системы", "прототипирование", "Figma", "no-code"] + : ["UX/UI design", "product design", "design systems", "prototyping", "Figma", "no-code"], + sameAs: ["https://t.me/mamadaevv", "https://vk.com/mamadaevv", "https://github.com/mamadaevv"], + }; + graph.push(person); + + graph.push({ + "@type": "WebSite", + "@id": websiteId, + url: `${SITE_ORIGIN}/`, + name: siteName, + inLanguage: loc, + author: { "@id": personId }, + publisher: { "@id": personId }, + }); + + const isCase = relNorm.startsWith("cases/"); + const pageNode = { + "@type": isCase ? "CreativeWork" : "WebPage", + "@id": `${canonical}#page`, + url: canonical, + name: meta.title, + description: meta.description, + isPartOf: { "@id": websiteId }, + author: { "@id": personId }, + inLanguage: loc, + }; + if (isCase && !NOINDEX_PAGES.has(relNorm.replace(/\.html$/, ""))) { + pageNode.creator = { "@id": personId }; + pageNode.datePublished = lastModFor(relNorm); + pageNode.about = isRu + ? "UX/UI-дизайн и проектирование интерфейсов" + : "UX/UI design and interface design"; + } + graph.push(pageNode); + + // Хлебные крошки для вложенных страниц. + const parts = sitePath(relNorm, loc).split("/").filter(Boolean); + if (parts.length > 0) { + const crumbs = [{ "@type": "ListItem", position: 1, name: siteName, item: `${SITE_ORIGIN}/` }]; + let acc = SITE_ORIGIN; + let pos = 2; + for (const part of parts) { + if (part === loc) continue; + acc += `/${part}`; + crumbs.push({ + "@type": "ListItem", + position: pos++, + name: part === "cases" ? (isRu ? "Кейсы" : "Cases") : meta.title || part, + item: `${acc}/`, + }); + } + if (crumbs.length > 1) { + graph.push({ + "@type": "BreadcrumbList", + "@id": `${canonical}#breadcrumbs`, + itemListElement: crumbs, + }); + } + } + + return { + "@context": "https://schema.org", + "@graph": graph, + }; +} + +/** Генерация sitemap.xml с hreflang-альтернатами. */ +function buildSitemap(pageFiles) { + const urls = []; + const localeSuffixRe = new RegExp(`\\.(${LOCALES.join("|")})\\.html$`, "i"); + for (const pagePath of pageFiles) { + const relNorm = path.relative(srcPagesDir, pagePath).split(path.sep).join("/"); + const noSlug = relNorm.replace(/\.html$/, ""); + if (NOINDEX_PAGES.has(noSlug)) continue; + const lastmod = lastModFor(relNorm); + const locUrl = canonicalFor(relNorm, DEFAULT_LOCALE); + const buildLocales = DEFAULT_LOCALE_ONLY_PAGES.has(noSlug) ? [DEFAULT_LOCALE] : LOCALES; + const alternates = buildLocales.map( + (loc) => + ` <xhtml:link rel="alternate" hreflang="${loc}" href="${canonicalFor(relNorm, loc)}" />` + ); + urls.push( + ` <url>\n <loc>${locUrl}</loc>\n <lastmod>${lastmod}</lastmod>\n` + + ` <xhtml:link rel="alternate" hreflang="x-default" href="${locUrl}" />\n` + + alternates.join("\n") + + `\n </url>` + ); + } + return ( + `<?xml version="1.0" encoding="UTF-8"?>\n` + + `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"\n` + + ` xmlns:xhtml="http://www.w3.org/1999/xhtml">\n` + + urls.join("\n") + + `\n</urlset>\n` + ); +} + +const includeRe = /\{\{\>\s*([a-z0-9-]+)\s*\}\}/gi; +const varRe = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g; +const i18nRe = /\{\{\s*i18n:([\w.-]+)\s*\}\}/g; + +function readUtf8(filePath) { + return fs.readFileSync(filePath, "utf8"); +} + +function walkHtmlFiles(dir, acc = []) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const abs = path.join(dir, entry.name); + if (entry.isDirectory()) { + walkHtmlFiles(abs, acc); + continue; + } + if (entry.isFile() && entry.name.endsWith(".html")) { + acc.push(abs); + } + } + return acc; +} + +function loadPartials() { + const partialMap = new Map(); + for (const filePath of walkHtmlFiles(srcPartialsDir)) { + const name = path.basename(filePath, ".html"); + partialMap.set(name, readUtf8(filePath)); + } + return partialMap; +} + +function loadI18n() { + const map = {}; + for (const loc of LOCALES) { + const file = path.join(srcI18nDir, `${loc}.json`); + map[loc] = fs.existsSync(file) ? JSON.parse(readUtf8(file)) : {}; + } + return map; +} + +function resolveI18n(key, dict, fallback) { + if (dict && Object.prototype.hasOwnProperty.call(dict, key)) return String(dict[key]); + if (fallback && Object.prototype.hasOwnProperty.call(fallback, key)) return String(fallback[key]); + return `{{i18n:${key}}}`; +} + +function renderTemplate(input, partials, vars, dict, fallback, loc, stack = []) { + const withIncludes = input.replace(includeRe, (_, name) => { + const key = String(name).trim(); + const partialKey = loc && loc !== DEFAULT_LOCALE && partials.has(`${key}.${loc}`) ? `${key}.${loc}` : key; + if (!partials.has(partialKey)) { + throw new Error(`Partial "${key}" not found`); + } + if (stack.includes(partialKey)) { + throw new Error(`Circular partial include: ${[...stack, partialKey].join(" -> ")}`); + } + const partialContent = partials.get(partialKey); + return renderTemplate(partialContent, partials, vars, dict, fallback, loc, [...stack, partialKey]); + }); + + const withI18n = withIncludes.replace(i18nRe, (_, key) => resolveI18n(String(key).trim(), dict, fallback)); + + return withI18n.replace(varRe, (_, name) => { + const key = String(name).trim(); + if (Object.prototype.hasOwnProperty.call(vars, key)) { + return String(vars[key]); + } + return `{{${key}}}`; + }); +} + +function ensureDir(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +/** На странице кейса убираем из HTML карусели карточку со ссылкой на текущий файл (href уже с подставленным casesPrefix). */ +function stripCurrentCasePortfolioCarouselItem(html, omitHrefBasename) { + if (!omitHrefBasename) return html; + const slug = omitHrefBasename.replace(/\.html$/, ""); + const hrefDouble = `href="${slug}/"`; + const hrefSingle = `href='${slug}/'`; + const itemClass = "projects-carousel__item"; + let search = 0; + while (true) { + const i = html.indexOf("<article", search); + if (i === -1) return html; + const tagEnd = html.indexOf(">", i); + if (tagEnd === -1) return html; + const openTag = html.slice(i, tagEnd + 1); + if (!openTag.includes(itemClass)) { + search = i + 9; + continue; + } + const contentStart = tagEnd + 1; + const closeIdx = html.indexOf("</article>", contentStart); + if (closeIdx === -1) return html; + const afterArticle = closeIdx + "</article>".length; + const block = html.slice(i, afterArticle); + if (block.includes(hrefDouble) || block.includes(hrefSingle)) { + return html.slice(0, i) + html.slice(afterArticle); + } + search = contentStart; + } +} + +/** Базовые переменны для страницы в дефолтной локали (как было до i18n). */ +function baseVars(relNorm, depth) { + if (relNorm.startsWith("cases/")) { + return { assetPrefix: "../../", casesPrefix: "../" }; + } + if (relNorm === "resume.html") { + return { assetPrefix: "../", casesPrefix: "" }; + } + if (relNorm === "cookie-policy.html") { + return { assetPrefix: "../", casesPrefix: "" }; + } + if (relNorm === "ai-dev.html") { + return { assetPrefix: "../", casesPrefix: "../cases/" }; + } + return { + assetPrefix: depth === 0 ? "" : "../".repeat(depth), + casesPrefix: depth === 0 ? "cases/" : "", + }; +} + +function build() { + const partials = loadPartials(); + const i18n = loadI18n(); + const defaultDict = i18n[DEFAULT_LOCALE] || {}; + const localeSuffixRe = new RegExp(`\\.(${LOCALES.join("|")})\\.html$`, "i"); + const pageFiles = walkHtmlFiles(srcPagesDir).filter((p) => !localeSuffixRe.test(p)); + + for (const pagePath of pageFiles) { + const rel = path.relative(srcPagesDir, pagePath); + const relNorm = rel.split(path.sep).join("/"); + const pageDirRel = path.dirname(rel); + const depth = pageDirRel === "." ? 0 : pageDirRel.split(path.sep).length; + const isCase = relNorm.startsWith("cases/"); + const isResume = relNorm === "resume.html"; + const isCookiePolicy = relNorm === "cookie-policy.html"; + const isAiDev = relNorm === "ai-dev.html"; + const omitFile = isCase ? path.basename(pagePath) : null; + const noSlug = relNorm.replace(/\.html$/, ""); + + for (const loc of LOCALES) { + if (loc !== DEFAULT_LOCALE && DEFAULT_LOCALE_ONLY_PAGES.has(noSlug)) continue; + const dict = i18n[loc] || {}; + const isDefault = loc === DEFAULT_LOCALE; + const langPrefix = isDefault ? "" : `${loc}/`; + + // Переведённый исходник страницы для локали, если есть. + let sourcePath = pagePath; + if (!isDefault) { + const override = pagePath.replace(/\.html$/, `.${loc}.html`); + if (fs.existsSync(override)) sourcePath = override; + } + const source = readUtf8(sourcePath); + + // --- SEO-переменные для head-seo / json-ld --- + const isNoindex = NOINDEX_PAGES.has(noSlug); + const canonicalUrl = canonicalFor(relNorm, loc); + const metaSource = extractMeta(source); + const pageTitle = metaSource.title || (isDefault ? SITE_NAME_RU : SITE_NAME_EN); + const pageDesc = metaSource.description || pageTitle; + const isCasePage = relNorm.startsWith("cases/"); + + const vars = baseVars(relNorm, depth); + vars.canonicalUrl = canonicalUrl; + vars.canonicalRu = canonicalFor(relNorm, "ru"); + // Для ru-only страниц en-альтернаты не существует — отдаём ru, + // иначе hreflang указывает на несуществующий /en/... URL. + vars.canonicalEn = DEFAULT_LOCALE_ONLY_PAGES.has(noSlug) + ? vars.canonicalRu + : canonicalFor(relNorm, "en"); + vars.authorName = isDefault ? AUTHOR_NAME_RU : AUTHOR_NAME_EN; + vars.siteName = isDefault ? SITE_NAME_RU : SITE_NAME_EN; + vars.metaRobots = isNoindex ? "noindex, nofollow" : "index, follow"; + vars.themeColor = "#0f1115"; + vars.ogType = isCasePage ? "article" : "website"; + vars.ogLocale = isDefault ? "ru_RU" : "en_US"; + vars.ogLocaleAlt = isDefault ? "en_US" : "ru_RU"; + vars.ogTitle = pageTitle; + vars.ogDescription = pageDesc; + vars.ogImage = PER_PAGE_OG_IMAGE[noSlug] || OG_IMAGE_DEFAULT; + vars.ogImageAlt = pageTitle; + vars.jsonLd = JSON.stringify(buildJsonLd(relNorm, loc, { title: pageTitle, description: pageDesc }, canonicalUrl), null, 2); + + // Префикс до КОРНЯ ЛОКАЛИ (без en-сдвига) — для навигационных ссылок, + // чтобы они вели в ту же локаль (/en/), а не в корень сайта (/). + vars.pagePrefix = vars.assetPrefix; + // Для не-дефолтной локали всё содержимое уходит на уровень глубже (public/<lang>/), + // поэтому к assetPrefix добавляем один ../, а пустой casesPrefix — в ../cases/. + if (!isDefault) { + vars.assetPrefix = vars.assetPrefix === "" ? "../" : vars.assetPrefix + "../"; + if (vars.casesPrefix === "") vars.casesPrefix = "../cases/"; + } + vars.lang = loc; + + let outPath; + if (isCase) { + const slug = path.basename(pagePath, ".html"); + outPath = path.join(outDir, langPrefix, "cases", slug, "index.html"); + } else if (isResume) { + outPath = path.join(outDir, langPrefix, "resume", "index.html"); + } else if (isCookiePolicy) { + outPath = path.join(outDir, langPrefix, "cookie-policy", "index.html"); + } else if (isAiDev) { + outPath = path.join(outDir, langPrefix, "ai-dev", "index.html"); + } else { + outPath = path.join(outDir, langPrefix, rel); + } + + let rendered = renderTemplate(source, partials, vars, dict, defaultDict, loc); + // Подменяем язык в <html lang="..."> под текущую локаль. + rendered = rendered.replace(/<html\s+lang="ru"/, `<html lang="${loc}"`); + if (isCase) { + rendered = stripCurrentCasePortfolioCarouselItem(rendered, omitFile); + } + ensureDir(path.dirname(outPath)); + fs.writeFileSync(outPath, rendered, "utf8"); + } + } + + const sitemapXml = buildSitemap(pageFiles); + fs.writeFileSync(path.join(outDir, "sitemap.xml"), sitemapXml, "utf8"); + + console.log(`Built ${pageFiles.length} pages × ${LOCALES.length} locales into public/`); + console.log(`Generated sitemap.xml (${sitemapXml.split("<url>").length - 1} urls)`); +} + +build(); diff --git a/scripts/gen-ai-dev-placeholders.mjs b/scripts/gen-ai-dev-placeholders.mjs new file mode 100644 index 0000000..d2010c6 --- /dev/null +++ b/scripts/gen-ai-dev-placeholders.mjs @@ -0,0 +1,32 @@ +// Генерация серых заглушек (16:10) для страницы ai-dev. +// Заглушки — намеренно нейтральный серый, без текста и градиентов. +import fs from "node:fs"; +import path from "node:path"; +import sharp from "sharp"; + +const outDir = path.join(process.cwd(), "public", "assets", "ai-dev"); +fs.mkdirSync(outDir, { recursive: true }); + +const W = 640; +const H = 400; + +// Разные оттенки серого, чтобы карточки не выглядели одинаковыми при загрузке. +const shades = [ + { fill: "#2a2d31", label: "01" }, + { fill: "#31353a", label: "02" }, + { fill: "#25282c", label: "03" }, +]; + +const tiles = await Promise.all( + shades.map(async ({ fill, label }) => { + const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}"> + <rect width="${W}" height="${H}" fill="${fill}" /> +</svg>`; + const file = path.join(outDir, `placeholder-${label}.webp`); + await sharp(Buffer.from(svg)).webp({ quality: 72 }).toFile(file); + return file; + }), +); + +console.log(`Generated ${tiles.length} placeholders in ${outDir}`); +for (const f of tiles) console.log(" ", path.basename(f), fs.statSync(f).size, "bytes"); \ No newline at end of file diff --git a/src/pages/ai-dev.html b/src/pages/ai-dev.html new file mode 100644 index 0000000..e5fade9 --- /dev/null +++ b/src/pages/ai-dev.html @@ -0,0 +1,351 @@ +<!DOCTYPE html> +<html lang="ru" data-theme="dark" data-radius="medium"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> + <meta + name="description" + content="Николай Мамадаев, AI-разработчик. Делаю целые продукты: Telegram-боты, MCP-серверы, плагины для Figma и Obsidian, desktop-утилиты." + /> + <title>AI-разработка · Николай Мамадаев + {{> head-links}} + {{> head-plyr}} + + + + {{> header-case}} + {{> fab-stack}} +
+
+

AI-разработка · идея, код, деплой

+

Делаю целые продукты

+

Telegram-боты, MCP-серверы, плагины и утилиты. Схема, код, деплой

+ +

+ Больше десяти лет я проектировал интерфейсы. Теперь довожу идеи до работающего + кода: бэкенд, база данных, деплой. Инфраструктура тоже моя часть работы. + В большинстве проектов код пишет ИИ-агент. Я задаю архитектуру, проверяю + результат и отвечаю за то, что продукт вообще работает. +

+ +

Что я делаю

+
    +
  • Продуктовая логика и API
  • +
  • Telegram-боты на aiogram 3
  • +
  • MCP-серверы
  • +
  • Плагины Figma и Obsidian
  • +
  • Автоматизация и агентные пайплайны
  • +
+ +

Проекты

+
    +
  1. +
    + +
    +
    +

    Personal Assistant

    +
    +

    Python · aiogram 3 · PostgreSQL

    +

    + Личный ассистент в Telegram: напоминания, планирование дел и работа с LLM + в одном диалоге. +

    + +
  2. +
  3. +
    + +
    +
    +

    Image Gen Bot

    +
    +

    Python · aiogram 3 · Redis · PostgreSQL

    +

    + Бот для генерации изображений: баланс, реферальная программа, админ-логи + в отдельные топики супергруппы. +

    + +
  4. +
  5. +
    + +
    +
    +

    MCP Image Server

    +
    +

    JavaScript · MCP · OpenRouter

    +

    + Собственный MCP-сервер для генерации картинок. Подключается к агентам + как инструмент. +

    + +
  6. +
  7. +
    + +
    +
    +

    AI assistant

    +
    +

    TypeScript · Figma Plugin API

    +

    + Плагин для Figma, который исполняет скрипты прямо в редакторе. + ИИ-ассистент для любого тарифа. +

    + +
  8. +
  9. +
    + +
    +
    +

    Card Columns

    +
    +

    TypeScript · Obsidian API

    +

    + Вью для заметок в стиле канбан-доски: группировка по колонкам с фильтрами + по тегам. +

    + +
  10. +
  11. +
    + +
    +
    +

    Hermes Desktop Plugins

    +
    +

    JavaScript · TypeScript · Electron

    +

    + Плагины для рабочего стола Hermes: чип баланса API, звук завершения задачи + и контроль трат. +

    + +
  12. +
  13. +
    + +
    +
    +

    Social Post Writer

    +
    +

    Python · aiogram 3 · SQLite

    +

    + Бот, который превращает тезисы в готовый пост под нужную площадку и тон, + учитывая голос автора. +

    + +
  14. +
  15. +
    + +
    +
    +

    AI Design Assistant

    +
    +

    HTML · CSS · GLSL · WebGL

    +

    + Этот сайт целиком: структура, дизайн-система, шейдеры, CI/CD. + Весь цикл создания вместе с ИИ-агентом. +

    + +
  16. +
+ +

Стек

+
    +
  • Python
  • +
  • TypeScript
  • +
  • JavaScript
  • +
  • Go
  • +
  • HTML/CSS
  • +
  • GLSL
  • +
  • aiogram 3
  • +
  • FastAPI
  • +
  • PostgreSQL
  • +
  • Redis
  • +
  • SQLite
  • +
  • Docker
  • +
  • Coolify
  • +
  • Gitea
  • +
  • MCP
  • +
+ + +

+ Код открыт: GitHub + и Gitea. + Сервисы, которые работают прямо сейчас, живут на + secrets-4-me. +

+
+
+ + \ No newline at end of file diff --git a/src/partials/mini-projects-section.html b/src/partials/mini-projects-section.html index 0c0f67b..2e0d551 100644 --- a/src/partials/mini-projects-section.html +++ b/src/partials/mini-projects-section.html @@ -1,191 +1,207 @@ -
- -
+
+ +