П/ВИН

Lightbox, видео-превью и починка автоблога gptweb.ru

·8 мин чтения

Когда продукт живёт в продакшене и им пользуются реальные люди, баги прилетают пачками. Причём не абстрактные «что-то упало», а вполне конкретные: «кликаю на картинку — ничего не происходит», «видео вообще не показывается», «блог не обновлялся три недели». Именно такой букет прилетел от пользователя gptweb.ru — нашего AI-агрегатора на базе LobeChat. Три проблемы, на первый взгляд не связанные, но все — про одно: продукт должен работать, а не просто запускаться.

В этой статье разберу, как мы диагностировали и починили каждую из трёх проблем, какие подводные камни нашли и какие уроки вынесли.

Что такое gptweb.ru и зачем ему библиотека

gptweb.ru — это AI-чат на базе форка LobeChat. Пользователи могут общаться с разными моделями (GPT-4o, Claude, Gemini и другими), а ещё — загружать файлы в библиотеку ресурсов. Библиотека — это страница /resource/library/[slug], где хранятся картинки, документы и видео. Рядом есть пресеты — готовые конфигурации промптов с превью-картинками.

Отдельная подсистема — автоматический блог. Скрипт на bash + Claude CLI каждые три часа генерирует SEO-статьи по ключевым словам из базы Supabase. Восемь слотов в день через systemd timer.

Стек: Next.js, React, TypeScript, Ant Design, Supabase (PostgreSQL), bash-скрипты для автоматизации.

Проблема первая: картинки в библиотеке не кликаются

Пользователь пишет: «В библиотеке нельзя кликнуть на картинку и увеличить её. Нет lightbox, нет превью на весь экран».

Первая мысль — наверное, забыли добавить. Открываю код — и вижу, что antd Image с preview уже стоит. Компонент ImageFileItem в MasonryView рендерит <Image preview> — lightbox должен работать из коробки.

Но не работает. Начинаю копать.

Корневая причина: перехват клика

В файле MasonryItem/index.tsx корневой <div> имеет обработчик onClick={handleItemClick}, который навигирует в FullscreenModal — это внутренний просмотрщик файлов LobeChat. Проблема в том, что клик по картинке перехватывается родительским div'ом раньше, чем antd успевает инициировать свой preview.

Дополнительно, overlay с кнопками действий (удалить, переименовать) лежит поверх картинки и ловит pointer events. А FullscreenModal использует обычный <img> без zoom-контролов — то есть даже если пользователь дойдёт до просмотрщика, увеличить картинку он не сможет.

Схематично, вот как выглядела иерархия:

// MasonryItem/index.tsx — до исправления
<div onClick={handleItemClick}> {/* ← перехватывает клик */}
  <div className="overlay"> {/* ← pointer-events блокируют */}
    <ActionButtons />
  </div>
  <ImageFileItem /> {/* ← antd <Image preview> внутри, но клик не доходит */}
</div>

Решение: inline preview с блокировкой всплытия

Вместо того чтобы чинить цепочку всплытия событий (что хрупко и может сломать DnD), мы пошли другим путём:

  1. На overlay поставили pointer-events: none — кнопки действий появляются только при hover, но не блокируют клик по самой картинке.
  2. Antd <Image preview> сделали controlled — состояние visible управляется явно через useState, а не через внутренний клик antd.
  3. На картинке поставили onClick с e.stopPropagation() — клик не всплывает до родительского div, а открывает lightbox.
// ImageFileItem.tsx — после исправления
const [previewVisible, setPreviewVisible] = useState(false);
 
<Image
  src={fileUrl}
  preview={{
    visible: previewVisible,
    onVisibleChange: setPreviewVisible,
  }}
  onClick={(e) => {
    e.stopPropagation();
    setPreviewVisible(true);
  }}
/>

Теперь клик по картинке открывает полноценный lightbox с зумом, поворотом и отражением — всё это antd даёт бесплатно.

Проблема вторая: видео без превью

В Masonry-раскладке библиотеки видеофайлы рендерились через DefaultFileItem — это просто иконка файла с названием. Никакого превью, никакого плеера. Пользователь видит «video.mp4» и серую иконку. Нужно кликнуть, попасть в FullscreenModal, и только там появится плеер.

Решение: новый VideoFileItem

Создали отдельный компонент VideoFileItem:

  1. Thumbnail — первый кадр видео через <video> с preload="metadata" и захват кадра на loadeddata.
  2. Кнопка Maximize — иконка в правом верхнем углу, открывает локальный Modal с полноценным <video controls autoPlay>.
  3. Интеграция — в MasonryItem/index.tsx добавлена ветка isVideo, которая рендерит VideoFileItem вместо DefaultFileItem.
// MasonryItem/index.tsx — dispatch по типу файла
if (isImage) {
  return <ImageFileItem file={file} />;
}
if (isVideo) {
  return <VideoFileItem file={file} />;
}
return <DefaultFileItem file={file} />;

Для определения типа используется MIME-тип файла из метаданных:

const isVideo = file.mimeType?.startsWith('video/');
const isImage = file.mimeType?.startsWith('image/');

Теперь видео в библиотеке показывает превью-кадр и разворачивается в полноэкранный плеер по клику — без навигации в отдельную страницу.

Проблема третья: zoom для пресетов

Пресеты (preset cards) — это карточки с настройками промптов. У каждой есть маленькая превью-картинка. Пользователь хочет увеличить превью, но вся карточка — это <button>, клик по которой активирует пресет.

Решение минимальное: добавили маленькую кнопку ZoomIn (иконка лупы) в правый верхний угол превью-картинки пресета. По клику — тот же controlled antd <Image preview>, e.stopPropagation() чтобы не активировать пресет.

// PresetCard — кнопка зума на превью
<div className={styles.previewContainer}>
  <img src={preset.previewUrl} alt={preset.title} />
  <button
    className={styles.zoomButton}
    onClick={(e) => {
      e.stopPropagation();
      setPreviewVisible(true);
    }}
  >
    <ZoomIn size={16} />
  </button>
  <Image
    src={preset.previewUrl}
    preview={{ visible: previewVisible, onVisibleChange: setPreviewVisible }}
    style={{ display: 'none' }}
  />
</div>

Hidden <Image> нужен только для того, чтобы antd preview-модалка была в DOM — сама картинка рендерится обычным <img> для контроля стилей.

Проблема четвёртая: мёртвый автоблог

Параллельно с UX-проблемами обнаружилось, что автоматический блог gptweb.ru не публиковал статьи 25 дней. Последняя публикация — 3 мая. При восьми слотах в день это 200 упущенных статей.

Симптомы

В логах blog-generate.log — бесконечный цикл: скрипт берёт ключевое слово «впн на компьютер бесплатно 2026», определяет его как тематический дубликат существующей статьи, пытается пометить как duplicate и перейти к следующему. Но следующий запуск снова получает то же самое слово.

Расследование: constraint отбивает PATCH

Скрипт generate-article.sh делает PATCH-запрос к Supabase REST API, чтобы обновить статус ключевого слова:

# Было — молча глотает ошибку
curl -s -X PATCH "$SUPABASE_URL/rest/v1/blog_keywords?id=eq.$KW_ID" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status": "duplicate"}' \
  >/dev/null 2>&1 || true

Проблема: в таблице blog_keywords есть CHECK-constraint blog_keywords_status_check, который допускает только три значения: pending, used, skipped. Статуса duplicate не существует.

PostgreSQL CHECK constraints возвращают ошибку 23514 при нарушении. Но >/dev/null 2>&1 || true в скрипте полностью глушит эту ошибку. PATCH фейлится, статус остаётся pending, эндпоинт /next возвращает то же слово — и так по кругу, 200 раз.

Корневая причина

Две ошибки наложились:

  1. Невалидный статус — скрипт пытается записать duplicate, а БД принимает только pending / used / skipped.
  2. Подавление ошибок|| true без логирования. Скрипт считает, что пометил слово, и выходит. Следующий cron-слот получает то же самое слово.

Фикс

Заменили duplicate на валидный skipped, добавили retry-логику и вывод ошибок:

# Стало — с retry и логированием
MAX_RETRIES=5
for attempt in $(seq 1 $MAX_RETRIES); do
  RESPONSE=$(curl -s -w "\n%{http_code}" -X PATCH \
    "$SUPABASE_URL/rest/v1/blog_keywords?id=eq.$KW_ID" \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"status": "skipped"}')
  
  HTTP_CODE=$(echo "$RESPONSE" | tail -1)
  if [ "$HTTP_CODE" = "204" ]; then
    echo "[$(date)] Keyword $KW_ID marked as skipped"
    break
  fi
  echo "[$(date)] PATCH failed (HTTP $HTTP_CODE), attempt $attempt/$MAX_RETRIES"
done

Также добавили аналогичную обработку в generate-hype-article.sh, который страдал от похожей проблемы с malformed JSON при ошибках Claude CLI.

Верификация

Написали 17 unit-тестов для встроенной Python-логики (парсинг ответов, валидация статусов), все прошли. Проверили, что заблокированное ключевое слово получило статус skipped и больше не возвращается из /next.

Тестирование всех изменений

Для UX-фиксов написали E2E smoke-тест на Playwright:

  • Фаза 1: авторизация через Telegram-бот (используется bridge auth flow).
  • Фаза 2: переход на страницу пресетов, hover над карточкой, проверка появления zoom-кнопки.
  • Фаза 3: открытие библиотеки, проверка рендеринга плиток, клик по картинке — проверка появления lightbox-overlay.

Дополнительно прогнали smoke-тест на production после деплоя:

https://ask.gptweb.ru/          — HTTP 200, TTFB 0.146s
https://ask.gptweb.ru/image      — HTTP 200, TTFB 0.135s
https://ask.gptweb.ru/resource   — HTTP 200, TTFB 0.141s

Все маршруты отвечают за 150ms — перформанс не пострадал от добавления новых компонентов.

Результаты

UX библиотеки:

  • Картинки кликабельны — lightbox с зумом, поворотом и отражением работает.
  • Видео показывает превью-кадр и разворачивается в плеер по клику.
  • Пресеты имеют кнопку зума на превью.

Автоблог:

  • Зацикленное ключевое слово разблокировано (статус skipped).
  • Retry-логика предотвращает повторное зацикливание.
  • Ошибки PATCH-запросов логируются, а не глушатся.

Выводы

Первый урок — не подавляйте ошибки в автоматизации. || true и >/dev/null 2>&1 — удобные конструкции, но они превращают баг в невидимку. Скрипт, который молча фейлится 200 раз подряд и выглядит «рабочим» — это хуже, чем скрипт, который упал один раз с ошибкой. Для cron-задач правило простое: логируй всё, фейлись громко, ставь алерт на повторяющиеся ошибки. Особенно если скрипт дёргает платное API — каждый холостой запуск стоит денег.

Второй урок — CHECK constraints в PostgreSQL работают, но только если вы обрабатываете ошибки. Constraint blog_keywords_status_check делал ровно то, для чего был создан — не пускал невалидный статус. Но вызывающий код игнорировал ответ базы. Constraint без обработки ошибок на клиенте — это замок без дверного проёма. Данные защищены, но бизнес-логика зависает в неопределённом состоянии.

Третий урок — перехват событий в React-компонентах с вложенными обработчиками требует осознанного управления. Когда у вас parent div с onClick, overlay с pointer events и дочерний antd <Image preview> — все три борются за один клик. Простое решение: e.stopPropagation() на нужном уровне и pointer-events: none на промежуточных слоях. Controlled-режим antd компонентов (visible + onVisibleChange) даёт полный контроль и не зависит от внутренней механики библиотеки.

Четвёртый урок — E2E-тесты на production после деплоя окупаются моментально. Playwright-скрипт, который проходит авторизацию и кликает по реальным элементам, ловит то, что не поймает ни TypeScript-компилятор, ни unit-тесты. TTFB-замеры через curl подтвердили, что новые компоненты не просадили перформанс. Три минуты на написание smoke-теста экономят часы на разборе «а почему после деплоя опять сломалось».

В целом, все четыре проблемы имели одну общую черту: на поверхности — «не работает», а внутри — конкретная техническая причина, которую можно было найти за 15 минут систематической диагностики. Не гадать, не пробовать наугад — а читать код, проверять гипотезы и фиксить root cause.

Паша Вин
Паша Вин

AI-инженер, предприниматель, маркетолог. Основатель feberra.com и x10seo.ru. 13 лет в перфоманс-маркетинге, 3 года в системной интеграции AI в бизнес.

Связанный проект

WebGPT Telegram Bot