// web/screens/review2.jsx — Ревью 2 · Тексты (§21.5). // Привели в соответствие с дизайном 2026-06-03: page-header с // breadcrumbs + «Перегенерировать», pipeline status strip, KwCard с // top/banner/suspect вариациями, разделение active/spare ключей, // «Развернуть анализ», CSV preview блок внизу. // EditableText — contentEditable div как в дизайне (вместо textarea). // Управляемый компонент: текст приходит из props.value, на blur вызывается // onChange. Placeholder показывается через CSS pseudo-element (data-placeholder). // // Зачем contentEditable а не textarea: класс .r2-text в дизайне стилизован // как inline-текст без рамки (transparent border, при hover серый фон, при // focus — обводка цвета акцента). У textarea свой нативный стиль (рамка + // fixed height по rows), который ломает визуал. ContentEditable div этого // не имеет — он растягивается по содержимому естественно. function EditableText({ value, onChange, className, placeholder }) { const ref = React.useRef(null); // Синхронизируем внешний value (например после reload фотки из API) с DOM // только если он реально изменился И элемент не в фокусе (чтобы не // прерывать редактирование). React.useEffect(() => { if (!ref.current) return; if (document.activeElement === ref.current) return; if (ref.current.textContent !== (value || "")) { ref.current.textContent = value || ""; } }, [value]); return (
{ const next = e.currentTarget.textContent; if (next !== value) onChange(next); }} // Enter не должен вставлять
или
— берём текст по blur. onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); e.currentTarget.blur(); } }}/> ); } // KwCard — карточка одного ключа в активном списке 50. // Поддерживает четыре визуальных режима: // top (i<10) — первая десятка, тёмно-зелёный фон // selling (Selling=true) — продающие за пределами первой десятки, светло-зелёный // banner (в наборе) — баннерные ключи, желтоватый фон // suspect — обнаружено как слитное слово, пунктир (предупреждение) function KwCard({ idx, en, ru, top, selling, banner, suspect, onRemove, onDragStart, onDragEnd, dragging, // dropSide: "left" | "right" | null — куда упадёт при drop // ИМЕННО на этой карточке. Подсветка показывает Лене точку // вставки до того как она отпустит мышь. dropSide, onDragOverCard, onDropCard }) { const cls = "kw" + (top ? " top" : "") + (!top && selling ? " selling" : "") // selling-стиль только если НЕ top + (banner ? " banner" : "") + (suspect ? " suspect" : "") + (dragging ? " dragging" : ""); const indicatorStyle = dropSide === "left" ? { boxShadow: "inset 3px 0 0 0 var(--accent)" } : dropSide === "right" ? { boxShadow: "inset -3px 0 0 0 var(--accent)" } : null; return ( {String(idx).padStart(2, "0")}
{en} {ru && {ru}}
); } // SpareKwCard — кнопка запасного ключа под основным списком. Клик → промоут // обратно в активные (через PUT /v1/keyword/:id/spare {Spare:false}). function SpareKwCard({ kw, onRestore }) { return ( ); } // Review2PhotoBlock — один фото-блок Ревью 2. Слева превью + Акцент + кнопка // «Развернуть анализ»; справа — Title, Description с авто-счётчиком. // Ниже — Keywords во всю ширину блока с разделением active / spare. function Review2PhotoBlock({ photo, allWords, isExpanded, onToggleExpand }) { const [title, setTitle] = React.useState(photo.Title || ""); const [titleRu, setTitleRu] = React.useState(photo.TitleRu || ""); const [desc, setDesc] = React.useState(photo.Description || ""); const [descRu, setDescRu] = React.useState(photo.DescriptionRu || ""); React.useEffect(() => { setTitle(photo.Title || ""); }, [photo.Title]); React.useEffect(() => { setTitleRu(photo.TitleRu || ""); }, [photo.TitleRu]); React.useEffect(() => { setDesc(photo.Description || ""); }, [photo.Description]); React.useEffect(() => { setDescRu(photo.DescriptionRu || ""); }, [photo.DescriptionRu]); // Autosave Title/Description в БД при изменении state (правка 2026-06-05). // До неё EditableText сохранял только в локальный state — Лена правила, // а в БД и CSV-превью оставалось старое значение. Дебаунс 600мс чтобы // не лупить PUT на каждую букву. React.useEffect(() => { if (!api.feed.savePhotoTexts) return; const t = setTimeout(() => { const matches = (title === (photo.Title || "")) && (desc === (photo.Description || "")) && (titleRu === (photo.TitleRu || "")) && (descRu === (photo.DescriptionRu || "")); if (matches) return; // нечего сохранять — текущее совпадает с props api.feed.savePhotoTexts(photo.ID, { title, description: desc, titleRu, descriptionRu: descRu, }).catch((e) => { console.warn("save photo texts failed:", e.message); }); }, 600); return () => clearTimeout(t); }, [title, desc, titleRu, descRu, photo.ID]); // Keywords — загружаются отдельно через /v1/keywords?photoID=N (active + spare // в одной выборке, фронт разделяет по полю Spare). const [kws, setKws] = React.useState([]); const [newKw, setNewKw] = React.useState(""); const [addBusy, setAddBusy] = React.useState(false); // блок UI пока идёт RU→EN + N add'ов const [_, toast] = useToasts(); // Защита от старого кэша api.js (если у юзера в браузере осталась версия // до 2026-06-03 — там ещё нет feed.keywords). Без этой проверки весь // Review2 падал с TypeError в Console (правка 2026-06-03). const reloadKws = React.useCallback(() => { if (!api.feed || !api.feed.keywords || typeof api.feed.keywords.list !== "function") { console.warn("api.feed.keywords недоступен — старый кэш api.js. Жми Ctrl+Shift+R"); return; } api.feed.keywords.list(photo.ID) .then((r) => setKws(Array.isArray(r) ? r : [])) .catch(() => {}); }, [photo.ID]); React.useEffect(() => { reloadKws(); }, [reloadKws]); // Auto-polling пока есть keywords без перевода (правка 2026-06-03). // После «Перегенерировать» backend сначала сохраняет 50 keywords (часть // с переводом из словаря, часть без), потом ОДНИМ батчем переводит // оставшиеся через GLM — это занимает ~10-20 сек на фотку. Без polling'а // фронт показывает «пустые ru» и Лена думает что переводы вообще не // работают. Останавливаемся когда нет untranslated active keywords, или // через 90 сек (если GLM не отвечает — нечего бесконечно опрашивать). const activeKws = kws.filter((k) => !k.Spare); const untranslatedCount = activeKws.filter((k) => !k.Ru).length; React.useEffect(() => { if (untranslatedCount === 0 || activeKws.length === 0) return; const start = Date.now(); const tick = setInterval(() => { if (Date.now() - start > 90000) { clearInterval(tick); return; } reloadKws(); }, 5000); return () => clearInterval(tick); }, [untranslatedCount, activeKws.length, reloadKws]); const active = kws .filter((k) => !k.Spare) .sort((a, b) => (a.Position || 0) - (b.Position || 0)); const spare = kws.filter((k) => k.Spare); const descLen = desc.length; // Окно валидации совпадает с серверным pika.DescMinLen/DescMaxLen (170-200). // 180-200 (исходник бота) → 150-220 (2026-06-02 из-за GLM-промахов) → // 170-200 (2026-06-03 — Лена сузила для стокового стандарта). // 2026-06-05: target Pika_Writer'а сузили до 180-195, hard-cap остался 200; // если модель промахнулась и выдала >200, auto-trim больше не режет — // Лена увидит подсветку «warn-strong» и сократит сама. const descOk = descLen >= 170 && descLen <= 200; const descOver = descLen > 200; const descShort = descLen > 0 && descLen < 170; const isBannerH = photo.BannerType === "horizontal"; const isBannerV = photo.BannerType === "vertical"; // Множество для KwCard.banner — какие ключи помечены как «баннерные». // На MVP — простой эвристикой: если у фото баннер, ключи "banner", // "horizontal"/"vertical" в активном списке подсвечиваем. const bannerSet = new Set(); if (isBannerH || isBannerV) { bannerSet.add("banner"); bannerSet.add(isBannerH ? "horizontal" : "vertical"); } // Suspect — детектор слитных слов из pkg/pika/glued.go. На MVP не дёргаем // — оставляем пустой Set'ом; интеграция отдельно. const suspectSet = new Set(); // Drag-and-drop для перестановки активных keywords. // Правка 2026-06-05: переписан под «drop-over-сама-карточка с left/right // hit-test», вместо узких 6px zone'ов между карточками. Старый вариант // (правка 2026-06-03) часто промахивался — мышь падала на gap flex'а и // drop падал «в конец списка» (жалоба Лены 2026-06-05). // // Принцип: при onDragOver карточки смотрим где курсор внутри её bounding box: // left half → дроп ПЕРЕД карточкой (idx), right half → ПОСЛЕ (idx+1). // dropTarget хранит индекс куда вставим, dropOverID — какая карточка под // курсором (для подсветки левой/правой границы). const dragIDRef = React.useRef(null); const [dragID, setDragID] = React.useState(null); const [dropTarget, setDropTarget] = React.useState(null); // {idx, side: "left"|"right"} or null const onDragStart = (e, id) => { dragIDRef.current = id; setDragID(id); try { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", String(id)); } catch {} }; const onDragEnd = () => { dragIDRef.current = null; setDragID(null); setDropTarget(null); }; // Карточка под курсором: вычисляем по позиции X внутри bounding box. const onDragOverCard = (e, idx, kwID) => { if (dragIDRef.current == null) return; if (dragIDRef.current === kwID) return; // не подсвечивать саму себя e.preventDefault(); e.stopPropagation(); const rect = e.currentTarget.getBoundingClientRect(); const side = (e.clientX - rect.left) < rect.width / 2 ? "left" : "right"; setDropTarget((prev) => prev && prev.idx === idx && prev.side === side ? prev : { idx, side }); }; const onDropCard = (e, idx, kwID) => { if (dragIDRef.current == null) return; e.preventDefault(); e.stopPropagation(); const rect = e.currentTarget.getBoundingClientRect(); const side = (e.clientX - rect.left) < rect.width / 2 ? "left" : "right"; const targetIdx = side === "left" ? idx : idx + 1; handleDrop(targetIdx); }; // Drop вне любой карточки → в конец списка. Только если drop состоялся // именно на контейнере, а не пробулькался от ребёнка (поэтому проверяем // currentTarget === target). const onContainerDragOver = (e) => { if (dragIDRef.current == null) return; e.preventDefault(); }; const onContainerDrop = (e) => { if (dragIDRef.current == null) return; e.preventDefault(); if (dropTarget) { handleDrop(dropTarget.side === "left" ? dropTarget.idx : dropTarget.idx + 1); } else { handleDrop(active.length); } }; const handleDrop = async (targetIdx) => { const id = dragIDRef.current; onDragEnd(); if (id == null) return; const curIdx = active.findIndex((k) => k.ID === id); if (curIdx < 0 || curIdx === targetIdx || (curIdx + 1) === targetIdx) return; // Локально пересобрать порядок и отправить на бэк. const reordered = [...active]; const [moved] = reordered.splice(curIdx, 1); // targetIdx считался до сдвига; если перетаскиваем вниз — индекс уменьшается. const insertAt = targetIdx > curIdx ? targetIdx - 1 : targetIdx; reordered.splice(insertAt, 0, moved); // Оптимистично применяем порядок локально. setKws((prev) => { const byID = new Map(reordered.map((k, i) => [k.ID, i])); return prev.map((k) => byID.has(k.ID) ? { ...k, Position: byID.get(k.ID) } : k ); }); try { await api.feed.keywords.reorder(photo.ID, reordered.map((k) => k.ID)); } catch (e2) { toast("Не удалось переставить: " + e2.message, "error"); reloadKws(); } }; const removeKw = async (id) => { try { await api.feed.keywords.setSpare(id, true); reloadKws(); } catch (e) { toast("Не удалось: " + e.message, "error"); } }; const restoreSpare = async (kw) => { try { await api.feed.keywords.setSpare(kw.ID, false); reloadKws(); } catch (e) { toast("Не удалось: " + e.message, "error"); } }; // Правка 2026-06-05: split по запятой + автоперевод RU→EN через // keyword.TranslateRuToEnBatch (словарь / GLM). RU-оригинал сохраняем в // keyword.Ru — иначе плашка показывает только EN без перевода (правка 2026-06-05/2). const addKw = async () => { if (addBusy) return; const tokens = newKw.split(/[,;]/).map((s) => s.trim()).filter(Boolean); if (tokens.length === 0) return; setAddBusy(true); try { const ruTokens = tokens.filter((t) => /[Ѐ-ӿ]/.test(t)); let ruToEn = {}; if (ruTokens.length > 0) { const res = await api.keyword.TranslateRuToEnBatch(ruTokens); for (const r of res) { if (r.en) ruToEn[r.ru.toLowerCase()] = r.en; } } const failed = []; // pairs: {en, ru} — ru заполнен только когда введён кириллицей. const pairs = []; for (const t of tokens) { if (/[Ѐ-ӿ]/.test(t)) { const en = ruToEn[t.toLowerCase()]; if (en) pairs.push({ en, ru: t }); else failed.push(t); } else { pairs.push({ en: t, ru: "" }); } } if (failed.length > 0) toast("Не удалось перевести: " + failed.join(", "), "warn"); const seen = new Set(); for (const p of pairs) { const k = p.en.trim().toLowerCase(); if (!k || seen.has(k)) continue; seen.add(k); await api.feed.keywords.add(photo.ID, p.en.trim(), p.ru); } setNewKw(""); reloadKws(); } catch (e) { toast("Не удалось добавить: " + e.message, "error"); } finally { setAddBusy(false); } }; // Группы слов анализа для «Развернуть анализ» — берём из общего allWords // (грузится один раз в Review2Screen через /v1/analysis?shootID=N). const myWords = (allWords || []).filter((w) => w.PhotoID === photo.ID); const grouped = {}; for (const w of myWords) { const g = (w.GroupKind || "").toLowerCase(); if (!grouped[g]) grouped[g] = []; grouped[g].push(w); } const groupLabels = { object: "ОБЪЕКТ", action: "ДЕЙСТВИЕ", role: "РОЛЬ", emotion: "ЭМОЦИЯ", place: "МЕСТО", }; return (
{photo.YandexPath ? {photo.Filename} :
}
{photo.Filename}
{photo.AccentEn && ( // Pill заменён на блок с word-wrap для длинных акцентов. // Перевод (AccentRu) выводится отдельным блоком ПОД плашкой, // серым меньшим шрифтом без рамки (правка 2026-06-03). <>
Акцент: {photo.AccentEn}
{photo.AccentRu && (
{photo.AccentRu}
)} )} {isBannerH && ( горизонт. баннер )} {isBannerV && ( вертикал. баннер )}
{/* TITLE — как в дизайне 2026-06-03: contentEditable div вместо textarea. Класс .r2-text стилизует как обычный текст без рамки, при hover — серый фон, при focus — синяя обводка. Это inline- редактирование, не «форма ввода». */} {/* hasCyrillicEn — детект кириллицы в EN-поле; правка 2026-06-05. EN-поля Title и Description должны быть на английском (стоковый стандарт). Если Лена случайно начала писать на русском (не переключила раскладку или скопировала перевод), подсвечиваем рамкой + надписью. Сохранение не блокируем — Лена может отредактировать в следующий момент. */}
Title {title.length} символов · 5–12 слов {/[Ѐ-ӿ]/.test(title) && ( только английский! )}
{/* DESCRIPTION */}
Description {descLen} / 170–200 {descOk && } {descOver && ( превышает на {descLen - 200} — сократи перед «Принять серию» )} {descShort && ( короче минимума на {170 - descLen} )} {/[Ѐ-ӿ]/.test(desc) && ( только английский! )}
{/* Expanded — analysis read-only (показывается по «Развернуть анализ»). Расположен ДО Keywords (правка 2026-06-03 по требованию Лены) — чтобы видеть источник из чего собрались ключи прежде чем смотреть сами ключи. Внутри каждой группы слова отсортированы: сначала selling, потом обычные синонимы (selling-карточки подсвечены неярким мятным фоном через класс .wcard-mini.selling). */} {isExpanded && myWords.length > 0 && (
Анализ (источник ключей) только просмотр · продающие выделены
{["object", "action", "role", "emotion", "place"].map((g) => { const ws = grouped[g]; if (!ws || ws.length === 0) return null; // Сортировка: сначала selling, в каждой подгруппе — по Position. const sorted = [...ws].sort((a, b) => { if (a.Selling !== b.Selling) return a.Selling ? -1 : 1; return (a.Position || 0) - (b.Position || 0); }); return (
{groupLabels[g] || g} {sorted.map((w) => ( {w.En} {w.Ru && {w.Ru}} ))}
); })}
)} {/* KEYWORDS — full width */}
Keywords {active.length} / 50 · топ-10 продающие {active.length === 50 && } {untranslatedCount > 0 && ( переводим {untranslatedCount}… )}
{active.length === 0 && ( ключи появятся после «Принять серию» в Ревью 1 )} {active.map((k, i) => ( removeKw(k.ID)} dragging={dragID === k.ID} dropSide={dropTarget && dropTarget.idx === i && dragID !== k.ID ? dropTarget.side : null} onDragStart={(e) => onDragStart(e, k.ID)} onDragEnd={onDragEnd} onDragOverCard={(e) => onDragOverCard(e, i, k.ID)} onDropCard={(e) => onDropCard(e, i, k.ID)}/> ))} {/* Трейлинг dropzone — drop в конец, занимает остаток строки. Большая зона (flex: 1) исключает «промахи на gap'е». */} {dragID != null && ( { e.preventDefault(); e.stopPropagation(); setDropTarget({ idx: active.length - 1, side: "right" }); }} onDrop={(e) => { e.preventDefault(); e.stopPropagation(); handleDrop(active.length); }} style={{ flex: "1 1 60px", minHeight: 24, alignSelf: "stretch", background: dropTarget && dropTarget.idx === active.length - 1 && dropTarget.side === "right" ? "var(--accent-soft)" : "transparent", border: "1px dashed " + (dropTarget && dropTarget.idx === active.length - 1 && dropTarget.side === "right" ? "var(--accent)" : "transparent"), borderRadius: 4, transition: "background 100ms, border-color 100ms", }}/> )}
setNewKw(e.target.value)} onKeyDown={(e) => e.key === "Enter" && addKw()}/> {spare.length > 0 && ( запасные: )} {spare.map((k) => ( ))}
× — переместить в запасные · клик по запасному — добавить в конец списка
{/* Поле «Комментарий к фото» убрано по запросу Лены 2026-06-05 — оно ничего не сохраняло, занимало место. */}
); } function Review2Screen({ shootID, setRoute, isAdmin }) { const [shootState] = useAsync(async () => { const list = await api.feed.shoots(); return (list || []).find((s) => s.ID === shootID) || null; }, [shootID]); const [photosState, reloadPhotos] = useAsync(() => api.feed.photos(shootID), [shootID]); const [wordsState] = useAsync(() => api.feed.analysis(shootID), [shootID]); const [_, toast] = useToasts(); const [exporting, setExporting] = React.useState(false); const [regenerating, setRegenerating] = React.useState(false); const [reshuffling, setReshuffling] = React.useState(false); const [expanded, setExpanded] = React.useState(null); // photoID or null const shoot = shootState.data; const photos = photosState.data || []; const words = wordsState.data || []; const exportCSV = async () => { setExporting(true); try { await api.shoot.AcceptReview2(shootID); toast("CSV отправлен в _Pikasrv/, серия отправлена на EXIF-watcher", "info"); } catch (e) { toast("Не удалось выгрузить CSV: " + e.message, "error"); } finally { setExporting(false); } }; // «Перегенерировать» — переанализ Title/Description/Keywords для всех фоток // серии. Используем тот же AcceptReview1 что и кнопка «Принять серию» в // Ревью 1 — он делает Assemble (keywords) + Generate (тексты) для каждой // recognized фотки. Теперь генерация ФОНОВАЯ (очередь на бэке): RPC // возвращается сразу, тексты пишутся в фоне и видны слева в «В работе» // (правка 2026-06-24). После прогона shoot остаётся в review2. const regenerate = async () => { if (!confirm("Перегенерировать тексты и ключи для всей серии?\n\n" + "Pika_Writer перепишет Title, Description и пересоберёт 50 ключей\n" + "для каждой фотки. Твои ручные правки в этих полях будут потеряны.\n" + "Анализ слов и акценты не трогаются.\n\n" + "Идёт в фоне (~30-60 сек на фотку), прогресс — слева в «В работе».")) return; setRegenerating(true); try { await api.shoot.AcceptReview1(shootID); toast("Тексты переписываются в фоне — прогресс слева в «В работе»", "info"); setRoute("shoots"); } catch (e) { toast("Не удалось: " + e.message, "error"); } finally { setRegenerating(false); } }; // «Перетасовать продающие + переписать» (только админ) — для случая, когда // тексты вышли однотипными, потому что распределение продающих по серии не // отработало (похожие кадры → почти одинаковый топ продающих → samey тексты, // жалоба Лены по серии 61, правка 2026-06-26). Один клик делает два шага: // 1) Redistribute — перетасовывает продающие синонимы по серии (якорь + // разнообразие), синхронно (~минуту); // 2) AcceptReview1 — переписывает тексты/ключи из НОВЫХ продающих (в фоне). // Просто «перетасовать» в Ревью 2 смысла не имеет: тексты уже сгенерированы и // от смены флагов selling сами не меняются — поэтому сразу и переписываем. const reshuffleAndRegen = async () => { if (!confirm("Перетасовать продающие по серии и переписать тексты?\n\n" + "1) Перераспределим продающие синонимы (разнообразие по серии);\n" + "2) Перепишем Title/Description и 50 ключей у каждой фотки.\n\n" + "Твои ручные правки текстов будут потеряны.\n" + "Шаг 1 — синхронно (~минуту), шаг 2 — в фоне (виден слева).")) return; setReshuffling(true); try { await api.feed.redistributeSelling(shootID); // ждём — перетасовка await api.shoot.AcceptReview1(shootID); // ставит генерацию в фон toast("Продающие перетасованы, тексты переписываются в фоне", "info"); setRoute("shoots"); } catch (e) { toast("Не удалось: " + e.message, "error"); } finally { setReshuffling(false); } }; // CSV preview + Скачать — генерируем CSV на фронте по формату §9.7: // 4 колонки (filename, title, description, keywords через запятую), // без BOM, без заголовка, всё в кавычках, символы `/` и `'` убраны. // Грузим keywords по всем фоткам один раз; пересчитываем при любом // изменении photos (после Перегенерировать) или после удаления ключа. const [allKws, setAllKws] = React.useState({}); const [refreshingCsv, setRefreshingCsv] = React.useState(false); // Авто-загрузка keywords для CSV-превью. ВАЖНО: зависим от СТРОКИ id-шников, // а не от массива photos. reloadPhotos меняет содержимое (Title/Description), // но НАБОР id тот же — поэтому loadKws не пересоздаётся, и авто-эффект не // зацикливается. Раньше (правка #42) reloadAllKws и зависел от photos, и сам // звал reloadPhotos → photos менялись → коллбэк пересоздавался → useEffect // снова его запускал → бесконечный reload-цикл «всё мигает» (фикс 2026-06-07). const photoIDs = photos.map((p) => p.ID).join(","); const loadKws = React.useCallback(async () => { if (!photos.length || !api.feed.keywords) return; setRefreshingCsv(true); try { const rs = await Promise.all(photos.map((p) => api.feed.keywords.list(p.ID).then((ks) => ({ pid: p.ID, ks: Array.isArray(ks) ? ks : [] })) )); const m = {}; for (const r of rs) m[r.pid] = r.ks; setAllKws(m); } catch (_) { // тихо игнорируем — старые данные останутся } finally { setRefreshingCsv(false); } // photos используется только ради набора id (.map(p=>p.ID)) — он стабилен, // поэтому зависимость по photoIDs корректна. }, [photoIDs]); React.useEffect(() => { loadKws(); }, [loadKws]); // «Внести изменения» — подтянуть свежие Title/Description (после autosave из // EditableText) И keywords. Тут reloadPhotos зовём явно, по клику — это не // создаёт цикл, потому что авто-эффект завязан на стабильный photoIDs. const reloadAllKws = React.useCallback(async () => { await reloadPhotos(); await loadKws(); }, [reloadPhotos, loadKws]); // Сборка строки CSV для одной фотки. Активные (не spare) keywords // через запятую в одной ячейке. Кавычки и запрещённые символы режутся. const buildCsvRow = (p) => { const clean = (s) => (s || "").replace(/[/']/g, "").replace(/"/g, ""); const kws = (allKws[p.ID] || []) .filter((k) => !k.Spare) .sort((a, b) => (a.Position || 0) - (b.Position || 0)) .map((k) => k.En) .join(", "); return `"${clean(p.Filename)}","${clean(p.Title)}","${clean(p.Description)}","${kws}"`; }; const csvAll = photos .filter((p) => p.Title && p.Description) .map(buildCsvRow) .join("\n"); // В превью теперь показываем ВСЕ строки (правка 2026-06-05 по запросу Лены — // блок шире, скроллируется по вертикали). Раньше резали до 2 — Лена не // могла проверить целиком CSV перед «Принять серию». const csvSample = csvAll; // Кнопка «Скачать» — формирует Blob и триггерит download. Имя файла — // _preview.csv чтобы не путать с финальным `_done.csv`. const downloadCsv = () => { if (!csvAll) { toast("Нечего скачивать — у фоток нет текстов", "error"); return; } const blob = new Blob([csvAll], { type: "text/csv;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = (shoot?.Title || `series-${shootID}`) + "_preview.csv"; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }; return (
/ · {photos.length} файлов

Ревью 2 · Тексты

Title и Description правишь прямо в полях. Keywords — карточки с ru, ✕ убирает в запасные.
{isAdmin && ( )}
{/* Pipeline status strip — этапы конвейера с зелёными/серыми индикаторами. Считается по тому что реально в БД на этой серии. */} {shoot && ( )} {photosState.loading &&
загружаем фото…
} {!photosState.loading && photos.length === 0 && ( )}
{photos.map((p) => ( setExpanded(expanded === p.ID ? null : p.ID)}/> ))}
{/* CSV preview — содержимое файла который уйдёт в _Pikasrv/_done.csv. Правка 2026-06-05: показываем ВСЕ строки (не только первые 2), максимум 420px высоты с вертикальной прокруткой, плюс кнопка «Обновить» — после правок ключей / Title / Description Лена жмёт её и видит свежий CSV до «Принять серию». */} {photos.length > 0 && csvSample && (
Превью CSV · строк: {csvAll ? csvAll.split("\n").length : 0} / {photos.length}
4 колонки · английский · без BOM, без заголовка · в кавычках · →{" "} _Pikasrv/_done.csv

{csvSample}
)}
); } // PipelineStrip — узкая полоса этапов конвейера серии. Берёт реальное // состояние из shoot + counts photos/words. Зелёная точка — этап пройден, // серая — ещё нет. function PipelineStrip({ shoot, photos, words }) { const recCount = photos.filter((p) => p.Stage === "recognized").length; const total = photos.length; const recOk = total > 0 && recCount === total; const planOk = photos.some((p) => p.AccentEn); const anaOk = words.length > 0; const textsOk = photos.some((p) => p.Title); const stage = shoot.Stage; const csvOk = stage === "csv" || stage === "done"; const exifOk = stage === "done"; const items = [ { l: "Распознано", v: `${recCount} / ${total}`, ok: recOk }, { l: "План", v: planOk ? "акценты есть" : "—", ok: planOk }, { l: "Анализ", v: anaOk ? "принят" : "—", ok: anaOk }, { l: "Тексты", v: textsOk ? "сгенерированы" : "ждут", ok: textsOk }, { l: "Ключи", v: textsOk ? "собраны" : "—", ok: textsOk }, { l: "CSV", v: csvOk ? "отправлен" : "ждёт", ok: csvOk }, { l: "EXIF", v: exifOk ? "записан" : "watcher", ok: exifOk }, ]; return (
{items.map((s, i) => (
{s.l}{" "} {s.v}
{i < items.length - 1 && ( )}
))}
); } window.Review2Screen = Review2Screen;