- 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.
334 lines
12 KiB
JavaScript
334 lines
12 KiB
JavaScript
// 오류 신고 페이지: 신고 양식 + 공개 신고 목록.
|
|
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) => `<option value="${t.key}">${esc(t.label)}</option>`).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 = `<span>선택한 용어: <strong>${esc(state.term.n)}</strong>${g ? ` <span class="faint">· ${esc(g)}</span>` : ""}</span>
|
|
<a href="/#/t/${encodeURIComponent(state.term.i)}" target="_blank" rel="noopener">개념 우주에서 보기</a>
|
|
<button type="button" class="btn small ghost" data-act="clear-term" aria-label="선택한 용어 지우기">지우기</button>`;
|
|
chosenEl.hidden = false;
|
|
termInput.value = state.term.n;
|
|
} else {
|
|
chosenEl.hidden = true;
|
|
chosenEl.innerHTML = "";
|
|
}
|
|
if (state.relation) {
|
|
relEl.innerHTML = `<span>신고 대상 관계: <code>${esc(state.relation)}</code></span>
|
|
<button type="button" class="btn small ghost" data-act="clear-rel" aria-label="관계 선택 지우기">지우기</button>`;
|
|
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 = `<li class="ac-empty" role="option" aria-disabled="true">찾는 용어가 없습니다. 빠진 용어라면 유형을 ‘빠진 용어’로 골라 주세요.</li>`;
|
|
} 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 `<li id="fb-ac-${i}" role="option" data-i="${i}" aria-selected="false">
|
|
<span class="ac-name">${esc(t.n)}</span>${alias ? ` <span class="faint">(${esc(alias)})</span>` : ""}
|
|
<span class="ac-sub">${esc(state.galaxies[t.g] || "")}</span></li>`;
|
|
}).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 = `<strong>신고가 접수되었습니다.</strong> 접수번호 <b>#${esc(r.id)}</b> · 처리 결과는 아래 목록에서 확인하실 수 있습니다. 고맙습니다.`;
|
|
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]) => `<button type="button" class="chip" data-status="${k}" aria-pressed="${k === "" ? "true" : "false"}">${esc(l)}</button>`).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 = `<a href="/#/t/${encodeURIComponent(f.target_id)}">${esc(f.target_label || f.target_id)}</a>`;
|
|
} 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 `<li class="fb-item">
|
|
<div class="fb-item-head">
|
|
<span class="fb-id">#${esc(f.id)}</span>
|
|
<span class="badge ${esc(st)}">${esc(FEEDBACK_STATUS_LABEL[st] || st)}</span>
|
|
<span class="fb-cat">${esc(f.category || "")}</span>
|
|
${target ? `<span class="fb-target">· ${target}</span>` : ""}
|
|
<time class="faint fb-date" datetime="${esc(f.created_at)}">${esc(fmtDate(f.created_at))}</time>
|
|
</div>
|
|
<p class="fb-msg${long ? " clamped" : ""}">${esc(m)}</p>
|
|
${long ? `<button type="button" class="linkish" data-act="expand" aria-expanded="false">전체 보기</button>` : ""}
|
|
${f.resolution ? `<p class="fb-res"><strong>처리 결과:</strong> ${esc(f.resolution)}</p>` : ""}
|
|
</li>`;
|
|
}
|
|
|
|
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 = `<li class="fb-empty muted">불러오는 중…</li>`;
|
|
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 || `<li class="fb-empty muted">아직 접수된 신고가 없습니다.</li>`;
|
|
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 = `<li class="fb-empty muted">목록을 불러오지 못했습니다.</li>`;
|
|
toast(err.message, true);
|
|
} finally {
|
|
moreBtn.disabled = false;
|
|
}
|
|
}
|
|
moreBtn.addEventListener("click", () => loadList(true));
|
|
|
|
prefill();
|
|
loadList(false);
|