diff --git a/dashboards/src/api.js b/dashboards/src/api.js index 5e1f8dd..54505d6 100644 --- a/dashboards/src/api.js +++ b/dashboards/src/api.js @@ -56,7 +56,9 @@ async function request(path, { method = 'GET', body } = {}, attempt = 0) { return new Promise(() => {}); // page is navigating away } } - throw new Error(data.error || `Request failed (${res.status})`); + const err = new Error(data.error || `Request failed (${res.status})`); + err.status = res.status; // callers can feature-detect (e.g. 404 = endpoint not deployed) + throw err; } return data; } @@ -335,4 +337,37 @@ export const api = { setMentorAvailability: (body) => request('/mentor/availability', { method: 'POST', body }), removeMentorSlot: (slotId) => request(`/mentor/availability/${slotId}`, { method: 'DELETE' }), connectMentorCalendar: (connected) => request('/mentor/calendar-connect', { method: 'POST', body: { connected } }), + + // Journal issue lifecycle (Director desk). These endpoints ship with the + // backend issue-lifecycle unit — the Director desk feature-detects their + // absence and quietly hides issue management until they exist. + journalMeta: () => request('/journal/meta'), + journalIssues: () => request('/journal/issues'), + journalIssueDetail: (volume, issue) => request(`/journal/issues/${encodeURIComponent(volume)}/${encodeURIComponent(issue)}`), + closeCurrentIssue: () => request('/editor/director/issues/close', { method: 'POST' }), + moveArticleToIssue: (body) => request('/editor/director/issues/move', { method: 'POST', body }), + // Crossref deposit XML for one issue. Raw fetch (not request()) because the + // response is an XML file, not JSON — and the Bearer header still has to go + // along, so a plain download won't do. + directorCrossrefXml: async ({ volume, issue }) => { + const token = getToken(); + const qs = new URLSearchParams({ volume, issue }).toString(); + const res = await fetch(`${API_BASE}/api/editor/director/crossref.xml?${qs}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (!res.ok) { + // Same expired-session handling as request(): don't leave the user + // toasting 401s against a dead token. + if (res.status === 401 && token) { + clearToken(); + window.location.assign('/login?expired=1'); + return new Promise(() => {}); // page is navigating away + } + const data = await res.json().catch(() => ({})); + const err = new Error(data.error || `Download failed (${res.status})`); + err.status = res.status; + throw err; + } + return res.blob(); + }, }; diff --git a/dashboards/src/pages/editor/DirectorDashboard.jsx b/dashboards/src/pages/editor/DirectorDashboard.jsx index 52dfea2..68bccfb 100644 --- a/dashboards/src/pages/editor/DirectorDashboard.jsx +++ b/dashboards/src/pages/editor/DirectorDashboard.jsx @@ -1,14 +1,21 @@ -import { useEffect, useState, useCallback, useMemo } from 'react'; +import { useEffect, useState, useCallback, useMemo, useRef } from 'react'; import { api } from '../../api.js'; -import { Card, Badge, Button, EmptyState, Field } from '../../components/ui.jsx'; +import { Card, Badge, Button, EmptyState, ErrorState, Field, Modal, Loading } from '../../components/ui.jsx'; +import { useToast } from '../../components/toast.jsx'; +import Icon from '../../components/Icon.jsx'; // --------------------------------------------------------------------------- // Director Desk — the top of the editorial pipeline. // -// Two jobs, two sections (JOURNAL_PIPELINE §9.6): -// 1. Papers to email — every approve/reject decision across all stages. +// Three jobs, three sections (JOURNAL_PIPELINE §9.6): +// 1. Current issue — the open journal issue: its articles, a close action +// (stamps the publication date, opens the next issue), +// per-issue Crossref XML, and per-article issue moves. +// Feature-detected: until the issue-lifecycle backend +// lands, this section is a quiet one-line note. +// 2. Papers to email — every approve/reject decision across all stages. // The Director emails the author, then marks it sent. -// 2. Papers to publish — Chief-approved papers. "Mark published" mints a DOI, +// 3. Papers to publish — Chief-approved papers. "Mark published" mints a DOI, // creates the public Publication, and notifies everyone. // Supporting panels: editor workload, reviews-editor reassignment, and the // Discord webhook used for live queue notifications. @@ -25,6 +32,20 @@ const fmtDay = (s) => { return Number.isNaN(d.getTime()) ? String(s) : d.toLocaleDateString(undefined, { dateStyle: 'medium' }); }; +// Trigger a browser download for a Blob. Attached to the DOM for Firefox, and +// the object URL is revoked on a delay — Safari can cancel a download whose +// URL dies in the same tick. +function downloadBlob(blob, filename) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + // Download the "papers to email" list (respecting the active filter) as CSV. function exportEmailsCsv(rows) { const head = ['Paper ID', 'Title', 'Author', 'Author email', 'Category', 'Stage', 'Decision', 'Decided at', 'Emailed', 'Emailed at']; @@ -33,12 +54,7 @@ function exportEmailsCsv(rows) { [e.paperId, e.title, e.authorName, e.authorEmail, e.category, e.state, e.decision, e.at, e.emailed ? 'yes' : 'no', e.emailedAt || ''].map(esc).join(',') ); const csv = [head.join(','), ...body].join('\n'); - const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv' })); - const a = document.createElement('a'); - a.href = url; - a.download = `synthica-papers-to-email-${new Date().toISOString().slice(0, 10)}.csv`; - a.click(); - URL.revokeObjectURL(url); + downloadBlob(new Blob([csv], { type: 'text/csv' }), `synthica-papers-to-email-${new Date().toISOString().slice(0, 10)}.csv`); } // A compact metric tile for the "act now" header. @@ -51,6 +67,294 @@ function Stat({ label, value, tone = 'blue' }) { ); } +// === Journal issues (issue lifecycle) ====================================== +// Everything below talks to the issue-lifecycle endpoints (journal meta + +// issues). Those ship in a separate backend unit — until it's merged the two +// bootstrap calls fail, the page flips to the 'absent' state, and this whole +// section renders as one quiet note. The rest of the desk never notices. + +const issueLabel = (v, i) => `Vol ${v} · Issue ${i}`; + +// Where an article lives on the public journal site: ${journalUrl}/articles/ +// per the journal contract, or the journal root while the slug is unknown +// (today's publish response carries no slug — the root link still lands +// somewhere sensible). +function journalArticleUrl(meta, slug) { + if (!meta?.journalUrl) return ''; + const base = String(meta.journalUrl).replace(/\/$/, ''); + return slug ? `${base}/articles/${encodeURIComponent(slug)}` : base; +} + +// Per-issue "Download Crossref XML". fetch + blob (not a bare link) so the +// Authorization header rides along with the request. +function CrossrefButton({ volume, issue }) { + const toast = useToast(); + const [busy, setBusy] = useState(false); + const go = async () => { + setBusy(true); + try { + const blob = await api.directorCrossrefXml({ volume, issue }); + downloadBlob(blob, `synthica-crossref-vol${volume}-issue${issue}.xml`); + } catch (e) { + toast.error(e.message); + } finally { + setBusy(false); + } + }; + return ( + + ); +} + +// Move one published article into another existing issue. `from` is the issue +// this article currently sits in (the article rows themselves may not repeat +// their issue coordinates). +function MoveArticleControl({ article, from, issues, onMoved }) { + const toast = useToast(); + const [busy, setBusy] = useState(false); + const targets = issues.filter((t) => !(t.volume === from.volume && t.issue === from.issue)); + if (!targets.length) return null; + + const move = async (value) => { + const [volume, issue] = value.split(':').map(Number); + setBusy(true); + try { + await api.moveArticleToIssue({ publicationId: article.id, volume, issue }); + toast.success(`Moved “${article.title}” to ${issueLabel(volume, issue)}.`); + onMoved(); + } catch (e) { + toast.error(e.message); + } finally { + setBusy(false); + } + }; + + return ( + + ); +} + +// The open issue: header, its articles (title · DOI · pages · move control), +// Close issue (with confirm), and the issue's Crossref XML download. +// `current` is derived once at the page level so the publish form and this +// card can never disagree about which issue is open. +function CurrentIssueCard({ current, issues, onChanged }) { + const toast = useToast(); + const [articles, setArticles] = useState(null); // null = not loaded yet + const [articlesError, setArticlesError] = useState(false); + const [detailTick, setDetailTick] = useState(0); // bump to retry the detail fetch + const [confirming, setConfirming] = useState(false); + const [closing, setClosing] = useState(false); + const loadedKey = useRef(''); + + useEffect(() => { + if (!current) return; + let stale = false; + // Refreshes keep the previous list on screen (no loading flash); only a + // genuinely different issue resets to the loading state. + const key = `${current.volume}:${current.issue}`; + if (key !== loadedKey.current) { + loadedKey.current = key; + setArticles(null); + setArticlesError(false); + } + api.journalIssueDetail(current.volume, current.issue) + .then((d) => { if (!stale) { setArticles(Array.isArray(d.articles) ? d.articles : []); setArticlesError(false); } }) + .catch(() => { if (!stale) setArticlesError(true); }); + return () => { stale = true; }; + }, [current, detailTick]); + + if (!current) { + return No open issue right now — issue history and Crossref downloads are in the list below.; + } + + // A failed detail fetch must not report the issue as empty — fall back to + // the count the issues list gave us. + const count = articles ? articles.length : current.articleCount ?? 0; + const next = issueLabel(current.volume, Number(current.issue) + 1); + + const doClose = async () => { + setClosing(true); + try { + const res = await api.closeCurrentIssue(); + const opened = res?.opened; + toast.success( + opened?.volume != null && opened?.issue != null + ? `${issueLabel(current.volume, current.issue)} closed — ${issueLabel(opened.volume, opened.issue)} is now open.` + : `${issueLabel(current.volume, current.issue)} closed.` + ); + setConfirming(false); + onChanged(); + } catch (e) { + toast.error(e.message); + } finally { + setClosing(false); + } + }; + + return ( + +
+

Volume {current.volume} · Issue {current.issue}

+ open +
+

+ {count} article{count === 1 ? '' : 's'} so far — newly published papers land here until the issue is closed. +

+ + {articles === null && articlesError ? ( + setDetailTick((t) => t + 1)}> + The issues list still works — try again in a moment. + + ) : articles === null ? ( + Loading articles… + ) : ( + <> + {articles.length === 0 ? ( +

No articles in this issue yet — publish a paper below to add the first one.

+ ) : ( + + + + + + {articles.map((a) => ( + + + + + + + ))} + +
ArticleDOIPages
{a.title}
{a.doi}{a.pages || '—'} + +
+ )} + {articlesError &&

Couldn't refresh the article list just now — showing the last known state.

} + + )} + +
+ + +
+ + {confirming && ( + setConfirming(false)}> +

+ Closing stamps the publication date on its {count} article{count === 1 ? '' : 's'} and opens the next + issue ({next}) for newly published papers. +

+

Articles stay live in the Archive — this only finalises the issue itself.

+
+ + +
+
+ )} +
+ ); +} + +// Every issue, open + closed, behind a collapsible — each with its own +// Crossref XML download. +function AllIssuesList({ issues }) { + if (!issues.length) return null; + return ( +
+ All issues ({issues.length}) — Crossref XML per issue + + + + + + + {issues.map((it) => ( + + + + + + + + ))} + +
IssueStatusArticlesPublished
{issueLabel(it.volume, it.issue)}
{it.status}{it.articleCount ?? 0}{it.publishedAt ? fmtDay(it.publishedAt) : '—'}
+
+
+ ); +} + +// The whole "Current issue" section: loading line → quiet absence note (the +// endpoints 404 until the issue-lifecycle backend merges) → retryable error → +// current-issue card + collapsible all-issues list. +function IssuesSection({ journal, current, onRefresh }) { + if (journal.state === 'loading') { + return ( +
+ Checking journal issues… +
+ ); + } + if (journal.state === 'absent') { + return ( +

+ Issue management unlocks once the journal issues backend lands. +

+ ); + } + if (journal.state === 'error') { + return ( +
+ + The issue endpoints answered with an error — the rest of the desk still works. + +
+ ); + } + const { meta, issues } = journal; + return ( +
+
+

+ Current issue {meta?.issn && ISSN {meta.issn}} +

+
+

+ Publishing adds papers to the open issue. Closing it stamps the publication date and opens the next one. +

+ {issues.length === 0 ? ( + No issues yet — the first one opens with the journal. + ) : ( + <> + + + + )} +
+ ); +} + // === Papers to email ======================================================= function EmailQueue({ rows, onMarkEmailed }) { const [stage, setStage] = useState('all'); @@ -165,7 +469,10 @@ function EmailQueue({ rows, onMarkEmailed }) { } // === Papers to publish ===================================================== -function PublishCard({ paper, onPublish }) { +// `openIssue` (when the issue-lifecycle backend is live) is the journal's open +// issue: blank volume/issue fields publish into it explicitly, so what the UI +// promises is what the backend receives. +function PublishCard({ paper, onPublish, openIssue }) { const [open, setOpen] = useState(false); const [volume, setVolume] = useState(''); const [issue, setIssue] = useState(''); @@ -176,8 +483,8 @@ function PublishCard({ paper, onPublish }) { setBusy(true); try { await onPublish(paper.id, { - volume: volume ? Number(volume) : undefined, - issue: issue ? Number(issue) : undefined, + volume: volume ? Number(volume) : openIssue?.volume ?? undefined, + issue: issue ? Number(issue) : openIssue?.issue ?? undefined, pages: pages.trim() || undefined, }); } finally { @@ -207,17 +514,22 @@ function PublishCard({ paper, onPublish }) {
+ {openIssue?.volume != null && openIssue?.issue != null && ( +

+ Leave volume/issue blank to publish into the open issue ({issueLabel(openIssue.volume, openIssue.issue)}). +

+ )} )} @@ -233,7 +545,7 @@ function PublishCard({ paper, onPublish }) { ); } -function PublishQueue({ rows, onPublish }) { +function PublishQueue({ rows, onPublish, openIssue }) { return (
@@ -249,7 +561,7 @@ function PublishQueue({ rows, onPublish }) { ) : (
{rows.map((p) => ( - + ))}
)} @@ -257,8 +569,10 @@ function PublishQueue({ rows, onPublish }) { ); } -// Recently published papers — confirmation that the DOI was minted + Archive link. -function PublishedList({ rows }) { +// Recently published papers — confirmation that the DOI was minted + Archive +// link. Papers published this session get an inline "just published" state +// with their assigned issue and a link to the public journal (when known). +function PublishedList({ rows, lastPublished = {}, meta }) { if (!rows.length) return null; return (
@@ -271,19 +585,35 @@ function PublishedList({ rows }) { PaperDOIPublished - {rows.map((p) => ( - - -
{p.title}
-
{p.authorName} · {p.category}
- - {p.doi} - {fmtDay(p.publishedAt)} - -
View in Archive - - - ))} + {rows.map((p) => { + const just = lastPublished[p.id]; + const journalHref = just ? journalArticleUrl(meta, just.slug) : ''; + return ( + + +
+ {p.title} {just && just published} +
+
{p.authorName} · {p.category}
+ + + {p.doi} + {just && just.volume != null && just.issue != null && ( +
{issueLabel(just.volume, just.issue)}
+ )} + + {fmtDay(p.publishedAt)} + + {journalHref && ( + + View on journal + + )} + View in Archive + + + ); + })} @@ -446,9 +776,17 @@ function WebhookCard() { // === Page ================================================================== export default function DirectorDashboard() { + const toast = useToast(); const [data, setData] = useState({ toEmail: [], toPublish: [], published: [] }); const [error, setError] = useState(''); const [loaded, setLoaded] = useState(false); + // Issue lifecycle: 'loading' → 'ready' (endpoints answered), 'absent' + // (they 404 — the backend unit isn't merged yet; show the quiet note), or + // 'error' (deployed but failing — show a retry instead of a wrong claim). + const [journal, setJournal] = useState({ state: 'loading', meta: null, issues: [] }); + const journalSeq = useRef(0); + // Publish confirmations from this session, keyed by paper id. + const [lastPublished, setLastPublished] = useState({}); const load = useCallback(() => { api.director() @@ -462,18 +800,64 @@ export default function DirectorDashboard() { return () => clearInterval(t); }, [load]); + const loadJournal = useCallback(() => { + // GETs retry with backoff, so an old call can outlive a newer one — the + // sequence check keeps a stale response from overwriting fresher state. + const seq = ++journalSeq.current; + Promise.all([api.journalMeta(), api.journalIssues()]) + .then(([meta, issues]) => { + if (seq !== journalSeq.current) return; + setJournal({ state: 'ready', meta, issues: Array.isArray(issues) ? issues : [] }); + }) + .catch((e) => { + if (seq !== journalSeq.current) return; + setJournal((j) => { + if (j.state === 'ready') return j; // keep a working panel through one flaky refresh + return e?.status === 404 + ? { state: 'absent', meta: null, issues: [] } + : { state: 'error', meta: null, issues: [] }; + }); + }); + }, []); + useEffect(() => { loadJournal(); }, [loadJournal]); + const markEmailed = (paperId, at) => api.markEmailed({ paperId, at }).then(load).catch((e) => setError(e.message)); const publish = (paperId, opts = {}) => - api.publish({ paperId, ...opts }).then(load).catch((e) => { setError(e.message); throw e; }); + api.publish({ paperId, ...opts }) + .then((pub) => { + // Confirmation surface: toast now, inline row state once the row lands + // in "Recently published" on reload. + setLastPublished((m) => ({ ...m, [paperId]: pub || {} })); + const where = pub?.volume != null && pub?.issue != null ? ` · ${issueLabel(pub.volume, pub.issue)}` : ''; + toast.success(`Published — DOI ${pub?.doi || 'assigned'}${where}`); + load(); + // Deliberate re-probe even when 'absent': if the issue backend was + // deployed mid-session this lights the panel up; otherwise it's one + // cheap 404 and nothing changes. + loadJournal(); + }) + .catch((e) => { setError(e.message); toast.error(e.message); }); const pendingEmails = data.toEmail.filter((e) => !e.emailed).length; + const journalMeta = journal.state === 'ready' ? journal.meta : null; + // The single definition of "the open issue" — the issue panel and the + // publish form both use this, so they can't disagree. + const openIssue = useMemo(() => { + if (journal.state !== 'ready') return null; + const { meta, issues } = journal; + return ( + issues.find((it) => it.status === 'open' && it.volume === meta?.currentVolume && it.issue === meta?.currentIssue) || + issues.find((it) => it.status === 'open') || + null + ); + }, [journal]); return (

Director Desk

-

Email authors the outcome of every decision, and publish accepted papers to the journal.

+

Email authors the outcome of every decision, publish accepted papers, and run the journal's issues.

@@ -483,11 +867,13 @@ export default function DirectorDashboard() { {error &&
{error}
} + + {loaded && ( <> - - + + )}