NoteStack-Test / frontend /src /components /ReportView.tsx
HumeraR's picture
Refactor code structure for improved readability and maintainability; removed redundant code blocks and optimized function calls.
0649f1b
Raw History Blame
15.7 kB
import { useEffect, useMemo, useRef, useState } from "react";
import type { Artifact, Citation, FlashcardData, MindNode, ReportBlock, ReportSuggestion } from "../api/types";
import { FlashcardDeck } from "./Flashcards";
import { InfographicFrame } from "./Infographic";
import { Markdown } from "./Markdown";
import { MindMapExplorer } from "./MindMap";
import { QuizRunner } from "./Quiz";
/** What the owner can do to a report: add a suggested visual, or remove one. Never given on a shared page. */
export type ReportEditor = {
onAdd: (s: ReportSuggestion) => void;
onRemove: (blockId: string) => void;
/** A visual is being added: no second one is started meanwhile. */
busy: boolean;
/** The suggestion being built right now (its card shows a loader), and what the job is doing. */
adding?: string | null;
addingMessage?: string | null;
};
const KIND_LABEL = {
mind_map: "Mind Constellation", flashcards: "Flashcards", quiz: "Quiz", table: "Comparison table", timeline: "Timeline",
key_terms: "Key terms", infographic: "Infographic",
} as const;
type Props = {
content: Record<string, any>;
/** The artifact's id, which a mind map uses to seed its layout. */
artifactId: string;
onCite?: (c: Citation) => void;
};
function MindMapBlock({ block, artifactId }: { block: Extract<ReportBlock, { type: "mind_map" }>; artifactId: string }) {
const [open, setOpen] = useState(false);
// The explorer reads an artifact's content: the map's own tree is that content here.
const asArtifact = useMemo(() => ({ id: `${artifactId}:${block.id}`, title: block.title ?? "Mind Constellation",
content: { root: block.root as MindNode } }) as unknown as Artifact, [artifactId, block]);
// The map itself, exactly as its own page shows it, in a frame in the report; the corner button opens it full screen.
return (
<>
<MindMapExplorer artifact={asArtifact} embedded warp={false} onClose={() => undefined} onExpand={() => setOpen(true)} />
<p className="muted small rp-map-hint">Drag to move around, click a star to read it. Hold Ctrl or Cmd and scroll to zoom.</p>
{open && <MindMapExplorer artifact={asArtifact} onClose={() => setOpen(false)} />}
</>
);
}
function Block({ block, content, artifactId, onCite }: { block: ReportBlock } & Props) {
switch (block.type) {
case "prose":
return <Markdown text={block.text} citations={(content.citations ?? []) as Citation[]} onCite={onCite} className="rp-prose" reportHeadings />;
case "callout":
return (
<aside className="rp-callout">
{block.title && <strong>{block.title}</strong>}
<p>{block.text}</p>
</aside>
);
case "key_terms":
return (
<section className="rp-block">
<h3 className="rp-h">{block.title || "Key terms"}</h3>
<dl className="rp-terms">
{block.terms.map((t) => (
<div key={t.term}>
<dt>{t.term}</dt>
<dd>{t.definition}</dd>
</div>
))}
</dl>
</section>
);
case "table":
return (
<section className="rp-block">
{block.title && <h3 className="rp-h">{block.title}</h3>}
<div className="rp-table-wrap">
<table className="rp-table">
<thead>
<tr>{block.headers.map((h, i) => <th key={i}>{h}</th>)}</tr>
</thead>
<tbody>
{block.rows.map((r, i) => <tr key={i}>{r.map((c, j) => <td key={j}>{c}</td>)}</tr>)}
</tbody>
</table>
</div>
</section>
);
case "timeline":
return (
<section className="rp-block">
{block.title && <h3 className="rp-h">{block.title}</h3>}
<ol className="rp-timeline">
{block.events.map((e, i) => (
<li key={i}>
<span className="mono muted">{e.when}</span>
<strong>{e.title}</strong>
{e.detail && <p>{e.detail}</p>}
</li>
))}
</ol>
</section>
);
case "mind_map":
return (
<section className="rp-block">
<MindMapBlock block={block} artifactId={artifactId} />
</section>
);
case "flashcards":
return (
<section className="rp-block">
<h3 className="rp-h">{block.title || "Flashcards"}</h3>
<FlashcardDeck cards={block.cards as FlashcardData[]} />
</section>
);
case "quiz":
return (
<section className="rp-block">
<h3 className="rp-h">{block.title || "Test yourself"}</h3>
<QuizRunner questions={block.questions} />
</section>
);
case "infographic":
return (
<section className="rp-block">
{block.title && <h3 className="rp-h">{block.title}</h3>}
{block.html ? (
<figure className="rp-infographic">
<InfographicFrame html={block.html_landscape || block.html} title={block.title ?? "Infographic"}
layout={block.html_landscape ? "landscape" : "portrait"} />
</figure>
) : block.status === "failed" || block.status === "missing" ? (
<p className="muted">{block.status === "missing" ? "This infographic was deleted." : "This infographic could not be made."}</p>
) : (
<p className="muted rp-building-label" role="status"><span className="nbv-send-spinner" aria-hidden="true" /> Writing the infographic...</p>
)}
</section>
);
default:
return null;
}
}
/** The sections of a report, for its contents list: each prose block that opens with a heading. */
function contents(blocks: ReportBlock[], withSources: boolean): { id: string; title: string }[] {
const list = blocks.flatMap((b) => {
const title = b.type === "prose" ? b.text.match(/^#{1,3}\s+(.+)/)?.[1]?.trim() : undefined;
return title ? [{ id: b.id, title }] : [];
});
return withSources && list.length ? [...list, { id: "sources", title: "Sources" }] : list;
}
type SourceRow = { title: string; url?: string | null; document_id?: string };
/** The sources a report was made from, always at its end: each post once (a link when it has a public address), with
* the numbers of the places it is cited as clickable chips, and how many chats were used. */
function SourcesSection({ content, onCite }: { content: Record<string, any>; onCite?: (c: Citation) => void }) {
const rows = (content.sources ?? []) as SourceRow[];
const chats = Number(content.chat_count ?? (content.source?.chat_ids?.length ?? 0));
const cites = (content.citations ?? []) as Citation[];
if (!rows.length && !chats) return null;
const citedBy = (r: SourceRow) => cites.filter((c) => (r.document_id ? c.document_id === r.document_id : c.title === r.title));
return (
<section id="rp-sources" className="rp-slot rp-sources-section" aria-label="Sources">
<h2 className="rp-sources-h">Sources</h2>
<ul className="cites cites-grouped">
{rows.map((r) => (
<li key={r.document_id ?? r.title}>
{r.url ? <a className="cite-title" href={r.url} target="_blank" rel="noreferrer noopener">{r.title}</a> : <span className="cite-title">{r.title}</span>}
{onCite && citedBy(r).length > 0 && (
<span className="cite-nums">
{citedBy(r).map((c, i) => (
<span key={c.marker}>
{i > 0 && <span className="muted">, </span>}
<button type="button" className="cite-marker cite-num mono" onClick={() => onCite(c)}
title={`lines ${c.line_start}${c.line_end > c.line_start ? ` to ${c.line_end}` : ""}`}>{c.marker}</button>
</span>
))}
</span>
)}
</li>
))}
{chats > 0 && <li><span className="cite-title">{chats} notebook chat{chats === 1 ? "" : "s"}</span></li>}
</ul>
</section>
);
}
/** A report's blocks, in order, with a numbered contents list beside them that follows the reader down the page. Used
* in the app and, read only, on the shared page. */
export function ReportView({ content, artifactId, onCite, editor }: Props & { editor?: ReportEditor }) {
const blocks = (content.blocks ?? []) as ReportBlock[];
const suggestions = (content.suggestions ?? []) as ReportSuggestion[];
const hasSources = ((content.sources ?? []) as unknown[]).length > 0 || Number(content.chat_count ?? (content.source?.chat_ids?.length ?? 0)) > 0;
const toc = useMemo(() => contents(blocks, hasSources), [blocks, hasSources]);
const [active, setActive] = useState<string | null>(toc[0]?.id ?? null);
// The section nearest the top of the window is the one the contents list marks.
useEffect(() => {
if (toc.length < 2 || typeof IntersectionObserver === "undefined") return;
const seen = new Map<string, boolean>();
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) seen.set(e.target.id.replace("rp-", ""), e.isIntersecting);
const first = toc.find((t) => seen.get(t.id));
if (first) setActive(first.id);
},
{ rootMargin: "-12% 0px -70% 0px" },
);
toc.forEach((t) => {
const el = document.getElementById(`rp-${t.id}`);
if (el) io.observe(el);
});
return () => io.disconnect();
}, [toc]);
// How far down the report the reader is, for the line that runs beside it.
const pathRef = useRef<HTMLElement>(null);
const [progress, setProgress] = useState(0);
useEffect(() => {
const onScroll = () => {
const el = pathRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const total = rect.height - window.innerHeight * 0.4;
setProgress(Math.max(0, Math.min(1, (window.innerHeight * 0.45 - rect.top) / Math.max(total, 1))));
};
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, [blocks.length]);
const activeIdx = toc.findIndex((t) => t.id === active);
const stop = (id: string) => toc.findIndex((t) => t.id === id);
const jump = (id: string) => {
setActive(id);
document.getElementById(`rp-${id}`)?.scrollIntoView({ behavior: "smooth", block: "start" });
};
return (
<div className={`rp-layout${toc.length > 1 ? " has-toc" : ""}`}>
{toc.length > 1 && (
<nav className="rp-toc" aria-label="Contents">
<span className="mono rp-toc-label">Flight plan</span>
<span className="rp-toc-icon" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
<path d="M9 6h11M9 12h11M9 18h11M4.5 6h.01M4.5 12h.01M4.5 18h.01" />
</svg>
</span>
<ol>
{toc.map((t, i) => (
<li key={t.id} className={i < activeIdx ? "done" : ""}>
<button type="button" className={active === t.id ? "on" : ""} aria-current={active === t.id ? "true" : undefined}
onClick={() => jump(t.id)}>
<span className="mono rp-toc-n">{String(i + 1).padStart(2, "0")}</span>
<span>{t.title}</span>
</button>
</li>
))}
</ol>
</nav>
)}
<article className="rp" ref={pathRef}>
<div className="rp-path" aria-hidden="true">
<div className="rp-path-fill" style={{ height: `${progress * 100}%` }} />
<div className="rp-path-ship" style={{ top: `${progress * 100}%` }} />
</div>
{blocks.map((b) => {
const n = stop(b.id);
return (
<div key={b.id} id={`rp-${b.id}`} className={`rp-slot${n >= 0 ? " rp-stop" : ""}${n >= 0 && n <= activeIdx ? " reached" : ""}`}>
{n >= 0 && (
<>
<span className="rp-node" aria-hidden="true" />
<p className="mono rp-stop-label">Waypoint {String(n + 1).padStart(2, "0")}</p>
</>
)}
<Block block={b} content={content} artifactId={artifactId} onCite={onCite} />
{editor && b.type !== "prose" && (
<button type="button" className="link-btn rp-remove" onClick={() => editor.onRemove(b.id)}>
Remove this {KIND_LABEL[b.type as keyof typeof KIND_LABEL]?.toLowerCase() ?? "visual"}
</button>
)}
{editor && suggestions.filter((s) => s.after_block_id === b.id).map((s) => {
const building = editor.adding === s.id;
return (
<div key={s.id} className={`rp-suggestion${building ? " building" : ""}`} aria-busy={building}>
<div>
<strong>Suggested: {KIND_LABEL[s.kind]}</strong>
<p className="muted">{s.brief}</p>
{building ? (
<div className="rp-building">
<span className="vw-focus-bar" />
<span className="vw-focus-bar short" />
</div>
) : s.why && <p className="muted small">{s.why}</p>}
</div>
{building ? (
<span className="rp-building-label" role="status">
<span className="nbv-send-spinner" aria-hidden="true" />
{editor.addingMessage || `Building the ${KIND_LABEL[s.kind].toLowerCase()}...`}
</span>
) : (
<button type="button" className="btn btn-small" disabled={editor.busy} onClick={() => editor.onAdd(s)}>Add</button>
)}
</div>
);
})}
</div>
);
})}
<SourcesSection content={content} onCite={onCite} />
</article>
</div>
);
}
/** A report as Markdown text, for downloading. Visual blocks are written out as plain lists. */
export function reportMarkdown(content: Record<string, any>): string {
const lines = [`# ${content.title ?? "Report"}`, ""];
for (const b of (content.blocks ?? []) as ReportBlock[]) {
if (b.type === "prose") lines.push(b.text, "");
else if (b.type === "callout") lines.push(`> ${b.title ? `**${b.title}** ` : ""}${b.text}`, "");
else if (b.type === "key_terms") lines.push(`## ${b.title || "Key terms"}`, ...b.terms.map((t) => `- **${t.term}**: ${t.definition}`), "");
else if (b.type === "table") {
lines.push(...(b.title ? [`## ${b.title}`, ""] : []), `| ${b.headers.join(" | ")} |`, `| ${b.headers.map(() => "---").join(" | ")} |`,
...b.rows.map((r) => `| ${r.join(" | ")} |`), "");
} else if (b.type === "timeline") lines.push(`## ${b.title || "Timeline"}`, ...b.events.map((e) => `- **${e.when}** ${e.title}${e.detail ? `: ${e.detail}` : ""}`), "");
else if (b.type === "flashcards") lines.push(`## ${b.title || "Flashcards"}`, ...b.cards.map((c) => `- **${c.front}**: ${c.back}`), "");
else if (b.type === "infographic") lines.push(`## ${b.title || "Infographic"}`, "");
else if (b.type === "quiz") lines.push(`## ${b.title || "Quiz"}`, ...b.questions.map((q, i) => `${i + 1}. ${q.question}`), "");
}
const cites = (content.citations ?? []) as Citation[];
if (cites.length) lines.push("## Sources", ...cites.map((c) => `[${c.marker}] ${c.title}, lines ${c.line_start} to ${c.line_end}`), "");
return lines.join("\n");
}