// web/app.jsx — корневой компонент. // Hash-routing: #/shoots, #/ingest, #/settings. // При первом запуске сам определяет где мы (Я.Диск подключён или нет). function App() { // Гейт авторизации (фаза 2). Токен в localStorage → проверяем auth.Me. const [auth, setAuth] = React.useState({ checked: false, me: null, view: "login", justRegistered: false }); React.useEffect(() => { if (!api.auth.getToken()) { setAuth((a) => ({ ...a, checked: true })); return; } api.auth.Me() .then((me) => setAuth((a) => ({ ...a, checked: true, me }))) .catch(() => { api.auth.logout(); setAuth((a) => ({ ...a, checked: true, me: null })); }); }, []); if (!auth.checked) { return
загрузка…
; } if (!auth.me) { return auth.view === "register" ? setAuth({ checked: true, me, view: "login", justRegistered: true })} onGoLogin={() => setAuth((a) => ({ ...a, view: "login" }))} /> : setAuth({ checked: true, me, view: "login", justRegistered: false })} onGoRegister={() => setAuth((a) => ({ ...a, view: "register" }))} />; } const logout = () => { api.auth.logout(); setAuth({ checked: true, me: null, view: "login", justRegistered: false }); }; return ( ); } function AppInner({ me, justRegistered, onLogout }) { const [routeState, _setRoute] = React.useState(() => parseHash()); const route = routeState.name; const routeArg = routeState.arg; const [yandex] = useAsync(() => api.yandex.Status(), []); // setRoute(name) — простой переход, setRoute(name, arg) — с параметром. const setRoute = React.useCallback((name, arg) => { const hash = arg != null ? "#/" + name + "/" + arg : "#/" + name; window.location.hash = hash; _setRoute({ name, arg }); }, []); // Impersonation (И3): админ «Смотреть как {логин}». setImpersonate шлёт // заголовок X-Impersonate-User на /rpc/. mode: view (просмотр) / control. const [imp, setImp] = React.useState(null); const startImpersonate = (user) => { api.admin.setImpersonate(user.id); setImp({ user, mode: "view" }); setRoute("shoots"); }; const stopImpersonate = () => { api.admin.setImpersonate(0); setImp(null); setRoute("users"); }; const takeControl = () => setImp((im) => im ? { ...im, mode: "control" } : im); // Эффективный профиль/права (И5.2): под impersonation — целевого юзера // (auth.Me с заголовком X-Impersonate-User возвращает ЕГО права), иначе сам. // По effMe гейтим UI — чтобы «Смотреть как» показывал реальный урезанный вид. const [effMe, setEffMe] = React.useState(me); React.useEffect(() => { let alive = true; if (imp) { api.auth.Me().then((m) => { if (alive) setEffMe(m); }).catch(() => { if (alive) setEffMe(me); }); } else { setEffMe(me); } return () => { alive = false; }; }, [imp, me]); const can = (code) => !!(effMe && (effMe.isAdmin || (effMe.rights || []).includes(code))); React.useEffect(() => { const h = () => _setRoute(parseHash()); window.addEventListener("hashchange", h); return () => window.removeEventListener("hashchange", h); }, []); // Когда узнали статус Я.Диска первый раз — если не подключён и route не задан явно, // перенаправляем на /settings. React.useEffect(() => { if (!yandex.data) return; if (!yandex.data.connected && route === "shoots" && !window.location.hash) { setRoute("settings"); } }, [yandex.data]); // Уводим на «Мои съёмки» с недоступных роутов (напр. из устаревшего хэша // после смены пользователя): админ-роуты — только админу, ingest — только // тем, у кого доступ к Я.Диску. React.useEffect(() => { if (!effMe) return; const adminOnly = route === "plans" || route === "users" || route === "settings"; if (adminOnly && !effMe.isAdmin) { setRoute("shoots"); return; } if (route === "ingest" && !(effMe.isAdmin || effMe.ownYandex)) { setRoute("shoots"); return; } if (route === "review1" && !can("r1.access")) { setRoute("shoots"); return; } if (route === "review2" && !can("r2.access")) { setRoute("shoots"); return; } if (route === "releases" && !can("release.access")) { setRoute("shoots"); } }, [effMe, route]); // Содержимое экрана. let screen; if (yandex.loading && route === "shoots") { screen =
загружаем…
; } else if (route === "settings") { screen = ; } else if (route === "ingest") { screen = ; } else if (route === "archive") { screen = ; } else if (route === "releases") { screen = ; } else if (route === "upload") { screen = ; } else if (route === "plans" && effMe && effMe.isAdmin) { screen = ; } else if (route === "users" && effMe && effMe.isAdmin) { screen = ; } else if (route === "series" && routeArg) { screen = ; } else if (route === "review1" && routeArg) { screen = can("r1.access") ? : ; } else if (route === "review2" && routeArg) { screen = can("r2.access") ? : ; } else if (route === "shoots") { // Экран «подключите Яндекс» — только для тех, кто с Я.Диском работает // (админ / «Свой Яндекс»). Обычный юзер всегда видит свои серии. const yAccess = effMe && (effMe.isAdmin || effMe.ownYandex); if (yAccess && yandex.data && !yandex.data.connected) { screen = ; } else { screen = ; } } else { screen = ; } return (
{imp && (
👁 Смотрю как {imp.user.login} · {imp.mode === "control" ? "режим управления — действия применяются к аккаунту пользователя" : "режим просмотра — изменения не сохраняются"} {imp.mode === "view" ? : ● Управление активно}
)} {effMe && effMe.onTrial && (
Пробный период: осталось{effMe.trialFilesLeft != null ? " " + effMe.trialFilesLeft + " фото" : ""}{effMe.trialUntil ? " / " + Math.max(0, Math.ceil((new Date(effMe.trialUntil).getTime() - Date.now()) / 86400000)) + " дн." : ""}. Потом аккаунт перейдёт на бесплатный тариф.
)} {screen}
); } // NoAccess — заглушка для роутов, недоступных по правам (И5.2). function NoAccess({ what, setRoute }) { return (
Нет доступа к «{what}»
Эта возможность не включена в твоих правах.
); } // Topbar — узкая верхняя полоса: бренд + хлебные крошки + статусы сервисов. // Крошки — настоящая навигация: для серии/ревью показывают НАЗВАНИЕ серии // (а не внутренний номер) + текущий этап, и кликабельны для возврата назад // (правка 2026-06-07 по запросу Лены). function Topbar({ route, routeArg, yandex, setRoute, me, onLogout }) { const [pwOpen, setPwOpen] = React.useState(false); // модалка «сменить пароль» // Для серии/ревью подгружаем shoot ради названия и этапа. Лёгкий поллинг // 10 сек — этап меняется медленно, держим крошку свежей. const isSeries = (route === "series" || route === "review1" || route === "review2") && routeArg; const [shoot, setShoot] = React.useState(null); React.useEffect(() => { if (!isSeries) { setShoot(null); return; } let alive = true; const load = () => api.feed.shoots() .then((list) => { if (!alive) return; const s = (list || []).find((x) => String(x.ID) === String(routeArg)); setShoot(s || null); }) .catch(() => {}); load(); const t = setInterval(load, 10000); return () => { alive = false; clearInterval(t); }; }, [route, routeArg]); const crumbs = crumbsFor(route, routeArg, shoot); return (
P
pikasrv
{crumbs.map((c, i) => { const isLast = i === crumbs.length - 1; return ( {i > 0 && } {c.route && !isLast ? : {c.label}} ); })}
{(me && me.login ? me.login.slice(0, 2) : "АД").toUpperCase()} {me ? me.login : "—"}{me && me.isAdmin ? " · админ" : ""} {onLogout && ( )}
{pwOpen && setPwOpen(false)} />}
); } // ChangePasswordModal — форма смены собственного пароля (auth.ChangePassword). function ChangePasswordModal({ onClose }) { const [oldP, setOldP] = React.useState(""); const [newP, setNewP] = React.useState(""); const [rep, setRep] = React.useState(""); const [busy, setBusy] = React.useState(false); const [, toast] = useToasts(); const canSave = oldP && newP.length >= 8 && newP === rep && !busy; const submit = async () => { if (!canSave) return; setBusy(true); try { await api.auth.ChangePassword(oldP, newP); toast("Пароль изменён", "info"); onClose(); } catch (e) { toast("Не удалось: " + (e.message || "ошибка"), "error"); } finally { setBusy(false); } }; const inp = { width: "100%", padding: "7px 9px", fontSize: 13, marginTop: 4, border: "1px solid var(--line-2, #d7dad3)", borderRadius: 6, boxSizing: "border-box" }; return (
e.stopPropagation()} style={{ background: "var(--surface, #fff)", padding: 22, borderRadius: 12, width: 320, boxShadow: "0 10px 40px rgba(0,0,0,.2)" }}>

Сменить пароль

{newP && rep && newP !== rep &&
пароли не совпадают
}
); } function StatusBadge({ label, ok, hint }) { return ( {label} ); } // crumbsFor возвращает массив крошек { label, route?, arg? }. Крошка с route — // кликабельная (переход). Последняя всегда «текущая» (не кликается). Для серии // показываем название + текущий этап вместо внутреннего номера. function crumbsFor(route, arg, shoot) { const title = (shoot && shoot.Title) ? shoot.Title : `Серия ${arg || ""}`; if (route === "shoots") return [{ label: "Мои съёмки" }]; if (route === "ingest") return [{ label: "Мои съёмки", route: "shoots" }, { label: "Обработать новые" }]; if (route === "archive") return [{ label: "Архив" }]; if (route === "releases") return [{ label: "Релизы" }]; if (route === "settings") return [{ label: "Настройки" }]; if (route === "upload") return [{ label: "Загрузить" }]; if (route === "plans") return [{ label: "Тарифы и группы" }]; if (route === "users") return [{ label: "Пользователи" }]; if (route === "series") { const crumbs = [{ label: "Мои съёмки", route: "shoots" }, { label: title }]; if (shoot) { const info = stageInfo(shoot.Stage, shoot); const stageLabel = (STAGES[info.idx] && STAGES[info.idx].label) || shoot.Stage; crumbs.push({ label: stageLabel }); } return crumbs; } if (route === "review1") return [{ label: "Мои съёмки", route: "shoots" }, { label: title, route: "series", arg }, { label: "Ревью 1" }]; if (route === "review2") return [{ label: "Мои съёмки", route: "shoots" }, { label: title, route: "series", arg }, { label: "Ревью 2" }]; return []; } function Sidebar({ route, setRoute, me }) { // Грузим серии для счётчиков и очереди + активные per-photo операции // (reanalyse/rerecognize). Polling 2.5 сек — было 5, Лена жаловалась что // после «Переделать» долго не появляется в работе (правка 2026-06-05). // shoots отдельно опрашиваем реже (10 сек) — стадии меняются медленно. const [active, setActive] = React.useState([]); const [archived, setArchived] = React.useState([]); const [jobs, setJobs] = React.useState([]); const refreshJobs = React.useCallback(() => { if (typeof api.jobs === "function") { api.jobs().then((r) => setJobs(Array.isArray(r) ? r : [])).catch(() => {}); } }, []); const refreshShoots = React.useCallback(() => { api.feed.shoots("false").then((r) => setActive(Array.isArray(r) ? r : [])).catch(() => {}); api.feed.shoots("true").then((r) => setArchived(Array.isArray(r) ? r : [])).catch(() => {}); }, []); React.useEffect(() => { refreshJobs(); refreshShoots(); const tj = setInterval(refreshJobs, 2500); const ts = setInterval(refreshShoots, 10000); return () => { clearInterval(tj); clearInterval(ts); }; }, [refreshJobs, refreshShoots]); const newCount = active.filter((s) => s.Stage === "new").length; // Серии в активной фазе модели — по Stage + фактическому прогрессу. // Правка 2026-06-05: если все фотки уже recognized/analyzed/generated, но // shoot.Stage ещё не переключён (нет автоматического перехода) — не считаем // running, иначе spinner торчит вечно (жалоба Лены на серию 260517 8/8). const seriesActive = active.filter((s) => { const total = s.Photos || 0; switch (s.Stage) { case "recognizing": return (s.Recognized || 0) < total; case "analyze": return (s.Analyzed || 0) < total; case "generate": return (s.Generated || 0) < total; case "plan": return true; // plan быстрый, прогресса не считаем default: return false; } }); // Человеко-читаемая подпись текущей серийной операции. const seriesLabel = (s) => { const t = s.Photos || 0; // Ждёт воркера в очереди анализа (стадия уже plan — мы помечаем её при // постановке, чтобы пережить рестарт, — но воркер ещё не взял): показываем // «в очереди», а не «план вариативности» (правка 2026-06-24). if (s.AnalyseQueued && !s.AnalyseRunning) return "в очереди на анализ…"; // Аналогично для генерации текстов (стадия уже generate, воркер ещё не взял). if (s.GenerateQueued && !s.GenerateRunning) return "в очереди на тексты…"; switch (s.Stage) { case "recognizing": { // Если в работе немного фоток (per-photo «Перераспознать», в т.ч. 2-3 // подряд) — показываем их имена, а не счётчик N/M серии. Для большого // батча (полная серия) остаётся прогресс N/M (правка 2026-06-07). const strip = (f) => (f || "").replace(/\.[a-z0-9]+$/i, ""); const inflight = (s.RecognizingFiles || []).slice().sort(); if (inflight.length >= 1 && inflight.length <= 3) { return inflight.map(strip).join(", ") + " — распознаётся"; } return `${s.Recognized || 0}/${t} — распознавание серии`; } case "plan": return "план вариативности"; case "analyze": return `${s.Analyzed || 0}/${t} — анализ серии`; case "generate": return `${s.Generated || 0}/${t} — тексты серии`; default: return s.Stage; } }; // Объединяем per-photo jobs с серийными активностями. Ключ — shootID // (одна серия может одновременно иметь и серийную операцию, и per-photo). const byShoot = new Map(); for (const s of seriesActive) { byShoot.set(s.ID, { shoot: s, jobs: [] }); } for (const j of jobs) { let entry = byShoot.get(j.shootId); if (!entry) { // Серия не в active-stage, но per-photo операция идёт. Найдём имя. const s = active.find((x) => x.ID === j.shootId); if (s) { entry = { shoot: s, jobs: [] }; byShoot.set(j.shootId, entry); } else { continue; // серия не нашлась — пропускаем } } entry.jobs.push(j); } // Серии, ждущие воркера в очереди анализа (Submit прошёл, очередь ещё не // взяла), и та, что воркер крутит, пока стадия не переключилась на plan/ // analyze. В seriesActive они не попадают — добавим по флагам с бэка, иначе // слева в «В работе» их не видно, хотя справа на карточке статус есть // (жалоба Лены 2026-06-23). for (const s of active) { if ((s.AnalyseQueued || s.AnalyseRunning || s.GenerateQueued || s.GenerateRunning) && !byShoot.has(s.ID)) { byShoot.set(s.ID, { shoot: s, jobs: [] }); } } const queueGroups = [...byShoot.values()]; // «Обработать новые» (ingest из Я.Диска) — только админ или члены группы // «Свой Яндекс»; остальные работают только через «Загрузить». const yandexAccess = me && (me.isAdmin || me.ownYandex); const items = [ { id: "shoots", label: "Мои съёмки", icon: "folder", count: active.length }, { id: "upload", label: "Загрузить", icon: "image" }, ...(yandexAccess ? [{ id: "ingest", label: "Обработать новые", icon: "inbox", count: newCount, badge: newCount > 0 }] : []), { id: "archive", label: "Архив", icon: "archive", count: archived.length }, ...((me && (me.isAdmin || (me.rights || []).includes("release.access"))) ? [{ id: "releases", label: "Релизы", icon: "image" }] : []), ]; return ( ); } // Простой парсер hash: #/route или #/route/arg → { name, arg }. function parseHash() { const h = window.location.hash.replace(/^#\/?/, ""); if (!h) return { name: "shoots", arg: null }; const [name, arg] = h.split("/"); if (["ingest", "settings", "shoots", "archive", "releases", "series", "review1", "review2", "upload", "plans", "users"].includes(name)) { return { name, arg: arg || null }; } return { name: "shoots", arg: null }; } window.App = App;