Sync responsive newsroom UI routes
This commit is contained in:
parent
3e37444710
commit
78a79912c3
216
src/index.ts
216
src/index.ts
@ -25,6 +25,7 @@ import {
|
||||
import { handleMcp } from "./mcp.js";
|
||||
import { SOURCES } from "./sources.js";
|
||||
import { syncAll } from "./sync.js";
|
||||
import { renderDashboard, renderOpportunities, renderOpportunity, renderStories, renderStory } from "./web.js";
|
||||
|
||||
const app = express();
|
||||
app.set("trust proxy", true);
|
||||
@ -186,81 +187,19 @@ app.get("/stories/:id", requireWebAuth, async (req, res) => {
|
||||
if (!story) return res.status(404).send("Story thread not found");
|
||||
|
||||
const user = (req as any).user ?? {};
|
||||
const identity = user.name || user.preferred_username || user.email || "Signed-in user";
|
||||
const nodes = Array.isArray(story.nodes) ? story.nodes : [];
|
||||
const edges = Array.isArray(story.edges) ? story.edges : [];
|
||||
const checks = Array.isArray(story.research_checks) ? story.research_checks : [];
|
||||
const counter = checks.find((c:any)=>c.check_type === "counter_evidence");
|
||||
|
||||
res.type("html").send(`<!doctype html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>${escapeHtml(story.title)} — Story Research</title>
|
||||
<style>
|
||||
body{font-family:system-ui,-apple-system,sans-serif;max-width:1180px;margin:32px auto;padding:0 20px;color:#1f2937}
|
||||
header{display:flex;justify-content:space-between;gap:18px;align-items:flex-start}.card{border:1px solid #d1d5db;border-radius:12px;padding:16px;margin:12px 0}
|
||||
.meta{font-size:.86rem;color:#6b7280}.toolbar{display:flex;gap:8px;flex-wrap:wrap;margin:16px 0}
|
||||
button{padding:8px 11px;border:1px solid #9ca3af;border-radius:7px;background:#fff;cursor:pointer}a{color:inherit}
|
||||
.graph-wrap{overflow:auto;border:1px solid #d1d5db;border-radius:14px;background:#fafafa;margin:18px 0}
|
||||
.story-graph{display:block;min-width:900px;width:100%;height:auto}.edge line{stroke:#9ca3af;stroke-width:2}.edge text{font-size:12px;fill:#4b5563;paint-order:stroke;stroke:#fafafa;stroke-width:5px}
|
||||
#arrow path{fill:#9ca3af}.thread-centre rect{fill:#111827}.thread-centre text{fill:#fff;font-size:13px}.graph-node rect{fill:#fff;stroke:#9ca3af;stroke-width:2}
|
||||
.graph-node.evidence rect,.graph-node.actual rect{fill:#eff6ff;stroke:#2563eb}.graph-node.claim rect{fill:#ecfdf5;stroke:#059669}.graph-node.counterclaim rect{fill:#fef2f2;stroke:#dc2626}
|
||||
.graph-node.question rect{fill:#fff7ed;stroke:#ea580c}.graph-node.forecast rect,.graph-node.trend rect{fill:#faf5ff;stroke:#9333ea}.graph-node.context rect{fill:#f8fafc;stroke:#64748b}
|
||||
.node-type{font-size:10px;text-transform:uppercase;fill:#6b7280}.node-title{font-size:12px;font-weight:650;fill:#111827}
|
||||
.node-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:10px}.node-card{scroll-margin-top:20px}
|
||||
.badge{display:inline-block;padding:3px 7px;border-radius:999px;background:#f3f4f6;font-size:.76rem}
|
||||
.check-ok{border-left:5px solid #059669}.check-pending{border-left:5px solid #d97706}
|
||||
</style></head><body>
|
||||
<header><div><a href="/stories">← Story Threads</a><h1>${escapeHtml(story.title)}</h1><p>${escapeHtml(story.question)}</p></div>
|
||||
<div class="meta">${escapeHtml(identity)} · <a href="/auth/logout">Sign out</a></div></header>
|
||||
<div class="card">
|
||||
<strong>Status:</strong> ${escapeHtml(story.status)} ·
|
||||
<strong>Nodes:</strong> ${nodes.length} ·
|
||||
<strong>Edges:</strong> ${edges.length}
|
||||
<div class="toolbar">
|
||||
${["researching","review","develop","closed"].map(s=>`<form method="post" action="/stories/${encodeURIComponent(story.id)}/status"><input type="hidden" name="status" value="${s}"><button type="submit">${s}</button></form>`).join("")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card ${counter ? 'check-ok' : 'check-pending'}">
|
||||
<strong>Mandatory counter-evidence pass:</strong>
|
||||
${counter ? `${escapeHtml(counter.result)} · sources checked: ${escapeHtml((counter.sources_checked ?? []).join(", "))}<p>${escapeHtml(counter.summary)}</p>` : "not completed"}
|
||||
</div>
|
||||
<div class="graph-wrap">${storyGraphSvg(story)}</div>
|
||||
<h2>Research nodes</h2>
|
||||
<div class="node-list">
|
||||
${nodes.map((n:any)=>`<div class="card node-card" id="node-${escapeHtml(n.id)}">
|
||||
<span class="badge">${escapeHtml(n.node_type)}</span>
|
||||
<h3>${escapeHtml(n.title)}</h3>
|
||||
<p>${escapeHtml(n.summary)}</p>
|
||||
<div class="meta">${n.provenance_source_id ? `source ${escapeHtml(n.provenance_source_id)} · ` : ""}${n.confidence != null ? `confidence ${escapeHtml(n.confidence)} · ` : ""}${escapeHtml(n.created_by_name)}</div>
|
||||
${n.evidence_url ? `<p><a href="${escapeHtml(n.evidence_url)}" target="_blank" rel="noreferrer">Open evidence ↗</a></p>` : ""}
|
||||
</div>`).join("")}
|
||||
</div>
|
||||
<h2>Relationships</h2>
|
||||
${edges.length ? edges.map((e:any)=>`<div class="card"><strong>${escapeHtml(e.relationship)}</strong><br><code>${escapeHtml(e.from_node_id.slice(0,8))}</code> → <code>${escapeHtml(e.to_node_id.slice(0,8))}</code><p>${escapeHtml(e.reason)}</p></div>`).join("") : '<div class="card meta">No graph edges yet.</div>'}
|
||||
</body></html>`);
|
||||
const identity = String(user.name || user.preferred_username || user.email || "Signed-in user");
|
||||
return res.type("html").send(renderStory({ story, identity }));
|
||||
});
|
||||
|
||||
app.get("/stories", requireWebAuth, async (req, res) => {
|
||||
const stories = await listStoryThreads({ limit: 100 });
|
||||
const user = (req as any).user ?? {};
|
||||
const identity = user.name || user.preferred_username || user.email || "Signed-in user";
|
||||
const rows = await listStoryThreads({ limit: 100 });
|
||||
const stories = (await Promise.all(
|
||||
rows.map((row: any) => getStoryThread(String(row.id)))
|
||||
)).filter(Boolean) as any[];
|
||||
|
||||
res.type("html").send(`<!doctype html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Story Threads — Media Intelligence</title>
|
||||
<style>
|
||||
body{font-family:system-ui,-apple-system,sans-serif;max-width:1040px;margin:36px auto;padding:0 20px;color:#1f2937}
|
||||
header{display:flex;justify-content:space-between;gap:20px}.card{border:1px solid #d1d5db;border-radius:12px;padding:16px;margin:12px 0}.meta{font-size:.86rem;color:#6b7280}
|
||||
.title{font-size:1.08rem;font-weight:700;text-decoration:none}a{color:inherit}.badge{display:inline-block;padding:3px 7px;border-radius:999px;background:#f3f4f6;font-size:.76rem}
|
||||
</style></head><body>
|
||||
<header><div><a href="/">← Dashboard</a><h1>360° Story Threads</h1><p>Evidence networks that require an explicit counter-evidence pass before editorial review.</p></div>
|
||||
<div class="meta">${escapeHtml(identity)} · <a href="/auth/logout">Sign out</a></div></header>
|
||||
${stories.length ? stories.map((s:any)=>`<div class="card">
|
||||
<a class="title" href="/stories/${encodeURIComponent(s.id)}">${escapeHtml(s.title)}</a>
|
||||
<p>${escapeHtml(s.question)}</p>
|
||||
<div class="meta"><span class="badge">${escapeHtml(s.status)}</span> · ${s.node_count} nodes · ${s.edge_count} edges · counter-evidence: ${escapeHtml(s.counter_evidence_result ?? "pending")}</div>
|
||||
</div>`).join("") : '<div class="card meta">No story threads yet.</div>'}
|
||||
</body></html>`);
|
||||
const user = (req as any).user ?? {};
|
||||
const identity = String(user.name || user.preferred_username || user.email || "Signed-in user");
|
||||
return res.type("html").send(renderStories({ stories, identity }));
|
||||
});
|
||||
|
||||
app.post("/messages/:id/ack", requireWebAuth, async (req, res) => {
|
||||
@ -284,39 +223,8 @@ app.get("/opportunities/:id", requireWebAuth, async (req, res) => {
|
||||
if (!opportunity) return res.status(404).send("Opportunity not found");
|
||||
|
||||
const user = (req as any).user ?? {};
|
||||
const identity = user.name || user.preferred_username || user.email || "Signed-in user";
|
||||
const evidence = Array.isArray(opportunity.evidence) ? opportunity.evidence : [];
|
||||
const notes = Array.isArray(opportunity.notes) ? opportunity.notes : [];
|
||||
|
||||
res.type("html").send(`<!doctype html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>${escapeHtml(opportunity.title)} — Media Intelligence</title>
|
||||
<style>
|
||||
body{font-family:system-ui,-apple-system,sans-serif;max-width:960px;margin:36px auto;padding:0 20px;color:#1f2937}
|
||||
header{display:flex;justify-content:space-between;gap:20px;align-items:flex-start}
|
||||
.card{border:1px solid #d1d5db;border-radius:12px;padding:18px;margin:14px 0}
|
||||
.meta{font-size:.88rem;color:#6b7280}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}
|
||||
button{padding:8px 12px;border:1px solid #9ca3af;border-radius:7px;background:#fff;cursor:pointer}
|
||||
code{background:#f3f4f6;padding:2px 6px;border-radius:5px}a{color:inherit}
|
||||
pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#f9fafb;padding:12px;border-radius:8px}
|
||||
</style></head><body>
|
||||
<header><div><a href="/opportunities">← Opportunities</a><h1>${escapeHtml(opportunity.title)}</h1></div>
|
||||
<div class="meta">${escapeHtml(identity)} · <a href="/auth/logout">Sign out</a></div></header>
|
||||
<div class="card">
|
||||
<div class="meta">${escapeHtml(opportunity.source_id)} · ${escapeHtml(nzTime(opportunity.published_at || opportunity.detected_at))}</div>
|
||||
<p>${escapeHtml(opportunity.summary)}</p>
|
||||
<p><strong>Status:</strong> ${escapeHtml(opportunity.status)} · <strong>Score:</strong> ${escapeHtml(opportunity.score)}</p>
|
||||
<p><strong>Why surfaced:</strong> ${escapeHtml((opportunity.reasons ?? []).join(" · "))}</p>
|
||||
<p><a href="${escapeHtml(opportunity.url)}" target="_blank" rel="noreferrer">Open original source ↗</a></p>
|
||||
<div class="actions">
|
||||
${["watch","investigate","develop","ignore","closed"].map(status=>`<form method="post" action="/opportunities/${encodeURIComponent(opportunity.id)}/status"><input type="hidden" name="status" value="${status}"><input type="hidden" name="returnTo" value="detail"><button type="submit">${status}</button></form>`).join("")}
|
||||
</div>
|
||||
</div>
|
||||
<h2>Evidence</h2>
|
||||
${evidence.length ? evidence.map((e:any)=>`<div class="card"><div class="meta">${escapeHtml(nzTime(e.retrieved_at))} · <code>${escapeHtml(e.content_hash?.slice(0,12))}</code></div><pre>${escapeHtml(e.excerpt)}</pre><a href="${escapeHtml(e.source_url)}" target="_blank" rel="noreferrer">Source ↗</a></div>`).join("") : '<div class="card meta">No evidence stored.</div>'}
|
||||
<h2>Notes</h2>
|
||||
${notes.length ? notes.map((n:any)=>`<div class="card"><div class="meta">${escapeHtml(nzTime(n.created_at))}</div><p>${escapeHtml(n.note)}</p></div>`).join("") : '<div class="card meta">No notes yet.</div>'}
|
||||
</body></html>`);
|
||||
const identity = String(user.name || user.preferred_username || user.email || "Signed-in user");
|
||||
return res.type("html").send(renderOpportunity({ opportunity, identity }));
|
||||
});
|
||||
|
||||
app.get("/opportunities", requireWebAuth, async (req, res) => {
|
||||
@ -324,44 +232,21 @@ app.get("/opportunities", requireWebAuth, async (req, res) => {
|
||||
const valid = new Set(["new","watch","investigate","develop","ignore","closed"]);
|
||||
const status = valid.has(requested) ? requested : undefined;
|
||||
const opportunities = await listOpportunities({ status, limit: 100 });
|
||||
const user = (req as any).user ?? {};
|
||||
const identity = user.name || user.preferred_username || user.email || "Signed-in user";
|
||||
|
||||
res.type("html").send(`<!doctype html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Opportunities — Media Intelligence</title>
|
||||
<style>
|
||||
body{font-family:system-ui,-apple-system,sans-serif;max-width:1040px;margin:36px auto;padding:0 20px;color:#1f2937}
|
||||
header{display:flex;justify-content:space-between;gap:20px;align-items:flex-start}.card{border:1px solid #d1d5db;border-radius:12px;padding:16px;margin:12px 0}
|
||||
.meta{font-size:.86rem;color:#6b7280}.filters{display:flex;gap:8px;flex-wrap:wrap;margin:18px 0}.filters a{padding:7px 10px;border:1px solid #d1d5db;border-radius:8px;text-decoration:none}
|
||||
.actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:10px}button{padding:7px 10px;border:1px solid #9ca3af;border-radius:7px;background:#fff;cursor:pointer}a{color:inherit}
|
||||
.title{font-size:1.05rem;font-weight:700;text-decoration:none}
|
||||
</style></head><body>
|
||||
<header><div><a href="/">← Dashboard</a><h1>Media Opportunities</h1><p>Human triage queue. No release drafting or distribution happens here.</p></div>
|
||||
<div class="meta">${escapeHtml(identity)} · <a href="/auth/logout">Sign out</a></div></header>
|
||||
<div class="filters">
|
||||
<a href="/opportunities">Active</a>
|
||||
${["new","watch","investigate","develop","ignore","closed"].map(s=>`<a href="/opportunities?status=${s}">${s}</a>`).join("")}
|
||||
</div>
|
||||
${opportunities.length ? opportunities.map((o:any)=>`<div class="card">
|
||||
<a class="title" href="/opportunities/${encodeURIComponent(o.id)}">${escapeHtml(o.title)}</a>
|
||||
<div class="meta">${escapeHtml(o.source_id)} · ${escapeHtml(nzTime(o.published_at || o.detected_at))} · status ${escapeHtml(o.status)} · score ${escapeHtml(o.score)}</div>
|
||||
<p>${escapeHtml(o.summary)}</p>
|
||||
<div class="meta">${escapeHtml((o.reasons ?? []).join(" · "))}</div>
|
||||
<div class="actions">
|
||||
${["watch","investigate","develop","ignore"].map(s=>`<form method="post" action="/opportunities/${encodeURIComponent(o.id)}/status"><input type="hidden" name="status" value="${s}"><button type="submit">${s}</button></form>`).join("")}
|
||||
</div>
|
||||
</div>`).join("") : '<div class="card meta">No opportunities in this view.</div>'}
|
||||
</body></html>`);
|
||||
const user = (req as any).user ?? {};
|
||||
const identity = String(user.name || user.preferred_username || user.email || "Signed-in user");
|
||||
return res.type("html").send(renderOpportunities({ opportunities, identity, status }));
|
||||
});
|
||||
|
||||
app.get("/", requireWebAuth, async (req, res) => {
|
||||
const sourceRows = await pool.query(
|
||||
`SELECT s.id,s.name,s.last_synced_at,s.last_error,s.baseline_complete,
|
||||
`SELECT s.id,s.name,s.source_type,s.source_class,s.publisher,s.authority_tier,
|
||||
s.last_synced_at,s.last_error,s.baseline_complete,
|
||||
COUNT(sig.id)::int AS signal_count
|
||||
FROM sources s
|
||||
LEFT JOIN signals sig ON sig.source_id=s.id
|
||||
GROUP BY s.id,s.name,s.last_synced_at,s.last_error,s.baseline_complete
|
||||
GROUP BY s.id,s.name,s.source_type,s.source_class,s.publisher,s.authority_tier,
|
||||
s.last_synced_at,s.last_error,s.baseline_complete
|
||||
ORDER BY s.id`
|
||||
);
|
||||
const counts = await pool.query(`
|
||||
@ -373,56 +258,21 @@ app.get("/", requireWebAuth, async (req, res) => {
|
||||
(SELECT COUNT(*) FROM story_threads WHERE status <> 'closed') AS story_threads
|
||||
`);
|
||||
const messages = await listAgentMessages({ acknowledged: false, limit: 10 });
|
||||
const c = counts.rows[0] ?? {};
|
||||
const user = (req as any).user ?? {};
|
||||
const identity = user.name || user.preferred_username || user.email || "Signed-in user";
|
||||
const storyRows = await listStoryThreads({ limit: 20 });
|
||||
const stories = (await Promise.all(
|
||||
storyRows.map((row: any) => getStoryThread(String(row.id)))
|
||||
)).filter(Boolean) as any[];
|
||||
|
||||
res.type("html").send(`<!doctype html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Media Intelligence</title>
|
||||
<style>
|
||||
body{font-family:system-ui,-apple-system,sans-serif;max-width:920px;margin:40px auto;padding:0 20px;color:#1f2937}
|
||||
header{display:flex;align-items:center;justify-content:space-between;gap:16px}
|
||||
.card{border:1px solid #d1d5db;border-radius:12px;padding:18px;margin:12px 0}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px}
|
||||
strong.big{font-size:2rem;display:block}
|
||||
.ok{color:#166534}.bad{color:#991b1b}
|
||||
code{background:#f3f4f6;padding:2px 6px;border-radius:5px}
|
||||
a{color:inherit}
|
||||
.user{font-size:.9rem;color:#4b5563;text-align:right}
|
||||
.message{border-left-width:5px}.message.notice{border-left-color:#2563eb}.message.warning{border-left-color:#d97706}.message.error{border-left-color:#b91c1c}
|
||||
.message-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}
|
||||
.message-body{white-space:pre-wrap;line-height:1.45}
|
||||
.meta{font-size:.84rem;color:#6b7280}
|
||||
button{margin-top:12px;padding:7px 11px;border:1px solid #9ca3af;border-radius:7px;background:#fff;cursor:pointer}
|
||||
</style></head><body>
|
||||
<header>
|
||||
<div><h1>Media Intelligence MVP</h1><p>Human-led NZ public-source monitoring behind a dedicated MCP.</p><p><a href="/opportunities">Open Opportunities →</a> · <a href="/stories">Open 360° Story Threads →</a></p></div>
|
||||
<div class="user"><strong>${escapeHtml(identity)}</strong><br><a href="/auth/logout">Sign out</a></div>
|
||||
</header>
|
||||
<div class="grid">
|
||||
<div class="card"><strong class="big">${c.signals ?? 0}</strong>signals</div>
|
||||
<div class="card"><strong class="big">${c.opportunities ?? 0}</strong>opportunities</div>
|
||||
<div class="card"><strong class="big">${c.new_opportunities ?? 0}</strong>new</div>
|
||||
<div class="card"><strong class="big">${c.unread_messages ?? 0}</strong>agent messages</div>
|
||||
<div class="card"><strong class="big">${c.story_threads ?? 0}</strong>story threads</div>
|
||||
</div>
|
||||
<h2>Agent Activity</h2>
|
||||
${messages.length
|
||||
? messages.map((m:any)=>`<div class="card message ${escapeHtml(m.severity)}">
|
||||
<div class="message-head">
|
||||
<div><strong>${escapeHtml(m.title)}</strong><br><span class="meta">${escapeHtml(m.kind)} · ${escapeHtml(m.severity)}</span></div>
|
||||
<div class="meta">${escapeHtml(nzTime(m.created_at))}</div>
|
||||
</div>
|
||||
<p class="message-body">${escapeHtml(m.body)}</p>
|
||||
<div class="meta">From ${escapeHtml(m.actor_name)} via <code>${escapeHtml(m.actor_client_id)}</code>${m.related_type ? ` · ${escapeHtml(m.related_type)} <code>${escapeHtml(m.related_id ?? "")}</code>` : ""}</div>
|
||||
<form method="post" action="/messages/${encodeURIComponent(m.id)}/ack"><button type="submit">Acknowledge</button></form>
|
||||
</div>`).join("")
|
||||
: '<div class="card"><span class="meta">No unread agent messages.</span></div>'}
|
||||
<h2>Sources</h2>
|
||||
${sourceRows.rows.map((s:any)=>`<div class="card"><strong>${s.name}</strong><br><code>${s.id}</code> · ${s.baseline_complete ? 'baseline ready' : 'awaiting baseline'}<br>${s.signal_count} stored signals<br><span class="${s.last_error?'bad':'ok'}">${s.last_error ? 'Error: '+s.last_error : (s.last_synced_at ? 'Last sync '+new Date(s.last_synced_at).toLocaleString() : 'Waiting for first sync')}</span></div>`).join("")}
|
||||
<p>MCP endpoint: <code>/api/mcp</code> — OAuth protected separately from this browser session.</p>
|
||||
</body></html>`);
|
||||
const user = (req as any).user ?? {};
|
||||
const identity = String(user.name || user.preferred_username || user.email || "Signed-in user");
|
||||
|
||||
return res.type("html").send(renderDashboard({
|
||||
identity,
|
||||
counts: counts.rows[0] ?? {},
|
||||
messages,
|
||||
sources: sourceRows.rows,
|
||||
stories
|
||||
}));
|
||||
});
|
||||
|
||||
async function start() {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user