/* blueyetutor — shared UI atoms. * Pure presentation. No store/data imports. Each component named for * grep-ability. Style objects (if any) are named with the component prefix * to avoid global-scope name collisions across babel scripts. */ const { useState, useEffect, useRef, useMemo, useCallback } = React; // ---- Tabs (controlled) ---------------------------------------------------- function Tabs({ tabs, value, onChange }) { return (
{tabs.map(t => ( ))}
); } // ---- Segmented control ---------------------------------------------------- function Segmented({ options, value, onChange, className }) { return (
{options.map(o => { const v = typeof o === "string" ? o : o.value; const l = typeof o === "string" ? o : o.label; const d = typeof o === "object" && o.disabled; return ( ); })}
); } // ---- Button --------------------------------------------------------------- function Button({ children, variant = "default", size, block, ...rest }) { const cls = [ "btn", variant === "primary" ? "primary" : "", variant === "danger" ? "danger" : "", variant === "ghost" ? "ghost" : "", size === "sm" ? "sm" : "", size === "lg" ? "lg" : "", block ? "block" : "", ].filter(Boolean).join(" "); return ; } // ---- Field wrapper -------------------------------------------------------- function Field({ label, help, children, className }) { return (
{label && } {children} {help &&
{help}
}
); } // ---- EmptyState ----------------------------------------------------------- function EmptyState({ title, hint, action }) { return (
{title}
{hint &&
{hint}
} {action &&
{action}
}
); } // ---- Banner --------------------------------------------------------------- function Banner({ kind = "info", title, children }) { return (
{title &&

{title}

}

{children}

); } // ---- Badge ---------------------------------------------------------------- function Badge({ children, kind = "", mono, ...rest }) { return {children}; } // ---- Metric --------------------------------------------------------------- function Metric({ label, value, delta, deltaKind, highlight }) { return (
{label}
{value}
{delta != null &&
{delta}
}
); } // ---- Card ----------------------------------------------------------------- function Card({ title, actions, padding = true, children, className }) { return (
{(title || actions) && (

{title}

{actions}
)}
{children}
); } // ---- Modal ---------------------------------------------------------------- function Modal({ open, title, onClose, children, footer, width }) { useEffect(() => { if (!open) return; const onKey = e => { if (e.key === "Escape") onClose && onClose(); }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); }, [open, onClose]); if (!open) return null; return (
e.stopPropagation()}>

{title}

{children}
{footer &&
{footer}
}
); } // ---- Lightbox ------------------------------------------------------------- // Fullscreen image viewer. Shows the crop as large as the viewport allows; // if the image is still bigger it scrolls inside the dark stage. Esc / click // outside / the × closes it. function Lightbox({ open, src, title, onClose }) { useEffect(() => { if (!open) return; const onKey = e => { if (e.key === "Escape") onClose && onClose(); }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); }, [open, onClose]); if (!open || !src) return null; // Rendered inside .problem (which may have a select-toggle onClick), so the // root must swallow clicks: stopPropagation here keeps card selection from // firing, and a click anywhere off the image closes the viewer. return (
{ e.stopPropagation(); onClose && onClose(); }}>
{title}
{title e.stopPropagation()} />
); } // ---- Progress ------------------------------------------------------------- function Progress({ value, indeterminate }) { if (indeterminate) { return
; } const pct = Math.max(0, Math.min(100, value || 0)); return
; } // ---- CropPlaceholder ------------------------------------------------------ // Renders a striped SVG-ish placeholder labeled with the path — the live UI // will swap this for . function CropPlaceholder({ kind = "mcq", label }) { return (
{label || "crop placeholder"}
); } // ---- CropImg — real cropped-problem image (v2 wire-up) ------------------- // Authed PNG from the backend (combined figure+stem, stimulus banner where // applicable). Falls back to the striped placeholder while loading / on 404. function CropImg({ src, kind = "mcq", label }) { const [err, setErr] = useState(false); if (!src || err) return ; // NOT the .crop placeholder box — that imposes aspect-ratio:4/3 + flex // centering which squashes a tall stimulus composite in a 1/3 grid cell. // The PNG is already pre-scaled (DISPLAY_SCALE) for legible on-screen text; // render at its true aspect, just bounded to the card width. return (
{label setErr(true)} />
); } // ---- ProblemHead — shared header for one problem card --------------------- // `editable` + `onEditAnswer` / `onEditSubject` → 답 / 과목 Badge 가 // 클릭 가능한 inline editor 로 바뀐다. 그 외 호출자는 prop 안 넘김 → 표시만. function ProblemHead({ qnum, qtype, subject, answer, units, difficulty, source, dim, editable, onEditAnswer, onEditSubject, calculator, sectionPart }) { const [editing, setEditing] = useState(false); const [val, setVal] = useState(answer || ""); const [subjOpen, setSubjOpen] = useState(false); const [subjBusy, setSubjBusy] = useState(false); const [subjVal, setSubjVal] = useState(subject || ""); const isMcq = qtype === "MCQ"; const allSubjects = Object.keys((window.TAXONOMY) || {}); useEffect(() => { setVal(answer || ""); }, [answer]); useEffect(() => { setSubjVal(subject || ""); }, [subject]); const commit = async () => { const v = (val || "").trim().toUpperCase(); if (v === (answer || "")) { setEditing(false); return; } if (onEditAnswer) await onEditAnswer(v); setEditing(false); }; const commitSubj = async () => { if (!subjVal || subjVal === subject) { setSubjOpen(false); return; } setSubjBusy(true); if (onEditSubject) await onEditSubject(subjVal); setSubjBusy(false); setSubjOpen(false); }; // §124 — Calc 류 (calculator !== undefined && !== null) 일 때 Part 배지 표시. // 'B'/calculator=true = 계산기 허용 / 'A'/false = 금지. section_part 없으면 // calculator 값만으로 표시 ('계산기' / 'no calc'). const calcLabel = (() => { if (calculator == null) return null; const part = sectionPart ? `Part ${sectionPart}` : null; const tag = calculator ? "🖩 계산기" : "✖ no-calc"; return part ? `${part} · ${tag}` : tag; })(); return (
Q{qnum}{qtype === "FRQ" ? " (FRQ)" : ""} {calcLabel && ( {calcLabel} )} {/* 과목 Badge: editable 면 클릭해서 변경 가능 (이 문제만, PDF 전체와 별개). */} {editable && onEditSubject ? ( subjOpen ? ( ) : ( ) ) : ( subject && {subject} )} {/* MCQ 답: editable 모드면 클릭/입력 가능, 아니면 표시만. */} {isMcq && editable ? ( editing ? ( setVal(e.target.value)} onBlur={commit} onKeyDown={e => { if (e.key === "Enter") commit(); else if (e.key === "Escape") { setVal(answer || ""); setEditing(false); } }} style={{ width: 60, padding: "1px 6px", fontSize: 12 }} /> ) : ( ) ) : ( answer && {answer} )} {difficulty != null && 난이도 {difficulty}} {source && {dim ? "" : source}}
); } // ---- UnitEditor ----------------------------------------------------------- // Click chip → modal multi-select from CB taxonomy[subject]. Save calls // `onSave(units[])` which the page wires to `setProblemUnits` (PATCH). function UnitEditor({ problem, onSave }) { const [open, setOpen] = useState(false); const [draft, setDraft] = useState(problem.units || []); useEffect(() => { setDraft(problem.units || []); }, [problem.units]); const subj = problem.subject || ""; const opts = (window.TAXONOMY[subj]) || []; const cur = problem.units || []; const toggle = u => setDraft(d => d.includes(u) ? d.filter(x => x !== u) : [...d, u]); return ( <> setOpen(true)} title="단원 수정" > {cur.length ? cur.join(", ") : "단원 미지정"} setOpen(false)} footer={ <> } >
{subj || "과목 미상"} · 복수 선택 가능 — 라벨러가 잘못 붙였으면 여기서 직접 교정
{opts.length === 0 ? ( 이 과목의 taxonomy 단원을 찾지 못했어요. ) : (
{opts.map(u => ( ))}
)}
); } // ---- ProblemCard ---------------------------------------------------------- // One problem in the grid. Composes head + (optional) unit editor + crop. // `mode = "view" | "select"` — select mode adds a primary toggle button. function ProblemCard({ problem, mode = "view", selected = false, onToggle, showUnitEditor = false, onSaveUnits, source, showAnswer = true, scoringPaths, editable = false, onEditAnswer, onEditSubject, }) { const isFrq = problem.qtype === "FRQ"; const [zoom, setZoom] = useState(false); return (
setZoom(false)} /> {showUnitEditor && (
e.stopPropagation()}>
)} {!showUnitEditor && problem.units && problem.units.length > 0 && (
{problem.units.join(" · ")}
)} {source &&
{source}
} {problem._stim_first && (
↳ {problem._stim_label}
)} {(() => { const sc = problem.scoring_urls || (scoringPaths || []).map(s => s && s.crop_url).filter(Boolean); return sc.length > 0 && (
Scoring Guide ({sc.length} pages)
{sc.map((u, i) => ( ))}
); })()} {mode === "select" && (
{selected ? "선택됨" : "선택하기"}
)}
); } // ---- useStore — subscribe component to global store ---------------------- function useStore() { const [, force] = useState(0); useEffect(() => window.storeSubscribe(() => force(n => n + 1)), []); return window.storeSnapshot(); } // ---- expose --------------------------------------------------------------- Object.assign(window, { Tabs, Segmented, Button, Field, EmptyState, Banner, Badge, Metric, Card, Modal, Lightbox, Progress, CropPlaceholder, CropImg, ProblemHead, UnitEditor, ProblemCard, useStore, });