// 오류 신고 페이지: 신고 양식 + 공개 신고 목록. 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);