76 lines
12 KiB
Plaintext
76 lines
12 KiB
Plaintext
---
|
||
import Layout from "../../layouts/Layout.astro";
|
||
import AdminNav from "../../components/AdminNav.astro";
|
||
const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||
---
|
||
<Layout title="Модерация уловов — RF4 Spotter">
|
||
<section class="form-hero"><div><span class="eyebrow"><b>ADMIN</b> Очередь проверки</span><h1>Модерация<br/><em>уловов</em></h1></div><p>Проверьте данные и скриншот до того, как запись повлияет на публичную статистику.</p></section>
|
||
<section class="moderation-app" data-api-url={apiUrl}>
|
||
<AdminNav />
|
||
<form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button data-action="primary" type="submit">Открыть очередь</button></form>
|
||
<p class="privacy">Токен хранится только в памяти страницы и не записывается в URL или localStorage.</p>
|
||
<div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div>
|
||
<div class="notice error" data-admin-error role="alert" hidden></div><div class="notice success" data-admin-status role="status" hidden></div><div class="moderation-list" data-moderation-list aria-live="polite"></div>
|
||
<nav data-pages aria-label="Страницы очереди" hidden><button data-action="secondary" type="button" data-previous>Предыдущая</button><span data-page-number aria-live="polite"></span><button data-action="secondary" type="button" data-next>Следующая</button><button data-action="secondary" type="button" data-refresh>Обновить</button></nav>
|
||
<p class="admin-shortcuts"><kbd>A</kbd> одобрить карточку с фокусом · отклонение и удаление — только кнопками</p>
|
||
</section>
|
||
<script>
|
||
import { adminEndsSession, adminErrorMessage } from "../../lib/admin-errors";
|
||
const root = document.querySelector<HTMLElement>(".moderation-app");
|
||
const login = document.querySelector<HTMLFormElement>(".admin-login");
|
||
const list = document.querySelector<HTMLElement>("[data-moderation-list]");
|
||
const error = document.querySelector<HTMLElement>("[data-admin-error]");
|
||
const status = document.querySelector<HTMLElement>("[data-admin-status]");
|
||
const pages = document.querySelector<HTMLElement>("[data-pages]");
|
||
const previous = document.querySelector<HTMLButtonElement>("[data-previous]");
|
||
const next = document.querySelector<HTMLButtonElement>("[data-next]");
|
||
const refresh = document.querySelector<HTMLButtonElement>("[data-refresh]");
|
||
const pageNumber = document.querySelector<HTMLElement>("[data-page-number]");
|
||
const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar");
|
||
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
|
||
let token = "";
|
||
let offset = 0;
|
||
let sessionTimer: ReturnType<typeof setTimeout> | undefined;
|
||
const loadingCards = () => `<div class="loading-grid" aria-hidden="true">${Array.from({length:2}, () => '<div class="loading-card"><span class="loading-line loading-line--label"></span><span class="loading-line loading-line--title"></span><span class="loading-line"></span><span class="loading-line loading-line--short"></span></div>').join("")}</div><span class="sr-only">Загружаем очередь модерации</span>`;
|
||
const setLoading = (loading: boolean) => list?.setAttribute("aria-busy", String(loading));
|
||
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char] ?? char));
|
||
const safeHttpUrl = (value: unknown) => { try { const url = new URL(String(value)); return url.protocol === "http:" || url.protocol === "https:" ? esc(url.href) : ""; } catch { return ""; } };
|
||
const fail = (message: string) => { if (status) status.hidden = true; if (error) { error.textContent = message; error.hidden = false; } };
|
||
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); };
|
||
async function loadQueue() {
|
||
if (!root || !list) return;
|
||
error?.setAttribute("hidden", ""); setLoading(true); list.innerHTML = loadingCards();
|
||
const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/catch-reports?status=pending&limit=51&offset=${offset}`, {headers:{Authorization:`Bearer ${token}`} });
|
||
if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(adminErrorMessage(response.status, "Не удалось загрузить очередь.")); }
|
||
const rows: Record<string, unknown>[] = await response.json();
|
||
keepSession();
|
||
setLoading(false);
|
||
if (!rows.length && offset > 0) { offset = 0; return loadQueue(); }
|
||
const reports = rows.slice(0, 50);
|
||
if (pages) pages.hidden = !reports.length;
|
||
if (previous) previous.disabled = offset === 0;
|
||
if (next) next.disabled = rows.length <= 50;
|
||
if (pageNumber) pageNumber.textContent = `Страница ${offset / 50 + 1}`;
|
||
if (!reports.length) { list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Новых уловов для проверки нет.</p></div>'; return; }
|
||
list.innerHTML = reports.map(report => { const screenshotUrl = safeHttpUrl(report.screenshot_url); return `<article class="moderation-card" data-report-id="${esc(report.id)}" data-version="${esc(report.moderation_version)}"><div class="moderation-summary"><span class="activity-pill"><i></i>На проверке</span><h2>${esc(report.fish)}</h2><p>${esc(report.waterbody)} · ${esc(report.coordinates)}</p><dl><div><dt>Вес</dt><dd>${esc(report.weight_g)} г</dd></div><div><dt>Приманка</dt><dd>${esc(report.bait)}</dd></div><div><dt>Игрок</dt><dd>${esc(report.player_name)}</dd></div><div><dt>Отправлено</dt><dd>${esc(new Date(String(report.reported_at)).toLocaleString("ru-RU"))}</dd></div></dl>${report.comment ? `<blockquote>${esc(report.comment)}</blockquote>` : ""}</div><div class="moderation-proof">${screenshotUrl ? `<a href="${screenshotUrl}" target="_blank" rel="noreferrer"><img src="${screenshotUrl}" alt="Скриншот улова ${esc(report.fish)}" /></a>` : '<div class="no-proof">Скриншот не приложен</div>'}</div><div class="moderation-actions"><label>Причина решения<textarea rows="2" maxlength="1000"></textarea></label><div><button data-action="primary" type="button" data-decision="approved">Одобрить</button><button data-action="danger" type="button" data-decision="rejected">Отклонить</button><button data-action="quiet-danger" type="button" data-delete>Удалить</button></div></div></article>`; }).join("");
|
||
}
|
||
async function changePage(delta: number) { const oldOffset = offset; offset = Math.max(0, offset + delta); if (previous) previous.disabled = true; if (next) next.disabled = true; try { await loadQueue(); } catch { offset = oldOffset; setLoading(false); fail("Не удалось загрузить страницу очереди."); } }
|
||
previous?.addEventListener("click", () => changePage(-50));
|
||
next?.addEventListener("click", () => changePage(50));
|
||
refresh?.addEventListener("click", async () => { refresh.disabled = true; try { await loadQueue(); succeed("Очередь обновлена."); } catch (cause) { fail(cause instanceof Error ? cause.message : "Не удалось обновить очередь."); } finally { refresh.disabled = false; } });
|
||
login?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; token = String(new FormData(login).get("token") || ""); try { await loadQueue(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; } catch (cause) { setLoading(false); if (list) list.innerHTML = ""; fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
|
||
logout?.addEventListener("click", () => endSession("Вы вышли из административной панели."));
|
||
list?.addEventListener("click", async event => {
|
||
const button = (event.target as HTMLElement).closest<HTMLButtonElement>("button[data-decision],button[data-delete]"); const card = button?.closest<HTMLElement>("[data-report-id]"); if (!button || !card || !root) return;
|
||
const reason = card.querySelector<HTMLTextAreaElement>("textarea")?.value.trim() || null;
|
||
if (button.dataset.decision === "rejected" && !reason) { fail("Укажите причину отклонения."); card.querySelector("textarea")?.focus(); return; }
|
||
if (button.hasAttribute("data-delete") && !window.confirm("Удалить и обезличить эту заявку? Действие нельзя отменить.")) return;
|
||
const cardButtons = card.querySelectorAll<HTMLButtonElement>("button"); cardButtons.forEach(item => item.disabled = true);
|
||
try { const deleting = button.hasAttribute("data-delete"); const decision = deleting ? "Заявка удалена и обезличена." : button.dataset.decision === "approved" ? "Улов одобрен и опубликован." : "Улов отклонён."; const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/catch-reports/${card.dataset.reportId}${deleting ? `?expected_version=${card.dataset.version}` : ""}`, {method:deleting ? "DELETE" : "PATCH",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:deleting ? undefined : JSON.stringify({status:button.dataset.decision,reason,expected_version:Number(card.dataset.version)})}); if (response.status === 409) { await loadQueue(); throw new Error("Запись уже изменена в другой вкладке. Очередь обновлена."); } if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(adminErrorMessage(response.status, "Не удалось сохранить решение.")); } keepSession(); card.remove(); succeed(decision); const nextAction = list.querySelector<HTMLButtonElement>("button[data-decision]"); if (nextAction) nextAction.focus(); else list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Все записи обработаны.</p></div>'; } catch (cause) { cardButtons.forEach(item => item.disabled = false); fail(cause instanceof Error ? cause.message : "Ошибка сохранения."); }
|
||
});
|
||
document.addEventListener("keydown", event => { const target = event.target as HTMLElement; if (event.key.toLowerCase() !== "a" || target.matches("input,textarea,select") || event.ctrlKey || event.metaKey || event.altKey) return; const card = target.closest<HTMLElement>("[data-report-id]"); const approve = card?.querySelector<HTMLButtonElement>('[data-decision="approved"]'); if (approve && !approve.disabled) { event.preventDefault(); approve.click(); } });
|
||
</script>
|
||
</Layout>
|