/* 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 (
av && setProgram(p)}
disabled={!av}
style={!av ? { opacity: 0.45, cursor: "not-allowed" } : undefined}>
{p}{!av && 준비 중 }
);
})}
setSubject(e.target.value)}>
{subjectsForProgram.map(s => (
{s}{dbSubj.has(s) ? "" : " · DB 비어있음"}
))}
{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 (
onChange(e.target.checked)} />
답지 포함 (해제 시 PDF에 미출력 — 히스토리에선 확인 가능)
);
}
// ---------- 단원자동 ----------
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 && setUnits([])}>선택 해제 }
{cb_units.length === 0 ? (
{subject} 의 taxonomy 단원을 찾을 수 없어요.
) : (
{cb_units.map(u => {
const c = counts[u] || 0;
return (
toggleUnit(u)} />
{u} ({c}{c ? "" : " — 라벨된 문제 없음"})
);
})}
)}
{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을 줄여보세요. }
{busy ? "생성 중…" : "문제집 PDF 생성"}
);
}
// ---------- 직접 선택 ----------
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 && setPicks(new Set())}>선택 해제 }
{(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} />
{busy ? "생성 중…" : `문제집 PDF 생성 (${picks.size}문제)`}
);
}
// ---------- 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}}>
);
}
// ---------- 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 (
setOpen(o => !o)}>
{open ? : }
{allMode
? <>전체 선택 · {opts.length}개 파일 전체에서 샘플 >
: <>{value.length}개 선택됨 / {opts.length} >}
{open ? "접기" : "파일 직접 선택"}
{open && (
)}
);
}
function UnitMultiPicker({ opts, value, onChange }) {
if (opts.length === 0) {
return 라벨된 단원이 아직 없어요.
;
}
return (
onChange(Array.from(e.target.selectedOptions).map(o => o.value))}>
{opts.map(o => {o} )}
);
}
Object.assign(window, { WorkbookPage });