Files
Community_Nursing_Atlas/web/static/js/admin.js
T
jsahn 4e943700fc feat: add common utilities and feedback handling scripts
- Introduced common.js for shared functions including API calls, error reporting, and UI helpers.
- Implemented feedback.js for the feedback reporting page, including form handling and displaying feedback status.
- Added layout.js for managing galaxy and section layouts in the visualization.
- Created login.js for user authentication with session management.
- Developed search.js for term searching based on user input and synonyms.
- Implemented signup.js for user registration with validation and immediate login.
2026-10-06 23:14:45 +09:00

1594 lines
102 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 관리 화면: 로그인, 대시보드, 피드백, 용어·관계 검수, 은하·단원, 설정, 기록.
import { esc, api, toast, fmtDate, debounce, STATUS_LABEL, FEEDBACK_STATUS_LABEL, REL_TYPE_LABEL, METHOD_LABEL } from "./common.js";
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
const TERM_STATUSES = ["draft", "review", "confirmed"];
const REL_STATUSES = ["draft", "review", "confirmed", "rejected"];
const KIND_LABEL = {
content: "내용 오류", bug: "화면·기능 오류", search: "검색 문제", suggestion: "제안",
client_error: "화면 자동 오류", server_error: "서버 자동 오류",
};
const ENTITY_LABEL = { account: "교수자 계정", signup_code: "가입 코드", term: "용어", relation: "관계", feedback: "피드백", galaxy: "은하", toc: "단원", settings: "설정", atlas: "데이터 가져오기", member: "회원", class: "반", question: "문제" };
const ACTION_LABEL = { create: "만들기", update: "수정", delete: "삭제", bulk: "일괄 변경", merge: "합치기", reseed: "다시 가져오기", seed: "가져오기" };
const S = {
me: null,
role: null, // owner | reviewer
structure: null, // {books, galaxies, toc}
galaxyById: {},
bookById: {},
tocById: {},
sections: [], // 주 교재 단원
itemsBySection: {},
editor: null, // 용어 편집 상태
hash: "",
};
// ---------------------------------------------------------------- 공통 도우미
class AuthError extends Error {}
async function aapi(path, opts) {
try {
return await api(path, opts);
} catch (e) {
if (e.status === 401 && !path.endsWith("/login")) {
showLogin();
const ae = new AuthError("로그인이 필요합니다.");
ae.status = 401;
throw ae;
}
throw e;
}
}
function fail(e) {
if (e instanceof AuthError) return;
toast(e.message || "요청에 실패했습니다.", true);
}
const badge = (st, labels = STATUS_LABEL) => st ? `<span class="badge ${esc(st)}">${esc(labels[st] || st)}</span>` : "";
const opt = (v, label, cur) => `<option value="${esc(v)}"${String(cur ?? "") === String(v) ? " selected" : ""}>${esc(label)}</option>`;
const opts = (obj, cur) => Object.entries(obj).map(([k, l]) => opt(k, l, cur)).join("");
const studentLink = (id, text = "학생 화면") => `<a href="/#/t/${encodeURIComponent(id)}" target="_blank" rel="noopener">${esc(text)}</a>`;
const termLink = (id, name) => `<a href="#term/${encodeURIComponent(id)}">${esc(name || id)}</a>`;
const pct = (a, b) => (b ? Math.round((a / b) * 1000) / 10 : 0);
const loading = (msg = "불러오는 중…") => `<div class="state">${esc(msg)}</div>`;
const empty = (msg) => `<div class="state">${esc(msg)}</div>`;
function prettyJSON(v) {
if (v == null || v === "") return "";
let o = v;
if (typeof v === "string") {
try { o = JSON.parse(v); } catch { return v; }
}
return JSON.stringify(o, null, 2);
}
function jsonBlock(v, summary = "자세히", open = false) {
const s = prettyJSON(v);
if (!s) return `<span class="faint">—</span>`;
return `<details class="json"${open ? " open" : ""}><summary>${esc(summary)}</summary><pre>${esc(s)}</pre></details>`;
}
function evidenceText(ev) {
if (!Array.isArray(ev) || !ev.length) return "";
return ev.map((e) => `${S.bookById[e.book_id]?.short || e.book_id || ""}${e.page ? ` p.${e.page}` : ""}`).join(", ");
}
function pager(total, offset, limit) {
if (total <= limit) return `<div class="pager faint">총 ${total}건</div>`;
const from = total ? offset + 1 : 0, to = Math.min(total, offset + limit);
return `<div class="pager"><span class="faint">총 ${total}건 중 ${from}–${to}</span>
<button type="button" class="btn small" data-page="prev"${offset <= 0 ? " disabled" : ""}>이전</button>
<button type="button" class="btn small" data-page="next"${to >= total ? " disabled" : ""}>다음</button></div>`;
}
function bindPager(root, f, reload) {
root.addEventListener("click", (e) => {
const b = e.target.closest("[data-page]");
if (!b || b.disabled) return;
f.offset = Math.max(0, f.offset + (b.dataset.page === "next" ? f.limit : -f.limit));
reload();
});
}
// 별칭 안에 쉼표가 있으면 줄바꿈으로 구분해 보여 준다. 줄바꿈이 있으면 줄 단위로만 나눈다.
function listToText(a) { a = a || []; return a.some((x) => String(x).includes(",")) ? a.join("\n") : a.join(", "); }
function textToList(s) {
s = String(s || "");
return s.split(s.includes("\n") ? /\n/ : /,/).map((x) => x.trim()).filter(Boolean);
}
function numOrNull(v) { const n = parseInt(v, 10); return Number.isFinite(n) ? n : null; }
function params(obj) {
const p = new URLSearchParams();
for (const [k, v] of Object.entries(obj)) if (v !== "" && v != null) p.set(k, v);
return p.toString();
}
async function loadStructure(force = false) {
if (S.structure && !force) return S.structure;
const d = await aapi("/api/admin/structure");
S.structure = d;
S.galaxyById = Object.fromEntries(d.galaxies.map((g) => [g.galaxy_id, g]));
S.bookById = Object.fromEntries(d.books.map((b) => [b.book_id, b]));
S.tocById = Object.fromEntries(d.toc.map((t) => [t.toc_id, t]));
const primary = new Set(d.books.filter((b) => b.is_primary).map((b) => b.book_id));
S.sections = d.toc.filter((t) => t.kind === "section" && primary.has(t.book_id));
S.itemsBySection = {};
for (const t of d.toc) if (t.kind === "item" && t.parent_id) (S.itemsBySection[t.parent_id] ||= []).push(t);
return d;
}
const galaxyName = (id) => S.galaxyById[id]?.name || (id ? id : "");
const secLabel = (t) => t ? `${t.number ? t.number + ". " : ""}${t.title || ""}` : "";
const bookShort = (id) => S.bookById[id]?.short || id || "";
function galaxyOptions(cur, emptyLabel = "전체 은하") {
return opt("", emptyLabel, cur) + (S.structure?.galaxies || []).map((g) => opt(g.galaxy_id, `${g.chapter_no ?? ""}. ${g.name}`, cur)).join("");
}
function sectionOptions(galaxy, cur, emptyLabel = "전체 단원") {
const list = S.sections.filter((s) => !galaxy || s.galaxy_id === galaxy);
return opt("", emptyLabel, cur) + list.map((s) => opt(s.toc_id, `[${bookShort(s.book_id)}] ${secLabel(s)}`, cur)).join("");
}
function bookOptions(cur, emptyLabel) {
return (emptyLabel != null ? opt("", emptyLabel, cur) : "") + (S.structure?.books || []).map((b) => opt(b.book_id, b.short || b.title, cur)).join("");
}
// 용어 자동 완성 (관리자용 검색 API 사용)
let acSeq = 0;
function termPicker(root, { placeholder = "용어 이름으로 찾기", label = "용어 찾기", exclude = () => null, onPick } = {}) {
const id = `ac-${++acSeq}`;
root.innerHTML = `<div class="ac"><input class="input" type="text" autocomplete="off" role="combobox" aria-autocomplete="list"
aria-expanded="false" aria-controls="${id}" aria-label="${esc(label)}" placeholder="${esc(placeholder)}">
<ul class="ac-list" id="${id}" role="listbox" hidden></ul></div>`;
const input = $("input", root), list = $("ul", root);
let items = [], active = -1, chosen = null, req = 0;
const close = () => {
list.hidden = true; list.innerHTML = ""; active = -1;
input.setAttribute("aria-expanded", "false"); input.removeAttribute("aria-activedescendant");
};
const setActive = (i) => {
const lis = $$("[data-i]", list);
if (!lis.length) return;
active = (i + lis.length) % lis.length;
lis.forEach((li, k) => li.setAttribute("aria-selected", String(k === active)));
input.setAttribute("aria-activedescendant", lis[active].id);
lis[active].scrollIntoView({ block: "nearest" });
};
const pick = (i) => {
const t = items[i];
if (!t) return;
chosen = t;
input.value = t.name;
close();
onPick && onPick(t);
};
const search = debounce(async () => {
const q = input.value.trim();
if (chosen && input.value !== chosen.name) chosen = null;
if (!q) return close();
const my = ++req;
try {
const d = await aapi(`/api/admin/terms?${params({ q, limit: 12 })}`);
if (my !== req) return;
const ex = exclude();
items = d.items.filter((t) => t.term_id !== ex);
list.innerHTML = items.length ? items.map((t, i) => `<li id="${id}-${i}" role="option" data-i="${i}" aria-selected="false">
<span>${esc(t.name)}</span>${t.hidden ? ' <span class="badge">숨김</span>' : ""}
<span class="ac-sub">${esc(galaxyName(t.galaxy_id))} · ${esc(t.term_id)}</span></li>`).join("")
: `<li class="ac-empty" role="option" aria-disabled="true">찾는 용어가 없습니다.</li>`;
list.hidden = false;
input.setAttribute("aria-expanded", "true");
active = -1;
} catch (e) { fail(e); }
}, 200);
input.addEventListener("input", search);
input.addEventListener("keydown", (e) => {
if (list.hidden) return;
if (e.key === "ArrowDown") { e.preventDefault(); setActive(active + 1); }
else if (e.key === "ArrowUp") { e.preventDefault(); setActive(active - 1); }
else if (e.key === "Enter") { e.preventDefault(); if (active >= 0) pick(active); }
else if (e.key === "Escape") { e.stopPropagation(); close(); }
});
list.addEventListener("mousedown", (e) => {
const li = e.target.closest("[data-i]");
if (li) { e.preventDefault(); pick(+li.dataset.i); }
});
input.addEventListener("blur", () => setTimeout(close, 150));
return { get: () => chosen, clear() { chosen = null; input.value = ""; close(); }, input };
}
// ---------------------------------------------------------------- 로그인·라우팅
const appView = $("#app-view"), view = $("#view");
function showLogin() {
// 로그인은 공용 로그인 페이지에서 (교수자 아이디·비밀번호)
location.replace("/login?next=" + encodeURIComponent(location.pathname + location.hash));
}
function showApp() {
appView.hidden = false;
$("#who").hidden = false;
$("#who-name").textContent = `${S.me} 님 · 교수자`;
$("#who-name").title = "관리 → 설정에서 아이디·비밀번호를 바꿀 수 있습니다";
refreshMemberCount();
}
$("#logout").addEventListener("click", async () => {
if (!confirmLeave()) return;
try { await api("/api/admin/logout", { method: "POST" }); } catch { /* 무시 */ }
S.structure = null;
location.href = "/";
});
function editorDirty() { return !!(S.editor && S.editor.isDirty && S.editor.isDirty()); }
function confirmLeave() {
if (!editorDirty()) return true;
if (confirm("저장하지 않은 변경 사항이 있습니다. 이 화면을 떠날까요?")) { S.editor = null; return true; }
return false;
}
window.addEventListener("beforeunload", (e) => {
if (editorDirty()) { e.preventDefault(); e.returnValue = ""; }
});
window.addEventListener("hashchange", () => {
const h = location.hash.slice(1);
if (h === S.hash) return;
if (!confirmLeave()) {
history.replaceState(null, "", "#" + S.hash);
return;
}
route();
});
const VIEWS = {};
async function route(focus = false) {
if (!S.me) return;
let h = location.hash.slice(1) || (location.pathname === "/members" ? "members" : "dashboard");
const [name, ...rest] = h.split("/");
const arg = rest.join("/") ? decodeURIComponent(rest.join("/")) : null;
const viewName = VIEWS[name] ? name : "dashboard";
if (!VIEWS[name]) h = "dashboard";
S.hash = h;
S.editor = null;
if (viewName === "feedback" && S.view === "feedback" && $("#ff-list")) {
try {
if (arg) await showFeedbackDetail(+arg);
else { $("#fb-split").classList.remove("has-detail"); $("#fb-detail").hidden = true; $$("#ff-list tr.sel").forEach((tr) => { tr.classList.remove("sel"); tr.removeAttribute("aria-selected"); }); }
} catch (e) { fail(e); }
return;
}
S.view = viewName;
const tab = viewName === "term" ? "terms" : viewName;
document.title = (viewName === "members" ? "회원 관리" : "관리") + " · 지역사회간호학 개념 우주";
$$("#tabs a").forEach((a) => (a.dataset.tab === tab ? a.setAttribute("aria-current", "page") : a.removeAttribute("aria-current")));
view.innerHTML = loading();
try {
await loadStructure();
await VIEWS[viewName](arg);
} catch (e) {
if (e instanceof AuthError) return;
view.innerHTML = `<div class="state error">화면을 불러오지 못했습니다: ${esc(e.message)}</div>`;
toast(e.message, true);
}
if (focus) view.focus({ preventScroll: true });
}
async function refreshFeedbackCount() {
try {
const d = await aapi("/api/admin/feedback?status=open&limit=1");
const c = $("#tab-fb-count");
c.textContent = d.total;
c.hidden = !d.total;
} catch { /* 무시 */ }
}
// ---------------------------------------------------------------- 1. 대시보드
VIEWS.dashboard = async () => {
const s = await aapi("/api/admin/stats");
const tot = TERM_STATUSES.reduce((a, k) => a + (s.terms[k] || 0), 0);
const relTot = Object.values(s.relations).reduce((a, b) => a + b, 0);
const openFb = Object.values(s.feedback_open_by_kind).reduce((a, b) => a + b, 0);
const meter = (n, d) => `<div class="meter" role="img" aria-label="${pct(n, d)}%"><span style="width:${Math.min(100, pct(n, d))}%"></span></div>`;
const kv = (obj, labels, cls) => Object.keys(obj).length
? `<ul class="kv">${Object.entries(obj).map(([k, v]) => `<li>${cls ? badge(k, labels) : esc(labels[k] || k || "(없음)")}<b>${esc(v)}</b></li>`).join("")}</ul>`
: `<p class="faint">없음</p>`;
view.innerHTML = `<div id="dash">
<div class="page-head">
<h1>대시보드</h1>
<p class="faint">파이프라인 생성: ${esc(fmtDate(s.meta.generated_at) || "—")} · DB 가져온 시각: ${esc(fmtDate(s.meta.seeded_at) || "—")}</p>
</div>
<section class="card quick-code" id="quick-code">${loading()}</section>
<div class="cards">
<section class="card stat"><h2>용어 검수 상태</h2>
<div class="big">${tot.toLocaleString()}<small>개 (숨김 ${esc(s.terms_hidden)})</small></div>
<ul class="kv">${TERM_STATUSES.map((k) => `<li><a href="#terms" data-goto-terms='{"status":"${k}"}'>${badge(k)}</a><b>${(s.terms[k] || 0).toLocaleString()}</b></li>`).join("")}</ul>
${meter(s.terms.confirmed || 0, tot)}<p class="faint small">확정 ${pct(s.terms.confirmed || 0, tot)}%</p>
</section>
<section class="card stat"><h2>쉬운 뜻</h2>
<div class="big">${pct(s.terms_easy, tot)}<small>%</small></div>
${meter(s.terms_easy, tot)}<p class="faint small">${s.terms_easy.toLocaleString()} / ${tot.toLocaleString()}개 · <a href="#terms" data-goto-terms='{"missing":"easy"}'>빠진 용어 보기</a></p>
</section>
<section class="card stat"><h2>교과서 정의</h2>
<div class="big">${pct(s.terms_definition, tot)}<small>%</small></div>
${meter(s.terms_definition, tot)}<p class="faint small">${s.terms_definition.toLocaleString()} / ${tot.toLocaleString()}개 · <a href="#terms" data-goto-terms='{"missing":"definition"}'>빠진 용어 보기</a></p>
</section>
<section class="card stat"><h2>단원 추정</h2>
<div class="big">${s.terms_section_guess.toLocaleString()}<small>개</small></div>
<p class="faint small">목차에서 직접 찾지 못해 단원을 추정한 용어입니다. · <a href="#terms" data-goto-terms='{"missing":"section"}'>확인하기</a></p>
</section>
<section class="card stat"><h2>관계 검수 상태</h2>
<div class="big">${relTot.toLocaleString()}<small>개</small></div>
${kv(s.relations, STATUS_LABEL, true)}
</section>
<section class="card stat"><h2>관계 생성 방법</h2>${kv(s.relations_by_method, METHOD_LABEL)}</section>
<section class="card stat"><h2>열린 피드백</h2>
<div class="big">${openFb}<small>건</small></div>
${kv(s.feedback_open_by_kind, KIND_LABEL)}
<p class="small"><a href="#feedback">피드백 처리하러 가기</a></p>
</section>
</div>
<section class="card">
<h2>은하별 검수 현황</h2>
<div class="table-wrap"><table class="data">
<thead><tr><th scope="col">은하</th><th scope="col" class="num">용어</th><th scope="col" class="num">확정</th><th scope="col" style="width:40%">확정 비율</th></tr></thead>
<tbody>${s.by_galaxy.map((g) => `<tr>
<td><span class="dot" style="background:${esc(S.galaxyById[g.galaxy_id]?.color || "#888")}"></span><a href="#terms" data-goto-terms='{"galaxy":"${esc(g.galaxy_id)}"}'>${esc(g.name)}</a></td>
<td class="num">${esc(g.terms)}</td><td class="num">${esc(g.confirmed || 0)}</td>
<td><div class="row nowrap">${meter(g.confirmed || 0, g.terms)}<span class="faint small">${pct(g.confirmed || 0, g.terms)}%</span></div></td></tr>`).join("")}</tbody>
</table></div>
</section>
<section class="card">
<h2>데이터 관리</h2>
<p class="muted">파이프라인(<code>data/atlas.json</code>)을 다시 돌린 뒤 결과를 가져옵니다. 사람이 고친 용어·관계·은하·단원은 그대로 유지되고, 고치지 않은 자동 항목만 새 결과로 바뀝니다.</p>
<div class="row">
<button type="button" class="btn primary" id="reseed">파이프라인 결과 다시 가져오기</button>
<a class="btn" href="/api/admin/export" download>전체 내보내기(JSON)</a>
</div>
<div id="reseed-result"></div>
</section></div>`;
renderQuickCode($("#quick-code"));
$("#dash").addEventListener("click", gotoTermsHandler);
$("#reseed").addEventListener("click", async (e) => {
if (!confirm("파이프라인 결과(data/atlas.json)를 다시 가져올까요?\n\n사람이 고친 항목은 유지되고, 고치지 않은 자동 항목만 갱신됩니다.")) return;
e.target.disabled = true;
e.target.textContent = "가져오는 중…";
try {
const r = await aapi("/api/admin/reseed", { method: "POST" });
const L = { terms_new: "새 용어", terms_updated: "갱신된 용어", terms_kept: "유지된(수정된) 용어", terms_removed: "삭제된 용어",
relations_new: "새 관계", relations_updated: "갱신된 관계", relations_kept: "유지된(수정된) 관계", relations_removed: "삭제된 관계" };
$("#reseed-result").innerHTML = `<ul class="kv cols">${Object.entries(r).map(([k, v]) => `<li><span>${esc(L[k] || k)}</span><b>${esc(v)}</b></li>`).join("")}</ul>`;
toast("다시 가져왔습니다.");
await loadStructure(true);
} catch (err) { fail(err); }
finally { e.target.disabled = false; e.target.textContent = "파이프라인 결과 다시 가져오기"; }
});
};
function gotoTermsHandler(e) {
const a = e.target.closest("[data-goto-terms]");
if (!a) return;
e.preventDefault();
Object.assign(TF, { q: "", galaxy: "", section: "", status: "", missing: "", hidden: "0", offset: 0 }, JSON.parse(a.dataset.gotoTerms));
location.hash = "#terms";
}
// ---------------------------------------------------------------- 2. 피드백
const FF = { status: "open", kind: "", q: "", limit: 100, offset: 0, items: [] };
VIEWS.feedback = async (arg) => {
view.innerHTML = `
<div class="page-head"><h1>피드백</h1></div>
<div class="filters" role="search">
<label><span class="sr-only">상태</span><select class="input" id="ff-status">
${opt("open", "열린 것(접수+처리 중)", FF.status)}${opt("", "전체", FF.status)}${opts(FEEDBACK_STATUS_LABEL, FF.status)}</select></label>
<label><span class="sr-only">유형</span><select class="input" id="ff-kind">${opt("", "모든 유형", FF.kind)}${opts(KIND_LABEL, FF.kind)}</select></label>
<label class="grow"><span class="sr-only">검색</span><input class="input" id="ff-q" type="search" placeholder="내용·용어·메모 검색" value="${esc(FF.q)}"></label>
</div>
<div class="split" id="fb-split">
<div class="split-main"><div id="ff-list">${loading()}</div></div>
<aside class="split-side" id="fb-detail" aria-label="피드백 상세" hidden></aside>
</div>`;
const reload = async () => { FF.offset = FF.offset || 0; await loadFeedbackList(); };
$("#ff-status").addEventListener("change", (e) => { FF.status = e.target.value; FF.offset = 0; reload(); });
$("#ff-kind").addEventListener("change", (e) => { FF.kind = e.target.value; FF.offset = 0; reload(); });
$("#ff-q").addEventListener("input", debounce((e) => { FF.q = e.target.value.trim(); FF.offset = 0; reload(); }, 300));
bindPager($("#ff-list"), FF, reload);
$("#ff-list").addEventListener("click", (e) => {
if (e.target.closest("a, button")) return;
const tr = e.target.closest("tr[data-id]");
if (tr) location.hash = `#feedback/${tr.dataset.id}`;
});
$("#ff-list").addEventListener("keydown", (e) => {
const tr = e.target.closest("tr[data-id]");
if (tr && (e.key === "Enter" || e.key === " ") && e.target === tr) { e.preventDefault(); location.hash = `#feedback/${tr.dataset.id}`; }
});
await loadFeedbackList();
if (arg) await showFeedbackDetail(+arg);
refreshFeedbackCount();
};
async function loadFeedbackList() {
const box = $("#ff-list");
if (!box) return;
const d = await aapi(`/api/admin/feedback?${params({ status: FF.status, kind: FF.kind, q: FF.q, limit: FF.limit, offset: FF.offset })}`);
FF.items = d.items;
const cur = S.hash.startsWith("feedback/") ? S.hash.split("/")[1] : null;
box.innerHTML = d.items.length ? `<div class="table-wrap"><table class="data clickable">
<thead><tr><th scope="col">#</th><th scope="col">접수</th><th scope="col">유형</th><th scope="col">대상</th><th scope="col">내용</th><th scope="col">상태</th></tr></thead>
<tbody>${d.items.map((f) => `<tr data-id="${f.id}" tabindex="0"${String(f.id) === cur ? ' aria-selected="true" class="sel"' : ""}>
<td class="num">${f.id}</td>
<td class="nowrap small">${esc(fmtDate(f.created_at))}</td>
<td><span class="small">${esc(KIND_LABEL[f.kind] || f.kind)}</span><br><span class="faint small">${esc(f.category || "")}</span></td>
<td>${fbTarget(f)}</td>
<td class="msg-cell"><span class="clamp2">${esc(f.message)}</span>${f.occurrences > 1 ? ` <span class="badge warnish">×${f.occurrences}</span>` : ""}</td>
<td>${badge(f.status, FEEDBACK_STATUS_LABEL)}</td></tr>`).join("")}</tbody></table></div>${pager(d.total, FF.offset, FF.limit)}`
: empty("조건에 맞는 피드백이 없습니다.");
}
function fbTarget(f) {
if (f.target_type === "term" && f.target_id) {
return `${termLink(f.target_id, f.target_label)} <span class="small">${studentLink(f.target_id, "↗")}</span>`;
}
if (f.target_type === "relation" && f.target_id) return `<span class="small">관계 <code>${esc(f.target_id)}</code></span>${f.target_label ? `<br><span class="faint small">${esc(f.target_label)}</span>` : ""}`;
return f.target_label ? esc(f.target_label) : `<span class="faint">—</span>`;
}
async function showFeedbackDetail(id) {
const pane = $("#fb-detail");
let f = FF.items.find((x) => x.id === id);
if (!f) {
f = await aapi(`/api/admin/feedback/${id}`).catch(() => null);
}
$("#fb-split").classList.add("has-detail");
pane.hidden = false;
if (!f) { pane.innerHTML = `<div class="card">${empty(`#${id} 피드백을 찾을 수 없습니다.`)}</div>`; return; }
$$("#ff-list tr[data-id]").forEach((tr) => { const on = +tr.dataset.id === id; tr.classList.toggle("sel", on); tr.toggleAttribute("aria-selected", on); });
const isTerm = f.target_type === "term" && f.target_id;
pane.innerHTML = `<div class="card detail">
<div class="row detail-head"><h2 class="grow">#${f.id} ${esc(f.category || KIND_LABEL[f.kind] || "")}</h2>
<a class="btn small ghost" href="#feedback" aria-label="상세 닫기">닫기</a></div>
<p class="row small">${badge(f.status, FEEDBACK_STATUS_LABEL)} <span class="faint">${esc(KIND_LABEL[f.kind] || f.kind)} · 접수 ${esc(fmtDate(f.created_at))}${f.updated_at && f.updated_at !== f.created_at ? ` · 수정 ${esc(fmtDate(f.updated_at))}` : ""}${f.handled_by ? ` · 처리자 ${esc(f.handled_by)}` : ""}</span>
${f.occurrences > 1 ? `<span class="badge warnish">${f.occurrences}회 발생</span>` : ""}</p>
<dl class="dl">
<dt>대상</dt><dd>${fbTarget(f)}${isTerm ? ` <a class="btn small" href="#term/${encodeURIComponent(f.target_id)}">용어 편집 열기</a>` : ""}</dd>
<dt>내용</dt><dd class="pre">${esc(f.message)}</dd>
${f.suggestion ? `<dt>제안</dt><dd class="pre">${esc(f.suggestion)}</dd>` : ""}
${f.page_url ? `<dt>화면 주소</dt><dd class="break"><a href="${esc(safeUrl(f.page_url))}" target="_blank" rel="noopener noreferrer">${esc(f.page_url)}</a></dd>` : ""}
${f.reporter || f.contact ? `<dt>신고자</dt><dd>${esc(f.reporter || "(이름 없음)")}${f.contact ? ` · ${esc(f.contact)}` : ""}</dd>` : ""}
${f.user_agent ? `<dt>브라우저</dt><dd class="small faint break">${esc(f.user_agent)}</dd>` : ""}
${f.context ? `<dt>상세 정보</dt><dd>${jsonBlock(f.context, "오류 상세(스택 등) 펼치기", (f.kind || "").endsWith("_error"))}</dd>` : ""}
</dl>
<form id="fb-edit">
<label class="field"><span>처리 상태</span><select class="input" name="status">${opts(FEEDBACK_STATUS_LABEL, f.status)}</select></label>
<label class="field"><span>처리 결과 (공개 목록에 표시됩니다)</span><textarea class="input" name="resolution" rows="3">${esc(f.resolution || "")}</textarea></label>
<label class="field"><span>내부 메모 (관리자만 봅니다)</span><textarea class="input" name="admin_note" rows="3">${esc(f.admin_note || "")}</textarea></label>
<div class="row">
<button class="btn primary" type="submit">저장</button>
<span class="faint small">빠른 처리:</span>
<button type="button" class="btn small" data-quick="in_progress">처리 중</button>
<button type="button" class="btn small" data-quick="resolved">반영 완료</button>
<button type="button" class="btn small" data-quick="rejected">반영 안 함</button>
<button type="button" class="btn small" data-quick="duplicate">중복</button>
</div>
<div class="row danger-zone"><button type="button" class="btn small danger" id="fb-del">삭제</button></div>
</form></div>`;
const form = $("#fb-edit");
const save = async (status) => {
const body = { status: status || form.status.value, resolution: form.resolution.value, admin_note: form.admin_note.value };
try {
const r = await aapi(`/api/admin/feedback/${id}`, { method: "PATCH", body });
Object.assign(f, r);
toast("저장했습니다.");
await loadFeedbackList();
await showFeedbackDetail(id);
refreshFeedbackCount();
} catch (e) { fail(e); }
};
form.addEventListener("submit", (e) => { e.preventDefault(); save(); });
$$("[data-quick]", form).forEach((b) => b.addEventListener("click", () => save(b.dataset.quick)));
$("#fb-del").addEventListener("click", async () => {
if (!confirm(`#${id} 피드백을 삭제할까요? 되돌릴 수 없습니다.`)) return;
try {
await aapi(`/api/admin/feedback/${id}`, { method: "DELETE" });
toast("삭제했습니다.");
await loadFeedbackList();
refreshFeedbackCount();
location.hash = "#feedback";
} catch (e) { fail(e); }
});
if (window.innerWidth < 1100) pane.scrollIntoView({ behavior: "smooth", block: "start" });
}
function safeUrl(u) {
try {
const x = new URL(u, location.origin);
return /^https?:$/.test(x.protocol) ? x.href : "#";
} catch { return "#"; }
}
// ---------------------------------------------------------------- 3. 용어 목록
const TF = { q: "", galaxy: "", section: "", status: "", missing: "", hidden: "0", sort: "name", limit: 100, offset: 0, selected: new Set() };
VIEWS.terms = async () => {
TF.selected.clear();
view.innerHTML = `
<div class="page-head row"><h1 class="grow">용어</h1><a class="btn primary" href="#term/new">새 용어</a></div>
<div class="filters" role="search">
<label class="grow"><span class="sr-only">검색</span><input class="input" id="tf-q" type="search" placeholder="이름·별칭·정의·쉬운 뜻·ID" value="${esc(TF.q)}"></label>
<label><span class="sr-only">은하</span><select class="input" id="tf-galaxy">${galaxyOptions(TF.galaxy)}</select></label>
<label><span class="sr-only">단원</span><select class="input" id="tf-section">${sectionOptions(TF.galaxy, TF.section)}</select></label>
<label><span class="sr-only">검수 상태</span><select class="input" id="tf-status">${opt("", "모든 상태", TF.status)}${TERM_STATUSES.map((k) => opt(k, STATUS_LABEL[k], TF.status)).join("")}</select></label>
<label><span class="sr-only">빠진 항목</span><select class="input" id="tf-missing">${opt("", "빠진 항목 무관", TF.missing)}${opt("easy", "쉬운 뜻 없음", TF.missing)}${opt("definition", "정의 없음", TF.missing)}${opt("section", "단원 추정", TF.missing)}</select></label>
<label><span class="sr-only">숨김</span><select class="input" id="tf-hidden">${opt("0", "보이는 용어", TF.hidden)}${opt("1", "숨긴 용어", TF.hidden)}${opt("", "모두", TF.hidden)}</select></label>
<label><span class="sr-only">정렬</span><select class="input" id="tf-sort">${opt("name", "이름순", TF.sort)}${opt("updated", "최근 수정순", TF.sort)}${opt("status", "상태순", TF.sort)}</select></label>
</div>
<div class="bulkbar" id="tf-bulk" hidden>
<span id="tf-selcount"></span>
<button type="button" class="btn small" data-bulk='{"review_status":"review"}'>검토 중으로</button>
<button type="button" class="btn small" data-bulk='{"review_status":"confirmed"}'>확정으로</button>
<button type="button" class="btn small" data-bulk='{"review_status":"draft"}'>검수 전으로</button>
<button type="button" class="btn small" data-bulk='{"hidden":true}'>숨기기</button>
<button type="button" class="btn small" data-bulk='{"hidden":false}'>다시 보이기</button>
<button type="button" class="btn small ghost" id="tf-clear">선택 해제</button>
</div>
<div id="tf-list">${loading()}</div>`;
const reload = () => loadTermList();
const set = (k) => (e) => { TF[k] = e.target.value; TF.offset = 0; reload(); };
$("#tf-q").addEventListener("input", debounce((e) => { TF.q = e.target.value.trim(); TF.offset = 0; reload(); }, 300));
$("#tf-galaxy").addEventListener("change", (e) => {
TF.galaxy = e.target.value;
if (TF.section && S.tocById[TF.section]?.galaxy_id !== TF.galaxy) TF.section = "";
$("#tf-section").innerHTML = sectionOptions(TF.galaxy, TF.section);
TF.offset = 0; reload();
});
["section", "status", "missing", "hidden", "sort"].forEach((k) => $(`#tf-${k}`).addEventListener("change", set(k)));
bindPager($("#tf-list"), TF, reload);
$("#tf-list").addEventListener("change", (e) => {
if (e.target.id === "tf-all") {
$$("#tf-list input[data-sel]").forEach((c) => { c.checked = e.target.checked; c.checked ? TF.selected.add(c.dataset.sel) : TF.selected.delete(c.dataset.sel); });
} else if (e.target.dataset.sel) {
e.target.checked ? TF.selected.add(e.target.dataset.sel) : TF.selected.delete(e.target.dataset.sel);
}
updateBulk();
});
$("#tf-clear").addEventListener("click", () => { TF.selected.clear(); $$("#tf-list input[type=checkbox]").forEach((c) => (c.checked = false)); updateBulk(); });
$$("[data-bulk]", $("#tf-bulk")).forEach((b) => b.addEventListener("click", async () => {
const ids = [...TF.selected];
if (!ids.length) return;
const body = { ids, ...JSON.parse(b.dataset.bulk) };
if (!confirm(`선택한 ${ids.length}개 용어를 ‘${b.textContent.trim()}’ 처리할까요?`)) return;
try {
const r = await aapi("/api/admin/terms/bulk", { method: "POST", body });
toast(`${r.count}개 용어를 바꿨습니다.`);
TF.selected.clear();
reload();
} catch (e) { fail(e); }
}));
await loadTermList();
};
function updateBulk() {
const n = TF.selected.size;
const bar = $("#tf-bulk");
if (!bar) return;
bar.hidden = !n;
$("#tf-selcount").textContent = `${n}개 선택`;
const all = $("#tf-all");
if (all) {
const boxes = $$("#tf-list input[data-sel]");
all.checked = boxes.length > 0 && boxes.every((c) => c.checked);
all.indeterminate = !all.checked && boxes.some((c) => c.checked);
}
}
async function loadTermList() {
const box = $("#tf-list");
if (!box) return;
const d = await aapi(`/api/admin/terms?${params({ q: TF.q, galaxy: TF.galaxy, section: TF.section, status: TF.status, missing: TF.missing, hidden: TF.hidden, sort: TF.sort, limit: TF.limit, offset: TF.offset })}`);
if (!$("#tf-list")) return;
box.innerHTML = d.items.length ? `<div class="table-wrap"><table class="data">
<thead><tr><th scope="col" class="chk"><input type="checkbox" id="tf-all" aria-label="이 페이지 모두 선택"></th>
<th scope="col">이름</th><th scope="col">은하</th><th scope="col">단원</th><th scope="col">상태</th>
<th scope="col" class="c" title="쉬운 뜻">쉬운 뜻</th><th scope="col" class="c" title="교과서 정의">정의</th><th scope="col">수정</th></tr></thead>
<tbody>${d.items.map((t) => {
const sec = S.tocById[t.section_id];
return `<tr${t.hidden ? ' class="dim"' : ""}>
<td class="chk"><input type="checkbox" data-sel="${esc(t.term_id)}" aria-label="${esc(t.name)} 선택"${TF.selected.has(t.term_id) ? " checked" : ""}></td>
<td>${termLink(t.term_id, t.name)}
${t.kind === "person" ? '<span class="hint">인물</span>' : ""}${t.lang === "en" ? '<span class="hint">영어</span>' : ""}${t.origin === "manual" ? '<span class="hint">직접 추가</span>' : ""}${t.hidden ? '<span class="badge">숨김</span>' : ""}
${t.aliases?.length ? `<div class="faint small clamp1">${esc(t.aliases.join(", "))}</div>` : ""}</td>
<td class="small">${esc(galaxyName(t.galaxy_id))}</td>
<td class="small">${sec ? esc(secLabel(sec)) : '<span class="faint">—</span>'}${t.section_guess ? ' <span class="badge warnish" title="목차에서 직접 찾지 못해 추정한 단원">추정</span>' : ""}</td>
<td>${badge(t.review_status)}</td>
<td class="c">${t.has_easy ? '<span class="ok" aria-label="있음">✓</span>' : '<span class="faint" aria-label="없음">·</span>'}</td>
<td class="c">${t.has_def ? '<span class="ok" aria-label="있음">✓</span>' : '<span class="faint" aria-label="없음">·</span>'}</td>
<td class="small nowrap">${t.edited ? '<span class="edited" title="사람이 고친 용어">✎</span> ' : ""}${t.reviewer ? esc(t.reviewer) + "<br>" : ""}<span class="faint">${esc(fmtDate(t.updated_at))}</span></td></tr>`;
}).join("")}</tbody></table></div>${pager(d.total, TF.offset, TF.limit)}`
: empty("조건에 맞는 용어가 없습니다.");
updateBulk();
}
// ---------------------------------------------------------------- 4. 용어 편집
const KIND_OPTS = { concept: "개념", person: "인물" };
const METHOD_NOTE = { auto: "자동 추출", wireframe: "시안 예시", manual: "직접 입력", draft: "AI 초안(교재 문장 기반)" };
// 화면 값과 같은 방식으로 정규화해, 열자마자 '변경됨'으로 보이지 않게 한다.
function termValues(t) {
const s = (v) => (v == null ? null : String(v).trim() || null);
return {
name: (t.name || "").trim(), kind: t.kind || "concept",
aliases: textToList(listToText(t.aliases)), search_terms: textToList(listToText(t.search_terms)),
galaxy_id: t.galaxy_id || null, section_id: t.section_id || null, item_id: t.item_id || null,
easy: s(t.easy), example: s(t.example), definition_text: s(t.definition_text),
definition_book: t.definition_book || null, definition_page: t.definition_page ?? null,
review_status: t.review_status || "draft", hidden: !!t.hidden, note: s(t.note),
};
}
VIEWS.term = async (arg) => {
const creating = !arg || arg === "new";
const t = creating ? { name: "", kind: "concept", review_status: "draft", sources: [], relations: [], feedback: [], history: [] }
: await aapi(`/api/admin/terms/${encodeURIComponent(arg)}`);
renderTermEditor(t, creating);
};
function renderTermEditor(t, creating) {
const orig = termValues(t);
const ed = { t, creating, orig, sourcesTouched: false };
view.innerHTML = `
<div class="page-head row">
<a href="#terms" class="btn small ghost">← 용어 목록</a>
<h1 class="grow">${creating ? "새 용어" : esc(t.name)} ${creating ? "" : badge(t.review_status)}${t.hidden ? ' <span class="badge">숨김</span>' : ""}</h1>
${creating ? "" : `<span class="faint small">${esc(t.term_id)}</span> ${studentLink(t.term_id, "학생 화면에서 보기")} · <a href="#questions/${encodeURIComponent(t.term_id)}">문제</a>`}
</div>
<div class="editor">
<form id="te-form" class="editor-main" novalidate>
<section class="card">
<h2>기본 정보</h2>
<div class="grid2">
<label class="field"><span>이름 *</span><input class="input" name="name" required maxlength="120" value="${esc(orig.name)}"></label>
<label class="field"><span>종류</span><select class="input" name="kind">${opts(KIND_OPTS, orig.kind)}</select></label>
<label class="field"><span>별칭 (쉼표나 줄바꿈으로 구분)</span><textarea class="input short" name="aliases" rows="2">${esc(listToText(orig.aliases))}</textarea></label>
<label class="field"><span>검색어 (쉼표나 줄바꿈으로 구분)</span><textarea class="input short" name="search_terms" rows="2">${esc(listToText(orig.search_terms))}</textarea></label>
</div>
<div class="grid3">
<label class="field"><span>은하</span><select class="input" name="galaxy_id">${galaxyOptions(orig.galaxy_id, "(없음)")}</select></label>
<label class="field"><span>단원${t.section_guess ? ' <span class="badge warnish">추정</span>' : ""}</span><select class="input" name="section_id"></select></label>
<label class="field"><span>세부 항목</span><select class="input" name="item_id"></select></label>
</div>
<p class="faint small">단원만 고르면 은하는 서버에서 단원에 맞춰 정해집니다.${t.section_guess ? " 이 용어의 단원은 자동으로 추정된 것이니 확인해 주세요." : ""}</p>
</section>
<section class="card">
<h2>설명</h2>
<label class="field"><span>쉬운 뜻 <span class="method">${esc(METHOD_NOTE[t.easy_method] || METHOD_LABEL[t.easy_method] || (t.easy_method ? t.easy_method : "없음"))}</span></span>
<textarea class="input" name="easy" rows="3">${esc(orig.easy || "")}</textarea></label>
<label class="field"><span>사례</span><textarea class="input" name="example" rows="3">${esc(orig.example || "")}</textarea></label>
<label class="field"><span>교과서 정의 — 원문 그대로 <span class="method">${esc(METHOD_NOTE[t.definition_method] || METHOD_LABEL[t.definition_method] || (t.definition_method ? t.definition_method : "없음"))}</span></span>
<textarea class="input" name="definition_text" rows="4">${esc(orig.definition_text || "")}</textarea></label>
<div class="grid2">
<label class="field"><span>정의 출처 교재</span><select class="input" name="definition_book">${bookOptions(orig.definition_book, "(없음)")}</select></label>
<label class="field"><span>정의 쪽수</span><input class="input" name="definition_page" type="number" min="1" inputmode="numeric" value="${esc(orig.definition_page ?? "")}"></label>
</div>
</section>
<section class="card">
<div class="row"><h2 class="grow">출처 (색인 위치)</h2><button type="button" class="btn small" id="src-add">출처 추가</button></div>
<p class="faint small">‘정의로 사용’을 누르면 해당 문장을 교과서 정의 칸에 옮기고 교재·쪽수를 맞춥니다.</p>
<div class="table-wrap"><table class="data sources"><thead><tr><th scope="col">교재</th><th scope="col">쪽</th><th scope="col">PDF쪽</th><th scope="col">위치·문장</th><th scope="col"><span class="sr-only">동작</span></th></tr></thead>
<tbody id="src-body"></tbody></table></div>
</section>
<section class="card">
<h2>검수</h2>
<fieldset class="radios"><legend>검수 상태</legend>
${TERM_STATUSES.map((k) => `<label><input type="radio" name="review_status" value="${k}"${orig.review_status === k ? " checked" : ""}> ${badge(k)}</label>`).join("")}
</fieldset>
<label class="check"><input type="checkbox" name="hidden"${orig.hidden ? " checked" : ""}> 학생 화면에서 숨기기</label>
<label class="field"><span>메모</span><textarea class="input short" name="note" rows="2">${esc(orig.note || "")}</textarea></label>
<div class="row savebar">
<button type="submit" class="btn primary" id="te-save">${creating ? "용어 만들기" : "저장"}</button>
<span class="faint small" id="te-dirty" aria-live="polite"></span>
${creating ? "" : `<span class="faint small">마지막 수정: ${esc(t.reviewer || "자동")} · ${esc(fmtDate(t.updated_at))}</span>`}
</div>
</section>
</form>
<aside class="editor-side">
${creating ? `<section class="card"><p class="muted">용어를 만든 뒤 관계·합치기를 할 수 있습니다.</p></section>` : `
<section class="card">
<h2>관계 <span class="faint small">(${t.relations.length})</span></h2>
<div id="te-rels"></div>
<details class="addbox"><summary>관계 추가</summary>
<form id="rel-add" class="stack">
<div class="field"><span class="lbl">상대 용어</span><div id="rel-add-picker"></div></div>
<div class="grid2">
<label class="field"><span>유형</span><select class="input" name="type">${opts(REL_TYPE_LABEL, "related_to")}</select></label>
<label class="field"><span>방향</span><select class="input" name="dir">${opt("out", "이 용어 → 상대", "out")}${opt("in", "상대 → 이 용어", "out")}</select></label>
</div>
<label class="field"><span>설명(라벨)</span><input class="input" name="label" maxlength="200"></label>
<label class="field"><span>근거</span><textarea class="input short" name="reason" rows="2" maxlength="2000"></textarea></label>
<button class="btn primary small" type="submit">관계 추가</button>
</form>
</details>
</section>
<section class="card">
<h2>다른 용어로 합치기</h2>
<p class="faint small">중복 용어일 때 사용합니다. 이 용어의 출처·관계·별칭을 대상 용어로 옮기고, 이 용어는 숨깁니다.</p>
<div id="merge-picker"></div>
<button type="button" class="btn small danger" id="merge-btn">합치기</button>
</section>
<section class="card">
<h2>관련 피드백 <span class="faint small">(${t.feedback.length})</span></h2>
${t.feedback.length ? `<ul class="mini">${t.feedback.map((f) => `<li><a href="#feedback/${f.id}">#${f.id}</a> ${badge(f.status, FEEDBACK_STATUS_LABEL)} <span class="faint small">${esc(fmtDate(f.created_at))} · ${esc(f.category || "")}</span><div class="clamp2 small">${esc(f.message)}</div></li>`).join("")}</ul>` : `<p class="faint">없음</p>`}
</section>
<section class="card">
<h2>수정 기록</h2>
${t.history.length ? `<ul class="mini">${t.history.map((h) => `<li><span class="small">${esc(fmtDate(h.at))} · ${esc(h.actor || "")} · ${esc(ACTION_LABEL[h.action] || h.action)}</span>
<div class="row nowrap">${h.before ? jsonBlock(h.before, "이전") : ""}${h.after ? jsonBlock(h.after, "이후") : ""}</div></li>`).join("")}</ul>` : `<p class="faint">없음</p>`}
</section>`}
</aside>
</div>`;
const form = $("#te-form");
const fillSections = () => {
const g = form.galaxy_id.value;
const cur = form.section_id.dataset.cur ?? orig.section_id;
let html = sectionOptions(g, cur, "(없음)");
// 현재 값이 주 교재 단원이 아니어도 보이게 한다.
if (cur && !S.sections.some((s) => s.toc_id === cur && (!g || s.galaxy_id === g))) {
const s = S.tocById[cur];
html += opt(cur, s ? `[${bookShort(s.book_id)}] ${secLabel(s)}${s.galaxy_id !== g ? " (다른 은하)" : ""}` : cur, cur);
}
form.section_id.innerHTML = html;
fillItems();
};
const fillItems = () => {
const sec = form.section_id.value;
const cur = form.item_id.dataset.cur ?? orig.item_id;
const items = S.itemsBySection[sec] || [];
let html = opt("", "(없음)", cur) + items.map((i) => opt(i.toc_id, secLabel(i), cur)).join("");
if (cur && !items.some((i) => i.toc_id === cur)) {
const i = S.tocById[cur];
html += opt(cur, i ? `${secLabel(i)}${i.parent_id !== sec ? " (다른 단원)" : ""}` : cur, cur);
}
form.item_id.innerHTML = html;
};
fillSections();
form.galaxy_id.addEventListener("change", () => {
const s = S.tocById[form.section_id.value];
const keep = s && s.galaxy_id === form.galaxy_id.value;
form.section_id.dataset.cur = keep ? form.section_id.value : "";
form.item_id.dataset.cur = keep ? form.item_id.value : "";
fillSections();
markDirty();
});
form.section_id.addEventListener("change", () => {
form.section_id.dataset.cur = form.section_id.value;
form.item_id.dataset.cur = "";
const s = S.tocById[form.section_id.value];
if (s && s.galaxy_id && form.galaxy_id.value !== s.galaxy_id) {
form.galaxy_id.value = s.galaxy_id;
}
fillItems();
markDirty();
});
form.item_id.addEventListener("change", () => { form.item_id.dataset.cur = form.item_id.value; });
// 출처
const srcBody = $("#src-body");
const srcRow = (s) => `<tr data-id="${esc(s.id ?? "")}" data-section="${esc(s.section_id ?? "")}">
<td><select class="input" data-f="book_id" aria-label="교재">${bookOptions(s.book_id)}</select></td>
<td><input class="input num-in" data-f="page" type="number" min="0" value="${esc(s.page ?? "")}" aria-label="쪽"></td>
<td><input class="input num-in" data-f="pdf_page" type="number" min="0" value="${esc(s.pdf_page ?? "")}" aria-label="PDF 쪽"></td>
<td class="src-text"><input class="input" data-f="path" value="${esc(s.path ?? "")}" placeholder="목차 위치" aria-label="목차 위치">
<textarea class="input short" data-f="snippet" rows="2" placeholder="본문 문장" aria-label="본문 문장">${esc(s.snippet ?? "")}</textarea></td>
<td class="src-act"><button type="button" class="btn small" data-act="use-def">정의로 사용</button>
<button type="button" class="btn small danger" data-act="del-src" aria-label="이 출처 지우기">삭제</button></td></tr>`;
srcBody.innerHTML = (t.sources || []).map(srcRow).join("") || "";
const srcEmpty = () => { if (!srcBody.children.length) srcBody.innerHTML = `<tr class="empty-row"><td colspan="5" class="faint">출처가 없습니다.</td></tr>`; };
srcEmpty();
$("#src-add").addEventListener("click", () => {
$(".empty-row", srcBody)?.remove();
const primary = S.structure.books.find((b) => b.is_primary);
srcBody.insertAdjacentHTML("beforeend", srcRow({ book_id: primary?.book_id }));
ed.sourcesTouched = true; markDirty();
$("tr:last-child input[data-f=page]", srcBody).focus();
});
srcBody.addEventListener("input", () => { ed.sourcesTouched = true; markDirty(); });
srcBody.addEventListener("click", (e) => {
const b = e.target.closest("[data-act]");
if (!b) return;
const tr = b.closest("tr");
if (b.dataset.act === "del-src") {
tr.remove(); ed.sourcesTouched = true; srcEmpty(); markDirty();
} else if (b.dataset.act === "use-def") {
const snip = $("[data-f=snippet]", tr).value.trim();
if (!snip) { toast("문장이 비어 있습니다.", true); return; }
form.definition_text.value = snip;
form.definition_book.value = $("[data-f=book_id]", tr).value;
form.definition_page.value = $("[data-f=page]", tr).value;
markDirty();
form.definition_text.focus();
toast("정의 칸에 옮겼습니다. 저장해야 반영됩니다.");
}
});
const readSources = () => $$("tr[data-id]", srcBody).map((tr) => {
const g = (f) => $(`[data-f=${f}]`, tr).value;
const o = { book_id: g("book_id"), page: numOrNull(g("page")), pdf_page: numOrNull(g("pdf_page")), path: g("path").trim() || null, snippet: g("snippet").trim() || null };
if (tr.dataset.id) o.id = +tr.dataset.id;
if (tr.dataset.section) o.section_id = tr.dataset.section;
return o;
});
const current = () => ({
name: form.name.value.trim(), kind: form.kind.value,
aliases: textToList(form.aliases.value), search_terms: textToList(form.search_terms.value),
galaxy_id: form.galaxy_id.value || null, section_id: form.section_id.value || null, item_id: form.item_id.value || null,
easy: form.easy.value.trim() || null, example: form.example.value.trim() || null,
definition_text: form.definition_text.value.trim() || null, definition_book: form.definition_book.value || null,
definition_page: numOrNull(form.definition_page.value),
review_status: (form.querySelector("input[name=review_status]:checked") || {}).value || "draft",
hidden: form.hidden.checked, note: form.note.value.trim() || null,
});
const diff = () => {
const c = current(), out = {};
for (const k of Object.keys(c)) if (JSON.stringify(c[k]) !== JSON.stringify(ed.orig[k])) out[k] = c[k];
return out;
};
ed.isDirty = () => Object.keys(diff()).length > 0 || ed.sourcesTouched;
const markDirty = () => { const el = $("#te-dirty"); if (el) el.textContent = ed.isDirty() ? "저장하지 않은 변경 사항이 있습니다." : ""; };
form.addEventListener("input", markDirty);
form.addEventListener("change", markDirty);
S.editor = ed;
form.addEventListener("submit", async (e) => {
e.preventDefault();
const d = diff();
const body = creating ? Object.fromEntries(Object.entries(current()).filter(([, v]) => v !== null && !(Array.isArray(v) && !v.length))) : d;
if (!body.name && creating) { toast("이름을 입력해 주세요.", true); form.name.focus(); return; }
if ("name" in body && !body.name) { toast("이름은 비울 수 없습니다.", true); form.name.focus(); return; }
if (ed.sourcesTouched) {
const src = readSources();
if (src.some((s) => !s.book_id)) { toast("출처의 교재를 골라 주세요.", true); return; }
body.sources = src;
}
if (!creating && !Object.keys(body).length) { toast("바뀐 내용이 없습니다."); return; }
const btn = $("#te-save");
btn.disabled = true;
try {
const r = creating
? await aapi("/api/admin/terms", { method: "POST", body })
: await aapi(`/api/admin/terms/${encodeURIComponent(t.term_id)}`, { method: "PUT", body });
toast(creating ? "용어를 만들었습니다." : "저장했습니다.");
S.editor = null;
if (creating) {
S.hash = `term/${r.term_id}`;
history.replaceState(null, "", `#term/${encodeURIComponent(r.term_id)}`);
}
const y = window.scrollY;
renderTermEditor(r, false);
window.scrollTo(0, y);
} catch (err) { fail(err); btn.disabled = false; }
});
if (creating) { form.name.focus(); return; }
// 관계
renderTermRelations(t);
const picker = termPicker($("#rel-add-picker"), { label: "상대 용어 찾기", exclude: () => t.term_id });
$("#rel-add").addEventListener("submit", async (e) => {
e.preventDefault();
const f = e.target, other = picker.get();
if (!other) { toast("상대 용어를 목록에서 골라 주세요.", true); picker.input.focus(); return; }
const out = f.dir.value === "out";
const body = { source: out ? t.term_id : other.term_id, target: out ? other.term_id : t.term_id, type: f.type.value,
label: f.label.value.trim() || null, reason: f.reason.value.trim() || null };
try {
await aapi("/api/admin/relations", { method: "POST", body });
toast("관계를 추가했습니다.");
picker.clear(); f.label.value = ""; f.reason.value = "";
await reloadTermRelations(t);
} catch (err) { fail(err); }
});
const mp = termPicker($("#merge-picker"), { label: "합칠 대상 용어 찾기", placeholder: "합칠 대상 용어", exclude: () => t.term_id });
$("#merge-btn").addEventListener("click", async () => {
const into = mp.get();
if (!into) { toast("합칠 대상 용어를 목록에서 골라 주세요.", true); mp.input.focus(); return; }
if (editorDirty() && !confirm("저장하지 않은 변경 사항은 사라집니다. 계속할까요?")) return;
if (!confirm(`‘${t.name}’을(를) ‘${into.name}’(으)로 합칠까요?\n출처·관계·별칭이 옮겨지고 ‘${t.name}’은(는) 숨겨집니다.`)) return;
try {
const r = await aapi(`/api/admin/terms/${encodeURIComponent(t.term_id)}/merge`, { method: "POST", body: { into: into.term_id } });
toast("합쳤습니다.");
S.editor = null;
location.hash = `#term/${encodeURIComponent(r.term_id)}`;
} catch (err) { fail(err); }
});
}
async function reloadTermRelations(t) {
const fresh = await aapi(`/api/admin/terms/${encodeURIComponent(t.term_id)}`);
t.relations = fresh.relations;
renderTermRelations(t);
}
function renderTermRelations(t) {
const box = $("#te-rels");
if (!box) return;
if (!t.relations.length) { box.innerHTML = `<p class="faint">연결된 관계가 없습니다.</p>`; return; }
box.innerHTML = `<ul class="rels">${t.relations.map((r) => {
const arrow = !r.directed ? "↔" : r.outgoing ? "→" : "←";
return `<li data-rid="${esc(r.relation_id)}"${r.hidden ? ' class="dim"' : ""}>
<div class="row rel-line"><span class="arrow" title="${r.directed ? (r.outgoing ? "이 용어에서 나가는 관계" : "이 용어로 들어오는 관계") : "방향 없음"}">${arrow}</span>
${termLink(r.other_id, r.other?.name)} ${r.other?.hidden ? '<span class="badge">숨김</span>' : ""}
<span class="faint small grow">${esc(METHOD_LABEL[r.method] || r.method || "")}${evidenceText(r.evidence) ? " · " + esc(evidenceText(r.evidence)) : ""}</span></div>
<div class="rel-edit">
<select class="input" data-f="type" aria-label="관계 유형">${opts(REL_TYPE_LABEL, r.type)}</select>
<input class="input" data-f="label" value="${esc(r.label || "")}" placeholder="설명" aria-label="관계 설명">
<select class="input" data-f="review_status" aria-label="검수 상태">${REL_STATUSES.map((k) => opt(k, STATUS_LABEL[k], r.review_status)).join("")}</select>
<button type="button" class="btn small" data-act="save">저장</button>
<button type="button" class="btn small danger" data-act="del" aria-label="관계 삭제">삭제</button>
</div></li>`;
}).join("")}</ul>`;
box.onclick = async (e) => {
const b = e.target.closest("[data-act]");
if (!b) return;
const li = b.closest("li[data-rid]"), rid = li.dataset.rid;
if (b.dataset.act === "save") {
const body = { type: $("[data-f=type]", li).value, label: $("[data-f=label]", li).value.trim() || null, review_status: $("[data-f=review_status]", li).value };
try { await aapi(`/api/admin/relations/${encodeURIComponent(rid)}`, { method: "PUT", body }); toast("관계를 저장했습니다."); await reloadTermRelations(t); }
catch (err) { fail(err); }
} else if (b.dataset.act === "del") {
if (!confirm("이 관계를 삭제할까요? 자동으로 만든 관계는 다시 생기지 않도록 ‘반려·숨김’ 처리됩니다.")) return;
try { await aapi(`/api/admin/relations/${encodeURIComponent(rid)}`, { method: "DELETE" }); toast("관계를 삭제했습니다."); await reloadTermRelations(t); }
catch (err) { fail(err); }
}
};
}
// ---------------------------------------------------------------- 5. 관계
const RF = { status: "draft", type: "", method: "", galaxy: "", q: "", limit: 100, offset: 0, selected: new Set() };
VIEWS.relations = async () => {
RF.selected.clear();
view.innerHTML = `
<div class="page-head"><h1>관계</h1></div>
<details class="card addbox" id="ra-box"><summary>관계 추가</summary>
<form id="ra-form" class="ra-grid">
<div class="field"><span class="lbl">시작 용어</span><div id="ra-src"></div></div>
<div class="field"><span class="lbl">도착 용어</span><div id="ra-tgt"></div></div>
<label class="field"><span>유형</span><select class="input" name="type">${opts(REL_TYPE_LABEL, "related_to")}</select></label>
<label class="field"><span>설명(라벨)</span><input class="input" name="label" maxlength="200"></label>
<label class="field ra-reason"><span>근거</span><input class="input" name="reason" maxlength="2000"></label>
<div class="field ra-btn"><button class="btn primary" type="submit">추가</button></div>
</form>
</details>
<div class="filters" role="search">
<label class="grow"><span class="sr-only">검색</span><input class="input" id="rf-q" type="search" placeholder="용어 이름·설명" value="${esc(RF.q)}"></label>
<label><span class="sr-only">검수 상태</span><select class="input" id="rf-status">${opt("", "모든 상태", RF.status)}${REL_STATUSES.map((k) => opt(k, STATUS_LABEL[k], RF.status)).join("")}</select></label>
<label><span class="sr-only">유형</span><select class="input" id="rf-type">${opt("", "모든 유형", RF.type)}${opts(REL_TYPE_LABEL, RF.type)}</select></label>
<label><span class="sr-only">생성 방법</span><select class="input" id="rf-method">${opt("", "모든 방법", RF.method)}${opts(METHOD_LABEL, RF.method)}</select></label>
<label><span class="sr-only">은하</span><select class="input" id="rf-galaxy">${galaxyOptions(RF.galaxy)}</select></label>
</div>
<div class="bulkbar" id="rf-bulk" hidden>
<span id="rf-selcount"></span>
<button type="button" class="btn small" data-bulk='{"review_status":"confirmed"}'>확정</button>
<button type="button" class="btn small" data-bulk='{"review_status":"review"}'>검토 중</button>
<button type="button" class="btn small" data-bulk='{"review_status":"rejected"}'>반려</button>
<button type="button" class="btn small" data-bulk='{"hidden":true}'>숨기기</button>
<button type="button" class="btn small" data-bulk='{"hidden":false}'>다시 보이기</button>
<button type="button" class="btn small ghost" id="rf-clear">선택 해제</button>
</div>
<div id="rf-list">${loading()}</div>`;
const reload = () => loadRelList();
$("#rf-q").addEventListener("input", debounce((e) => { RF.q = e.target.value.trim(); RF.offset = 0; reload(); }, 300));
["status", "type", "method", "galaxy"].forEach((k) => $(`#rf-${k}`).addEventListener("change", (e) => { RF[k] = e.target.value; RF.offset = 0; reload(); }));
bindPager($("#rf-list"), RF, reload);
const src = termPicker($("#ra-src"), { label: "시작 용어 찾기", placeholder: "시작 용어" });
const tgt = termPicker($("#ra-tgt"), { label: "도착 용어 찾기", placeholder: "도착 용어", exclude: () => src.get()?.term_id });
$("#ra-form").addEventListener("submit", async (e) => {
e.preventDefault();
const f = e.target;
if (!src.get() || !tgt.get()) { toast("시작·도착 용어를 목록에서 골라 주세요.", true); return; }
try {
await aapi("/api/admin/relations", { method: "POST", body: { source: src.get().term_id, target: tgt.get().term_id, type: f.type.value,
label: f.label.value.trim() || null, reason: f.reason.value.trim() || null } });
toast("관계를 추가했습니다.");
src.clear(); tgt.clear(); f.label.value = ""; f.reason.value = "";
reload();
} catch (err) { fail(err); }
});
const list = $("#rf-list");
list.addEventListener("change", (e) => {
if (e.target.id === "rf-all") {
$$("input[data-sel]", list).forEach((c) => { c.checked = e.target.checked; c.checked ? RF.selected.add(c.dataset.sel) : RF.selected.delete(c.dataset.sel); });
} else if (e.target.dataset.sel) {
e.target.checked ? RF.selected.add(e.target.dataset.sel) : RF.selected.delete(e.target.dataset.sel);
}
updateRelBulk();
});
list.addEventListener("click", async (e) => {
const b = e.target.closest("[data-act]");
if (!b) return;
const tr = b.closest("tr[data-rid]"), rid = tr.dataset.rid;
if (b.dataset.act === "save") {
const body = { type: $("[data-f=type]", tr).value, label: $("[data-f=label]", tr).value.trim() || null, review_status: $("[data-f=review_status]", tr).value };
try { await aapi(`/api/admin/relations/${encodeURIComponent(rid)}`, { method: "PUT", body }); toast("저장했습니다."); tr.classList.add("saved"); }
catch (err) { fail(err); }
} else if (b.dataset.act === "del") {
if (!confirm("이 관계를 삭제할까요? 자동으로 만든 관계는 ‘반려·숨김’ 처리됩니다.")) return;
try { await aapi(`/api/admin/relations/${encodeURIComponent(rid)}`, { method: "DELETE" }); toast("삭제했습니다."); reload(); }
catch (err) { fail(err); }
}
});
$("#rf-clear").addEventListener("click", () => { RF.selected.clear(); $$("input[type=checkbox]", list).forEach((c) => (c.checked = false)); updateRelBulk(); });
$$("[data-bulk]", $("#rf-bulk")).forEach((b) => b.addEventListener("click", async () => {
const ids = [...RF.selected];
if (!ids.length) return;
if (!confirm(`선택한 ${ids.length}개 관계를 ‘${b.textContent.trim()}’ 처리할까요?`)) return;
try {
const r = await aapi("/api/admin/relations/bulk", { method: "POST", body: { ids, ...JSON.parse(b.dataset.bulk) } });
toast(`${r.count}개 관계를 바꿨습니다.`);
RF.selected.clear();
reload();
} catch (err) { fail(err); }
}));
await loadRelList();
};
function updateRelBulk() {
const n = RF.selected.size, bar = $("#rf-bulk");
if (!bar) return;
bar.hidden = !n;
$("#rf-selcount").textContent = `${n}개 선택`;
const all = $("#rf-all");
if (all) {
const boxes = $$("#rf-list input[data-sel]");
all.checked = boxes.length > 0 && boxes.every((c) => c.checked);
all.indeterminate = !all.checked && boxes.some((c) => c.checked);
}
}
async function loadRelList() {
const box = $("#rf-list");
if (!box) return;
const d = await aapi(`/api/admin/relations?${params({ status: RF.status, type: RF.type, method: RF.method, galaxy: RF.galaxy, q: RF.q, limit: RF.limit, offset: RF.offset })}`);
if (!$("#rf-list")) return;
box.innerHTML = d.items.length ? `<div class="table-wrap"><table class="data rel-table">
<thead><tr><th scope="col" class="chk"><input type="checkbox" id="rf-all" aria-label="이 페이지 모두 선택"></th>
<th scope="col">연결</th><th scope="col">유형</th><th scope="col">설명</th><th scope="col">방법·근거</th><th scope="col">상태</th><th scope="col"><span class="sr-only">동작</span></th></tr></thead>
<tbody>${d.items.map((r) => `<tr data-rid="${esc(r.relation_id)}"${r.hidden ? ' class="dim"' : ""}>
<td class="chk"><input type="checkbox" data-sel="${esc(r.relation_id)}" aria-label="${esc(r.source_name)} ${r.directed ? "→" : "↔"} ${esc(r.target_name)} 선택"${RF.selected.has(r.relation_id) ? " checked" : ""}></td>
<td class="rel-pair">${termLink(r.source, r.source_name)} <span class="arrow">${r.directed ? "→" : "↔"}</span> ${termLink(r.target, r.target_name)}
${r.hidden ? ' <span class="badge">숨김</span>' : ""}${r.reason ? `<div class="faint small clamp2" title="${esc(r.reason)}">${esc(r.reason)}</div>` : ""}</td>
<td><select class="input compact" data-f="type" aria-label="유형">${opts(REL_TYPE_LABEL, r.type)}</select></td>
<td><input class="input compact" data-f="label" value="${esc(r.label || "")}" aria-label="설명"></td>
<td class="small">${esc(METHOD_LABEL[r.method] || r.method || "")}${evidenceText(r.evidence) ? `<br><span class="faint">${esc(evidenceText(r.evidence))}</span>` : ""}</td>
<td><select class="input compact" data-f="review_status" aria-label="검수 상태">${REL_STATUSES.map((k) => opt(k, STATUS_LABEL[k], r.review_status)).join("")}</select></td>
<td class="nowrap"><button type="button" class="btn small" data-act="save">저장</button> <button type="button" class="btn small danger" data-act="del">삭제</button></td></tr>`).join("")}</tbody>
</table></div>${pager(d.total, RF.offset, RF.limit)}`
: empty("조건에 맞는 관계가 없습니다.");
updateRelBulk();
}
// ---------------------------------------------------------------- 6. 은하·단원
VIEWS.structure = async () => {
const d = await loadStructure(true);
const secsBy = {};
for (const s of S.sections) (secsBy[s.galaxy_id || ""] ||= []).push(s);
const secRow = (s) => `<tr data-toc="${esc(s.toc_id)}">
<td class="nowrap small">${esc(bookShort(s.book_id))}<br><span class="faint">${esc(s.number || "")} · p.${esc(s.page ?? "")}</span></td>
<td><input class="input compact" data-f="title" value="${esc(s.title || "")}" aria-label="단원 제목"></td>
<td><input class="input compact" data-f="easy_sub" value="${esc(s.easy_sub || "")}" placeholder="쉬운 보조 설명" aria-label="쉬운 보조 설명"></td>
<td><select class="input compact" data-f="galaxy_id" aria-label="소속 은하">${galaxyOptions(s.galaxy_id, "(없음)")}</select></td>
<td class="nowrap">${s.edited ? '<span class="edited" title="사람이 고친 단원">✎</span> ' : ""}<button type="button" class="btn small" data-act="save-toc">저장</button></td></tr>`;
const secTable = (list) => list?.length ? `<div class="table-wrap"><table class="data"><thead><tr><th scope="col">교재</th><th scope="col">단원 제목</th><th scope="col">쉬운 보조 설명</th><th scope="col">은하</th><th scope="col"><span class="sr-only">동작</span></th></tr></thead>
<tbody>${list.map(secRow).join("")}</tbody></table></div>` : `<p class="faint">주 교재 단원이 없습니다.</p>`;
view.innerHTML = `<div id="st-root">
<div class="page-head"><h1>은하·단원</h1><p class="faint">은하는 큰 단원(장), 단원은 주 교재의 절입니다. 고친 항목은 파이프라인 결과를 다시 가져와도 유지됩니다.</p></div>
${d.galaxies.map((g) => `<section class="card galaxy" data-gid="${esc(g.galaxy_id)}">
<form class="g-form" data-gid="${esc(g.galaxy_id)}">
<span class="g-no">${esc(g.chapter_no ?? "")}</span>
<label class="field"><span>은하 이름</span><input class="input" name="name" value="${esc(g.name || "")}"></label>
<label class="field grow"><span>쉬운 보조 설명</span><input class="input" name="sub" value="${esc(g.sub || "")}"></label>
<label class="field"><span>색</span><input class="color" type="color" name="color" value="${esc(/^#[0-9a-f]{6}$/i.test(g.color || "") ? g.color : "#888888")}"></label>
<div class="field"><button class="btn small primary" type="submit">은하 저장</button>${g.edited ? ' <span class="edited" title="사람이 고친 은하">✎</span>' : ""}</div>
</form>
<details${(secsBy[g.galaxy_id] || []).length <= 8 ? " open" : ""}><summary>단원 ${(secsBy[g.galaxy_id] || []).length}개</summary>${secTable(secsBy[g.galaxy_id])}</details>
</section>`).join("")}
${secsBy[""]?.length ? `<section class="card"><h2>은하가 없는 단원</h2>${secTable(secsBy[""])}</section>` : ""}</div>`;
$$(".g-form", view).forEach((f) => f.addEventListener("submit", async (e) => {
e.preventDefault();
const body = { name: f.name.value.trim(), sub: f.sub.value.trim(), color: f.color.value };
if (!body.name) { toast("은하 이름을 입력해 주세요.", true); return; }
try {
await aapi(`/api/admin/galaxies/${encodeURIComponent(f.dataset.gid)}`, { method: "PUT", body });
toast("은하를 저장했습니다.");
await loadStructure(true);
} catch (err) { fail(err); }
}));
$("#st-root").addEventListener("click", async (e) => {
const b = e.target.closest("[data-act=save-toc]");
if (!b) return;
const tr = b.closest("tr[data-toc]");
const body = { title: $("[data-f=title]", tr).value.trim(), easy_sub: $("[data-f=easy_sub]", tr).value.trim() || null, galaxy_id: $("[data-f=galaxy_id]", tr).value || null };
if (!body.title) { toast("단원 제목을 입력해 주세요.", true); return; }
const moved = S.tocById[tr.dataset.toc]?.galaxy_id !== body.galaxy_id;
try {
await aapi(`/api/admin/toc/${encodeURIComponent(tr.dataset.toc)}`, { method: "PUT", body });
toast(moved ? "단원을 저장했습니다. 은하를 옮겼으니 목록을 새로 고칩니다." : "단원을 저장했습니다.");
if (moved) VIEWS.structure(); else await loadStructure(true);
} catch (err) { fail(err); }
});
};
// ---------------------------------------------------------------- 7. 설정
VIEWS.settings = async () => {
const [s, acc] = await Promise.all([aapi("/api/admin/settings"), aapi("/api/admin/account")]);
const me = acc.items[0] || {};
const pub = new Set(s.public_statuses || []);
view.innerHTML = `
<div class="page-head"><h1>설정</h1></div>
<section class="card"><h2>교수자 계정</h2>
<p class="faint small">관리 화면은 이 계정으로만 들어올 수 있습니다. 로그인은 <a href="/login">/login</a> 에서 합니다. 바꾸려면 현재 비밀번호를 입력하세요.</p>
<form id="acc-form" class="settings">
<label class="field"><span>아이디</span><input class="input" name="username" value="${esc(me.username || "")}" required minlength="3" maxlength="30" autocomplete="username"></label>
<label class="field"><span>이름 (수정 기록에 남는 이름)</span><input class="input" name="name" value="${esc(me.name || "")}" maxlength="40"></label>
<label class="field"><span>새 비밀번호 (바꿀 때만, 8자 이상)</span><input class="input" type="password" name="new_password" minlength="8" maxlength="100" autocomplete="new-password"></label>
<label class="field"><span>새 비밀번호 확인</span><input class="input" type="password" name="new_password2" autocomplete="new-password"></label>
<label class="field"><span>현재 비밀번호 (필수)</span><input class="input" type="password" name="current_password" required autocomplete="current-password"></label>
<p class="faint small">최근 로그인: ${esc(fmtDate(me.last_login) || "-")}</p>
<button type="submit" class="btn primary">계정 저장</button>
</form>
</section>
<form id="set-form" class="settings">
<section class="card">
<fieldset class="radios"><legend>학생 화면에 보여줄 검수 상태</legend>
<p class="faint small">학생 화면에 보여줄 검수 상태입니다. 검수가 진행되면 ‘확정’만 남기세요.</p>
${TERM_STATUSES.map((k) => `<label><input type="checkbox" name="public_statuses" value="${k}"${pub.has(k) ? " checked" : ""}> ${badge(k)}</label>`).join("")}
</fieldset>
</section>
<section class="card">
<label class="check"><input type="checkbox" name="show_status_badges"${s.show_status_badges ? " checked" : ""}> 학생 화면에 ‘검수 전’ 등 검수 상태 표시</label>
</section>
<div class="row"><button type="submit" class="btn primary">설정 저장</button></div>
</form>`;
$("#acc-form").addEventListener("submit", async (e) => {
e.preventDefault();
const f = e.target;
if (f.new_password.value !== f.new_password2.value) return toast("새 비밀번호 확인이 일치하지 않습니다.", true);
const body = { current_password: f.current_password.value, username: f.username.value.trim(), name: f.name.value.trim() };
if (f.new_password.value) body.new_password = f.new_password.value;
try {
const r = await aapi("/api/admin/account", { method: "PUT", body });
S.me = r.name || r.username;
$("#who-name").textContent = `${S.me} 님 · 교수자`;
f.current_password.value = f.new_password.value = f.new_password2.value = "";
toast("계정을 저장했습니다.");
} catch (err) { fail(err); }
});
$("#set-form").addEventListener("submit", async (e) => {
e.preventDefault();
const f = e.target;
const statuses = $$("input[name=public_statuses]:checked", f).map((c) => c.value);
if (!statuses.length) { toast("최소 한 가지 상태는 골라 주세요.", true); return; }
try {
await aapi("/api/admin/settings", { method: "PUT", body: { public_statuses: statuses, show_status_badges: f.show_status_badges.checked } });
toast("설정을 저장했습니다.");
} catch (err) { fail(err); }
});
};
// 기록 화면 필터 상태
const AF = { entity: "", limit: 50, offset: 0 };
VIEWS.audit = async () => {
view.innerHTML = `
<div class="page-head"><h1>기록</h1></div>
<div class="filters">
<label><span class="sr-only">대상</span><select class="input" id="af-entity">${opt("", "모든 대상", AF.entity)}${opts(ENTITY_LABEL, AF.entity)}</select></label>
</div>
<div id="af-list">${loading()}</div>`;
const reload = () => loadAudit();
$("#af-entity").addEventListener("change", (e) => { AF.entity = e.target.value; AF.offset = 0; reload(); });
bindPager($("#af-list"), AF, reload);
await loadAudit();
};
async function loadAudit() {
const box = $("#af-list");
if (!box) return;
const d = await aapi(`/api/admin/audit?${params({ entity: AF.entity, limit: AF.limit, offset: AF.offset })}`);
if (!$("#af-list")) return;
const entLink = (a) => {
if (!a.entity_id) return `<span class="faint">—</span>`;
if (a.entity === "term" && a.action !== "bulk") return termLink(a.entity_id);
if (a.entity === "feedback") return `<a href="#feedback/${esc(a.entity_id)}">#${esc(a.entity_id)}</a>`;
return `<span class="small break">${esc(a.entity_id.length > 60 ? a.entity_id.slice(0, 60) + "…" : a.entity_id)}</span>`;
};
box.innerHTML = d.items.length ? `<div class="table-wrap"><table class="data">
<thead><tr><th scope="col">시각</th><th scope="col">검수자</th><th scope="col">동작</th><th scope="col">대상</th><th scope="col">ID</th><th scope="col">이전</th><th scope="col">이후</th></tr></thead>
<tbody>${d.items.map((a) => `<tr>
<td class="nowrap small">${esc(fmtDate(a.at))}</td><td>${esc(a.actor || "")}</td>
<td>${esc(ACTION_LABEL[a.action] || a.action)}</td><td>${esc(ENTITY_LABEL[a.entity] || a.entity || "")}</td>
<td>${entLink(a)}</td><td>${jsonBlock(a.before, "보기")}</td><td>${jsonBlock(a.after, "보기")}</td></tr>`).join("")}</tbody>
</table></div>${pager(d.total, AF.offset, AF.limit)}` : empty("기록이 없습니다.");
}
// ---------------------------------------------------------------- 회원·반 (소유자 전용)
const MEMBER_STATUS = { pending: "대기", approved: "이용 중", rejected: "거절", disabled: "사용 중지" };
const MEMBER_BADGE = { pending: "new", approved: "confirmed", rejected: "rejected", disabled: "duplicate" };
async function refreshMemberCount() {
// 가입 코드로 바로 가입하므로 승인 대기 표시는 없음
}
VIEWS.members = async (arg) => {
const [sub, subArg] = (arg || "list").split("/");
const [m, c] = await Promise.all([aapi("/api/admin/members"), aapi("/api/admin/classes")]);
const classes = c.items;
const counts = m.counts || {};
const classOpts = (cur, blank = "반 선택") => `<option value="">${blank}</option>` + classes.map((k) => opt(k.class_id, k.name, cur)).join("");
const rate = (a, b) => (b ? `${Math.round((a / b) * 100)}%` : "-");
const SUBS = [["list", `회원 목록 <span class="count">${m.items.length}</span>`], ["codes", "가입 코드"], ["classes", "반"], ["stats", "학습 현황"]];
const cur = sub === "u" ? "list" : sub;
view.innerHTML = `
<div class="page-head"><h1>회원 관리</h1><p class="muted">학생은 수업에서 알려 준 <b>가입 코드</b>를 넣으면 바로 회원이 되어 사례와 문제를 볼 수 있습니다. 코드마다 반이 정해져 있어 그 반으로 들어갑니다.</p></div>
<nav class="subtabs">${SUBS.map(([k, l]) => `<a href="#members/${k}"${k === cur ? ' aria-current="page"' : ""}>${l}</a>`).join("")}</nav>
<div id="mem-body"></div>`;
const body = $("#mem-body");
const reload = () => VIEWS.members(arg);
const patch = async (uid, b, msg) => {
try { await aapi(`/api/admin/members/${uid}`, { method: "PATCH", body: b }); toast(msg); refreshMemberCount(); reload(); } catch (e) { fail(e); }
};
const memberRow = (u, pendingMode) => `<tr data-uid="${u.user_id}">
<td><a href="#members/u/${u.user_id}">${esc(u.username)}</a></td><td>${esc(u.name)}</td><td>${esc(u.student_no || "")}</td>
${pendingMode ? `<td class="small">${esc(u.note || "")}</td><td class="small"><code>${esc(u.signup_code || "")}</code></td><td class="small nowrap">${esc(fmtDate(u.created_at))}</td>`
: `<td>${badge(u.status, MEMBER_STATUS).replace(`badge ${u.status}`, `badge ${MEMBER_BADGE[u.status]}`)}</td>`}
<td><select class="input" data-class>${classOpts(u.class_id)}</select></td>
${pendingMode ? "" : `<td class="small">${u.attempts} · ${rate(u.correct, u.attempts)}</td><td class="small nowrap">${esc(fmtDate(u.last_login))}</td>`}
<td class="nowrap">${pendingMode ? `<button class="btn small primary" data-approve>승인</button> <button class="btn small danger" data-reject>거절</button>`
: `<button class="btn small" data-move>반 변경</button> ${u.status === "approved" ? `<button class="btn small" data-disable>사용 중지</button>` : `<button class="btn small" data-enable>다시 사용</button>`}`}</td></tr>`;
const wireRows = () => $$("tr[data-uid]", body).forEach((tr) => {
const uid = tr.dataset.uid;
const cls = () => +$("[data-class]", tr).value || null;
$("[data-approve]", tr)?.addEventListener("click", () => cls() ? patch(uid, { status: "approved", class_id: cls() }, "승인했습니다.") : toast("승인할 반을 골라 주세요.", true));
$("[data-reject]", tr)?.addEventListener("click", () => confirm("가입을 거절할까요?") && patch(uid, { status: "rejected" }, "거절했습니다."));
$("[data-move]", tr)?.addEventListener("click", () => cls() ? patch(uid, { class_id: cls() }, "반을 바꿨습니다.") : toast("반을 골라 주세요.", true));
$("[data-disable]", tr)?.addEventListener("click", () => confirm("이 회원의 사용을 중지할까요?") && patch(uid, { status: "disabled" }, "사용을 중지했습니다."));
$("[data-enable]", tr)?.addEventListener("click", () => cls() ? patch(uid, { status: "approved", class_id: cls() }, "다시 사용할 수 있게 했습니다.") : toast("반을 골라 주세요.", true));
});
const noClassWarn = classes.length ? "" : `<p class="small warn-text">반이 없습니다. <a href="#members/classes">반</a>을 먼저 만든 뒤 가입 코드를 만들어 주세요.</p>`;
if (cur === "list") {
body.innerHTML = `<section class="card">
<div class="row filters-inline"><input class="input grow" id="mf-q" placeholder="아이디·이름·학번 검색">
<select class="input" id="mf-status" style="width:auto"><option value="">모든 상태</option>${Object.entries(MEMBER_STATUS).map(([k, l]) => opt(k, l, "")).join("")}</select>
<select class="input" id="mf-class" style="width:auto">${classOpts("", "모든 반")}</select></div>
<div id="mf-list"></div></section><section class="card" id="mem-detail" hidden></section>`;
const draw = () => {
const q = $("#mf-q").value.trim().toLowerCase(), st = $("#mf-status").value, k = $("#mf-class").value;
const items = m.items.filter((u) => (!st || u.status === st) && (!k || String(u.class_id) === k) &&
(!q || [u.username, u.name, u.student_no].some((x) => (x || "").toLowerCase().includes(q))));
$("#mf-list").innerHTML = items.length ? `<p class="small muted">${items.length}명</p><div class="table-wrap"><table class="data"><thead><tr><th>아이디</th><th>이름</th><th>학번</th><th>상태</th><th>반</th><th>푼 문제·정답률</th><th>최근 로그인</th><th></th></tr></thead>
<tbody>${items.map((u) => memberRow(u, false)).join("")}</tbody></table></div>` : empty("조건에 맞는 회원이 없습니다.");
wireRows();
};
["#mf-q", "#mf-status", "#mf-class"].forEach((s) => $(s).addEventListener("input", debounce(draw, 120)));
draw();
if (sub === "u" && subArg) showMemberDetail(+subArg, classOptions => classOpts(classOptions), patch);
} else if (cur === "codes") {
const d = await aapi("/api/admin/signup-codes");
body.innerHTML = `<section class="card"><h2>새 가입 코드</h2>
${noClassWarn}<p class="small muted">수업 시간에 학생들에게 알려 줄 코드입니다. 코드를 넣고 가입하면 바로 회원이 되어 지정한 반에 들어갑니다. 코드를 비워 두면 자동으로 만듭니다(예: K7P-2QX).</p>
<form class="code-form" id="code-new">
<label class="field"><span>코드 (선택)</span><input class="input" name="code" placeholder="자동 생성" maxlength="40"></label>
<label class="field"><span>반 (필수)</span><select class="input" name="class_id" required>${classOpts("", "반 선택")}</select></label>
<label class="field"><span>사용 횟수 제한 (선택)</span><input class="input" name="max_uses" type="number" min="1" placeholder="예: 40"></label>
<label class="field"><span>만료일 (선택)</span><input class="input" name="expires_at" type="date"></label>
<label class="field"><span>메모 (선택)</span><input class="input" name="note" maxlength="200" placeholder="예: 3월 첫 수업"></label>
<button class="btn primary" type="submit">코드 만들기</button>
</form></section>
<section class="card"><h2>가입 코드 <span class="faint">${d.items.length}</span></h2>
${d.items.length ? `<div class="table-wrap"><table class="data"><thead><tr><th>코드</th><th>반</th><th>사용</th><th>만료일</th><th>메모</th><th>상태</th><th></th></tr></thead><tbody>
${d.items.map((k) => `<tr data-code="${k.code_id}"><td><code class="big-code">${esc(k.code)}</code> <button class="btn small ghost" data-copy="${esc(k.code)}">복사</button></td>
<td><select class="input" data-f="class_id">${classOpts(k.class_id, "반 선택")}</select></td>
<td class="small">${k.uses}${k.max_uses ? ` / ${k.max_uses}` : ""}</td>
<td><input class="input" type="date" data-f="expires_at" value="${esc(k.expires_at || "")}"></td>
<td><input class="input" data-f="note" value="${esc(k.note || "")}"></td>
<td><label class="check"><input type="checkbox" data-f="active"${k.active ? " checked" : ""}> 사용</label></td>
<td class="nowrap"><button class="btn small" data-save>저장</button> <button class="btn small danger" data-del>삭제</button></td></tr>`).join("")}
</tbody></table></div>` : empty("아직 가입 코드가 없습니다. 위에서 만들어 학생들에게 알려 주세요.")}</section>`;
$("#code-new").addEventListener("submit", async (e) => {
e.preventDefault();
const f = e.target;
const b = { code: f.code.value.trim() || null, class_id: +f.class_id.value || null, note: f.note.value.trim() || null,
max_uses: +f.max_uses.value || null, expires_at: f.expires_at.value || null, active: true };
try { const r = await aapi("/api/admin/signup-codes", { method: "POST", body: b }); toast(`가입 코드 ${r.code} 를 만들었습니다.`); reload(); } catch (err) { fail(err); }
});
$$("tr[data-code]", body).forEach((tr) => {
const id = tr.dataset.code;
const k = d.items.find((x) => String(x.code_id) === id);
const g = (f) => $(`[data-f="${f}"]`, tr);
$("[data-copy]", tr).addEventListener("click", async (e) => {
try { await navigator.clipboard.writeText(e.target.dataset.copy); toast("복사했습니다."); } catch { toast(e.target.dataset.copy); }
});
$("[data-save]", tr).addEventListener("click", async () => {
const b = { class_id: +g("class_id").value || null, note: g("note").value.trim() || null, active: g("active").checked,
max_uses: k.max_uses, expires_at: g("expires_at").value || null };
try { await aapi(`/api/admin/signup-codes/${id}`, { method: "PUT", body: b }); toast("저장했습니다."); reload(); } catch (e) { fail(e); }
});
$("[data-del]", tr).addEventListener("click", async () => {
if (!confirm("이 코드를 삭제할까요? 이미 가입한 회원은 그대로 남습니다.")) return;
try { await aapi(`/api/admin/signup-codes/${id}`, { method: "DELETE" }); toast("삭제했습니다."); reload(); } catch (e) { fail(e); }
});
});
} else if (cur === "classes") {
body.innerHTML = `<section class="card">
<form class="row" id="class-new"><input class="input grow" name="name" placeholder="새 반 이름 (예: 2026-1 A반)" required maxlength="40"><input class="input grow" name="note" placeholder="메모 (선택)" maxlength="200"><button class="btn primary" type="submit">반 만들기</button></form>
${classes.length ? `<div class="table-wrap" style="margin-top:10px"><table class="data"><thead><tr><th>반</th><th>메모</th><th>회원</th><th>푼 문제</th><th>정답률</th><th></th></tr></thead><tbody>
${classes.map((k) => `<tr data-cid="${k.class_id}"><td><input class="input" data-cname value="${esc(k.name)}" maxlength="40"></td><td><input class="input" data-cnote value="${esc(k.note || "")}"></td>
<td><a href="#members/list" data-filter-class="${k.class_id}">${k.members}</a></td><td>${k.attempts}</td><td>${rate(k.correct, k.attempts)}</td>
<td class="nowrap"><button class="btn small" data-csave>저장</button> <a class="btn small" href="#members/stats/${k.class_id}">학습 현황</a> <button class="btn small danger" data-cdel>삭제</button></td></tr>`).join("")}
</tbody></table></div>` : empty("반이 없습니다.")}</section>`;
$("#class-new").addEventListener("submit", async (e) => {
e.preventDefault();
const f = e.target;
try { await aapi("/api/admin/classes", { method: "POST", body: { name: f.name.value.trim(), note: f.note.value.trim() || null } }); toast("반을 만들었습니다."); reload(); } catch (err) { fail(err); }
});
$$("tr[data-cid]", body).forEach((tr) => {
const cid = tr.dataset.cid;
$("[data-csave]", tr).addEventListener("click", async () => {
try { await aapi(`/api/admin/classes/${cid}`, { method: "PUT", body: { name: $("[data-cname]", tr).value.trim(), note: $("[data-cnote]", tr).value.trim() || null } }); toast("저장했습니다."); } catch (e) { fail(e); }
});
$("[data-cdel]", tr).addEventListener("click", async () => {
if (!confirm("이 반을 삭제할까요?")) return;
try { await aapi(`/api/admin/classes/${cid}`, { method: "DELETE" }); toast("삭제했습니다."); reload(); } catch (e) { fail(e); }
});
});
} else if (cur === "stats") {
const cid = subArg || "";
body.innerHTML = `<section class="card"><div class="row"><label class="small muted">반</label><select class="input" id="st-class" style="width:auto">${classOpts(cid, "전체")}</select></div><div id="st-body">${loading()}</div></section>`;
$("#st-class").addEventListener("change", (e) => (location.hash = `members/stats/${e.target.value}`));
const d = await aapi(`/api/admin/class-stats${cid ? `?class_id=${cid}` : ""}`);
$("#st-body").innerHTML = `<div class="split-2" style="margin-top:12px"><div><h3 class="small muted">단원별 정답률</h3>${d.by_galaxy.length ? d.by_galaxy.map((g) => {
const p = pct(g.correct, g.attempts);
return `<div class="row small"><span class="grow">${esc(g.name)}</span><span>${g.correct}/${g.attempts} · ${p}%</span></div><div class="pbar"><i style="width:${p}%"></i></div>`;
}).join("") : empty("아직 푼 문제가 없습니다.")}</div>
<div><h3 class="small muted">많이 틀린 용어</h3>${d.wrong_terms.length ? `<ul class="plain">${d.wrong_terms.map((t) => `<li>${termLink(t.term_id, t.name)} <span class="faint">${t.wrong}/${t.attempts} 오답</span></li>`).join("")}</ul>` : empty("틀린 용어가 없습니다.")}</div></div>`;
}
};
async function showMemberDetail(uid, _classOpts, patch) {
const box = $("#mem-detail");
box.hidden = false;
box.innerHTML = loading();
let u;
try { u = await aapi(`/api/admin/members/${uid}`); } catch (e) { box.innerHTML = ""; fail(e); return; }
const total = u.by_galaxy.reduce((a, g) => a + g.attempts, 0), right = u.by_galaxy.reduce((a, g) => a + g.correct, 0);
box.innerHTML = `<div class="row"><h2 class="grow">${esc(u.name)} <span class="faint small">${esc(u.username)}${u.student_no ? " · " + esc(u.student_no) : ""}</span></h2>
${badge(u.status, MEMBER_STATUS).replace(`badge ${u.status}`, `badge ${MEMBER_BADGE[u.status]}`)} <a class="btn small" href="#members/list">닫기</a></div>
<p class="small muted">반: ${esc(u.class_name || "미지정")} · 가입 코드: ${esc(u.signup_code || "-")} · 가입 ${esc(fmtDate(u.created_at))} · 승인 ${esc(fmtDate(u.approved_at) || "-")} (${esc(u.approved_by || "")}) · 최근 로그인 ${esc(fmtDate(u.last_login) || "-")}</p>
${u.note ? `<p class="small">남긴 말: ${esc(u.note)}</p>` : ""}
<p><b>${total}</b>문제 중 <b>${right}</b>개 정답 (${total ? Math.round((right / total) * 100) : 0}%)</p>
<div class="split-2"><div><h3 class="small muted">단원별</h3>${u.by_galaxy.length ? u.by_galaxy.map((g) => {
const p = pct(g.correct, g.attempts);
return `<div class="row small"><span class="grow">${esc(g.name)}</span><span>${g.correct}/${g.attempts}</span></div><div class="pbar"><i style="width:${p}%"></i></div>`;
}).join("") : empty("푼 문제가 없습니다.")}
<h3 class="small muted" style="margin-top:12px">최근 학습일</h3>${u.days.length ? `<ul class="plain small">${u.days.map((d) => `<li>${esc(d.day)} · ${d.attempts}문제 · 정답 ${d.correct}</li>`).join("")}</ul>` : empty("기록 없음")}</div>
<div><h3 class="small muted">자주 틀린 용어</h3>${u.wrong_terms.length ? `<ul class="plain">${u.wrong_terms.map((t) => `<li>${termLink(t.term_id, t.name)} <span class="faint">${t.wrong}회</span></li>`).join("")}</ul>` : empty("없음")}
<h3 class="small muted" style="margin-top:12px">최근 풀이</h3>${u.recent.length ? `<ul class="plain small">${u.recent.map((r) => `<li>${r.correct ? "⭕" : "❌"} ${esc(r.term_name || "-")} <span class="faint">${esc(fmtDate(r.created_at))}</span></li>`).join("")}</ul>` : empty("없음")}</div></div>
<div class="row" style="margin-top:12px">
<button class="btn small" data-reset>비밀번호 재설정</button>
${u.status === "approved" ? `<button class="btn small" data-disable>사용 중지</button>` : ""}
<button class="btn small danger" data-del>회원 삭제</button></div>`;
$("[data-reset]", box).addEventListener("click", () => {
const pw = prompt("새 비밀번호 (6자 이상). 학생에게 직접 알려 주세요.");
if (pw) patch(uid, { new_password: pw }, "비밀번호를 바꿨습니다.");
});
$("[data-disable]", box)?.addEventListener("click", () => confirm("이 회원의 사용을 중지할까요?") && patch(uid, { status: "disabled" }, "사용을 중지했습니다."));
$("[data-del]", box).addEventListener("click", async () => {
if (!confirm("회원을 삭제할까요? 다시 로그인할 수 없습니다.")) return;
try { await aapi(`/api/admin/members/${uid}`, { method: "DELETE" }); toast("삭제했습니다."); location.hash = "members/list"; } catch (e) { fail(e); }
});
box.scrollIntoView({ behavior: "smooth", block: "start" });
}
// ---------------------------------------------------------------- 문제 (직접 출제)
VIEWS.codes = async () => VIEWS.members("codes");
/** 대시보드 상단: 반을 골라 바로 가입 코드 만들기 + 사용 중인 코드 */
async function renderQuickCode(box) {
if (!box) return;
let classes, codes;
try {
[classes, codes] = await Promise.all([aapi("/api/admin/classes"), aapi("/api/admin/signup-codes")]);
} catch (e) { box.innerHTML = ""; fail(e); return; }
const active = codes.items.filter((k) => k.active);
const copyBtn = (c) => `<button type="button" class="btn small ghost" data-copy="${esc(c)}">복사</button>`;
box.innerHTML = `<div class="row"><h2 class="grow">가입 코드</h2><a class="btn small" href="#codes">코드 관리</a></div>
${classes.items.length ? `<form class="row" id="qc-form">
<select class="input" name="class_id" required style="width:auto">${classes.items.map((k) => opt(k.class_id, k.name, "")).join("")}</select>
<input class="input" name="max_uses" type="number" min="1" placeholder="사용 횟수 (선택)" style="width:150px">
<input class="input" name="expires_at" type="date" title="만료일 (선택)" style="width:auto">
<button class="btn primary" type="submit">코드 만들기</button></form>
<div id="qc-new"></div>`
: `<form class="row" id="qc-class"><span class="small muted">먼저 반을 만들어 주세요.</span><input class="input grow" name="name" placeholder="반 이름 (예: 2026-1 A반)" required maxlength="40"><button class="btn primary" type="submit">반 만들기</button></form>`}
${active.length ? `<div class="qc-list">${active.map((k) => `<div class="qc-item"><code class="big-code">${esc(k.code)}</code><span class="small muted">${esc(k.class_name || "")} · ${k.uses}${k.max_uses ? "/" + k.max_uses : ""}명${k.expires_at ? " · ~" + esc(k.expires_at) : ""}</span>${copyBtn(k.code)}</div>`).join("")}</div>`
: `<p class="small muted" style="margin-top:8px">사용 중인 가입 코드가 없습니다.</p>`}`;
$("#qc-form")?.addEventListener("submit", async (e) => {
e.preventDefault();
const f = e.target;
try {
const r = await aapi("/api/admin/signup-codes", { method: "POST", body: { class_id: +f.class_id.value, max_uses: +f.max_uses.value || null, expires_at: f.expires_at.value || null, active: true } });
toast(`가입 코드 ${r.code} 를 만들었습니다.`);
await renderQuickCode(box);
$("#qc-new", box).innerHTML = `<div class="qc-made">새 코드 <code class="big-code">${esc(r.code)}</code> ${copyBtn(r.code)} <span class="small muted">학생들에게 알려 주세요. /signup 에서 입력합니다.</span></div>`;
wireCopy(box);
} catch (err) { fail(err); }
});
$("#qc-class")?.addEventListener("submit", async (e) => {
e.preventDefault();
try { await aapi("/api/admin/classes", { method: "POST", body: { name: e.target.name.value.trim() } }); toast("반을 만들었습니다."); renderQuickCode(box); } catch (err) { fail(err); }
});
wireCopy(box);
}
function wireCopy(root) {
$$("[data-copy]", root).forEach((b) => (b.onclick = async () => {
try { await navigator.clipboard.writeText(b.dataset.copy); toast("복사했습니다."); } catch { toast(b.dataset.copy); }
}));
}
VIEWS.questions = async (arg) => {
const termId = arg || null;
const d = await aapi("/api/admin/questions" + (termId ? `?term=${encodeURIComponent(termId)}` : ""));
let termName = "";
if (termId) { try { termName = (await aapi(`/api/admin/terms/${encodeURIComponent(termId)}`)).name; } catch { /* 무시 */ } }
view.innerHTML = `
<div class="page-head"><h1>문제</h1><p class="muted">쉬운 설명이 있는 용어는 자동 객관식(뜻→용어, 용어→뜻)이 만들어집니다. 여기서는 직접 출제한 문제를 관리합니다.
‘검토 중’·‘확정’ 문제만 회원에게 나갑니다.</p></div>
${termId ? `<p>용어: <b>${esc(termName || termId)}</b> · ${termLink(termId, "용어 편집")} · <a href="#questions">전체 문제</a></p>` : ""}
<div class="split-2">
<section class="card"><h2 id="q-form-title">새 문제</h2><form id="q-form">
<label class="field"><span>관련 용어 (선택)</span><input class="input" name="term_q" placeholder="용어 이름으로 찾기" value="${esc(termName)}" autocomplete="off"><input type="hidden" name="term_id" value="${esc(termId || "")}"><div class="q-ac" id="q-ac" hidden></div></label>
<label class="field"><span>유형</span><select class="input" name="qtype"><option value="mcq">객관식</option><option value="ox">OX</option></select></label>
<label class="field"><span>문제</span><textarea class="input" name="prompt" required maxlength="2000"></textarea></label>
<label class="field" data-mcq><span>보기 (한 줄에 하나, 2~6개)</span><textarea class="input" name="choices"></textarea></label>
<label class="field"><span>정답</span><select class="input" name="answer"></select></label>
<label class="field"><span>해설 (선택)</span><textarea class="input" name="explanation" maxlength="2000"></textarea></label>
<label class="field"><span>검수 상태</span><select class="input" name="review_status">${opts(STATUS_LABEL, "review").replace(/<option value="rejected".*?<\/option>/, "")}</select></label>
<label class="check"><input type="checkbox" name="hidden"> 숨기기</label>
<div class="row" style="margin-top:10px"><button class="btn primary" type="submit">저장</button><button class="btn" type="button" id="q-reset">새로 쓰기</button></div>
</form></section>
<section class="card"><h2>자동 문제 미리보기</h2><div id="q-preview">${termId ? loading() : empty("관련 용어를 고르면 그 용어로 만들어지는 자동 문제를 보여 줍니다.")}</div></section>
</div>
<section class="card"><h2>직접 출제한 문제 <span class="faint">${d.items.length}</span></h2>
${d.items.length ? `<div class="table-wrap"><table class="data"><thead><tr><th>#</th><th>용어</th><th>유형</th><th>문제</th><th>정답</th><th>상태</th><th>풀이·정답률</th><th></th></tr></thead><tbody>
${d.items.map((q) => `<tr data-qid="${q.question_id}"><td>${q.question_id}</td><td>${q.term_id ? termLink(q.term_id, q.term_name) : `<span class="faint">-</span>`}</td>
<td>${q.qtype === "ox" ? "OX" : "객관식"}</td><td class="small">${esc(q.prompt)}</td>
<td class="small">${esc(q.qtype === "ox" ? ["O", "X"][q.answer] : (q.choices || [])[q.answer] || "")}</td>
<td>${badge(q.review_status)}${q.hidden ? ` <span class="badge">숨김</span>` : ""}</td><td class="small">${q.attempts} · ${q.attempts ? pct(q.correct, q.attempts) + "%" : "-"}</td>
<td class="nowrap"><button class="btn small" data-edit>수정</button> <button class="btn small danger" data-del>삭제</button></td></tr>`).join("")}
</tbody></table></div>` : empty("직접 출제한 문제가 없습니다.")}
</section>`;
const f = $("#q-form");
let editing = null;
const syncAnswer = (cur = 0) => {
const ox = f.qtype.value === "ox";
$("[data-mcq]", f).hidden = ox;
const ch = ox ? ["O", "X"] : f.choices.value.split("\n").map((x) => x.trim()).filter(Boolean);
f.answer.innerHTML = ch.map((c, i) => opt(i, `${ox ? "" : i + 1 + ". "}${c}`, cur)).join("") || `<option value="">보기를 먼저 입력하세요</option>`;
};
f.qtype.addEventListener("change", () => syncAnswer());
f.choices.addEventListener("input", () => syncAnswer(+f.answer.value || 0));
syncAnswer();
const loadPreview = async (tid) => {
const box = $("#q-preview");
if (!tid) { box.innerHTML = empty("관련 용어를 고르면 그 용어로 만들어지는 자동 문제를 보여 줍니다."); return; }
box.innerHTML = loading();
try {
const p = await aapi(`/api/admin/questions/preview?term=${encodeURIComponent(tid)}`);
box.innerHTML = p.items.length ? p.items.map((q) => `<div class="q-prev"><p><b>${esc(q.prompt)}</b></p>${q.quote ? `<p class="small muted">「${esc(q.quote)}」</p>` : ""}
<ol class="small">${q.choices.map((c, i) => `<li${i === q.answer ? ' class="ok-text"' : ""}>${esc(c)}${i === q.answer ? " ✓" : ""}</li>`).join("")}</ol></div>`).join("") : empty(p.reason || "자동 문제를 만들 수 없습니다.");
} catch (e) { box.innerHTML = ""; fail(e); }
};
if (termId) loadPreview(termId);
// 용어 자동완성
const ac = $("#q-ac");
f.term_q.addEventListener("input", debounce(async () => {
const q = f.term_q.value.trim();
f.term_id.value = "";
if (!q) { ac.hidden = true; loadPreview(null); return; }
try {
const r = await aapi(`/api/admin/terms?q=${encodeURIComponent(q)}&limit=10`);
ac.innerHTML = r.items.map((t) => `<button type="button" data-tid="${esc(t.term_id)}">${esc(t.name)} <span class="faint">${esc(S.galaxyById[t.galaxy_id]?.name || "")}</span></button>`).join("") || `<div class="faint small">없음</div>`;
ac.hidden = false;
} catch (e) { fail(e); }
}, 200));
ac.addEventListener("click", (e) => {
const b = e.target.closest("[data-tid]");
if (!b) return;
f.term_id.value = b.dataset.tid;
f.term_q.value = b.textContent.replace(/\s+\S+$/, "").trim();
ac.hidden = true;
loadPreview(b.dataset.tid);
});
const reset = () => { editing = null; f.reset(); f.term_id.value = termId || ""; f.term_q.value = termName; $("#q-form-title").textContent = "새 문제"; syncAnswer(); };
$("#q-reset").addEventListener("click", reset);
f.addEventListener("submit", async (e) => {
e.preventDefault();
const body = {
term_id: f.term_id.value || null, qtype: f.qtype.value, prompt: f.prompt.value.trim(),
choices: f.qtype.value === "mcq" ? f.choices.value.split("\n").map((x) => x.trim()).filter(Boolean) : [],
answer: +f.answer.value, explanation: f.explanation.value.trim() || null,
review_status: f.review_status.value, hidden: f.hidden.checked,
};
try {
if (editing) await aapi(`/api/admin/questions/${editing}`, { method: "PUT", body });
else await aapi("/api/admin/questions", { method: "POST", body });
toast("저장했습니다.");
VIEWS.questions(termId);
} catch (err) { fail(err); }
});
$$("tr[data-qid]", view).forEach((tr) => {
const q = d.items.find((x) => x.question_id === +tr.dataset.qid);
$("[data-edit]", tr).addEventListener("click", () => {
editing = q.question_id;
$("#q-form-title").textContent = `문제 #${q.question_id} 수정`;
f.term_id.value = q.term_id || "";
f.term_q.value = q.term_name || "";
f.qtype.value = q.qtype;
f.prompt.value = q.prompt;
f.choices.value = (q.choices || []).join("\n");
f.explanation.value = q.explanation || "";
f.review_status.value = q.review_status;
f.hidden.checked = !!q.hidden;
syncAnswer(q.answer);
loadPreview(q.term_id);
f.scrollIntoView({ behavior: "smooth", block: "start" });
});
$("[data-del]", tr).addEventListener("click", async () => {
if (!confirm("이 문제를 삭제할까요?")) return;
try { await aapi(`/api/admin/questions/${q.question_id}`, { method: "DELETE" }); toast("삭제했습니다."); VIEWS.questions(termId); } catch (e) { fail(e); }
});
});
};
// ---------------------------------------------------------------- 시작
function syncTopbarHeight() {
document.documentElement.style.setProperty("--topbar-h", `${$("#topbar").offsetHeight}px`);
}
window.addEventListener("resize", debounce(syncTopbarHeight, 100));
syncTopbarHeight();
(async function init() {
try {
const me = await api("/api/admin/me");
if (!me.name) return showLogin();
S.me = me.name;
S.role = me.role;
showApp();
route();
refreshFeedbackCount();
} catch (e) {
showLogin();
toast(e.message, true);
}
})();