// web/screens/review1.jsx — Ревью 1 · Анализ.
// Подсасывает живые analysis_words из /v1/analysis?shootID=N и рисует их
// по группам Объект / Действие / Роль / Эмоция / Место.
const ANA_GROUPS = [
{ id: "object", label: "Объект" },
{ id: "action", label: "Действие" },
{ id: "role", label: "Роль" },
{ id: "emotion", label: "Эмоция" },
{ id: "place", label: "Место" },
];
// db.AnalysisWord.GroupKind пишется как "object" / "action" / "role" / "emotion" / "place".
function wordsByGroup(words) {
const out = {};
for (const g of ANA_GROUPS) out[g.id] = [];
for (const w of words || []) {
const g = (w.GroupKind || "").toLowerCase();
if (out[g]) out[g].push(w);
}
// Внутри каждой группы — сортировка по Position.
for (const g of ANA_GROUPS) {
out[g.id].sort((a, b) => (a.Position || 0) - (b.Position || 0));
}
return out;
}
function Review1PhotoBlock({ photo, allWords, allComments, reloadComments, onAnaChanged, activeJobs }) {
const myWords = (allWords || []).filter((w) => w.PhotoID === photo.ID);
const grouped = wordsByGroup(myWords);
const isFailed = photo.Stage === "failed";
// Два типа комментариев Лены для каждой фотки (см. дизайн §21.4):
// "fix" — поправка распознавания
// "more" — дополнительная информация
// Pikasrv хранит в reviewComments и подмешивает в AnalysePrompt при
// «Применить правки» (review.ApplyAll → reAnalyse).
// Внимание: review.ListComments (RPC через zenrpc) сериализует поля в
// lowerCamelCase: c.photoId / c.commentType / c.body. REST-эндпоинты типа
// /v1/photos возвращают UpperCamelCase (ID, Filename). Это давало баг
// 2026-06-02: после reload текстовое поле «Поправка распознавания» было
// пустым, потому что myComments фильтровал по c.PhotoID (===undefined)
// и получал пустой массив. Теперь обращаемся к камелкейс-полям из RPC.
const myComments = (allComments || []).filter((c) => c.photoId === photo.ID);
const findText = (kind) => {
const c = myComments.find((c) => c.commentType === kind);
return c ? c.body : "";
};
const [fix, setFix] = React.useState(findText("fix"));
const [more, setMore] = React.useState(findText("more"));
const [_, toast] = useToasts();
// fixRef — всегда содержит АКТУАЛЬНОЕ значение fix (без stale closure).
// ReanalyseButton.run() через getCurrentText() читает именно ref,
// чтобы при клике на кнопку даже если textarea не blur'нулась,
// мы могли явно upsert'нуть текущий текст до запуска reanalyse.
const fixRef = React.useRef(fix);
React.useEffect(() => { fixRef.current = fix; }, [fix]);
// При появлении свежих comments (например после reload) подтягиваем их —
// НО только если у нас нет «висящего» локального ввода. Иначе reload
// после reanalyse / параллельный save могли обнулять textarea если
// запрос успевал раньше нашего save (баг 2026-06-02).
React.useEffect(() => {
const server = findText("fix");
// Если локальный fix совпадает с тем что в БД — обновляем (no-op).
// Если различается И сервер вернул непустое — берём с сервера (свежее).
// Если различается И сервер пустой — оставляем локальный (он или
// только что введён, или save ещё не отработал).
if (server !== fix && server !== "") {
setFix(server);
}
const moreSrv = findText("more");
if (moreSrv !== more && moreSrv !== "") {
setMore(moreSrv);
}
}, [allComments]);
// Сохраняем на blur через атомарный upsert. Раньше делали два запроса
// (Delete + Add) — если Add падал, текст пропадал из БД и UI обнулял
// textarea (баг 2026-06-02 «при клике на Перераспознать поле очищалось»).
// upsertComment делает обе операции в одном handler'е: сначала вставка
// новой записи, потом удаление старой; при ошибке вставки старая
// НЕ удаляется.
const save = async (kind, text) => {
const existing = myComments.find((c) => c.commentType === kind);
const orig = existing ? existing.body : "";
if (text === orig) return;
try {
await api.feed.upsertComment(photo.ShootID, photo.ID, kind, text);
reloadComments && reloadComments();
} catch (e) {
toast("Не удалось сохранить комментарий: " + e.message, "error");
}
};
// Голосовой ввод правки распознавания — дописать + сразу сохранить.
const appendFix = (t) => {
setFix((prev) => {
const next = (prev.trim() ? prev.trim() + " " : "") + t;
api.feed.upsertComment(photo.ShootID, photo.ID, "fix", next)
.then(() => reloadComments && reloadComments())
.catch(() => toast("Не удалось сохранить", "error"));
return next;
});
};
return (
{photo.YandexPath
?
: null}
{photo.Filename}
{/* Акцент теперь редактируется в правой колонке как 6-й блок
(AccentBlock); здесь он не дублируется. */}
{photo.Gist && }
{/* Два типа комментариев (дизайн §21.4) — Лена видит распознавание
и может поправить ошибки или добавить контекст. Уйдёт в analyser
при «Применить правки». */}
{!isFailed && (
<>
>
)}
{isFailed ? (
Фото не распозналось
) : (
{ANA_GROUPS.map((g) => (
))}
{/* Акцент — последний блок (после Места), стилизован как ana-group.
Одно редактируемое поле; перевод второго языка добирается на
бэке через короткий запрос к Pika_Writer (см. accent.go). */}
)}
);
}
// GistBlock — карточка с GIST + клик-открывающийся поповер с полным текстом
// recognition. Поповер рендерится через ReactDOM.createPortal в document.body,
// чтобы не обрезаться overflow:hidden родителя. Click вместо hover чтобы
// можно было выделять и копировать текст.
function GistBlock({ gist, recognition }) {
const [open, setOpen] = React.useState(false);
const anchorRef = React.useRef(null);
const [pos, setPos] = React.useState({ top: 0, left: 0 });
const toggle = () => {
if (!recognition) return;
if (!open && anchorRef.current) {
const r = anchorRef.current.getBoundingClientRect();
// Прижимаем к якорю снизу; если справа не помещается — сдвигаем влево.
const w = Math.min(560, window.innerWidth - 24);
let left = r.left;
if (left + w > window.innerWidth - 12) left = window.innerWidth - w - 12;
if (left < 12) left = 12;
setPos({ top: r.bottom + 6, left, width: w });
}
setOpen((v) => !v);
};
return (
<>
>,
document.body
)}
>
);
}
// ReanalyseButton — кнопка «Переделать» под textarea «Поправка распознавания»
// (правка 2026-06-02 — заменила прежнюю «Перераспознать с поправкой»).
//
// Pika_Writer (текстовая модель) пересчитает акцент и переанализирует
// слова этой фотки с учётом её комментариев. Vision (Pika_Painter) НЕ
// дёргается — recognition_text остаётся прежним. ~30-60 сек.
//
// Принимает getCurrentText() — callback который возвращает АКТУАЛЬНЫЙ
// текст из textarea на момент клика (не из props, чтобы не было stale
// closure). Перед запуском reanalyse явно сохраняем текст в БД через
// upsert — это снимает race condition с blur'ом textarea, который иначе
// конкурировал с click'ом и мог либо опоздать с записью, либо запустить
// параллельный reloadComments который обнулял textarea (баг 2026-06-02
// «жму переделать, поле очищается, кнопка пропадает»).
//
// Без confirm — операция не destructive, Лена просила не пугать confirm'ами.
function ReanalyseButton({ photo, hasFix, getCurrentText, onDone, activeJobs }) {
const [busy, setBusy] = React.useState(false);
const [_, toast] = useToasts();
if (!hasFix) return null;
// Серверная очередь (правка 2026-06-05): если в JobTracker уже есть job
// для этой фотки — кто-то жмёт «Переделать» из другой вкладки / ждёт
// ответа сервера. Покажем спиннер и заблокируем повтор.
const serverBusy = (activeJobs || []).some(
(j) => j.photoId === photo.ID && (j.op || "").includes("анализ"),
);
const isBusy = busy || serverBusy;
const run = async (e) => {
if (e) { e.preventDefault(); e.stopPropagation(); }
if (isBusy) return;
setBusy(true);
try {
// 1. Гарантированно сохраняем актуальный fix-коммент.
// Если Лена не blur'нула textarea — текст всё равно уйдёт в БД.
// Если уже blur'нула и save отработал — этот upsert идемпотентен.
const text = (getCurrentText ? getCurrentText() : "") || "";
await api.feed.upsertComment(photo.ShootID, photo.ID, "fix", text);
// 2. Только теперь — reanalyse, который читает свежие комментарии.
await api.feed.reanalysePhoto(photo.ID);
toast("Анализ обновлён", "info");
onDone && onDone();
} catch (e2) {
toast("Не удалось: " + e2.message, "error");
} finally {
setBusy(false);
}
};
// Подпись зависит от того откуда busy: если только локально (наш клик) —
// «переделываем…»; если уже из JobTracker (другая вкладка/перезагрузка) —
// «идёт переанализ…».
const busyLabel = busy
? "переделываем…"
: serverBusy
? "идёт переанализ…"
: null;
return (
);
}
// WordCard — карточка слова в группе анализа. Поддерживает:
// • drag по карточке: можно перенести в "Продающие" / "Синонимы" (в той же
// категории) и переставить внутри ряда (точное место — между двумя карточками)
// • soft-delete по ✕ с 5-секундным окном «Откатить» (потом hard-delete)
function WordCard({ w, onToggleSelling, onDelete, onDragStart, onDragEnd, dragging, highlight,
// dropSide: "left" | "right" | null — для подсветки точки вставки
// прямо на карточке (правка 2026-06-05, как в Ревью 2). Раньше
// были узкие 8px Gap-zone'ы — Лена в них промахивалась.
dropSide, onDragOverCard, onDropCard }) {
const [pending, setPending] = React.useState(false); // флаг «в очереди на удаление»
const timerRef = React.useRef(null);
// wasDraggingRef — флаг что только что закончился drag. Браузер после
// dragend всё равно может выстрелить click; без этого флага каждое
// перетаскивание ещё и переключало бы selling.
const wasDraggingRef = React.useRef(false);
const [_, toast] = useToasts();
const handleDeleteClick = () => {
if (pending) return;
setPending(true);
timerRef.current = setTimeout(async () => {
try {
await api.feed.deleteAnalysisWord(w.ID);
onDelete && onDelete(w.ID); // родитель уберёт из state
} catch (e) {
toast("Не удалось удалить: " + e.message, "error");
setPending(false);
}
}, 5000);
};
const handleUndo = () => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = null;
setPending(false);
};
React.useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []);
// Клик по самой карточке — переключает selling и роняет слово в конец
// нового ряда (правка 2026-05-31). Drag и pending игнорируем.
const handleCardClick = (e) => {
if (pending) return;
if (wasDraggingRef.current) { wasDraggingRef.current = false; return; }
onToggleSelling && onToggleSelling();
};
// Подсветка точки вставки слева/справа толстой бирюзовой линией.
const indicatorStyle = dropSide === "left"
? { boxShadow: "inset 3px 0 0 0 var(--accent)" }
: dropSide === "right"
? { boxShadow: "inset -3px 0 0 0 var(--accent)" }
: null;
return (
);
}
// AccentBlock — последний блок правой колонки Ревью 1. Стилизован как обычная
// ana-group (Объект / Действие / …), но без рядов «Продающие/Синонимы» —
// акцент это одна короткая фраза, а не набор слов.
//
// Одно редактируемое поле — Лена пишет на удобном языке. Перевод во второй
// язык докатывается на бэке через короткий запрос к Pika_Writer (см.
// pkg/app/accent.go) и показывается мелким серым под основным полем —
// так же как `.ru` у обычных wcard.
//
// AccentEn критичен для детерминированной сборки 50 ключей (pkg/pika режет
// его на токены), поэтому второе поле должно быть заполнено всегда.
function AccentBlock({ photo, onSaved }) {
// text — то что в textarea сейчас. Инициализируем «русским первым», потому
// что Лена русскоязычная: если есть AccentRu — показываем его, иначе EN.
const initial = photo.AccentRu || photo.AccentEn || "";
const [text, setText] = React.useState(initial);
const [busy, setBusy] = React.useState(false);
const [_, toast] = useToasts();
React.useEffect(() => {
setText(photo.AccentRu || photo.AccentEn || "");
}, [photo.AccentRu, photo.AccentEn]);
// «Другой язык» для подписи под полем — то, что НЕ совпадает с text.
// Простая эвристика: текущий text сравниваем с обоими полями фото;
// показываем тот, что отличается. Если совпадают (или модель ещё не
// перевела) — показываем оба или ничего.
const otherLang = (() => {
const ru = photo.AccentRu || "";
const en = photo.AccentEn || "";
if (text === ru && en) return en;
if (text === en && ru) return ru;
if (ru && en && ru !== en) return ru === text ? en : ru;
return ""; // нечего показывать
})();
const save = async () => {
const cur = text.trim();
const oldPrimary = (photo.AccentRu || photo.AccentEn || "").trim();
if (cur === oldPrimary) return;
setBusy(true);
try {
await api.feed.saveAccent(photo.ID, cur);
onSaved && onSaved();
} catch (e) {
toast("Не удалось сохранить акцент: " + e.message, "error");
} finally { setBusy(false); }
};
// Голосовой ввод акцента — дописать к текущему тексту и сразу сохранить.
const appendAccent = (t) => {
setText((prev) => {
const next = (prev.trim() ? prev.trim() + " " : "") + t;
setBusy(true);
api.feed.saveAccent(photo.ID, next.trim())
.then(() => onSaved && onSaved())
.catch(() => toast("Не удалось сохранить акцент", "error"))
.finally(() => setBusy(false));
return next;
});
};
return (
Акцент
{/* Подсказка — нижний регистр и светлее: .ana-head задаёт uppercase
на всё содержимое, поэтому здесь переопределяем inline. */}
продающий угол · можно править на любом языке
{busy && }
{busy ? "переводим…" : "основной"}
{/* textarea вместо input — длинные акценты типа «женщина показывает
укоренённый черенок во мхе. растение хойя» не влезали в одну
строку (правка 2026-06-02). Auto-resize по содержимому, минимум
2 строки. Ctrl+Enter = blur (сохраняет); просто Enter — перевод
строки внутри. */}
);
}
// AnaGroupBlock — один блок «Объект» / «Действие» / etc. с двумя рядами
// (Продающие / Синонимы), drag-and-drop между рядами, удалением слов и
// добавлением нового. Drag только внутри своей категории.
//
// Обёрнут в React.memo с компаратором ПО ЗНАЧЕНИЮ (см. низ функции): родитель
// (Review1Screen) пере-рендерится каждые 3с из-за polling /v1/jobs и каждый раз
// отдаёт НОВЫЙ массив words (grouped = wordsByGroup(...)). Без memo это
// пересобирало DOM карточек ровно в момент, когда Лена начинает тащить слово, и
// браузер обрывал зарождающийся нативный drag — «слово не хватается», тем чаще
// чем больше слов в группе (баг 2026-06-23). Компаратор сравнивает words по
// сигнатуре-значению, поэтому ре-рендер от поллинга больше не доходит до блока.
function AnaGroupBlockImpl({ group, words, photoID, onChanged }) {
// Локальное состояние, синхронизируется с props.words при изменении сервера.
// Ресинкаемся ТОЛЬКО когда реально поменялся состав серверных слов, а не на
// каждый ре-рендер родителя. Иначе: родитель пересоздаёт массив words на
// каждый рендер (grouped = wordsByGroup(...)), а экран ре-рендерится каждые
// 3с из-за polling /v1/jobs → useEffect([words]) срабатывал по смене ссылки и
// сбрасывал items к серверному снимку. Свежедобавленное локально слово (его
// ещё нет в снимке) «появлялось и сразу пропадало», а drag-перенос
// откатывался (баг 2026-06-23). Сигнатура по value → строка сравнивается по
// значению, эффект молчит пока состав не изменится по-настоящему.
const wordsSig = words.map((w) => `${w.ID}:${w.Selling ? 1 : 0}:${w.En}:${w.Ru || ""}`).join("|");
const [items, setItems] = React.useState(words);
React.useEffect(() => {
// Серверный снимок как база, но СОХРАНЯЕМ локально добавленные слова,
// которых в снимке ещё нет (иначе свежее слово пропадёт до reloadWords).
// И НИКОГДА не ресинкаемся пока идёт перетаскивание (dragIDRef задан):
// иначе пересборка списка под курсором срывает захват карточки — именно
// это давало «через 3 сек слово перестаёт хвататься» в группах с большим
// числом слов (баг 2026-06-23, группа «Объект» с двумя героями).
if (dragIDRef.current != null) return;
setItems((prev) => {
const serverIDs = new Set(words.map((w) => w.ID));
const localOnly = prev.filter((w) => !serverIDs.has(w.ID));
return localOnly.length ? [...words, ...localOnly] : words;
});
}, [wordsSig]);
// Drag state. dragID держим И в state (для рендера зазоров и подсветки),
// И в ref (для надёжного чтения внутри handler-ов dragOver/drop). Без ref
// между dragstart и первым dragover React успевает rerender не всегда, и
// handleDrop видит stale dragID = null → новое слово не получалось
// переместить сразу после добавления. Ref решает эту race.
const dragIDRef = React.useRef(null);
const itemsRef = React.useRef(items);
React.useEffect(() => { itemsRef.current = items; }, [items]);
const [dragID, setDragID] = React.useState(null);
const [dropTarget, setDropTarget] = React.useState(null); // { row: "selling"|"syn", index: number }
const [_, toast] = useToasts();
// Подсветка дубликата: AddWordRow ставит сюда ID карточки, у которой
// совпало слово, на ~1 секунду — карточка пульсирует мятным, потом флаг
// сбрасывается. Чтобы повторное нажатие на тот же дубль снова подсвечивало,
// храним ещё nonce — он меняется каждый клик, перезапуская анимацию.
const [flash, setFlash] = React.useState({ id: null, nonce: 0 });
const flashDuplicate = (id) => {
setFlash((s) => ({ id, nonce: s.nonce + 1 }));
setTimeout(() => setFlash((s) => s.id === id ? { id: null, nonce: s.nonce } : s), 1100);
};
const onDragStart = (e, w) => {
dragIDRef.current = w.ID;
setDragID(w.ID);
try { e.dataTransfer.setData("text/plain", String(w.ID)); } catch {}
e.dataTransfer.effectAllowed = "move";
};
const onDragEnd = () => {
dragIDRef.current = null;
setDragID(null);
setDropTarget(null);
};
// Hit-test внутри карточки: левая половина → дроп ПЕРЕД (index=i),
// правая → ПОСЛЕ (index=i+1). Правка 2026-06-05 — то же что в Ревью 2.
const onDragOverCard = (e, row, index, wID) => {
if (dragIDRef.current == null) return;
if (dragIDRef.current === wID) 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" ? index : index + 1;
setDropTarget((prev) =>
prev && prev.row === row && prev.index === targetIdx && prev._side === side
? prev
: { row, index: targetIdx, _side: side });
};
const onDropCard = (e, row, index, wID) => {
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" ? index : index + 1;
handleDrop(row, targetIdx);
};
const handleDrop = async (row, index) => {
// Берём id из ref — он гарантированно актуален, в отличие от state.
const id = dragIDRef.current;
const dragged = itemsRef.current.find((w) => w.ID === id);
if (!dragged) { onDragEnd(); return; }
const wantSelling = row === "selling";
if (dragged.Selling !== wantSelling) {
// Меняем selling через RPC ToggleSelling.
try { await api.review.ToggleSelling(dragged.ID); }
catch (e) { toast("Не удалось переместить: " + e.message, "error"); onDragEnd(); return; }
}
// Локально перерассчитаем порядок (визуально). Position в БД мы здесь не
// сохраняем — это упрощение для MVP; в следующей итерации добавим
// batch-update Position через отдельный RPC.
setItems((prev) => {
const out = prev.filter((w) => w.ID !== dragged.ID);
const samePart = out.filter((w) => w.Selling === wantSelling);
const otherPart = out.filter((w) => w.Selling !== wantSelling);
samePart.splice(index, 0, { ...dragged, Selling: wantSelling });
return [...samePart, ...otherPart];
});
onDragEnd();
};
const removeLocal = (id) => {
setItems((prev) => prev.filter((w) => w.ID !== id));
// НЕ дёргаем onChanged → reloadWords здесь: DELETE уже прошёл, локально
// удалили; reload вызвал бы лишний round-trip. Свежий снимок придёт сам
// при возврате на экран или при «Применить правки».
};
const addLocal = (w) => {
// То же что и в removeLocal: НЕ зовём onChanged. До правки 2026-05-31
// вызов reloadWords здесь срабатывал между моментом, когда новое слово
// отрендерилось как WordCard, и моментом, когда Лена пробовала его
// потащить — useEffect пересинхронизировал items с props.words, новая
// карточка теряла handler'ы drag-and-drop. Без reload карточка живёт
// в локальном state до следующего обновления props.words и нормально
// перетаскивается сразу после добавления.
setItems((prev) => [...prev, w]);
};
const toggleSelling = async (w) => {
try {
await api.review.ToggleSelling(w.ID);
// После переключения слово уходит в **конец** своего нового ряда —
// именно этого Лена ждёт по UX (правка 2026-05-31): клик по слову в
// «Продающих» → оно падает вниз в конец «Синонимов», и наоборот.
// Реализуется простым переносом в конец массива items: рендер
// фильтрует items по Selling, порядок внутри ряда — порядок items.
setItems((prev) => {
const out = prev.filter((x) => x.ID !== w.ID);
return [...out, { ...w, Selling: !w.Selling }];
});
} catch (e) { toast("Не удалось: " + e.message, "error"); }
};
// Защита от дублей по ID: одинаковый React-ключ (wc-) у двух карточек
// ломает реконсиляцию и обработчики drag для всей группы («слово не
// лифтуется»). Подстраховка на случай рассинхрона items со снимком сервера.
const seenID = new Set();
const uniqItems = items.filter((w) => {
if (seenID.has(w.ID)) return false;
seenID.add(w.ID);
return true;
});
const selling = uniqItems.filter((w) => w.Selling);
const syn = uniqItems.filter((w) => !w.Selling);
// Drag&drop через hit-test внутри карточки (правка 2026-06-05, как в
// Ревью 2). Раньше были узкие 8px Gap-zone'ы — Лена в них промахивалась.
const renderRow = (row, list) => (
{
// Сюда событие доходит ТОЛЬКО когда курсор над свободной зоной ряда
// (карточки глушат всплытие в onDragOverCard). Это в т.ч. нижняя
// полоска-паддинг — постоянная зона приёма для забитого под край
// ряда. Целимся «в конец» и подсвечиваем (баг 2026-06-23: в полный
// ряд нельзя было бросить слово).
if (dragIDRef.current == null) return;
e.preventDefault();
if (!(dropTarget?.row === row && dropTarget?.index === list.length)) {
setDropTarget({ row, index: list.length });
}
}}
onDrop={(e) => {
if (dragIDRef.current == null) return;
e.preventDefault();
handleDrop(row, dropTarget?.row === row ? dropTarget.index : list.length);
}}>
{list.length === 0 && (
{row === "selling" ? "нет продающих" : "—"}
)}
{list.map((w, i) => {
// dropSide для подсветки границы карточки: left если dropTarget.index===i,
// right если index===i+1 (и это не сама перетаскиваемая).
let side = null;
if (dragID !== w.ID && dropTarget?.row === row) {
if (dropTarget.index === i) side = "left";
else if (dropTarget.index === i + 1) side = "right";
}
return (
onDragOverCard(e, row, i, w.ID)}
onDropCard={(e) => onDropCard(e, row, i, w.ID)}
onToggleSelling={() => toggleSelling(w)}
onDelete={removeLocal}/>
);
})}
{/* Бывшая drag-only «хвостовая» зона приёма УБРАНА (баг 2026-06-23): она
появлялась только во время перетаскивания и в забитом ряду вызывала
перенос строки → скачок высоты → срыв захвата нижнего ряда; а при
полном ряде сама сжималась в ноль (flex-shrink) и бросок не ловила.
Теперь «бросок в конец» ловит сам контейнер: его постоянная нижняя
полоса (.ana-cards padding-bottom) + onDragOver/onDrop ниже. */}
);
return (
{group.label}
Продающие · {selling.length}
{renderRow("selling", selling)}
Синонимы · {syn.length}
{renderRow("syn", syn)}
);
}
// Сигнатура слов по ЗНАЧЕНИЮ — для memo-компаратора (и идентична wordsSig внутри
// блока). Реордер при drag в БД не сохраняется (Position не пишем), серверные
// позиции стабильны, поэтому отсутствие Position в сигнатуре не мешает.
function anaWordsSig(ws) {
return (ws || []).map((w) => `${w.ID}:${w.Selling ? 1 : 0}:${w.En}:${w.Ru || ""}`).join("|");
}
// React.memo: пере-рендериваем блок только когда реально изменился состав слов
// (по значению), сменилась фотка/группа или колбэк. onChanged (reloadWords) —
// стабильный useCallback на [shootID], так что сравнение по идентичности тут ОК.
// Собственные drag/toggle/добавление идут через внутренний setState и memo им не
// мешает (memo гасит только ре-рендеры, инициированные родителем).
const AnaGroupBlock = React.memo(AnaGroupBlockImpl, (prev, next) =>
prev.photoID === next.photoID &&
prev.group.id === next.group.id &&
prev.onChanged === next.onChanged &&
anaWordsSig(prev.words) === anaWordsSig(next.words)
);
// AddWordRow — поле ввода нового слова + кнопка "+". Создаёт слово(а) в
// синонимах текущей группы.
//
// Правка 2026-06-05 (по запросу Лены):
// • Split по запятой / точке с запятой — «бокал, женщина» создаёт 2 слова.
// • Кириллица БОЛЬШЕ НЕ блокируется: каждый кириллический токен сначала
// уходит в keyword.TranslateRuToEnBatch (словарь → GLM fallback) и в БД
// попадает уже EN. UI показывает спиннер пока ждём перевод.
// • Дубликат проверяется после split+translate, по полученным EN. Если
// хотя бы один — дубль, мы добавляем остальные и сигналим про дубль.
function AddWordRow({ photoID, group, existingWords, onAdded, onDuplicate }) {
const [input, setInput] = React.useState("");
const [busy, setBusy] = React.useState(false);
const [_, toast] = useToasts();
// tokens — для подсветки/preview: показываем сколько слов уйдёт.
const tokens = input.split(/[,;]/).map((s) => s.trim()).filter(Boolean);
const hasCyrillic = /[Ѐ-ӿ]/.test(input);
const submit = async () => {
if (tokens.length === 0 || busy) return;
setBusy(true);
try {
// 1) Развести кириллические → EN. Латинские идут как есть.
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;
}
}
// 2) Финальный список пар {en, ru} в порядке ввода. ru заполнен только
// для кириллического ввода (оригинал) — чтобы перевод показался сразу.
// Для EN-ввода ru="" — бэкенд подтянет из словаря / GLM (правка 2026-06-08).
const enPairs = [];
const failed = [];
for (const t of tokens) {
if (/[Ѐ-ӿ]/.test(t)) {
const en = ruToEn[t.toLowerCase()];
if (en) enPairs.push({ en, ru: t });
else failed.push(t);
} else {
enPairs.push({ en: t, ru: "" });
}
}
if (failed.length > 0) {
toast("Не удалось перевести: " + failed.join(", "), "warn");
}
if (enPairs.length === 0) {
setBusy(false);
return;
}
// 3) Дедуп с уже существующими.
const existingLow = new Set((existingWords || []).map((w) => (w.En || "").trim().toLowerCase()));
const seen = new Set();
let firstDupID = null;
const toCreate = [];
for (const p of enPairs) {
const key = p.en.trim().toLowerCase();
if (!key || seen.has(key)) continue;
seen.add(key);
if (existingLow.has(key)) {
if (!firstDupID) {
const dup = (existingWords || []).find((w) => (w.En || "").trim().toLowerCase() === key);
if (dup) firstDupID = dup.ID;
}
continue;
}
toCreate.push({ en: p.en.trim(), ru: p.ru });
}
// 4) Создаём (один за другим, чтобы Position шёл правильно).
for (const p of toCreate) {
const w = await api.feed.addAnalysisWord(photoID, group, p.en, p.ru);
onAdded && onAdded(w);
}
if (firstDupID && toCreate.length === 0) {
// Был только дубль — сигналим как раньше, input оставляем.
onDuplicate && onDuplicate(firstDupID);
} else {
setInput("");
if (firstDupID) onDuplicate && onDuplicate(firstDupID);
}
} catch (e) {
toast("Не удалось добавить: " + e.message, "error");
} finally {
setBusy(false);
}
};
return (
setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submit()}
placeholder="новое слово (EN или RU, можно через запятую)"
disabled={busy}
title={hasCyrillic ? "RU → переведу автоматически через словарь / GLM" : "перевод подтянет Pika_Writer"}
style={{ flex: 1, padding: "3px 6px", fontSize: 11,
border: "1px solid var(--line-2)", borderRadius: "var(--r)",
outline: "none" }}/>
);
}
function Review1Screen({ shootID, setRoute, isAdmin }) {
const [shootState, reloadShoot] = useAsync(async () => {
const list = await api.feed.shoots();
return (list || []).find((s) => s.ID === shootID) || null;
}, [shootID]);
const [photosState] = useAsync(() => api.feed.photos(shootID), [shootID]);
const [wordsState, reloadWords] = useAsync(() => api.feed.analysis(shootID), [shootID]);
const [commentsState, reloadComments] = useAsync(() => api.review.ListComments(shootID), [shootID]);
const [accepting, setAccepting] = React.useState(false);
// mountedRef — жив ли экран. «Принять серию» ждёт генерацию минуты; если Лена
// за это время ушла в другую серию, по завершении НЕ выдёргиваем её обратно
// (баг 2026-06-17): редирект и setState только пока экран на месте.
const mountedRef = React.useRef(true);
React.useEffect(() => () => { mountedRef.current = false; }, []);
// Polling /v1/jobs каждые 3 сек — чтобы кнопка «Переделать» знала, что
// эта же фотка уже обрабатывается (правка 2026-06-05). Без этого Лена
// могла нажать «Переделать» дважды в разных вкладках; теперь видит спиннер.
const [activeJobs, setActiveJobs] = React.useState([]);
React.useEffect(() => {
let alive = true;
const tick = () => {
if (typeof api.jobs !== "function") return;
api.jobs().then((r) => { if (alive) setActiveJobs(Array.isArray(r) ? r : []); }).catch(() => {});
};
tick();
const t = setInterval(tick, 3000);
return () => { alive = false; clearInterval(t); };
}, []);
// Серийный (общий) комментарий «к этой партии» — тип "fix" без photoID.
// Применяется ко ВСЕМ фоткам серии при «Переписать всю серию».
const [seriesFix, setSeriesFix] = React.useState("");
// seriesFixRef — актуальное значение для applyAll(), не stale closure.
const seriesFixRef = React.useRef("");
React.useEffect(() => { seriesFixRef.current = seriesFix; }, [seriesFix]);
const [applying, setApplying] = React.useState(false);
const [_, toast] = useToasts();
const shoot = shootState.data;
const photos = photosState.data || [];
const words = wordsState.data || [];
const failed = photos.filter((p) => p.Stage === "failed").length;
const recognized = photos.filter((p) => p.Stage === "recognized").length;
// Подтягиваем серийный fix-коммент из БД, но НЕ обнуляем локальный ввод
// если в БД его ещё нет (race с blur/applyAll — то же что для per-photo
// fix, см. Review1PhotoBlock).
React.useEffect(() => {
const list = commentsState.data || [];
// RPC review.ListComments возвращает поля в lowerCamelCase.
const ser = list.find((c) => c.commentType === "fix" && (!c.photoId || c.photoId === 0));
const server = ser ? ser.body : "";
if (server !== seriesFix && server !== "") {
setSeriesFix(server);
}
}, [commentsState.data]);
const saveSeriesFix = async () => {
try {
await api.feed.upsertComment(shootID, 0, "fix", seriesFix);
reloadComments && reloadComments();
} catch (e) { toast("Не удалось сохранить: " + e.message, "error"); }
};
// Голосовой ввод общего комментария — дописать + сразу сохранить.
const appendSeriesFix = (t) => {
setSeriesFix((prev) => {
const next = (prev.trim() ? prev.trim() + " " : "") + t;
api.feed.upsertComment(shootID, 0, "fix", next)
.then(() => reloadComments && reloadComments())
.catch(() => toast("Не удалось сохранить", "error"));
return next;
});
};
const applyAll = async () => {
if (applying) return;
setApplying(true);
try {
// 1. Гарантированно сохраняем актуальный серийный fix-коммент.
// Берём из ref'а — он содержит АКТУАЛЬНОЕ значение даже если
// textarea ещё не blur'нулась.
await api.feed.upsertComment(shootID, 0, "fix", seriesFixRef.current || "");
// 2. Серверный ApplyAll: для каждой фотки — mini-plan (с учётом fix-
// комментариев) + ReAnalyse. Акценты пересчитаются с учётом
// общего комментария.
// Через api.review.ApplyAll — с токеном Authorization2 (иначе /rpc/ 401).
await api.review.ApplyAll(shootID);
toast("Серия переписана — слова и акценты обновлены", "info");
reloadWords && reloadWords();
reloadComments && reloadComments();
} catch (e) {
toast("Не удалось: " + e.message, "error");
} finally { setApplying(false); }
};
const accept = async () => {
setAccepting(true);
try {
// Через api.shoot.AcceptReview1 (а не прямой fetch) — иначе не уходит
// токен Authorization2 и /rpc/ отвечает 401 (фаза 2).
// Теперь генерация идёт в ФОНЕ (очередь на бэке): RPC возвращается сразу,
// тексты пишутся в фоне и видны слева в «В работе». Можно спокойно уйти —
// обрыв запроса/навигация больше не рвут генерацию (правка 2026-06-24).
await api.shoot.AcceptReview1(shootID);
toast("Серия принята → тексты генерируются в фоне", "info");
// Уводим в СПИСОК серий (а не в карточку серии): генерация фоновая, её
// прогресс виден и слева в «В работе», и в списке. На карточке держать
// незачем — там всё равно ничего не поменять, пока серия в работе
// (выбор Лены 2026-06-24).
if (mountedRef.current) setRoute("shoots");
} catch (e) {
toast("Не удалось принять: " + e.message, "error");
} finally {
if (mountedRef.current) setAccepting(false);
}
};
const [redistributing, setRedistributing] = React.useState(false);
// Видимость перераспределения: сервер регистрирует серийный job
// (photoId = -shootID, op «перераспределение продающих»). Пока он в activeJobs
// — кнопка показывает «распределяем…» (в т.ч. если запущено из другой вкладки),
// а слева висит строка «В работе». Завершение ловит сам синхронный запрос ниже.
const redistribJobActive = activeJobs.some(
(j) => j.photoId === -shootID || (j.shootId === shootID && (j.op || "").indexOf("перераспределение") >= 0)
);
// «Перераспределить продающие» (только админ) — серийный шаг распределения
// продающих по серии. Запрос СИНХРОННЫЙ: ждём всю операцию (кнопка крутится
// спиннером, слева в «В работе» висит job), по ответу сразу обновляем слова.
// Синонимы/тексты не трогает.
const redistribute = async () => {
setRedistributing(true);
try {
await api.feed.redistributeSelling(shootID); // дожидается завершения
reloadWords && reloadWords();
toast("Перераспределение завершено", "info");
} catch (e) {
toast("Не удалось: " + e.message, "error");
} finally {
setRedistributing(false);
}
};
return (
Ревью 1 · Анализ
{recognized} распознано
{failed > 0 && <> · {failed} ошибок>}
{" · "}{words.length} слов анализа
{isAdmin && (
)}
{/* Серийный (общий) комментарий + кнопка «Переписать всю серию».
Кнопка теперь на одной строке с textarea (правка 2026-06-03 —
раньше она была на уровне label, что выглядело несогласованно). */}
{photos.length > 0 && words.length > 0 && (