/* 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 (
);
})}
);
}
// ---- 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}
e.stopPropagation()} />
);
}
// ---- Progress -------------------------------------------------------------
function Progress({ value, indeterminate }) {
if (indeterminate) {
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 (
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 (