Интерактивное персональное портфолио-приложение, собираемое в статический артефакт и хостящееся на GitHub Pages. Содержит AI-ассистента поверх DeepSeek V3, двуязычный UI (RU/EN), параллакс-анимации и SPA-роутинг.
- Обзор
- Стек технологий
- Архитектура
- Структура проекта
- Быстрый старт
- Конфигурация
- Сборка и деплой
- Безопасность
- Acceptance checks
Приложение — single-page React-сайт, собираемый Vite в статический dist/. CI публикует артефакт в GitHub Pages. Клиентский бандл не содержит секретов: LLM-вызовы уходят в отдельную Vercel Edge Function (fighter90-chat-proxy), которая хранит OpenRouter-ключ и SYSTEM_PROMPT на стороне сервера.
- Статическая сборка — GitHub Pages, без runtime-сервера на стороне сайта.
- Два языка — RU/EN с переключателем, автоопределением по
navigator.language, persist вlocalStorage. - AI-чат —
FloatingChatс SSE-стримингом, серверным system prompt, локальным fallback при отсутствии/недоступности прокси. - SPA-роутинг —
public/404.htmlсохраняет полный URL (pathname + search + hash) и отдаёт управление клиенту черезhistory.replaceState. - Параллакс и анимации — CSS-анимации + позиционирование за курсором мыши в Hero-секции.
┌───────────────────────────────────────────┐
│ GitHub (Fighter90/fighter90.github.io) │
│ ─────────────────────────────────────── │
│ src/** → vite build → dist/** │
└────────────────────┬──────────────────────┘
│ push to main
▼
┌───────────────────────────────────────────┐
│ GitHub Actions (.github/workflows/ │
│ deploy.yml) │
│ npm ci → tsc → vite build → artifact │
└────────────────────┬──────────────────────┘
│ actions/deploy-pages
▼
┌───────────────────────────────────────────┐
│ GitHub Pages (fighter90.github.io) │
│ ─────────────────────────────────────── │
│ Static HTML/JS/CSS + 404.html SPA shim │
└────────────────────┬──────────────────────┘
│ user opens chat, POST /api/chat
▼
┌───────────────────────────────────────────┐
│ Vercel Edge Function │
│ fighter90-chat-proxy.vercel.app │
│ ─────────────────────────────────────── │
│ CORS allowlist + rate limit │
│ Prepends server-side SYSTEM_PROMPT │
│ Authorization: Bearer $OPENROUTER_KEY │
└────────────────────┬──────────────────────┘
│ SSE passthrough
▼
┌───────────────────────────────────────────┐
│ OpenRouter → deepseek/deepseek-chat-v3 │
└───────────────────────────────────────────┘
Клиент (src/components/FloatingChat.tsx):
- Плавающий чат в правом нижнем углу, lazy-loaded чанк в бандле.
- Сообщения хранятся в local state (
useState), история отправляется на сервер массивом{ role, content }[]. - Стрим из SSE парсится вручную:
getReader()→TextDecoder→ построчный разборdata: {...}→ извлечениеchoices[0].delta.content→onChunk(token). - При ошибке сети/прокси или пустом
VITE_CHAT_PROXY_URL— автоматический fallback на локальные ответы по ключевым словам (localFallback), чтобы чат всегда отвечал хоть чем-то. - Markdown-подобный рендер (
ChatMarkdown):**bold**, заголовки, списки, auto-linkify URL/email/@handles — через React-элементы, безdangerouslySetInnerHTML.
Прокси (fighter90-chat-proxy/api/chat.ts):
- Vercel Edge Function (
runtime: 'edge') — близко к пользователю, холодный старт ~миллисекунды. - Проверяет
Originпо allowlist:https://fighter90.github.io,http://localhost:5173. Иначе403. - Per-IP rate limit: 20 запросов в минуту, sliding window в
Map(сбрасывается на cold start — устраивает текущий трафик). - Принимает
POST { messages: [...] }, берёт последние 20, дописывает серверныйSYSTEM_PROMPTкакrole: system, форвардит вopenrouter.ai/api/v1/chat/completionsсstream: true. response.bodyотдаётся клиенту as-is → тот же SSE-формат. Upstream-ошибка →502.
Последовательность вызова:
FloatingChat.sendToLLM(userText)
│
├─ push userMsg → messages
├─ setIsStreaming(true)
│
├─ CHAT_PROXY_URL пуст?
│ └─ yes → await localFallback → done
│
├─ fetch(CHAT_PROXY_URL, { method, body: { messages }, signal })
│ ▼
│ [Vercel Edge /api/chat]
│ │ origin allowlist → rate limit → slice(-20)
│ │ upstream.fetch(openrouter, stream: true)
│ └ return upstream.body (SSE passthrough)
│ ▼
├─ reader.read() loop
│ → decode → split '\n'
│ → parse JSON → delta.content → onChunk(token)
│ → setStreamText(fullText) // live UI update
│
├─ [DONE] or reader done
│ → push assistantMsg { text: fullText } → messages
│
└─ catch → localFallback
- Весь переводимый контент лежит в src/i18n.ts — плоский объект
{ ru: {...}, en: {...} }. - src/contexts/LangContext.tsx предоставляет
useLang()→{ lang, setLang }. - Определение языка при первой загрузке:
?lang=ru|enиз URL — высший приоритет;localStorage['lang'];navigator.language.startsWith('ru')→ru, иначеen.
- Переключатель (src/components/LangSwitcher.tsx) пишет в
localStorageи ставит<html lang>.
Четыре LinkedIn-поста на странице рендерятся через src/components/LinkedInEmbed.tsx. Компонент сознательно устойчив к региональной блокировке LinkedIn:
- Lazy-mount через
IntersectionObserver(rootMargin: 200px) — iframe не монтируется, пока пользователь не доскроллит до секции. - Тайм-аут
6000 msнаonLoad. Если iframe не успел подгрузиться, состояние переключается вfailed, iframe размонтируется. - Fallback-карточка. В состоянии
failedрендерится нейтральная карточка с LinkedIn-иконкой, заголовком поста, датой и кнопкой «Открыть в LinkedIn». Без проб сети, без детекции страны — просто «не дождалисьonLoad→ показываем ссылку». sandbox+referrerPolicy="strict-origin-when-cross-origin"на iframe. Контент постов не имеет доступа к родительскому origin.- Код-сплит через
React.lazy()—LinkedInEmbedуходит в отдельный chunk и не влияет на время до интерактивности главной страницы.
Единственный источник данных о публикациях — publications: [{ embedUrl, postUrl, title, date, height }] в src/i18n.ts (по одному массиву на локаль). Чтобы добавить/удалить пост — достаточно отредактировать i18n, JSX трогать не нужно.
GitHub Pages не знает про клиентский роутинг: запрос /any/path без соответствующего файла возвращает 404.html. Workaround:
- public/404.html на старте сохраняет полный URL (
pathname + search + hash) вsessionStorage['redirect']и делаетwindow.location.replace('/'). - src/main.tsx читает
sessionStorage['redirect']доcreateRoot().render(...)и восстанавливает URL черезwindow.history.replaceState(null, '', redirect). - Приложение инициализируется уже на правильном URL →
LangContext.detectLang()видит?lang=…, компоненты видят#hash.
Важно: порядок replaceState → render критичен. Иначе LangProvider прочитает window.location.search слишком рано (пустой) и выберет язык по navigator.language.
.
├── .github/
│ └── workflows/
│ └── deploy.yml # CI: npm ci → tsc → vite build → deploy-pages
│
├── public/ # копируется в dist/ как есть
│ ├── 404.html # SPA-shim, сохраняет URL в sessionStorage
│ ├── favicon.svg # + favicon.ico, apple-touch-icon, android-chrome-*
│ ├── robots.txt
│ ├── sitemap.xml
│ ├── llms.txt # hint для AI-краулеров
│ ├── og-image.png # OpenGraph превью
│ ├── foto-avatar.webp # основное фото
│ ├── foto-avatar-sm.webp # уменьшенное фото для чата
│ ├── testimonial-*.jpg # сканы отзывов (lightbox)
│ ├── Emelyanov_Sergey_CV.pdf
│ ├── Sergey_Emelyanov_CV_EN.pdf
│ ├── Webguru_Portfolio_RU.pdf
│ ├── Webguru_Portfolio_EN.pdf
│ └── fonts/ # Space Grotesk + DM Sans (self-hosted)
│
├── src/
│ ├── App.tsx # Hero, Story, Experience, Portfolio, Education,
│ │ # Skills, Testimonials, Publications, Contact
│ ├── GlobalNav.tsx # фиксированный sidebar + theme toggle
│ ├── main.tsx # createRoot + SPA-redirect recovery
│ ├── i18n.ts # словарь переводов RU/EN
│ ├── index.css # Tailwind v4 + CSS variables + keyframes
│ │
│ ├── contexts/
│ │ └── LangContext.tsx # React Context: язык, localStorage, detect
│ │
│ └── components/
│ ├── LangSwitcher.tsx # RU/EN toggle со SVG-флагами
│ ├── FloatingChat.tsx # AI-чат: прокси + SSE + fallback + Linkify
│ └── LinkedInEmbed.tsx # lazy iframe + 6s timeout + fallback-карточка
│
├── index.html # Vite entry, мета-теги, JSON-LD
├── package.json
├── tsconfig.json
├── tsconfig.app.json
├── tsconfig.node.json
├── vite.config.ts
├── eslint.config.js
└── README.md
| Файл | За что отвечает |
|---|---|
| src/main.tsx | React entry, восстановление SPA-URL перед рендером |
| src/App.tsx | Все основные секции страницы |
| src/components/FloatingChat.tsx | Клиентская часть AI-ассистента, callLLM, fallback |
| src/contexts/LangContext.tsx | Язык и автоопределение |
| src/i18n.ts | Единственный источник локализованного контента |
| public/404.html | SPA-redirect с сохранением search + hash |
| .github/workflows/deploy.yml | CI/CD в GitHub Pages |
git clone https://github.com/Fighter90/fighter90.github.io.git
cd fighter90.github.io
npm install
npm run devDev-сервер поднимется на http://localhost:5173.
| Скрипт | Что делает |
|---|---|
npm run dev |
Vite dev-сервер c HMR |
npm run build |
tsc -b + vite build → dist/ |
npm run preview |
Локальный preview продакшн-бандла |
npm run lint |
ESLint по src/** |
Все клиентские настройки — через Vite env-переменные с префиксом VITE_*.
| Переменная | Обязательна? | По умолчанию | Назначение |
|---|---|---|---|
VITE_CHAT_PROXY_URL |
нет | https://fighter90-chat-proxy.vercel.app/api/chat |
URL прокси. Пустая строка → FloatingChat сразу идёт в localFallback. |
Пример локального запуска против своего прокси:
VITE_CHAT_PROXY_URL=https://my-proxy.example.com/api/chat npm run buildЧто точно НЕ делать:
- Не добавлять
VITE_OPENROUTER_KEYили любой другой live-секрет. Vite инлайнит всё, что попадает вimport.meta.env.VITE_*, в публичный JS — это утечка. - Не класть
.envв коммиты. В .gitignore уже прописаны.env,.env.local,.env*.local.
Триггер: push в main или ручной workflow_dispatch.
Пайплайн .github/workflows/deploy.yml:
actions/checkout@v4
→ actions/setup-node@v4 (Node 20, npm cache)
→ npm ci
→ npm run build # tsc -b && vite build → dist/
→ actions/upload-pages-artifact@v3 (path: dist)
→ actions/deploy-pages@v4
Никаких секретов в workflow не пробрасывается.
Прокси живёт в отдельном репозитории: Fighter90/fighter90-chat-proxy.
- Импорт в Vercel → Framework Preset
Other. - Environment Variable:
OPENROUTER_KEY(Production + Preview). - Deploy. Production URL:
https://fighter90-chat-proxy.vercel.app.
Деплой прокси не связан с пайплайном основного сайта — они независимы. Обновление прокси → git push в его репо → Vercel автоматически пересобирает Edge Function.
- Ни одного секрета в клиентском бандле. Любой
VITE_*попадает в публичный JS. LLM-ключи и system-промпты держим только на сервере. - Allowlist по Origin, а не по User-Agent/Referer (последние легко подделать).
- Rate limit на стороне прокси — защита от абуза чужого ключа, даже если кто-то нашёл эндпоинт.
- Никакого
dangerouslySetInnerHTML,eval,new Functionв клиентском коде. Markdown из ответа LLM рендерится через React-узлы. .envне трекается — проверено в .gitignore иgit log --all -S sk-or-.- Content-Security-Policy прописана
<meta http-equiv>вindex.html:frame-srcограниченwww.linkedin.com;connect-src—'self', прокси иopenrouter.ai;object-src 'none';frame-ancestors 'self'.Referrer-Policy: strict-origin-when-cross-originтоже прописан мета-тегом. - Регулярный
npm audit. В CI/локально —npm auditнаhigh-уровне. Текущее состояние:0 vulnerabilitiesпосле апгрейда Vite ≥ 7.3.2 и зависимых транзитивных пакетов.
После npm run build в dist/:
grep -rE 'sk-or-|openrouter\.ai|SYSTEM_PROMPT' dist/ && echo FAIL || echo OKДолжно печатать OK.
- В OpenRouter dashboard → Keys создать новый ключ.
- В Vercel →
fighter90-chat-proxy→ Settings → Environment Variables обновитьOPENROUTER_KEY+ нажать Redeploy. - После успешного Redeploy отозвать (
Revoke) старый ключ.
Старый ключ нельзя отзывать до смены — иначе будет окно нерабочего чата.
Быстрый чек-лист, чтобы убедиться, что всё работает:
# 1. Нет утечек в бандле
npm run build
grep -rE 'sk-or-|openrouter\.ai|Sergey Emelyanov.s AI portfolio assistant' dist/ && echo FAIL || echo OK
# 2. В CI нет упоминаний старого секрета
grep -n 'VITE_OPENROUTER_KEY' .github/workflows/deploy.yml && echo FAIL || echo OK
# 3. Прокси отвечает SSE с правильного Origin
curl -N -X POST https://fighter90-chat-proxy.vercel.app/api/chat \
-H 'content-type: application/json' \
-H 'origin: https://fighter90.github.io' \
-d '{"messages":[{"role":"user","content":"Say OK."}]}'
# 4. Прокси режет левый Origin
curl -s -o /dev/null -w "%{http_code}\n" -X POST \
https://fighter90-chat-proxy.vercel.app/api/chat \
-H 'content-type: application/json' \
-H 'origin: https://evil.example.com' \
-d '{"messages":[{"role":"user","content":"hi"}]}'
# expect: 403
# 5. 404.html сохраняет search и hash
# Открыть https://fighter90.github.io/any/missing?lang=en#contact
# После редиректа URL должен быть тем же, язык — en.