Spaces:
Runtime error
Runtime error
File size: 6,657 Bytes
0649f1b 66de9c8 0649f1b 66de9c8 0649f1b 66de9c8 0649f1b 66de9c8 0649f1b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 | import { useCallback, useEffect, useMemo, useState } from "react";
import type { GenerateBody } from "../api/endpoints";
import type { Artifact, ChatSummary, FlashcardData } from "../api/types";
import { Pills, SourceFocusFields, sourceSummary, type SourceSelection } from "./SourceFocusFields";
import { ArtifactFeedback } from "./ArtifactFeedback";
import { StudyOverlay } from "./StudyOverlay";
import { Modal } from "./ui";
const LEVELS = [
{ id: "easy", label: "Easy" },
{ id: "medium", label: "Medium (Default)" },
{ id: "hard", label: "Hard" },
] as const;
export type FlashcardsRequest = Omit<GenerateBody, "type">;
/** The flashcard settings: the same source and focus fields as the quiz and the video wizard, then how many cards. */
export function FlashcardsDialog({ notebookId, notebookTitle, chats, currentChatId, busy, error, onClose, onCreate }: {
notebookId: string;
notebookTitle?: string;
chats: ChatSummary[];
currentChatId: string | null;
busy: boolean;
error: string | null;
onClose: () => void;
onCreate: (body: FlashcardsRequest) => void;
}) {
const [sel, setSel] = useState<SourceSelection | null>(null);
const [level, setLevel] = useState<(typeof LEVELS)[number]["id"]>("medium");
return (
<Modal title="Flashcards" onClose={onClose} wide>
<div className="stack vw-in-modal">
<section className="stack vw qz-setup">
<div className="field">
<span className="vw-label">Level of difficulty</span>
<Pills label="Level of difficulty" value={level} options={LEVELS} onChange={setLevel} />
</div>
<SourceFocusFields notebookId={notebookId} notebookTitle={notebookTitle} chats={chats} currentChatId={currentChatId}
preselectChat what="flashcard set" largeSelection onChange={setSel} />
<div className="qz-foot">
<div>
{!sel?.ready && <p className="muted small">Select at least one post or chat to generate flashcards.</p>}
{error && <p className="error-text">{error}</p>}
</div>
<button type="button" className="btn btn-primary vw-next" disabled={busy || !sel?.ready || sel.loading}
onClick={() => sel && onCreate({ ...sel.request, difficulty: level })}>
{busy ? "Starting..." : "Generate Flashcards"}
</button>
</div>
</section>
</div>
</Modal>
);
}
const shuffled = <T,>(list: T[]): T[] => {
const a = [...list];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
};
/** A deck you flip through: tap the card (or press Space) to flip, mark it known or to review, arrows to move. Used on
* its own, in a modal, and inside a report. */
export function FlashcardDeck({ cards }: { cards: FlashcardData[] }) {
const [order, setOrder] = useState(() => cards.map((_, i) => i));
const [pos, setPos] = useState(0);
const [flipped, setFlipped] = useState(false);
const [known, setKnown] = useState<Set<number>>(new Set());
const card = cards[order[pos]];
const go = useCallback((d: number) => {
setFlipped(false);
setPos((p) => Math.min(Math.max(p + d, 0), order.length - 1));
}, [order.length]);
const onKey = useCallback((e: KeyboardEvent) => {
const tag = (e.target as HTMLElement | null)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return;
if (e.key === "ArrowRight") go(1);
else if (e.key === "ArrowLeft") go(-1);
}, [go]);
useEffect(() => {
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onKey]);
const left = useMemo(() => order.filter((i) => !known.has(i)).length, [order, known]);
if (!card) return <p className="muted">No cards.</p>;
const mark = (isKnown: boolean) => {
setKnown((cur) => {
const next = new Set(cur);
if (isKnown) next.add(order[pos]);
else next.delete(order[pos]);
return next;
});
if (pos < order.length - 1) go(1);
else setFlipped(false);
};
return (
<div className="fc-deck">
<p className="mono muted small fc-count">Card {pos + 1} of {order.length} 路 {known.size} known 路 {left} to review</p>
<button type="button" className={`fc-card${flipped ? " flipped" : ""}`} aria-label={flipped ? "Show the front" : "Show the answer"}
onClick={() => setFlipped((f) => !f)}>
<span className="fc-side">{flipped ? "Answer" : "Card"}</span>
<span className="fc-text">{flipped ? card.back : card.front}</span>
{flipped && card.sources?.length > 0 && (
<span className="mono muted small fc-src">
{card.sources.slice(0, 2).map((s) => `${s.title}, lines ${s.line_start}${s.line_end > s.line_start ? ` to ${s.line_end}` : ""}`).join(" 路 ")}
</span>
)}
{!flipped && <span className="muted small fc-hint">Tap to flip</span>}
</button>
<div className="fc-actions">
<button type="button" className="btn btn-small" onClick={() => go(-1)} disabled={pos === 0}>Previous</button>
<button type="button" className="btn btn-small" onClick={() => mark(false)}>Review again</button>
<button type="button" className="btn btn-small btn-primary" onClick={() => mark(true)}>Got it</button>
<button type="button" className="btn btn-small" onClick={() => go(1)} disabled={pos === order.length - 1}>Next</button>
<button type="button" className="btn btn-small" onClick={() => { setOrder(shuffled(order)); setPos(0); setFlipped(false); }}>Shuffle</button>
</div>
</div>
);
}
export function FlashcardsPlayer({ artifact, onClose }: { artifact: Artifact; onClose: () => void }) {
return (
<StudyOverlay title={artifact.title} onClose={onClose}>
<FlashcardDeck cards={(artifact.content.cards ?? []) as FlashcardData[]} />
<ArtifactFeedback artifact={artifact} label="How are these cards?" />
</StudyOverlay>
);
}
/** What a finished flashcard set shows in a card: its size and a way in. */
export function FlashcardsBody({ artifact }: { artifact: Artifact }) {
const [open, setOpen] = useState(false);
const c = artifact.content;
const from = sourceSummary(c.source);
return (
<div className="artifact-body">
<p className="muted">{c.card_count ?? 0} cards 路 from {from}</p>
<button type="button" className="btn btn-small" onClick={() => setOpen(true)}>Study cards</button>
{open && <FlashcardsPlayer artifact={artifact} onClose={() => setOpen(false)} />}
</div>
);
}
|