Lightbox, видео-превью и починка автоблога gptweb.ru
Когда продукт живёт в продакшене и им пользуются реальные люди, баги прилетают пачками. Причём не абстрактные «что-то упало», а вполне конкретные: «кликаю на картинку — ничего не происходит», «видео вообще не показывается», «блог не обновлялся три недели». Именно такой букет прилетел от пользователя 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), мы пошли другим путём:
- На overlay поставили
pointer-events: none— кнопки действий появляются только при hover, но не блокируют клик по самой картинке. - Antd
<Image preview>сделали controlled — состояниеvisibleуправляется явно черезuseState, а не через внутренний клик antd. - На картинке поставили
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:
- Thumbnail — первый кадр видео через
<video>сpreload="metadata"и захват кадра наloadeddata. - Кнопка Maximize — иконка в правом верхнем углу, открывает локальный
Modalс полноценным<video controls autoPlay>. - Интеграция — в
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 раз.
Корневая причина
Две ошибки наложились:
- Невалидный статус — скрипт пытается записать
duplicate, а БД принимает толькоpending/used/skipped. - Подавление ошибок —
|| 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 →