fix: serialize media publishing decisions
This commit is contained in:
@@ -34,7 +34,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||||
const roleFilter = filters?.querySelector<HTMLSelectElement>('[name="media_role"]');
|
||||
const rolesByEntity: Record<string, Set<string>> = { waterbody: new Set(["waterbody_cover", "waterbody_map", "waterbody_depth_map", "waterbody_screenshot"]), tackle: new Set(["tackle_card", "tackle_detail", "rig_diagram", "tackle_screenshot"]) };
|
||||
const syncRoleFilter = () => { const allowed = rolesByEntity[entityFilter?.value ?? ""] ?? new Set<string>(); if (roleFilter) { [...roleFilter.options].forEach(option => { if (!option.value) return; option.hidden = !allowed.has(option.value); option.disabled = !allowed.has(option.value); }); if (roleFilter.value && !allowed.has(roleFilter.value)) roleFilter.value = ""; roleFilter.disabled = allowed.size === 0; } };
|
||||
let token = ""; let offset = 0; let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let token = ""; let offset = 0; let manifestVersion = 1; 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:") && !parsed.username && !parsed.password && parsed.hostname ? esc(parsed.href) : ""; } catch { return ""; } };
|
||||
const fail = (message: string) => { if (status) status.hidden = true; if (error) { error.textContent = message; error.hidden = false; } };
|
||||
@@ -48,13 +48,13 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||||
const values = new FormData(filters); const params = new URLSearchParams({limit:"51", offset:String(offset)}); for (const key of ["q", "entity_type", "media_role", "status"]) { const value = String(values.get(key) || "").trim(); 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");
|
||||
manifestVersion = Number(response.headers.get("X-Media-Manifest-Version") || 1); 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 role = String(asset.media_role || ""); 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"${role ? ` data-media-role="${esc(role)}"` : ""}><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>${role ? `<small>${esc(mediaRoleLabel(role))}</small>` : ""}<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); }
|
||||
list.innerHTML = assets.map(asset => { const image = url(asset.image_url); const source = url(asset.source_url); const role = String(asset.media_role || ""); 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.id)}" type="button" data-action="secondary">Откатить замену</button>` : ""; return `<article class="media-library__card"${role ? ` data-media-role="${esc(role)}"` : ""}><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>${role ? `<small>${esc(mediaRoleLabel(role))}</small>` : ""}<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_id:button.dataset.mediaRollback, expected_version:manifestVersion, 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 asset_ids = assets.filter(asset => asset.status === "upgrade_stored").map(asset => String(asset.id)); 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({asset_ids, expected_version:manifestVersion, 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 : "Ошибка фильтрации."); } });
|
||||
|
||||
Reference in New Issue
Block a user