import { useEffect, useRef, useState, useSyncExternalStore, type ReactNode } from "react"; import { artifactsApi } from "../api/endpoints"; import type { AnswerFeedback as Feedback, Artifact } from "../api/types"; import { Thumb } from "./AnswerFeedback"; import { errorMessage } from "./ui"; /** What can be rated: finished reports, quizzes, flashcard sets and infographics. */ export const RATEABLE = ["report", "quiz", "flashcards", "infographic"]; /** Why a thumbs down, per kind (the ids the server keeps; see services/artifact_feedback.py). */ const REASONS: Record = { report: [ { id: "inaccurate", label: "Inaccurate or made up" }, { id: "missed_points", label: "Missed key points" }, { id: "structure", label: "Poorly structured" }, { id: "tone", label: "Wrong tone or style" }, { id: "too_long", label: "Too long" }, { id: "too_short", label: "Too short" }, { id: "other", label: "Something else" }, ], quiz: [ { id: "wrong_answers", label: "Wrong answers" }, { id: "too_easy", label: "Too easy" }, { id: "too_hard", label: "Too hard" }, { id: "unclear", label: "Unclear questions" }, { id: "off_topic", label: "Not from my material" }, { id: "other", label: "Something else" }, ], flashcards: [ { id: "inaccurate", label: "Inaccurate" }, { id: "too_basic", label: "Too basic" }, { id: "too_detailed", label: "Too detailed" }, { id: "unclear", label: "Unclear wording" }, { id: "other", label: "Something else" }, ], infographic: [ { id: "messy_layout", label: "Messy layout" }, { id: "cut_off", label: "Text cut off or overlapping" }, { id: "missing_content", label: "Wrong or missing content" }, { id: "look", label: "The look does not suit" }, { id: "hard_to_read", label: "Hard to read" }, { id: "other", label: "Something else" }, ], }; // A rating is shown wherever its artifact is (the card, the viewer, the results), so they share one copy. const ratings = new Map(); const listeners = new Set<() => void>(); const subscribe = (fn: () => void) => { listeners.add(fn); return () => listeners.delete(fn); }; function put(id: string, fb: Feedback | null) { ratings.set(id, fb); listeners.forEach((l) => l()); } /** Thumbs up and thumbs down on a generated report, quiz, flashcard set or infographic, so it is clear which ones are * good. A thumbs down asks why (optional). Saved as soon as it is chosen; shared by every place the artifact shows. * `compact` opens the reasons over the page instead of pushing it down. */ export function ArtifactFeedback({ artifact, label, compact = false }: { artifact: Artifact; label?: ReactNode; compact?: boolean }) { const initial = artifact.feedback ?? null; const fb = useSyncExternalStore(subscribe, () => (ratings.has(artifact.id) ? ratings.get(artifact.id)! : initial), () => initial); const [open, setOpen] = useState(false); const [reasons, setReasons] = useState(fb?.reasons ?? []); const [comment, setComment] = useState(fb?.comment ?? ""); const [error, setError] = useState(null); const [thanks, setThanks] = useState(false); const options = REASONS[artifact.type] ?? []; const whyRef = useRef(null); useEffect(() => { if (open) whyRef.current?.scrollIntoView({ block: "nearest", behavior: "smooth" }); // in a fixed size popup it can open below the fold }, [open, fb?.rating]); if (!RATEABLE.includes(artifact.type) || artifact.status !== "ready") return null; const save = async (rating: "up" | "down" | null, r: string[] = [], c = "") => { const before = fb; setError(null); put(artifact.id, rating ? { rating, reasons: r, comment: c || null } : null); // shows at once; undone if the save fails try { const res = await artifactsApi.rate(artifact.id, { rating, reasons: r, comment: c || undefined }); put(artifact.id, res.feedback); } catch (e) { put(artifact.id, before); setError(errorMessage(e)); } }; const choose = (rating: "up" | "down") => { setThanks(false); if (fb?.rating === rating) { setOpen(false); void save(null); return; } setOpen(true); // both ratings can take a few words (a thumbs down also asks why) if (rating === "up") { setReasons([]); setComment(""); } void save(rating, rating === "down" ? reasons : []); }; return (
{label && {label}} {thanks && Thanks, saved.} {error && Could not save that. Try again.}
{!open && fb && (fb.comment || fb.reasons.length > 0) && (
Your review {fb.reasons.length > 0 && (
{fb.reasons.map((id) => {options.find((o) => o.id === id)?.label ?? id})}
)} {fb.comment &&

{fb.comment}

}
)} {open && fb && (

{fb.rating === "up" ? "What worked well? (optional)" : "What was wrong? (optional)"}

{fb.rating === "down" &&
{options.map((r) => ( ))}
}