61 lines
11 KiB
Plaintext
61 lines
11 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" noindex>
|
||
<section class="form-hero"><div><span class="eyebrow"><b>ADMIN</b> Media review</span><h1>Проверка<br/><em>медиа</em></h1></div><p>Сравнение approved и upgrade_queued файлов перед отдельным редакционным решением.</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">Публикация требует явной причины и атомарно сохраняет fallback для rollback. Токен хранится только в памяти страницы.</p>
|
||
<div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div>
|
||
<form class="admin-queue-filters" hidden><label>Тип<select name="entity_type"><option value="">Все типы</option><option value="fish">Рыбы</option><option value="waterbody">Водоёмы</option><option value="tackle">Снасти</option><option value="reference">Справка</option></select></label><label>Состояние<select name="status"><option value="">Все состояния</option><option value="approved">Approved</option><option value="upgrade_queued">Upgrade queued</option><option value="upgrade_stored">Upgrade stored</option></select></label><button data-action="primary" type="submit">Применить</button></form>
|
||
<div class="notice error" data-admin-error role="alert" hidden></div><div class="notice success" data-admin-status role="status" hidden></div><section class="media-library__grid" data-media-list aria-live="polite"></section>
|
||
<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>
|
||
</section>
|
||
<script>
|
||
import { adminEndsSession, adminErrorMessage } from "../../lib/admin-errors";
|
||
const root = document.querySelector<HTMLElement>("[data-api-url]");
|
||
const login = document.querySelector<HTMLFormElement>(".admin-login");
|
||
const filters = document.querySelector<HTMLFormElement>(".admin-queue-filters");
|
||
const list = document.querySelector<HTMLElement>("[data-media-list]");
|
||
const error = document.querySelector<HTMLElement>("[data-admin-error]");
|
||
const status = document.querySelector<HTMLElement>("[data-admin-status]");
|
||
const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar");
|
||
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
|
||
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]");
|
||
let token = ""; let offset = 0; let timer: ReturnType<typeof setTimeout> | undefined;
|
||
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char] ?? char));
|
||
const url = (value: unknown) => { try { const parsed = new URL(String(value), root?.dataset.apiUrl); return parsed.protocol === "http:" || parsed.protocol === "https:" ? esc(parsed.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 (timer) clearTimeout(timer); timer = undefined; if (login) { login.hidden = false; login.reset(); } if (filters) filters.hidden = true; if (sessionBar) sessionBar.hidden = true; if (list) list.innerHTML = ""; pages?.setAttribute("hidden", ""); if (message) fail(message); };
|
||
const keepSession = () => { if (timer) clearTimeout(timer); timer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); };
|
||
const loading = () => '<div class="loading-card" aria-hidden="true"></div><span class="sr-only">Загружаем медиа</span>';
|
||
async function load() {
|
||
if (!root || !list || !filters) return;
|
||
error?.setAttribute("hidden", ""); list.innerHTML = loading(); list.setAttribute("aria-busy", "true");
|
||
const values = new FormData(filters); const params = new URLSearchParams({limit:"51", offset:String(offset)}); for (const key of ["entity_type", "status"]) { const value = String(values.get(key) || ""); if (value) params.set(key, value); }
|
||
const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/media/catalog?${params}`, {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(); list.removeAttribute("aria-busy");
|
||
if (!rows.length && offset > 0) { offset = 0; return load(); }
|
||
const assets = rows.slice(0, 50); if (pages) pages.hidden = !assets.length; if (previous) previous.disabled = offset === 0; if (next) next.disabled = rows.length <= 50; if (pageNumber) pageNumber.textContent = `Страница ${offset / 50 + 1}`;
|
||
if (!assets.length) { list.innerHTML = '<div class="state"><h2>Кандидатов нет</h2><p>Для выбранных фильтров нет approved или upgrade_queued файлов.</p></div>'; return; }
|
||
list.innerHTML = assets.map(asset => { const image = url(asset.image_url); const source = url(asset.source_url); const variants = (asset.derivatives as Record<string, unknown>[] ?? []).map(item => `${esc(item.format)} ${esc(item.width)}×${esc(item.height)}`).join(", "); const rollback = asset.status === "approved" && asset.supersedes ? `<button data-media-rollback="${esc(asset.asset_url)}" type="button" data-action="secondary">Откатить замену</button>` : ""; return `<article class="media-library__card"><a href="${image}" target="_blank" rel="noreferrer">${image ? `<img src="${image}" alt="${esc(asset.label)}" loading="lazy" />` : ""}</a><h2>${esc(asset.label)}</h2><span>${esc(asset.status)} · ${esc(asset.entity_type)} · ${esc(asset.width)}×${esc(asset.height)}</span><p>${esc(asset.source_system)}${asset.duplicate_of ? ` · duplicate_of ${esc(asset.duplicate_of)}` : ""}</p>${variants ? `<small>Производные: ${variants}</small>` : "<small>Производных нет</small>"}${source ? `<a href="${source}" target="_blank" rel="noreferrer">Первоисточник →</a>` : ""}${rollback}</article>`; }).join("");
|
||
list.querySelectorAll<HTMLButtonElement>("[data-media-rollback]").forEach(button => button.addEventListener("click", async () => { const note = window.prompt("Причина отката:"); if (!note?.trim()) return; button.disabled = true; try { const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/media/upgrades/rollback`, {method:"POST", headers:{Authorization:`Bearer ${token}`, "Content-Type":"application/json"}, body:JSON.stringify({asset_url:button.dataset.mediaRollback, note:note.trim()})}); if (!response.ok) throw new Error(adminErrorMessage(response.status, "Не удалось выполнить откат.")); succeed("Fallback восстановлен."); await load(); } catch (cause) { fail(cause instanceof Error ? cause.message : "Не удалось выполнить откат."); } finally { button.disabled = false; } }));
|
||
if (assets.some(asset => asset.status === "upgrade_stored")) { const publish = document.createElement("button"); publish.type = "button"; publish.dataset.action = "primary"; publish.textContent = "Опубликовать сохранённые замены"; publish.addEventListener("click", async () => { const note = window.prompt("Причина публикации замен:"); if (!note?.trim()) return; publish.disabled = true; try { const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/media/upgrades/publish`, {method:"POST", headers:{Authorization:`Bearer ${token}`, "Content-Type":"application/json"}, body:JSON.stringify({note:note.trim()})}); if (!response.ok) throw new Error(adminErrorMessage(response.status, "Не удалось опубликовать замены.")); succeed("Замены опубликованы атомарно, fallback сохранены."); await load(); } catch (cause) { fail(cause instanceof Error ? cause.message : "Не удалось опубликовать замены."); } finally { publish.disabled = false; } }); list.prepend(publish); }
|
||
}
|
||
login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); offset = 0; try { await load(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; if (filters) filters.hidden = false; } catch (cause) { list && (list.innerHTML = ""); fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
|
||
filters?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; try { await load(); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка фильтрации."); } });
|
||
const move = async (delta: number) => { offset = Math.max(0, offset + delta); try { await load(); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка загрузки страницы."); } };
|
||
previous?.addEventListener("click", () => move(-50)); next?.addEventListener("click", () => move(50)); refresh?.addEventListener("click", async () => { refresh.disabled = true; try { await load(); succeed("Медиатека обновлена."); } catch (cause) { fail(cause instanceof Error ? cause.message : "Не удалось обновить медиатеку."); } finally { refresh.disabled = false; } });
|
||
logout?.addEventListener("click", () => endSession("Вы вышли из административной панели."));
|
||
</script>
|
||
</Layout>
|