EcoSpecies-Atlas/apps/web/app.js

350 lines
13 KiB
JavaScript

const apiBase = "";
const speciesList = document.querySelector("#species-list");
const searchInput = document.querySelector("#search");
const archiveFilterGroup = document.querySelector("#archive-filter-group");
const detailEmpty = document.querySelector("#detail-empty");
const detail = document.querySelector("#detail");
const detailCode = document.querySelector("#detail-code");
const detailCommonName = document.querySelector("#detail-common-name");
const detailArchiveBadge = document.querySelector("#detail-archive-badge");
const detailArchiveNote = document.querySelector("#detail-archive-note");
const detailScientificName = document.querySelector("#detail-scientific-name");
const detailSummary = document.querySelector("#detail-summary");
const detailSections = document.querySelector("#detail-sections");
const speciesCount = document.querySelector("#species-count");
const sectionCount = document.querySelector("#section-count");
const authTokenInput = document.querySelector("#auth-token");
const authSaveButton = document.querySelector("#auth-save");
const authClearButton = document.querySelector("#auth-clear");
const authStatus = document.querySelector("#auth-status");
const editorPanel = document.querySelector("#editor-panel");
const editorPublicationStatus = document.querySelector("#editor-publication-status");
const editorSummary = document.querySelector("#editor-summary");
const editorNotes = document.querySelector("#editor-notes");
const editorIsArchived = document.querySelector("#editor-is-archived");
const editorSaveButton = document.querySelector("#editor-save");
const editorStatus = document.querySelector("#editor-status");
const auditPanel = document.querySelector("#audit-panel");
const auditList = document.querySelector("#audit-list");
let currentItems = [];
let currentSlug = null;
let currentSession = null;
let currentArchiveFilter = "active";
function getAuthToken() {
return window.localStorage.getItem("ecospecies_auth_token") || "";
}
function getAuthHeaders() {
const token = getAuthToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
function escapeHtml(value) {
return value
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
async function requestJson(path, options = {}) {
const headers = new Headers(options.headers || {});
const authHeaders = getAuthHeaders();
for (const [key, value] of Object.entries(authHeaders)) {
headers.set(key, value);
}
if (options.body && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
const response = await fetch(`${apiBase}${path}`, { ...options, headers });
const data = await response.json();
return { response, data };
}
function isEditorSession() {
return Boolean(currentSession && currentSession.user && ["editor", "admin"].includes(currentSession.user.role));
}
function getVisibleItems(items) {
if (!isEditorSession()) {
return items;
}
if (currentArchiveFilter === "archived") {
return items.filter((item) => item.is_archived);
}
if (currentArchiveFilter === "all") {
return items;
}
return items.filter((item) => !item.is_archived);
}
function syncArchiveFilterUi() {
archiveFilterGroup.classList.toggle("hidden", !isEditorSession());
for (const button of archiveFilterGroup.querySelectorAll("[data-archive-filter]")) {
button.classList.toggle("is-active", button.dataset.archiveFilter === currentArchiveFilter);
}
}
async function loadSession() {
const { data } = await requestJson("/api/auth/session");
currentSession = data;
if (!isEditorSession()) {
currentArchiveFilter = "active";
}
authTokenInput.value = getAuthToken();
if (data.authenticated) {
authStatus.textContent = `${data.user.username} (${data.user.role})`;
} else if (data.auth_configured) {
authStatus.textContent = "Auth configured, public session";
} else {
authStatus.textContent = "Public access";
}
syncArchiveFilterUi();
}
async function loadSummary() {
const { data } = await requestJson("/api/insights/summary");
speciesCount.textContent = data.species_count;
sectionCount.textContent = data.section_count;
}
function renderSpecies(items) {
speciesList.innerHTML = "";
const visibleItems = getVisibleItems(items);
if (!visibleItems.length) {
speciesList.innerHTML = `<p class="editor-status">${isEditorSession() ? "No species match the current archive filter." : "No species match the current search."}</p>`;
return;
}
for (const item of visibleItems) {
const button = document.createElement("button");
button.className = item.is_archived ? "species-card species-card-archived" : "species-card";
button.type = "button";
const archivedMeta = item.is_archived ? `<span class="species-state-badge">Archived</span>` : "";
button.innerHTML = `
<span class="species-name">${escapeHtml(item.common_name || item.title)}</span>
<span class="species-meta">${escapeHtml(item.scientific_name || "Scientific name missing")}</span>
<span class="species-meta">${escapeHtml(item.publication_status || "published")}${archivedMeta}</span>
<span class="species-meta">${item.diagnostic_count ? `${item.diagnostic_count} ingest flags` : "No ingest flags"}</span>
<span class="species-snippet">${escapeHtml((item.summary || "No summary extracted yet.").slice(0, 180))}</span>
`;
button.addEventListener("click", () => loadSpecies(item.slug));
speciesList.appendChild(button);
}
}
async function loadSpeciesList(search = "") {
const query = search ? `?search=${encodeURIComponent(search)}` : "";
const path = isEditorSession() ? `/api/editor/species${query}` : `/api/species${query}`;
const { data } = await requestJson(path);
currentItems = data.items;
syncArchiveFilterUi();
renderSpecies(currentItems);
}
async function loadSpecies(slug) {
currentSlug = slug;
const path = isEditorSession() ? `/api/editor/species/${slug}` : `/api/species/${slug}`;
const { response, data } = await requestJson(path);
if (!response.ok) {
detailEmpty.classList.remove("hidden");
detail.classList.add("hidden");
speciesList.innerHTML = `<p class="error">${escapeHtml(data.error || "Unable to load species.")}</p>`;
return;
}
detailEmpty.classList.add("hidden");
detail.classList.remove("hidden");
detailCode.textContent = data.flelmr_code ? `FLELMR ${data.flelmr_code}` : "Legacy source file";
detailCommonName.textContent = data.common_name || data.title;
detailArchiveBadge.classList.toggle("hidden", !data.is_archived);
detailArchiveNote.classList.toggle("hidden", !data.is_archived);
detailScientificName.textContent = data.scientific_name || "Scientific name missing in source";
detailSummary.textContent = data.summary || "No summary extracted from the current source file.";
editorPanel.classList.toggle("hidden", !isEditorSession());
auditPanel.classList.toggle("hidden", !isEditorSession());
if (isEditorSession()) {
editorPublicationStatus.value = data.publication_status || "published";
editorSummary.value = data.summary || "";
editorNotes.value = data.editor_notes || "";
editorIsArchived.checked = Boolean(data.is_archived);
editorStatus.textContent = data.last_modified_by
? `Last modified by ${data.last_modified_by}`
: "Editor session active";
await loadAudit(slug);
}
detailSections.innerHTML = "";
if (data.diagnostics.length) {
const diagnosticsEl = document.createElement("section");
diagnosticsEl.className = "detail-section detail-diagnostics";
diagnosticsEl.innerHTML = `
<h3>Ingest Diagnostics</h3>
<ul class="diagnostic-list">
${data.diagnostics
.map(
(diagnostic) =>
`<li><strong>${escapeHtml(diagnostic.code)}</strong>: ${escapeHtml(diagnostic.message)}</li>`,
)
.join("")}
</ul>
`;
detailSections.appendChild(diagnosticsEl);
}
for (const section of data.sections) {
const sectionEl = document.createElement("section");
sectionEl.className = "detail-section";
if (isEditorSession()) {
sectionEl.innerHTML = `
<h3>${escapeHtml(section.heading)}</h3>
<textarea class="section-editor" data-section-position="${section.position}" rows="10">${escapeHtml(section.content)}</textarea>
<div class="editor-actions">
<button type="button" class="section-save" data-section-position="${section.position}">Save Section</button>
</div>
`;
} else {
sectionEl.innerHTML = `
<h3>${escapeHtml(section.heading)}</h3>
<pre>${escapeHtml(section.content)}</pre>
`;
}
detailSections.appendChild(sectionEl);
}
if (isEditorSession()) {
for (const button of detailSections.querySelectorAll(".section-save")) {
button.addEventListener("click", async (event) => {
const position = event.currentTarget.dataset.sectionPosition;
const textarea = detailSections.querySelector(`textarea[data-section-position="${position}"]`);
await saveSectionContent(Number(position), textarea.value);
});
}
}
}
function renderAudit(items) {
auditList.innerHTML = "";
if (!items.length) {
auditList.innerHTML = `<p class="editor-status">No audit entries yet.</p>`;
return;
}
for (const item of items) {
const entry = document.createElement("article");
entry.className = "audit-entry";
const detailRows = Object.entries(item.details)
.map(([field, values]) => {
if (values && typeof values === "object" && "from" in values && "to" in values) {
return `<li><strong>${escapeHtml(field)}</strong>: ${escapeHtml(String(values.from || ""))} -> ${escapeHtml(String(values.to || ""))}</li>`;
}
return `<li><strong>${escapeHtml(field)}</strong>: ${escapeHtml(String(values ?? ""))}</li>`;
})
.join("");
entry.innerHTML = `
<p class="audit-meta">${escapeHtml(item.changed_by)}${escapeHtml(item.changed_at)}${escapeHtml(item.action)}</p>
<ul class="diagnostic-list">${detailRows}</ul>
`;
auditList.appendChild(entry);
}
}
async function loadAudit(slug) {
if (!isEditorSession()) {
return;
}
const { response, data } = await requestJson(`/api/editor/species/${slug}/audit`);
if (!response.ok) {
auditList.innerHTML = `<p class="error">${escapeHtml(data.error || "Unable to load audit history.")}</p>`;
return;
}
renderAudit(data.items);
}
async function saveEditorialChanges() {
if (!currentSlug || !isEditorSession()) {
return;
}
editorStatus.textContent = "Saving...";
const { response, data } = await requestJson(`/api/editor/species/${currentSlug}/editorial`, {
method: "POST",
body: JSON.stringify({
publication_status: editorPublicationStatus.value,
summary: editorSummary.value,
editor_notes: editorNotes.value,
is_archived: editorIsArchived.checked,
}),
});
if (!response.ok) {
editorStatus.textContent = data.error || "Save failed";
return;
}
editorStatus.textContent = `Saved by ${data.last_modified_by}`;
await Promise.all([loadSummary(), loadSpeciesList(searchInput.value), loadSpecies(currentSlug)]);
}
async function saveSectionContent(sectionPosition, content) {
if (!currentSlug || !isEditorSession()) {
return;
}
editorStatus.textContent = `Saving section ${sectionPosition}...`;
const { response, data } = await requestJson(`/api/editor/species/${currentSlug}/sections/${sectionPosition}`, {
method: "POST",
body: JSON.stringify({ content }),
});
if (!response.ok) {
editorStatus.textContent = data.error || "Section save failed";
return;
}
editorStatus.textContent = `Section ${sectionPosition} saved by ${data.last_modified_by}`;
await loadSpecies(currentSlug);
}
searchInput.addEventListener("input", async (event) => {
await loadSpeciesList(event.target.value);
});
for (const button of archiveFilterGroup.querySelectorAll("[data-archive-filter]")) {
button.addEventListener("click", () => {
currentArchiveFilter = button.dataset.archiveFilter || "active";
syncArchiveFilterUi();
renderSpecies(currentItems);
});
}
authSaveButton.addEventListener("click", async () => {
const token = authTokenInput.value.trim();
if (token) {
window.localStorage.setItem("ecospecies_auth_token", token);
}
await loadSession();
await loadSpeciesList(searchInput.value);
if (currentSlug) {
await loadSpecies(currentSlug);
}
});
authClearButton.addEventListener("click", async () => {
window.localStorage.removeItem("ecospecies_auth_token");
authTokenInput.value = "";
await loadSession();
await loadSpeciesList(searchInput.value);
if (currentSlug) {
await loadSpecies(currentSlug);
}
});
editorSaveButton.addEventListener("click", saveEditorialChanges);
async function bootstrap() {
await loadSession();
await Promise.all([loadSummary(), loadSpeciesList()]);
}
bootstrap().catch((error) => {
speciesList.innerHTML = `<p class="error">Failed to load data: ${escapeHtml(String(error))}</p>`;
});