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>
  );
}