diff --git a/apps/web/src/pages/admin/moderation.astro b/apps/web/src/pages/admin/moderation.astro index 141bc71..e650d6d 100644 --- a/apps/web/src/pages/admin/moderation.astro +++ b/apps/web/src/pages/admin/moderation.astro @@ -10,7 +10,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";

Токен хранится только в памяти страницы и не записывается в URL или localStorage.

-
+

A одобрить карточку с фокусом · отклонение и удаление — только кнопками

@@ -29,6 +29,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000"; const sessionBar = document.querySelector(".admin-session-bar"); const logout = document.querySelector("[data-admin-logout]"); const bulk = document.querySelector("[data-bulk]"); + const selectAll = document.querySelector("[data-bulk-select-all]"); let token = ""; let offset = 0; let sessionTimer: ReturnType | undefined; @@ -40,13 +41,14 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000"; const succeed = (message: string) => { if (error) error.hidden = true; if (status) { status.textContent = message; status.hidden = false; } }; const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (list) list.innerHTML = ""; if (message) fail(message); }; const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); }; - const syncBulk = () => { const cards = [...(list?.querySelectorAll("[data-bulk-select]:checked") ?? [])]; if (bulk) bulk.hidden = cards.length === 0; const count = bulk?.querySelector("[data-bulk-count]"); if (count) count.textContent = `${cards.length} выбрано`; }; + const syncBulk = () => { const inputs = [...(list?.querySelectorAll("[data-bulk-select]") ?? [])]; const cards = inputs.filter(input => input.checked); if (bulk) bulk.hidden = cards.length === 0; if (selectAll) { selectAll.checked = inputs.length > 0 && cards.length === inputs.length; selectAll.indeterminate = cards.length > 0 && cards.length < inputs.length; } const count = bulk?.querySelector("[data-bulk-count]"); if (count) count.textContent = `${cards.length} выбрано`; }; const hydrateBulk = () => { list?.querySelectorAll("[data-report-id]").forEach(card => { if (card.querySelector("[data-bulk-select]")) return; const label = document.createElement("label"); label.className = "moderation-select"; label.innerHTML = ``; card.prepend(label); }); syncBulk(); }; window.setInterval(hydrateBulk, 1000); async function runBulk(statusValue: "approved" | "rejected") { const selected = [...(list?.querySelectorAll("[data-bulk-select]:checked") ?? [])].map(input => input.closest("[data-report-id]")); const reason = bulk?.querySelector("[data-bulk-reason]")?.value.trim() || null; if (!selected.length) return; if (statusValue === "rejected" && !reason) { fail("Укажите причину отклонения."); return; } const buttons = bulk?.querySelectorAll("button") ?? []; buttons.forEach(button => button.disabled = true); try { const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/catch-reports/bulk`, {method:"POST",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:JSON.stringify({status:statusValue,reason,items:selected.map(card => ({id:card?.dataset.reportId,expected_version:Number(card?.dataset.version)}))})}); if (response.status === 409) { await loadQueue(); throw new Error("Часть выбранных записей уже изменена. Очередь обновлена."); } if (!response.ok) throw new Error(adminErrorMessage(response.status, "Не удалось применить пакетное решение.")); keepSession(); await loadQueue(); succeed(`Обработано записей: ${selected.length}.`); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка пакетного решения."); } finally { buttons.forEach(button => button.disabled = false); syncBulk(); } } bulk?.querySelector("[data-bulk-approve]")?.addEventListener("click", () => runBulk("approved")); bulk?.querySelector("[data-bulk-reject]")?.addEventListener("click", () => runBulk("rejected")); list?.addEventListener("change", event => { if ((event.target as HTMLElement).matches("[data-bulk-select]")) syncBulk(); }); + selectAll?.addEventListener("change", () => { list?.querySelectorAll("[data-bulk-select]").forEach(input => input.checked = selectAll.checked); syncBulk(); }); async function loadQueue() { if (!root || !list) return; error?.setAttribute("hidden", ""); setLoading(true); list.innerHTML = loadingCards(); diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css index b558dd7..54600b3 100644 --- a/apps/web/src/styles/global.css +++ b/apps/web/src/styles/global.css @@ -17,6 +17,7 @@ .admin-queue-filters{display:grid;grid-template-columns:1.3fr repeat(3,1fr) auto;gap:10px;align-items:end;margin:0 0 18px;padding:16px;border-radius:12px;background:var(--deep)}.admin-queue-filters[hidden]{display:none}.admin-queue-filters label{color:#b8c5c2;font-size:11px;text-transform:uppercase;letter-spacing:.08em}.admin-queue-filters input,.admin-queue-filters select{display:block;width:100%;height:42px;margin-top:6px;padding:0 10px;border:1px solid #ffffff29;border-radius:8px;background:#ffffff0c;color:#fff}.admin-queue-filters option{color:var(--ink)}.admin-queue-filters button{height:42px} .admin-shortcuts{margin:14px 0;color:var(--text-muted);font-size:12px}.admin-shortcuts kbd{padding:2px 6px;border:1px solid var(--line);border-bottom-width:2px;border-radius:5px;background:var(--surface);font:700 11px Inter,sans-serif}.moderation-card:focus-within{outline:2px solid var(--focus);outline-offset:3px} .moderation-provenance{margin-top:18px;border-top:1px solid var(--border-soft);padding-top:14px}.moderation-provenance summary{cursor:pointer;color:var(--deep);font-weight:700}.moderation-provenance dl{margin-top:14px}.moderation-provenance p{margin:12px 0 0;color:var(--text-muted);font-size:12px;line-height:1.5} +.queue-select-all{display:inline-flex;align-items:center;gap:8px;margin:0 0 12px;color:var(--text-muted);font-size:13px}.moderation-select{display:block;grid-column:1/-1}.moderation-select input{width:18px;height:18px}.moderation-bulk,[data-external-bulk]{display:flex;align-items:end;flex-wrap:wrap;gap:12px;margin:0 0 18px;padding:14px;border:1px solid var(--border);border-radius:12px;background:var(--surface);color:var(--text)}.moderation-bulk strong,[data-external-bulk] strong{margin-right:auto}.moderation-bulk label,[data-external-bulk] label{min-width:180px;color:var(--text-muted);font-size:11px;text-transform:uppercase;letter-spacing:.08em}.moderation-bulk textarea,[data-external-bulk] textarea,.moderation-bulk select,[data-external-bulk] select{display:block;width:100%;margin-top:5px;padding:9px;border:1px solid var(--border);border-radius:7px;background:var(--surface-field);color:var(--text);resize:vertical}.moderation-bulk button,[data-external-bulk] button{min-height:42px;padding:0 15px;border:0;border-radius:8px;background:var(--lime);color:var(--deep);font-weight:750}.moderation-bulk button[data-action="danger"],[data-external-bulk] button[data-action="danger"]{background:var(--danger-soft);color:var(--danger)} @media(max-width:900px){.admin-queue-filters{grid-template-columns:1fr 1fr}}@media(max-width:520px){.admin-queue-filters{grid-template-columns:1fr}} .admin-dashboard{width:min(1120px,calc(100% - 64px));margin:0 auto 100px}.admin-dashboard>.privacy{margin:10px 4px 25px}.admin-kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}.admin-kpis>a,.admin-kpis>article,.admin-dashboard-grid>section{padding:22px;border:1px solid var(--line);border-radius:14px;background:var(--surface)}.admin-kpis>a{color:inherit;text-decoration:none}.admin-kpis span,.admin-kpis small{display:block;color:var(--text-muted)}.admin-kpis strong{display:block;margin:10px 0;font:400 42px Georgia,serif;color:var(--deep)}.admin-kpis>a:hover{border-color:var(--focus);transform:translateY(-2px)}.admin-dashboard-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-top:18px}.admin-dashboard-grid h2{margin:0 0 16px;font:400 25px Georgia,serif}.admin-dashboard-grid ul{list-style:none;margin:0 0 16px;padding:0}.admin-dashboard-grid li{display:flex;justify-content:space-between;gap:14px;padding:10px 0;border-bottom:1px solid var(--border-soft);font-size:13px}.admin-dashboard-grid li span{display:flex;align-items:center;gap:8px}.admin-dashboard-grid time{color:var(--text-muted)} @media(max-width:900px){.admin-kpis{grid-template-columns:1fr 1fr}}@media(max-width:720px){.admin-dashboard{width:calc(100% - 28px)}.admin-kpis,.admin-dashboard-grid{grid-template-columns:1fr}} diff --git a/apps/web/src/styles/theme.css b/apps/web/src/styles/theme.css index e25495f..9ab7fe1 100644 --- a/apps/web/src/styles/theme.css +++ b/apps/web/src/styles/theme.css @@ -76,6 +76,7 @@ .active-filters span, .spot-card, .record-table, .report-form, .periods, .detail-grid aside, .screenshot-retry, .moderation-card, + .moderation-bulk, [data-external-bulk], .admin-kpis > a, .admin-kpis > article, .admin-dashboard-grid > section, .source-health-grid article, .state, .load-more, .signal-more { background: var(--surface-elevated); @@ -88,7 +89,8 @@ .spot-card:hover { background: var(--surface-raised); border-color: var(--border-strong); box-shadow: 0 18px 40px var(--shadow-color); } .report-form input, .report-form textarea, .report-form select, - .moderation-actions textarea { background: var(--surface-control); color: var(--text-primary); border-color: var(--border-strong); } + .moderation-actions textarea, .moderation-bulk textarea, .moderation-bulk select, + [data-external-bulk] textarea, [data-external-bulk] select { background: var(--surface-control); color: var(--text-primary); border-color: var(--border-strong); } .record-head, .no-proof { background: var(--surface-raised); } .record-row, .periods > div, .catch-list article, .detail-grid aside li, .bait-line, .spot-stats, .moderation-summary dl div, @@ -190,7 +192,7 @@ @media (forced-colors: active) { .theme-switcher, .theme-switcher button, .spot-card, .record-table, - .report-form, .moderation-card, .catalog-grid > a, .data-passport, + .report-form, .moderation-card, .moderation-bulk, [data-external-bulk], .catalog-grid > a, .data-passport, .source-chip, .notice, .state { border: 1px solid CanvasText; } .theme-switcher button[aria-pressed="true"] { background: Highlight; color: HighlightText; } .source-chip i, .health-state i, .quality-chip i, .activity-pill i { forced-color-adjust: none; }