import { useState, type ReactNode } from "react";
import { Link } from "react-router-dom";
import { artifactsApi, videosApi } from "../api/endpoints";
import type { Artifact, Citation, Segment } from "../api/types";
import { useJob } from "../hooks/useJob";
import { Markdown } from "./Markdown";
import { MindMapExplorer } from "./MindMap";
import { FlashcardsBody } from "./Flashcards";
import { InfographicBody } from "./Infographic";
import { QuizBody } from "./Quiz";
import { StudyOverlay } from "./StudyOverlay";
import { ReportBody } from "./ReportBody";
import { ConfirmButton, errorMessage, formatDate, formatDuration, JobProgress, StatusPill } from "./ui";
export function CitationList({
citations,
onCite,
collapsible = false,
}: {
citations: Citation[];
onCite?: (c: Citation) => void;
collapsible?: boolean;
}) {
if (!citations.length) return null;
// The same source is listed once: its title, then one clickable number for each place it was cited.
const groups: { key: string; title: string; cites: Citation[] }[] = [];
for (const c of [...citations].sort((x, y) => x.marker - y.marker)) {
const key = c.document_id || c.path || c.title;
const group = groups.find((g) => g.key === key);
if (group) group.cites.push(c);
else groups.push({ key, title: c.title, cites: [c] });
}
const lines = (c: Citation) => `lines ${c.line_start}${c.line_end > c.line_start ? ` to ${c.line_end}` : ""}`;
const list = (
{groups.map((g) => (
onCite?.(g.cites[0])}>
{g.title}
{g.cites.map((c, i) => (
{i > 0 && , }
onCite?.(c)}
title={`${lines(c)}: ${c.span.length > 160 ? `${c.span.slice(0, 160)}...` : c.span}`}>
{c.marker}
))}
))}
);
if (!collapsible) return list;
return (
Citations
{citations.length}
{list}
);
}
export function Transcript({ segments, onSeek }: { segments: Segment[]; onSeek: (t: number) => void }) {
return (
{segments.map((s, i) => (
onSeek(s.start)}>
{formatDuration(s.start) || "0:00"}
{s.speaker === "host_b" ? "B" : "A"}
{s.text}
{s.sources.length > 0 && [{s.sources.map((r) => r.title ?? r.path).join("; ")}] }
))}
);
}
/** Cards whose buttons (Open, Share, Delete...) sit together on one line, all the same size. */
const INLINE_ACTIONS = ["summary", "report", "quiz", "flashcards", "mind_map", "launch_kit", "infographic"];
/** A summary in its card is a title and a way in: it opens full screen (like the Mind Constellation) and can be minimised. */
function SummaryBody({ artifact, onCite }: { artifact: Artifact; onCite?: (c: Citation) => void }) {
const [open, setOpen] = useState(false);
const c = artifact.content;
const themes = (c.themes as string[] | undefined) ?? [];
return (
{themes.length > 0 &&
{themes.length} themes · {(c.citations ?? []).length} citations
}
setOpen(true)}>Open summary
{open && (
setOpen(false)}>
{themes.length > 0 && (
{themes.map((t) => {t} )}
)}
)}
);
}
/** The player, image or text of a finished artifact (nothing while it is still being made). */
export function Body({ artifact, onCite }: { artifact: Artifact; onCite?: (c: Citation) => void }) {
const [showTranscript, setShowTranscript] = useState(false);
const [audioEl, setAudioEl] = useState(null);
const [exploring, setExploring] = useState(false);
const c = artifact.content;
if (artifact.status !== "ready") return null;
switch (artifact.type) {
case "summary":
return ;
case "audio_overview":
return (
{artifact.url &&
}
{c.segments?.length > 0 && (
<>
setShowTranscript((v) => !v)}>
{showTranscript ? "Hide transcript" : `Show transcript (${c.segments.length} lines)`}
{showTranscript && (
{
if (audioEl) {
audioEl.currentTime = t;
void audioEl.play();
}
}}
/>
)}
>
)}
);
case "video":
if (artifact.provider === "blog2video") {
return (
{artifact.url && }
Open editor
);
}
return artifact.url ? (
) : null;
case "quote_card":
return artifact.url ? (
) : null;
case "carousel":
return (
{artifact.slide_urls.map((u, i) => (
))}
);
case "mind_map":
return (
{c.node_count ?? 0} stars across {c.post_count ?? 0} posts.
setExploring(true)}>
Open Mind Constellation
{exploring &&
setExploring(false)} />}
);
case "quiz":
return ;
case "flashcards":
return ;
case "report":
return ;
case "infographic":
return ;
case "launch_kit":
return (
{c.claims?.length ?? 0} claims, {c.hooks?.length ?? 0} hooks, posts for 4 platforms, SEO pack and carousel.
Open Launch Kit
);
default:
return null;
}
}
/** Any generated artifact: live progress while it runs, then the right player or preview. */
export function ArtifactCard({
artifact: initial,
onCite,
onRemoved,
onChanged,
actions,
}: {
artifact: Artifact;
onCite?: (c: Citation) => void;
onRemoved?: (id: string) => void;
onChanged?: (a: Artifact) => void;
actions?: (a: Artifact) => ReactNode;
}) {
const [artifact, setArtifact] = useState(initial);
const [error, setError] = useState(null);
const job = useJob(artifact.job, () => {
artifactsApi.get(artifact.id).then((a) => {
setArtifact(a);
onChanged?.(a);
});
});
const running = job && (job.status === "queued" || job.status === "running");
const failedMessage = artifact.status === "failed" ? (artifact.content.error as string | undefined) ?? job?.error : null;
return (
{running && job && }
{failedMessage && {failedMessage}
}
{error && {error}
}
{actions?.(artifact)}
{artifact.download_url && artifact.type !== "infographic" && (
Download
)}
{artifact.status === "failed" && (
{
setError(null);
try {
setArtifact(await artifactsApi.retry(artifact.id));
} catch (e) {
setError(errorMessage(e));
}
}}
>
Retry
)}
{onRemoved && (
{
// A blog2video video is also removed from blog2video.
await (artifact.provider === "blog2video" ? videosApi.remove(artifact.id) : artifactsApi.remove(artifact.id));
onRemoved(artifact.id);
}}
>
Delete
)}
);
}