/* blueyetutor — /workbook * * Restructure: a single "config" card at the top picks * 1) 과목 카테고리 (PROGRAMS: AP / IB / A-Level) * 2) 세부 과목 (e.g. Physics 1, Calculus BC) * 3) 문제집 종류 토글 (Segmented: 단원별 / 직접선택 / Mock Exam) * Below: the kind-specific form (units / pick / mock) operates on the * subject chosen above. Each sub-component receives `subject` as a prop. * * Assumed endpoint (build): * POST /api/workbooks/build { kind, program, subject, qtype, units?, pdf_ids?, * n?, n_mcq?, n_frq?, problem_ids?, tag, * answers_included } */ const { useState: wbUseState, useMemo: wbUseMemo, useEffect: wbUseEffect } = React; function WorkbookPage() { const store = useStore(); const programs = Object.keys(PROGRAMS); const [program, setProgram] = wbUseState("AP"); const subjectsForProgram = PROGRAMS[program].subjects; const [subject, setSubject] = wbUseState(subjectsForProgram[0] || ""); const [kind, setKind] = wbUseState("auto"); // Top-level sub-tab: 만들기 / history (legacy #/history 도 ?tab=history 로 redirect). const initialTab = (typeof hashQueryParam === "function" && hashQueryParam("tab")) || "make"; const [tab, setTab] = wbUseState(initialTab === "history" ? "history" : "make"); // when program changes, reset subject to the first available in that program wbUseEffect(() => { const subs = PROGRAMS[program].subjects; if (!subs.includes(subject)) setSubject(subs[0] || ""); }, [program]); const dbSubj = new Set(store.pdfs.map(p => p.subject)); const subjectIsReady = PROGRAMS[program].available && dbSubj.has(subject); return (
Workbook

문제집을 만들고 기록을 확인합니다

{tab === "history" ? : ( <>
{programs.map(p => { const av = PROGRAMS[p].available; return ( ); })}
{kind === "auto" && "선택한 단원에서 자동으로 문제를 샘플링해 한 권으로 묶습니다."} {kind === "pick" && "라벨링된 풀에서 카드를 직접 클릭해 개별 문항을 모읍니다."} {kind === "mock" && "College Board 형식 (40 MCQ + 4 FRQ, 3시간) 으로 한 권의 모의시험을 구성합니다."}
{!PROGRAMS[program].available ? ( {program} 파이프라인은 추후 합류 예정입니다. 지금은 AP 과목군만 사용할 수 있어요. ) : ( <> {kind === "auto" && } {kind === "pick" && } {kind === "mock" && } )} )}
); } // 만든 문제집 기록 — Workbook 페이지 안 sub-tab. 이전 #/history 페이지 내용을 // 단순히 이쪽으로 옮긴 것. HistoryRow / HistoryAnswers 는 History.jsx 에서 그대로 // 재사용 (window 전역으로 노출돼 있음). function WorkbookHistorySection() { const store = useStore(); const rows = store.history || []; if (rows.length === 0) { return ; } return (
{rows.map(w => )}
); } Object.assign(window, { WorkbookHistorySection }); // 문제집 PDF 합성은 백엔드 subprocess(build_workbook.py) 동기 호출 — 큰 PDF // 일수록 분 단위로 걸린다. 그 동안 페이지가 멈춰 보이지 않도록 명시적인 // fullscreen 오버레이로 진행 중임을 알리고 다른 클릭을 막는다. backdrop 의 // onClick 은 비워 둬서 우발적 dismiss 도 막는다 (취소 = 페이지 이동). function BuildingOverlay({ open }) { if (!open) return null; return (
e.stopPropagation()}>
현재 문제집을 생성중입니다…
PDF 합성·렌더링에 시간이 좀 걸려요.
페이지를 닫지 말고 잠시만 기다려 주세요.
); } function AnswersToggle({ value, onChange }) { return ( ); } // ---------- 단원자동 ---------- function WbAuto({ subject }) { const store = useStore(); const [pdfIds, setPdfIds] = wbUseState([]); const [qtype, setQtype] = wbUseState("MCQ"); const [units, setUnits] = wbUseState([]); const [tag, setTag] = wbUseState("web-" + new Date().toISOString().slice(0, 10)); const [incAns, setIncAns] = wbUseState(true); wbUseEffect(() => { setUnits([]); setPdfIds([]); }, [subject, qtype]); const cb_units = TAXONOMY[subject] || []; const both = qtype === "MCQ + FRQ"; const mcq_c = wbUseMemo(() => window.unitsFor(subject, "MCQ", pdfIds), [subject, pdfIds, store.problems]); const frq_c = wbUseMemo(() => window.unitsFor(subject, "FRQ", pdfIds), [subject, pdfIds, store.problems]); const counts = both ? Object.fromEntries(cb_units.map(u => [u, (mcq_c[u] || 0) + (frq_c[u] || 0)])) : qtype === "MCQ" ? mcq_c : frq_c; const poolMcq = units.length ? units.reduce((s, u) => s + (mcq_c[u] || 0), 0) : Object.values(mcq_c).reduce((a, b) => a + b, 0); const poolFrq = units.length ? units.reduce((s, u) => s + (frq_c[u] || 0), 0) : Object.values(frq_c).reduce((a, b) => a + b, 0); const poolN = units.length ? units.reduce((s, u) => s + (counts[u] || 0), 0) : Object.values(counts).reduce((a, b) => a + b, 0); const [n, setN] = wbUseState(10); const [nMcq, setNMcq] = wbUseState(10); const [nFrq, setNFrq] = wbUseState(2); const pdfOpts = store.pdfs.filter(p => p.subject === subject); const ok = both ? (nMcq <= poolMcq && nFrq <= poolFrq && nMcq + nFrq > 0) : (poolN >= n); const toggleUnit = u => setUnits(s => s.includes(u) ? s.filter(x => x !== u) : [...s, u]); // §122 calculator filter (Calc subjects 전용 — Physics 등은 토글 안 보임) const isCalcSubject = /^Calculus\b/i.test(subject || ""); const [calculator, setCalculator] = wbUseState("both"); wbUseEffect(() => { if (!isCalcSubject) setCalculator("both"); }, [isCalcSubject]); const [busy, setBusy] = wbUseState(false); const build = async () => { setBusy(true); const r = await window.storeActions.buildWorkbook({ mode: "auto", subject, qtype: both ? "BOTH" : qtype, units, pdf_ids: pdfIds, n, n_mcq: nMcq, n_frq: nFrq, tag, answers: incAns, calculator: isCalcSubject && calculator !== "both" ? calculator : undefined, }); setBusy(false); if (r) window.storeActions.downloadWorkbook(r.workbook_id, r.file_name); }; return ( {subject}}>
단원 {units.length > 0 && }
{cb_units.length === 0 ? ( {subject} 의 taxonomy 단원을 찾을 수 없어요. ) : (
{cb_units.map(u => { const c = counts[u] || 0; return ( ); })}
)}
{both ? ( <>
풀 — MCQ {poolMcq} {" · "}FRQ {poolFrq} {" "}({units.length ? "선택 단원" : "과목 전체"})
MCQ 문제 수 — } className="grow"> setNMcq(+e.target.value)} className="stretch" /> FRQ 문제 수 — } className="grow"> setNFrq(+e.target.value)} className="stretch" />
) : ( <>
{poolN} ({qtype})
문제 수 — }> setN(+e.target.value)} className="stretch" /> )} setTag(e.target.value)} maxLength={40} /> {isCalcSubject && ( 계산기 (CB 표준: Part A no-calc / Part B calc)}> )} {!ok && 풀 크기가 요청보다 작아요. 단원을 더 고르거나 N을 줄여보세요.}
); } // ---------- 직접 선택 ---------- function WbPick({ subject }) { const store = useStore(); const [qtype, setQtype] = wbUseState("MCQ"); const [filterUnits, setFilterUnits] = wbUseState([]); const [picks, setPicks] = wbUseState(new Set()); const [tag, setTag] = wbUseState("pick-" + new Date().toISOString().slice(0, 10)); const [incAns, setIncAns] = wbUseState(true); wbUseEffect(() => { setFilterUnits([]); setPicks(new Set()); }, [subject, qtype]); const cb_units = TAXONOMY[subject] || []; const counts = wbUseMemo(() => window.unitsFor(subject, qtype === "MCQ + FRQ" ? "MCQ" : qtype), [subject, qtype, store.problems]); const unitOpts = cb_units.filter(u => counts[u] > 0).map(u => `${u} (${counts[u]})`); const unitMap = Object.fromEntries(cb_units.map(u => [`${u} (${counts[u] || 0})`, u])); const problems = wbUseMemo(() => { const both = qtype === "MCQ + FRQ"; if (both) { return [ ...window.browseProblems(subject, "MCQ", filterUnits), ...window.browseProblems(subject, "FRQ", filterUnits), ]; } return window.browseProblems(subject, qtype, filterUnits); }, [subject, qtype, filterUnits, store.problems]); const toggle = id => { const next = new Set(picks); if (next.has(id)) next.delete(id); else next.add(id); setPicks(next); }; const mcqs = problems.filter(p => p.qtype === "MCQ"); const frqs = problems.filter(p => p.qtype === "FRQ"); const names = Object.fromEntries(store.pdfs.map(p => [p.pdf_id, p.display_name || p.pdf_id])); const [busy, setBusy] = wbUseState(false); const build = async () => { if (picks.size === 0) return; const ordered = problems.filter(p => picks.has(p.id)).map(p => p.id); setBusy(true); const r = await window.storeActions.buildWorkbook({ mode: "pick", subject, qtype, problem_ids: ordered, tag, answers: incAns, }); setBusy(false); if (r) { window.storeActions.downloadWorkbook(r.workbook_id, r.file_name); setPicks(new Set()); } }; return ( {subject}}>
`${u} (${counts[u] || 0})`)} onChange={vals => setFilterUnits(vals.map(v => unitMap[v]).filter(Boolean))} />
{picks.size} 선택됨 / {problems.length} 표시 중 {picks.size > 0 && }
{(qtype === "MCQ" || qtype === "MCQ + FRQ") && mcqs.length > 0 && (

MCQ ({mcqs.length})

{mcqs.map(p => ( toggle(p.id)} source={names[p.pdf_id]} /> ))}
)} {(qtype === "FRQ" || qtype === "MCQ + FRQ") && frqs.length > 0 && (

FRQ ({frqs.length})

{frqs.map(p => ( toggle(p.id)} source={names[p.pdf_id]} scoringPaths={p.scoring_paths} /> ))}
)} setTag(e.target.value)} maxLength={40} />
); } // ---------- Mock(2026) ---------- function WbMock({ subject }) { const store = useStore(); const [pdfIds, setPdfIds] = wbUseState([]); wbUseEffect(() => { setPdfIds([]); }, [subject]); const pdfOpts = store.pdfs.filter(p => p.subject === subject); const mcq_have = wbUseMemo(() => window.subjectPool(subject, "MCQ", pdfIds), [subject, pdfIds, store.problems]); const frq_have = wbUseMemo(() => window.subjectPool(subject, "FRQ", pdfIds), [subject, pdfIds, store.problems]); const [nMcq, setNMcq] = wbUseState(MOCK_SPEC.n_mcq); const [nFrq, setNFrq] = wbUseState(MOCK_SPEC.n_frq); const [tag, setTag] = wbUseState("mock-" + new Date().toISOString().slice(0, 10)); const [incAns, setIncAns] = wbUseState(true); const ok = mcq_have >= nMcq && frq_have >= nFrq && (nMcq + nFrq) > 0; const gaps = []; if (mcq_have < MOCK_SPEC.n_mcq) gaps.push(`MCQ ${MOCK_SPEC.n_mcq - mcq_have}개 부족`); if (frq_have < MOCK_SPEC.n_frq) gaps.push(`FRQ ${MOCK_SPEC.n_frq - frq_have}개 부족`); // §122 calculator filter (Calc subjects 전용) const isCalcSubject = /^Calculus\b/i.test(subject || ""); const [calculator, setCalculator] = wbUseState("both"); wbUseEffect(() => { if (!isCalcSubject) setCalculator("both"); }, [isCalcSubject]); const [busy, setBusy] = wbUseState(false); const build = async () => { setBusy(true); const r = await window.storeActions.buildWorkbook({ mode: "mock", subject, pdf_ids: pdfIds, n_mcq: nMcq, n_frq: nFrq, tag, answers: incAns, calculator: isCalcSubject && calculator !== "both" ? calculator : undefined, }); setBusy(false); if (r) window.storeActions.downloadWorkbook(r.workbook_id, r.file_name); }; return ( {subject}}>
기본 {MOCK_SPEC.n_mcq} MCQ + {MOCK_SPEC.n_frq} FRQ, {MOCK_SPEC.duration_minutes}분. 풀이 부족하면 고급 옵션으로 수를 줄이거나, 업로드를 더 하세요.
= MOCK_SPEC.n_mcq} delta={mcq_have - MOCK_SPEC.n_mcq} deltaKind={mcq_have >= MOCK_SPEC.n_mcq ? "ok" : "bad"} /> = MOCK_SPEC.n_frq} delta={frq_have - MOCK_SPEC.n_frq} deltaKind={frq_have >= MOCK_SPEC.n_frq ? "ok" : "bad"} />
{gaps.length > 0 && ( {subject} 의 라벨된 풀이 부족 — {gaps.join(", ")}. 더 많은 PDF를 업로드/라벨링한 뒤 재시도하세요. )}
고급 — 문항 수 override
setNMcq(+e.target.value)} /> setNFrq(+e.target.value)} />
setTag(e.target.value)} maxLength={40} /> {isCalcSubject && ( 계산기 (CB 표준: Part A no-calc / Part B calc)}> )}
); } // ---------- shared sub-components ---------- // Click the number → type it directly (slider stays the source-of-truth // control; this is just a faster way to set an exact value). Commits on // Enter/blur, clamped to [min,max]; Esc cancels. function EditableNum({ value, min, max, onChange }) { const [editing, setEditing] = wbUseState(false); const [draft, setDraft] = wbUseState(String(value)); wbUseEffect(() => { setDraft(String(value)); }, [value]); const commit = () => { let v = parseInt(draft, 10); if (isNaN(v)) v = value; v = Math.max(min, Math.min(max, v)); onChange(v); setEditing(false); }; if (editing) { return ( setDraft(e.target.value)} onFocus={e => e.target.select()} onBlur={commit} onKeyDown={e => { if (e.key === "Enter") commit(); else if (e.key === "Escape") { setDraft(String(value)); setEditing(false); } }} style={{ width: 64, display: "inline-block", padding: "1px 6px", fontSize: 12, verticalAlign: "baseline" }} /> ); } return ( setEditing(true)} title="클릭해서 직접 입력" style={{ cursor: "text", fontWeight: 700, color: "var(--navy)", textDecoration: "underline dotted", textUnderlineOffset: 3 }} >{value} ); } // Default = "전체 선택" (value === [] → backend samples from all files). // The file list stays collapsed until the user opens it; once there are // many PDFs the search box keeps it usable. function FilePickerMulti({ opts, value, onChange }) { const [open, setOpen] = wbUseState(false); const [q, setQ] = wbUseState(""); if (opts.length === 0) { return
이 과목으로 처리된 PDF가 아직 없어요.
; } const allMode = value.length === 0; const nameOf = p => p.display_name || p.pdf_id; const needle = q.trim().toLowerCase(); const filtered = needle ? opts.filter(p => nameOf(p).toLowerCase().includes(needle)) : opts; const toggle = id => onChange(value.includes(id) ? value.filter(x => x !== id) : [...value, id]); return (
{open && (
setQ(e.target.value)} />
{filtered.length === 0 ? (
검색 결과 없음
) : filtered.map(p => ( ))}
)}
); } function UnitMultiPicker({ opts, value, onChange }) { if (opts.length === 0) { return
라벨된 단원이 아직 없어요.
; } return ( ); } Object.assign(window, { WorkbookPage });