Add Astro moderation queue

This commit is contained in:
ik
2026-09-03 08:11:23 +07:00
parent a528fc707f
commit 03a0ecb5f4
6 changed files with 53 additions and 7 deletions
+36
View File
@@ -0,0 +1,36 @@
---
import Layout from "../../layouts/Layout.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}>
<form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button type="submit">Открыть очередь</button></form>
<p class="privacy">Токен хранится только в памяти страницы и не записывается в URL или localStorage.</p>
<div class="notice error" data-admin-error hidden></div><div class="moderation-list" data-moderation-list aria-live="polite"></div>
</section>
<script>
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]");
let token = "";
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[char] ?? char));
const fail = (message: string) => { if (error) { error.textContent = message; error.hidden = false; } };
async function loadQueue() {
if (!root || !list) return;
error?.setAttribute("hidden", ""); list.innerHTML = '<div class="state"><h2>Загружаем очередь…</h2></div>';
const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/catch-reports?status=pending`, {headers:{Authorization:`Bearer ${token}`}});
if (!response.ok) throw new Error(response.status === 401 ? "Неверный административный токен." : "Не удалось загрузить очередь.");
const reports: Record<string, unknown>[] = await response.json();
if (!reports.length) { list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Новых уловов для проверки нет.</p></div>'; return; }
list.innerHTML = reports.map(report => `<article class="moderation-card" data-report-id="${esc(report.id)}"><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">${report.screenshot_url ? `<a href="${esc(report.screenshot_url)}" target="_blank" rel="noreferrer"><img src="${esc(report.screenshot_url)}" 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 type="button" data-decision="approved">Одобрить</button><button class="reject" type="button" data-decision="rejected">Отклонить</button></div></div></article>`).join("");
}
login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); try { await loadQueue(); login.hidden = true; } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
list?.addEventListener("click", async event => {
const button = (event.target as HTMLElement).closest<HTMLButtonElement>("button[data-decision]"); const card = button?.closest<HTMLElement>("[data-report-id]"); if (!button || !card || !root) return;
button.disabled = true; const reason = card.querySelector<HTMLTextAreaElement>("textarea")?.value || null;
try { const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/catch-reports/${card.dataset.reportId}`, {method:"PATCH",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:JSON.stringify({status:button.dataset.decision,reason})}); if (!response.ok) throw new Error("Не удалось сохранить решение."); card.remove(); if (list && !list.children.length) list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Все записи обработаны.</p></div>'; } catch (cause) { button.disabled = false; fail(cause instanceof Error ? cause.message : "Ошибка сохранения."); }
});
</script>
</Layout>
+4 -2
View File
@@ -1,5 +1,5 @@
:root{--paper:#f2f5ee;--deep:#082226;--teal:#12383b;--lime:#c9f45b;--ink:#092226;--muted:#647472;--line:#cbd6ce;--white:#fff;--orange:#ffb65c;font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;color:var(--ink);background:var(--paper)}
*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;min-width:320px;background:var(--paper);color:var(--ink)}a{color:inherit}button,input,select,textarea{font:inherit}button{cursor:pointer}.content-grid{width:min(1360px,calc(100% - 64px));margin-inline:auto}main{min-height:calc(100vh - 204px)}
*{box-sizing:border-box}[hidden]{display:none!important}html{scroll-behavior:smooth}body{margin:0;min-width:320px;background:var(--paper);color:var(--ink)}a{color:inherit}button,input,select,textarea{font:inherit}button{cursor:pointer}.content-grid{width:min(1360px,calc(100% - 64px));margin-inline:auto}main{min-height:calc(100vh - 204px)}
.topbar{height:86px;width:min(1480px,calc(100% - 48px));margin:auto;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:28px}.brand{display:flex;align-items:center;gap:12px;text-decoration:none;min-width:max-content}.brand-mark{width:42px;height:42px;display:grid;place-items:center;border:1px solid #9eb0a7;border-radius:50%;background:var(--deep);color:var(--lime);font-size:28px}.brand-name{display:flex;flex-direction:column;font:17px/.95 Georgia,serif}.brand-name strong{font-size:20px;font-style:italic}.topbar nav{height:100%;display:flex;gap:23px;align-items:center}.topbar nav a{height:100%;display:flex;align-items:center;gap:7px;padding:0 8px;text-decoration:none;color:#526461;font-size:14px;border-bottom:3px solid transparent}.topbar nav a.active{color:var(--deep);border-color:var(--deep)}.live-badge{justify-self:end;display:flex;align-items:center;gap:9px;color:#60716e;font-size:13px}.live-badge>span{width:8px;height:8px;border-radius:50%;background:#83b83a;box-shadow:0 0 0 5px #83b83a20}
.eyebrow,.overline{text-transform:uppercase;letter-spacing:.14em;font-size:12px;font-weight:750;color:#657673}.eyebrow{display:flex;align-items:center;gap:10px}.eyebrow b{display:grid;place-items:center;min-width:41px;height:25px;padding:0 8px;border:1px solid #9aaa9f;border-radius:20px;color:var(--deep);letter-spacing:.05em}.intro{padding:74px 0 42px;display:grid;grid-template-columns:.92fr 1.08fr;align-items:end;gap:70px}.intro h1,.records-hero h1,.form-hero h1,.spot-hero h1{margin:18px 0 22px;font:400 clamp(58px,6.3vw,102px)/.87 Georgia,"Times New Roman",serif;letter-spacing:-.065em}.intro h1 em,.records-hero h1 em,.form-hero h1 em{color:#497074;font-weight:400}.intro-copy>p{font-size:18px;line-height:1.55;color:#586a68;max-width:560px}.lake-card{position:relative;border-radius:18px;overflow:hidden;height:326px;box-shadow:0 24px 70px #173c3f24}.lake-card img{width:100%;height:100%;object-fit:cover;display:block}.lake-card:after{content:"";position:absolute;inset:0;background:linear-gradient(180deg,transparent 35%,#041c20d9)}.lake-overlay{position:absolute;z-index:2;inset:auto 24px 22px 26px;display:flex;justify-content:space-between;align-items:end;color:#fff}.lake-overlay .overline{color:#d2dedb;display:block;margin-bottom:7px}.lake-overlay strong{font:400 21px Georgia,serif}.pulse-orb{width:45px;height:45px;border:1px solid #ffffff7d;border-radius:50%;display:grid;place-items:center}.pulse-orb span{width:9px;height:9px;border-radius:50%;background:var(--lime);box-shadow:0 0 0 8px #c9f45b25}
.filters-wrap{background:var(--deep);padding:24px 0;position:sticky;top:0;z-index:20;box-shadow:0 10px 35px #08222620}.filters{display:grid;grid-template-columns:repeat(4,1fr) auto;gap:12px;align-items:end}.filters label,.report-form label,.record-filters label{color:#9fb0ad;font-size:12px;text-transform:uppercase;letter-spacing:.1em;font-weight:700}.filters select{display:block;width:100%;height:48px;margin-top:7px;padding:0 13px;border:1px solid #ffffff29;border-radius:10px;background:#ffffff0c;color:#f5f8f3}.filters option{color:var(--ink)}.filters button,.report-form button,.record-filters button{height:48px;padding:0 24px;border:0;border-radius:10px;background:var(--lime);color:var(--deep);font-weight:750}
@@ -7,8 +7,10 @@
.detail-card{position:sticky;top:116px;margin:0;background:var(--deep);color:#f5f8f3;border:0;border-radius:18px;padding:27px;overflow:hidden}.detail-card:before{content:"";position:absolute;width:420px;height:420px;border:1px solid #ffffff0f;border-radius:50%;left:48%;top:-210px;box-shadow:0 0 0 48px #ffffff08,0 0 0 96px #ffffff05}.detail-card>*{position:relative;z-index:1}.detail-head{display:flex;justify-content:space-between}.detail-head .overline,.best-lure .overline{color:#a7b6b3}.detail-head h2{font-size:30px}.detail-head h2 em{color:var(--lime);font-size:22px}.detail-head>a{width:40px;height:40px;display:grid;place-items:center;border:1px solid #ffffff34;border-radius:50%;text-decoration:none}.detail-score{display:grid;grid-template-columns:126px 1fr;gap:20px;align-items:center;padding:34px 0 29px}.score-ring{width:122px;height:122px;display:grid;place-items:center;border-radius:50%;background:conic-gradient(var(--lime) var(--score),#ffffff16 0);position:relative}.score-ring:after{content:"";position:absolute;inset:8px;background:var(--deep);border-radius:50%}.score-ring>div{z-index:1;text-align:center;display:flex;flex-direction:column}.score-ring strong{font:400 39px/1 Georgia,serif}.score-ring span{color:#a9b8b5;font-size:10px;text-transform:uppercase}.detail-score>div>span{color:#a9b8b5;font-size:11px;text-transform:uppercase;letter-spacing:.1em}.detail-score>div>strong{display:block;margin:5px 0 8px;font:400 24px Georgia,serif;color:var(--lime)}.detail-score p{color:#b7c3c0;font-size:13px;line-height:1.5;margin:0}.metric-grid{display:grid;grid-template-columns:1fr 1fr;border:1px solid #ffffff1c;border-radius:12px;overflow:hidden}.metric-grid>div{min-height:91px;padding:15px;display:grid;grid-template-columns:23px 1fr;gap:3px 8px;border-bottom:1px solid #ffffff1c}.metric-grid>div:nth-child(odd){border-right:1px solid #ffffff1c}.metric-grid>div:nth-last-child(-n+2){border-bottom:0}.metric-grid>div>span{grid-row:1/3;color:var(--lime)}.metric-grid small{color:#9cadaa;text-transform:uppercase;font-size:9px}.metric-grid strong{font:400 17px Georgia,serif}.best-lure{margin-top:28px}.best-lure>div{display:grid;grid-template-columns:18px 1fr auto;gap:11px;align-items:center;padding:15px 0;border-bottom:1px solid #ffffff17}.best-lure>div span{color:var(--lime);font-size:12px}.confidence-note{display:flex;gap:10px;padding:16px;background:#ffffff0a;border-radius:10px;margin-top:22px;font-size:12px;line-height:1.45;color:#aebcba}.confidence-note>span:first-child{color:var(--lime)}.confidence-note strong{color:#fff}
.state{min-height:240px;display:grid;place-items:center;align-content:center;text-align:center;border:1px dashed #b9c7bf;border-radius:16px;color:#71817f}.state h2{font:400 28px Georgia,serif;color:var(--deep);margin:0}.how-it-works{border-top:1px solid #cfd8d1;padding:85px 0 105px;display:grid;grid-template-columns:.8fr 1.2fr;gap:70px}.how-it-works h2{font-size:47px;line-height:1.05}.principles{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}.principles article{padding-top:24px;border-top:2px solid #294b4e}.principles article>span{font:italic 16px Georgia,serif;color:#82928f}.principles h3{margin:28px 0 8px;font:400 22px Georgia,serif}.principles p{font-size:14px;line-height:1.55;color:#657572}
.records-hero,.form-hero{width:min(1360px,calc(100% - 64px));margin:auto;padding:76px 0 48px;display:flex;align-items:end;justify-content:space-between;gap:70px}.records-hero h1,.form-hero h1{font-size:clamp(58px,7vw,108px);margin-bottom:0}.records-hero>div:last-child,.form-hero>p{max-width:500px;color:#627370;line-height:1.65}.source-status{display:grid;grid-template-columns:auto 1fr;gap:4px 9px}.source-status small{grid-column:2}.status-dot{width:9px;height:9px;border-radius:50%;background:#82928f}.status-dot.success{background:#83b83a}.record-filters,.report-form{width:min(1360px,calc(100% - 64px));margin:0 auto 55px;padding:25px;background:#fff;border:1px solid #d4ddd6;border-radius:18px;box-shadow:0 30px 80px #16383c12}.record-filters{display:flex;gap:14px;background:var(--deep)}.record-filters label{flex:1}.record-filters input,.report-form input,.report-form textarea,.report-form select{display:block;width:100%;margin-top:7px;padding:13px 14px;border:1px solid #d6dfd7;border-radius:9px;background:#f3f6f1;color:var(--deep)}.record-table{width:min(1360px,calc(100% - 64px));margin:auto;background:#fff;border-radius:16px;overflow:hidden}.record-row{display:grid;grid-template-columns:1.2fr .65fr 1.1fr 1.5fr 1fr .75fr;gap:14px;padding:20px;border-top:1px solid #e3e9e3}.record-head{background:#e4ebe3;border:0}.record-head span{font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:#647572}.record-row>span,.record-row>time{font-size:13px;color:#60716e}.official-note{width:min(1360px,calc(100% - 64px));margin:20px auto 100px;color:#647572;font-size:12px}.form-hero{align-items:center}.form-hero>div{}.form-hero>p{font-size:17px}.notice{width:min(1360px,calc(100% - 64px));margin:0 auto 18px;padding:16px;border-radius:10px}.notice.success{background:#dff0c3;color:#426315}.notice.error{background:#f5d8d2;color:#8a3025}.report-form{max-width:900px;padding:34px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px 16px}.report-form label{display:block;color:#60716e}.report-form .wide{display:block;margin-top:20px}.report-form input:focus,.report-form textarea:focus,.report-form select:focus{outline:none;border-color:#839d4b;box-shadow:0 0 0 3px #c9f45b45}.privacy{font-size:12px;color:#647572}.honeypot{position:absolute;left:-9999px}.back{display:inline-block;margin:40px max(32px,calc((100% - 1360px)/2)) 10px;color:#60716e}.spot-hero,.periods,.detail-grid{width:min(1180px,calc(100% - 64px));margin-inline:auto}.spot-hero{padding:50px;background:var(--deep);color:#fff;border-radius:18px;display:flex;justify-content:space-between}.spot-hero h1{font-size:70px}.spot-hero p{color:#a9b8b5}.pin{width:160px;height:160px;border:1px solid #ffffff34;border-radius:50%;display:grid;place-items:center;color:var(--lime);font:400 35px Georgia}.periods{display:grid;grid-template-columns:repeat(3,1fr);margin-top:20px;background:#fff;border-radius:14px;overflow:hidden}.periods>div{text-align:center;padding:25px;border-right:1px solid #e2e8e2}.periods strong{display:block;font:400 32px Georgia;color:#537014}.periods span{font-size:12px;color:#647572}.detail-grid{display:grid;grid-template-columns:2fr 1fr;gap:24px;padding-bottom:80px}.catch-list article{display:flex;justify-content:space-between;padding:16px 0;border-bottom:1px solid #d5ded7}.catch-list article>div{display:flex;flex-direction:column}.catch-list article>div:last-child{text-align:right}.catch-list span{font-size:12px;color:#647572}.detail-grid aside{margin-top:50px;padding:25px;background:#fff;border-radius:14px}.detail-grid aside li{padding:10px 0;border-bottom:1px solid #e2e8e2}.note{font-size:12px;color:#647572}
.moderation-app{width:min(1120px,calc(100% - 64px));margin:0 auto 100px}.admin-login{display:flex;gap:14px;align-items:end;padding:25px;background:var(--deep);border-radius:16px}.admin-login label{flex:1;color:#a8b7b4;font-size:12px;text-transform:uppercase;letter-spacing:.1em;font-weight:700}.admin-login input{display:block;width:100%;margin-top:7px;padding:13px 14px;border:1px solid #ffffff29;border-radius:9px;background:#ffffff0c;color:#fff}.admin-login button,.moderation-actions button{height:48px;padding:0 24px;border:0;border-radius:9px;background:var(--lime);color:var(--deep);font-weight:750}.moderation-app>.privacy{margin:10px 4px 25px}.moderation-app>.notice{width:100%}.moderation-list{display:grid;gap:18px}.moderation-card{display:grid;grid-template-columns:1fr 300px;gap:25px;padding:25px;background:#fff;border:1px solid #d4ddd6;border-radius:16px}.moderation-summary h2{font:400 32px Georgia,serif;margin:12px 0 4px}.moderation-summary>p{color:#647572;margin:0 0 20px}.moderation-summary dl{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin:0}.moderation-summary dl div{border-top:1px solid #e1e8e2;padding-top:10px}.moderation-summary dt{font-size:10px;text-transform:uppercase;color:#7a8a87}.moderation-summary dd{margin:3px 0 0}.moderation-summary blockquote{margin:18px 0 0;padding:13px;border-left:3px solid var(--lime);background:#f2f6ef;color:#566865}.moderation-proof img,.no-proof{width:100%;height:210px;object-fit:cover;border-radius:11px}.no-proof{display:grid;place-items:center;background:#edf2ec;color:#71817f}.moderation-actions{grid-column:1/-1;display:flex;align-items:end;gap:14px;border-top:1px solid #e1e8e2;padding-top:18px}.moderation-actions label{flex:1;color:#60716e;font-size:12px;text-transform:uppercase;letter-spacing:.08em}.moderation-actions textarea{display:block;width:100%;margin-top:6px;padding:10px;border:1px solid #d4ddd6;border-radius:8px;resize:vertical}.moderation-actions>div{display:flex;gap:8px}.moderation-actions .reject{background:#f0d4ce;color:#842f25}.moderation-actions button:disabled{opacity:.55}
footer{min-height:118px;background:var(--deep);color:#dbe4df;padding:28px max(32px,calc((100vw - 1360px)/2));display:grid;grid-template-columns:1fr 1fr auto;align-items:center;gap:28px}footer .brand-mark{border-color:#ffffff32}footer p{font-size:12px;color:#92a4a0}footer>span{font:italic 16px Georgia;color:var(--lime)}
@media(max-width:1040px){.content-grid{width:min(100% - 36px,900px)}.topbar{width:calc(100% - 36px);grid-template-columns:1fr auto}.live-badge{display:none}.intro{grid-template-columns:1fr;gap:34px;padding-top:54px}.lake-card{height:280px}.dashboard{grid-template-columns:1fr}.detail-card{position:relative;top:auto}.how-it-works{grid-template-columns:1fr}.records-hero,.form-hero{display:block}.records-hero>div:last-child,.form-hero>p{margin-top:25px}}
@media(max-width:720px){.content-grid,.records-hero,.form-hero,.record-filters,.record-table,.official-note,.report-form,.spot-hero,.periods,.detail-grid{width:calc(100% - 28px)}.topbar{width:100%;padding:13px 14px 0;display:flex;flex-wrap:wrap;height:auto}.topbar .brand{flex:1}.topbar nav{order:2;width:100%;height:46px;overflow-x:auto}.topbar nav a{flex:0 0 auto;font-size:13px}.intro h1,.records-hero h1,.form-hero h1{font-size:55px}.intro{padding-top:48px}.lake-card{height:225px}.filters-wrap{position:relative}.filters{grid-template-columns:1fr 1fr}.dashboard{padding:45px 0 74px}.spot-card{grid-template-columns:34px 1fr;padding:18px 18px 18px 14px;gap:10px}.spot-stats{grid-column:2;border:0;border-top:1px solid #e2e8e2;padding:13px 0 0;grid-template-columns:repeat(4,1fr)}.card-arrow{display:none}.detail-score{grid-template-columns:105px 1fr}.score-ring{width:100px;height:100px}.principles{grid-template-columns:1fr}.record-filters{display:grid}.record-row{grid-template-columns:1fr 1fr}.record-head{display:none}.record-row>*:nth-child(even){text-align:right}.form-grid,.detail-grid{grid-template-columns:1fr}.spot-hero{padding:28px}.spot-hero h1{font-size:48px}.pin{display:none}footer{grid-template-columns:1fr auto;padding:30px 20px}footer p{grid-column:1/-1;order:3}}
@media(max-width:480px){.filters{grid-template-columns:1fr}.spot-stats strong{font-size:16px}.topbar nav{gap:4px}.topbar nav a{padding:0 5px}.brand-name{font-size:14px}.brand-name strong{font-size:17px}}
@media(max-width:720px){.moderation-app{width:calc(100% - 28px)}.moderation-card{grid-template-columns:1fr}.moderation-proof{grid-row:2}.moderation-actions{grid-column:1;display:block}.moderation-actions>div{margin-top:12px}.admin-login{display:block}.admin-login button{width:100%;margin-top:12px}}
@media(max-width:480px){.filters{grid-template-columns:1fr}.spot-stats strong{font-size:16px}.topbar nav{gap:4px}.topbar nav a{padding:0 5px}.brand-name{font-size:14px}.brand-name strong{font-size:17px}.moderation-actions>div{display:grid}.moderation-actions button{width:100%}}
@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition-duration:.01ms!important}}
+3 -2
View File
@@ -3,8 +3,9 @@ import { expect, test } from "@playwright/test";
test("player can open an active spot", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "Выбирай место, пока клюёт." })).toBeVisible();
await expect(page.getByRole("heading", { name: "Щука" })).toBeVisible();
await page.getByTestId("spot-110-103").click();
const seedSpot = page.getByTestId("spot-110-103");
await expect(seedSpot.getByRole("heading", { name: "Щука" })).toBeVisible();
await seedSpot.click();
await expect(page.getByRole("heading", { name: "Точка 110:103" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Последние уловы" })).toBeVisible();
});