350 lines
13 KiB
JavaScript
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("<", "<")
|
|
.replaceAll(">", ">");
|
|
}
|
|
|
|
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>`;
|
|
});
|