// 오류 신고 페이지: 신고 양식 + 공개 신고 목록.
import { esc, api, toast, fmtDate, debounce, qs, FEEDBACK_STATUS_LABEL } from "./common.js";
const TYPES = [
{ key: "meaning", label: "뜻·정의가 틀림", kind: "content" },
{ key: "relation", label: "관계가 틀림", kind: "content" },
{ key: "source", label: "출처·페이지가 틀림", kind: "content" },
{ key: "chapter", label: "소속 단원이 틀림", kind: "content" },
{ key: "typo", label: "오타·표기", kind: "content" },
{ key: "missing", label: "빠진 용어", kind: "suggestion" },
{ key: "search", label: "검색이 안 됨", kind: "search" },
{ key: "bug", label: "화면·기능 오류", kind: "bug" },
{ key: "other", label: "기타 제안", kind: "suggestion" },
];
const PAGE = 20;
const $ = (id) => document.getElementById(id);
const form = $("fb-form");
const typeSel = $("fb-type");
const termInput = $("fb-term");
const termList = $("fb-term-list");
const chosenEl = $("fb-term-chosen");
const relEl = $("fb-rel-chosen");
const msg = $("fb-message");
const state = {
terms: null, // [{i,n,a,g}]
galaxies: {},
term: null, // 선택한 용어 {i,n,g}
relation: null, // relation_id
acItems: [],
acActive: -1,
status: "",
offset: 0,
};
// ---------------------------------------------------------------- 양식
typeSel.innerHTML = TYPES.map((t) => ``).join("");
async function loadAtlas() {
if (state.terms) return state.terms;
try {
const d = await api("/api/atlas");
state.galaxies = Object.fromEntries((d.galaxies || []).map((g) => [g.galaxy_id, g.name]));
state.terms = (d.terms || []).map((t) => ({ i: t.i, n: t.n, a: t.a || [], g: t.g, key: [t.n, ...(t.a || [])].join("\u0001").toLowerCase() }));
} catch (e) {
state.terms = [];
toast("용어 목록을 불러오지 못했습니다. 용어 이름을 메시지에 적어 주세요.", true);
}
return state.terms;
}
function renderChosen() {
if (state.term) {
const g = state.galaxies[state.term.g];
chosenEl.innerHTML = `선택한 용어: ${esc(state.term.n)}${g ? ` · ${esc(g)}` : ""}
개념 우주에서 보기
`;
chosenEl.hidden = false;
termInput.value = state.term.n;
} else {
chosenEl.hidden = true;
chosenEl.innerHTML = "";
}
if (state.relation) {
relEl.innerHTML = `신고 대상 관계: ${esc(state.relation)}
`;
relEl.hidden = false;
} else {
relEl.hidden = true;
relEl.innerHTML = "";
}
}
chosenEl.addEventListener("click", (e) => {
if (e.target.closest("[data-act=clear-term]")) {
state.term = null;
termInput.value = "";
renderChosen();
termInput.focus();
}
});
relEl.addEventListener("click", (e) => {
if (e.target.closest("[data-act=clear-rel]")) {
state.relation = null;
renderChosen();
}
});
function closeAc() {
termList.hidden = true;
termList.innerHTML = "";
termInput.setAttribute("aria-expanded", "false");
termInput.removeAttribute("aria-activedescendant");
state.acItems = [];
state.acActive = -1;
}
function searchTerms(q) {
const s = q.trim().toLowerCase();
if (!s || !state.terms) return [];
const starts = [], has = [];
for (const t of state.terms) {
const idx = t.key.indexOf(s);
if (idx < 0) continue;
(t.n.toLowerCase().startsWith(s) ? starts : has).push(t);
if (starts.length >= 12) break;
}
return [...starts, ...has].slice(0, 12);
}
const onType = debounce(async () => {
await loadAtlas();
const q = termInput.value;
if (state.term && q !== state.term.n) {
state.term = null;
renderChosen();
}
const items = searchTerms(q);
state.acItems = items;
state.acActive = -1;
if (!q.trim()) return closeAc();
if (!items.length) {
termList.innerHTML = `
찾는 용어가 없습니다. 빠진 용어라면 유형을 ‘빠진 용어’로 골라 주세요.`;
} else {
const s = q.trim().toLowerCase();
termList.innerHTML = items.map((t, i) => {
const alias = t.n.toLowerCase().includes(s) ? "" : (t.a.find((a) => a.toLowerCase().includes(s)) || "");
return `
${esc(t.n)}${alias ? ` (${esc(alias)})` : ""}
${esc(state.galaxies[t.g] || "")}`;
}).join("");
}
termList.hidden = false;
termInput.setAttribute("aria-expanded", "true");
}, 120);
function setActive(i) {
const lis = termList.querySelectorAll("[data-i]");
if (!lis.length) return;
state.acActive = (i + lis.length) % lis.length;
lis.forEach((li, k) => li.setAttribute("aria-selected", String(k === state.acActive)));
const cur = lis[state.acActive];
termInput.setAttribute("aria-activedescendant", cur.id);
cur.scrollIntoView({ block: "nearest" });
}
function pick(i) {
const t = state.acItems[i];
if (!t) return;
state.term = t;
closeAc();
renderChosen();
}
termInput.addEventListener("input", onType);
termInput.addEventListener("focus", () => loadAtlas());
termInput.addEventListener("keydown", (e) => {
if (termList.hidden) return;
if (e.key === "ArrowDown") { e.preventDefault(); setActive(state.acActive + 1); }
else if (e.key === "ArrowUp") { e.preventDefault(); setActive(state.acActive - 1); }
else if (e.key === "Enter" && state.acActive >= 0) { e.preventDefault(); pick(state.acActive); }
else if (e.key === "Escape") { closeAc(); }
});
termList.addEventListener("mousedown", (e) => {
const li = e.target.closest("[data-i]");
if (li) { e.preventDefault(); pick(+li.dataset.i); }
});
termInput.addEventListener("blur", () => setTimeout(closeAc, 150));
msg.addEventListener("input", () => { $("fb-message-count").textContent = `${msg.value.length} / 4000`; });
async function prefill() {
const page = qs("page");
$("fb-page").value = page || (document.referrer && new URL(document.referrer).origin === location.origin ? document.referrer : "");
const rel = qs("relation");
if (rel) {
state.relation = rel.slice(0, 80);
typeSel.value = "relation";
}
const tid = qs("term");
if (tid) {
const terms = await loadAtlas();
const t = terms.find((x) => x.i === tid);
if (t) state.term = t;
else state.term = { i: tid, n: tid, a: [], g: null };
}
renderChosen();
}
form.addEventListener("reset", () => {
setTimeout(() => {
state.term = null;
state.relation = null;
renderChosen();
$("fb-message-count").textContent = "0 / 4000";
});
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
const message = msg.value.trim();
if (message.length < 2) {
toast("무엇이 잘못되었는지 2자 이상 적어 주세요.", true);
msg.focus();
return;
}
const type = TYPES.find((t) => t.key === typeSel.value) || TYPES[0];
const body = {
kind: type.kind,
category: type.label,
message,
suggestion: $("fb-suggestion").value.trim() || null,
page_url: $("fb-page").value.trim().slice(0, 500) || null,
reporter: $("fb-reporter").value.trim() || null,
contact: $("fb-contact").value.trim() || null,
website: $("fb-website").value || null,
};
if (state.relation) {
body.target_type = "relation";
body.target_id = state.relation;
body.target_label = state.term ? `${state.term.n}의 관계` : null;
} else if (state.term) {
body.target_type = "term";
body.target_id = state.term.i;
body.target_label = state.term.n;
} else if (termInput.value.trim()) {
// 목록에 없는 용어(빠진 용어 등)는 이름만 남긴다.
body.target_type = "text";
body.target_label = termInput.value.trim().slice(0, 200);
}
const btn = $("fb-submit");
btn.disabled = true;
btn.textContent = "보내는 중…";
try {
const r = await api("/api/feedback", { method: "POST", body });
const done = $("fb-done");
done.innerHTML = `신고가 접수되었습니다. 접수번호 #${esc(r.id)} · 처리 결과는 아래 목록에서 확인하실 수 있습니다. 고맙습니다.`;
done.hidden = false;
form.reset();
done.focus();
state.offset = 0;
loadList(false);
} catch (err) {
if (err.status === 429) toast("신고가 너무 잦습니다. 잠시 후 다시 시도해 주세요.", true);
else if (err.status === 400 || err.status === 422) toast(`입력을 확인해 주세요: ${err.message}`, true);
else toast(err.message || "신고를 보내지 못했습니다.", true);
} finally {
btn.disabled = false;
btn.textContent = "신고 보내기";
}
});
// ---------------------------------------------------------------- 공개 목록
const CHIPS = [["", "전체"], ["new", "접수"], ["in_progress", "처리 중"], ["resolved", "반영 완료"], ["rejected", "반영 안 함"]];
const chipsEl = $("fb-chips");
chipsEl.innerHTML = CHIPS.map(([k, l]) => ``).join("");
chipsEl.addEventListener("click", (e) => {
const b = e.target.closest("[data-status]");
if (!b) return;
state.status = b.dataset.status;
chipsEl.querySelectorAll(".chip").forEach((c) => c.setAttribute("aria-pressed", String(c === b)));
state.offset = 0;
loadList(false);
});
const listEl = $("fb-list");
const moreBtn = $("fb-more");
const LONG = 220;
function itemHtml(f) {
let target = "";
if (f.target_type === "term" && f.target_id) {
target = `${esc(f.target_label || f.target_id)}`;
} else if (f.target_label) {
target = esc(f.target_label);
}
const m = f.message || "";
const long = m.length > LONG;
const st = f.status || "new";
return `
#${esc(f.id)}
${esc(FEEDBACK_STATUS_LABEL[st] || st)}
${esc(f.category || "")}
${target ? `· ${target}` : ""}
${esc(m)}
${long ? `` : ""}
${f.resolution ? `처리 결과: ${esc(f.resolution)}
` : ""}
`;
}
listEl.addEventListener("click", (e) => {
const b = e.target.closest("[data-act=expand]");
if (!b) return;
const p = b.previousElementSibling;
const open = p.classList.toggle("clamped") === false;
b.setAttribute("aria-expanded", String(open));
b.textContent = open ? "접기" : "전체 보기";
});
let listReq = 0;
async function loadList(append) {
const my = ++listReq;
if (!append) listEl.innerHTML = `불러오는 중…`;
moreBtn.disabled = true;
const p = new URLSearchParams({ limit: PAGE, offset: state.offset });
if (state.status) p.set("status", state.status);
try {
const d = await api(`/api/feedback?${p}`);
if (my !== listReq) return;
const html = d.items.map(itemHtml).join("");
if (append) listEl.insertAdjacentHTML("beforeend", html);
else listEl.innerHTML = html || `아직 접수된 신고가 없습니다.`;
state.offset += d.items.length;
$("fb-total").textContent = `총 ${d.total}건`;
moreBtn.hidden = state.offset >= d.total;
} catch (err) {
if (my !== listReq) return;
if (!append) listEl.innerHTML = `목록을 불러오지 못했습니다.`;
toast(err.message, true);
} finally {
moreBtn.disabled = false;
}
}
moreBtn.addEventListener("click", () => loadList(true));
prefill();
loadList(false);