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.
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
// 내 정보: 로그인한 학생의 반·학습 기록 (로그인 안 했으면 /login 으로)
|
||||
import { api, esc, toast } from "./common.js";
|
||||
|
||||
const main = document.getElementById("main");
|
||||
const next = new URLSearchParams(location.search).get("next") || "/";
|
||||
|
||||
async function load() {
|
||||
let me;
|
||||
try { me = await api("/api/members/me"); } catch (e) { main.innerHTML = `<p class="muted">불러오지 못했습니다: ${esc(e.message)}</p>`; return; }
|
||||
if (!me.logged_in) return renderGuest(me);
|
||||
if (me.status === "approved") return renderMember(me);
|
||||
main.innerHTML = `<h1>${esc(me.name)} 님</h1>
|
||||
<div class="card" style="margin-top:16px">
|
||||
<div class="acc-status"><span class="badge new">사용할 수 없는 계정</span>
|
||||
<span class="acc-note">담당 교수자에게 문의해 주세요.</span></div>
|
||||
<p style="margin-top:14px"><button class="btn" id="logout">로그아웃</button> <a class="btn ghost" href="/">개념 우주로</a></p>
|
||||
</div>`;
|
||||
document.getElementById("logout").onclick = logout;
|
||||
}
|
||||
|
||||
function renderGuest(me) {
|
||||
if (me.admin) {
|
||||
main.innerHTML = `<h1>${esc(me.admin)} 님</h1><p class="acc-note">교수자로 로그인되어 있습니다.</p>
|
||||
<p><a class="btn primary" href="/admin">관리 화면</a> <a class="btn" href="/members">회원 관리</a></p>`;
|
||||
return;
|
||||
}
|
||||
location.replace("/login?next=" + encodeURIComponent("/account"));
|
||||
}
|
||||
|
||||
async function renderMember(me) {
|
||||
const rate = me.attempts ? Math.round((me.correct / me.attempts) * 100) : 0;
|
||||
main.innerHTML = `<h1>${esc(me.name)} 님</h1>
|
||||
<div class="acc-status"><span class="badge confirmed">승인됨</span><span class="acc-note">${esc(me.class_name || "")}</span>
|
||||
<button class="btn small" id="logout">로그아웃</button></div>
|
||||
<div class="acc-stats">
|
||||
<div class="acc-stat"><small>푼 문제</small><b>${me.attempts}</b></div>
|
||||
<div class="acc-stat"><small>맞힌 문제</small><b>${me.correct}</b></div>
|
||||
<div class="acc-stat"><small>정답률</small><b>${rate}%</b></div>
|
||||
</div>
|
||||
<p style="margin-top:14px"><a class="btn primary" href="/#/quiz">문제 풀러 가기</a> <a class="btn" href="/#/quiz/wrong">틀린 문제 다시 풀기</a></p>
|
||||
<div class="acc-grid" id="stats"><p class="muted">기록을 불러오는 중…</p></div>`;
|
||||
document.getElementById("logout").onclick = logout;
|
||||
try {
|
||||
const s = await api("/api/members/my-stats");
|
||||
document.getElementById("stats").innerHTML = `
|
||||
<div class="card"><h2>단원별 정답률</h2>${s.by_galaxy.length ? s.by_galaxy.map((g) => {
|
||||
const p = g.attempts ? Math.round((g.correct / g.attempts) * 100) : 0;
|
||||
return `<div style="margin-bottom:10px"><div class="row"><span class="grow">${esc(g.name)}</span><span class="acc-note">${g.correct}/${g.attempts} · ${p}%</span></div><div class="bar"><i style="width:${p}%"></i></div></div>`;
|
||||
}).join("") : `<p class="acc-note">아직 푼 문제가 없습니다.</p>`}</div>
|
||||
<div class="card"><h2>자주 틀린 용어</h2>${s.wrong_terms.length ? `<div class="chips">${s.wrong_terms.map((t) => `<a href="/#/t/${esc(t.term_id)}">${esc(t.name)} · ${t.wrong}</a>`).join("")}</div>` : `<p class="acc-note">틀린 용어가 없습니다.</p>`}</div>`;
|
||||
} catch (e) { document.getElementById("stats").innerHTML = `<p class="muted">${esc(e.message)}</p>`; }
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try { await api("/api/members/logout", { method: "POST" }); } catch { /* 무시 */ }
|
||||
load();
|
||||
}
|
||||
|
||||
load();
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
||||
// 모든 페이지 공통: API 호출, 오류 자동 보고, 작은 도우미.
|
||||
|
||||
export const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
|
||||
export const STATUS_LABEL = { draft: "검수 전", review: "검토 중", confirmed: "확정", rejected: "반려" };
|
||||
export const FEEDBACK_STATUS_LABEL = { new: "접수", in_progress: "처리 중", resolved: "반영 완료", rejected: "반영 안 함", duplicate: "중복" };
|
||||
export const REL_TYPE_LABEL = {
|
||||
part_of: "구성 요소",
|
||||
precedes: "다음 단계",
|
||||
informs: "자료로 활용",
|
||||
contrasts_with: "구분할 개념",
|
||||
related_to: "관련 개념",
|
||||
};
|
||||
export const METHOD_LABEL = { wireframe: "시안 예시", curated: "비교 개념 초안", toc: "목차 구조", definition: "정의 문장", name: "이름 포함", manual: "직접 입력", auto: "자동 추출", draft: "AI 초안(교재 문장 기반)" };
|
||||
|
||||
export async function api(path, opts = {}) {
|
||||
const init = { credentials: "same-origin", ...opts, headers: { ...(opts.headers || {}) } };
|
||||
if (opts.body && typeof opts.body !== "string") {
|
||||
init.body = JSON.stringify(opts.body);
|
||||
init.headers["Content-Type"] = "application/json";
|
||||
}
|
||||
const res = await fetch(path, init);
|
||||
let data = null;
|
||||
const ct = res.headers.get("content-type") || "";
|
||||
if (ct.includes("json")) data = await res.json();
|
||||
else data = await res.text();
|
||||
if (!res.ok) {
|
||||
const msg = (data && data.detail) ? (Array.isArray(data.detail) ? data.detail.map((d) => d.msg).join(", ") : data.detail) : `요청 실패 (${res.status})`;
|
||||
const err = new Error(msg);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
let toastTimer;
|
||||
export function toast(msg, isError = false) {
|
||||
let el = document.querySelector(".toast");
|
||||
if (!el) {
|
||||
el = document.createElement("div");
|
||||
el.className = "toast";
|
||||
el.setAttribute("role", "status");
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
el.textContent = msg;
|
||||
el.classList.toggle("error", isError);
|
||||
el.hidden = false;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => (el.hidden = true), 3500);
|
||||
}
|
||||
|
||||
// 화면 오류를 서버로 보내 관리자 피드백 목록(자동 수집 오류)에 쌓는다.
|
||||
const sent = new Set();
|
||||
function report(message, stack, source) {
|
||||
const key = message + "|" + (stack || "").split("\n")[0];
|
||||
if (sent.has(key) || sent.size > 20) return;
|
||||
sent.add(key);
|
||||
try {
|
||||
fetch("/api/client-errors", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: String(message).slice(0, 2000), stack: (stack || "").slice(0, 8000), url: location.href, source }),
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
} catch (_) { /* 보고 실패는 무시 */ }
|
||||
}
|
||||
window.addEventListener("error", (e) => report(e.message, e.error && e.error.stack, `${e.filename}:${e.lineno}:${e.colno}`));
|
||||
window.addEventListener("unhandledrejection", (e) => {
|
||||
const r = e.reason || {};
|
||||
if (r.status && r.status < 500) return; // 4xx는 사용자 입력 문제
|
||||
report(r.message || String(r), r.stack, "promise");
|
||||
});
|
||||
|
||||
export function fmtDate(s) {
|
||||
if (!s) return "";
|
||||
return s.replace("T", " ").slice(0, 16);
|
||||
}
|
||||
|
||||
export function debounce(fn, ms = 150) {
|
||||
let t;
|
||||
return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
|
||||
}
|
||||
|
||||
export function qs(name) {
|
||||
return new URLSearchParams(location.search).get(name);
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
// 오류 신고 페이지: 신고 양식 + 공개 신고 목록.
|
||||
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);
|
||||
@@ -0,0 +1,154 @@
|
||||
// 지도 배치와 그래프 계산 (DOM 없음). 좌표는 '세계 좌표'이며 화면에서 확대·이동을 적용한다.
|
||||
|
||||
const GOLDEN = Math.PI * (3 - Math.sqrt(5));
|
||||
|
||||
// 전체 우주: 은하 9개의 고정 배치 (지역사회 간호과정을 가운데에)
|
||||
const UNIVERSE_SLOTS = {
|
||||
g1: [-0.36, -0.30], g2: [0, -0.36], g4: [0.36, -0.30],
|
||||
g5: [-0.40, 0.02], g3: [0, 0], g6: [0.40, 0.02],
|
||||
g7: [-0.34, 0.33], g8: [0, 0.38], g9: [0.34, 0.33],
|
||||
};
|
||||
|
||||
export function universeLayout(galaxies) {
|
||||
const W = 1000, H = 760;
|
||||
const pos = {};
|
||||
galaxies.forEach((g, i) => {
|
||||
const slot = UNIVERSE_SLOTS[g.galaxy_id] || [Math.cos(i) * 0.35, Math.sin(i) * 0.3];
|
||||
pos[g.galaxy_id] = { x: slot[0] * W, y: slot[1] * H };
|
||||
});
|
||||
return { pos, bounds: { minX: -W / 2, maxX: W / 2, minY: -H / 2, maxY: H / 2 } };
|
||||
}
|
||||
|
||||
/** 은하 안: 절(별자리)을 원형으로 */
|
||||
export function galaxyLayout(sections, countOf) {
|
||||
const n = sections.length;
|
||||
const sizes = sections.map((s) => 128 + Math.sqrt(countOf(s.id)) * 7); // 지름
|
||||
const gap = 34;
|
||||
const circumference = sizes.reduce((a, b) => a + b + gap, 0);
|
||||
const R = Math.max(240, circumference / (2 * Math.PI) / 0.9);
|
||||
const pos = {};
|
||||
let angle = -Math.PI / 2;
|
||||
sections.forEach((s, i) => {
|
||||
const share = ((sizes[i] + gap) / circumference) * Math.PI * 2;
|
||||
const a = angle + share / 2;
|
||||
pos[s.id] = { x: Math.cos(a) * R, y: Math.sin(a) * R * 0.86, r: sizes[i] / 2 };
|
||||
angle += share;
|
||||
});
|
||||
const pad = Math.max(...sizes, 60) / 2 + 30;
|
||||
return { pos, R, bounds: { minX: -R - pad, maxX: R + pad, minY: -R * 0.86 - pad, maxY: R * 0.86 + pad } };
|
||||
}
|
||||
|
||||
/** 절 안: 항목별 작은 묶음 + 묶음 안 해바라기 배치 */
|
||||
export function sectionLayout(groups) {
|
||||
// groups: [{id, title, terms:[term]}]
|
||||
const SPACING = 40;
|
||||
const clusters = groups.map((g) => ({ ...g, r: Math.sqrt(g.terms.length) * SPACING * 0.62 + 34 }));
|
||||
const pos = {};
|
||||
const centers = {};
|
||||
if (clusters.length === 1) {
|
||||
centers[clusters[0].id] = { x: 0, y: 0, r: clusters[0].r };
|
||||
} else {
|
||||
const circumference = clusters.reduce((a, c) => a + c.r * 2 + 50, 0);
|
||||
const R = Math.max(230, circumference / (2 * Math.PI));
|
||||
let angle = -Math.PI / 2;
|
||||
for (const c of clusters) {
|
||||
const share = ((c.r * 2 + 50) / circumference) * Math.PI * 2;
|
||||
const a = angle + share / 2;
|
||||
centers[c.id] = { x: Math.cos(a) * R, y: Math.sin(a) * R, r: c.r };
|
||||
angle += share;
|
||||
}
|
||||
}
|
||||
for (const c of clusters) {
|
||||
const ctr = centers[c.id];
|
||||
// 이름순 정렬 후 해바라기 배치 (관계 많은 별은 가운데)
|
||||
c.terms.forEach((t, k) => {
|
||||
const rr = Math.sqrt(k + 0.6) * SPACING * 0.62;
|
||||
const a = k * GOLDEN;
|
||||
pos[t.i] = { x: ctr.x + Math.cos(a) * rr, y: ctr.y + Math.sin(a) * rr * 0.9 };
|
||||
});
|
||||
}
|
||||
let minX = 0, maxX = 0, minY = 0, maxY = 0;
|
||||
for (const c of Object.values(centers)) {
|
||||
minX = Math.min(minX, c.x - c.r); maxX = Math.max(maxX, c.x + c.r);
|
||||
minY = Math.min(minY, c.y - c.r); maxY = Math.max(maxY, c.y + c.r);
|
||||
}
|
||||
const pad = 70;
|
||||
return { pos, centers, bounds: { minX: minX - pad, maxX: maxX + pad, minY: minY - pad - 20, maxY: maxY + pad } };
|
||||
}
|
||||
|
||||
/** 화면 크기에 맞춰 보기 (scale, tx, ty) */
|
||||
export function fit(bounds, w, h, { max = 1.4, min = 0.25, margin = 24 } = {}) {
|
||||
const bw = bounds.maxX - bounds.minX, bh = bounds.maxY - bounds.minY;
|
||||
const s = Math.max(min, Math.min(max, (w - margin * 2) / bw, (h - margin * 2) / bh));
|
||||
const cx = (bounds.minX + bounds.maxX) / 2, cy = (bounds.minY + bounds.maxY) / 2;
|
||||
return { s, tx: w / 2 - cx * s, ty: h / 2 - cy * s };
|
||||
}
|
||||
|
||||
/** 라벨 겹침 제거: 우선순위 순으로 넣고 겹치면 숨김. items: [{id, x, y, w, h, prio}] → Set(보이는 id) */
|
||||
export function cullLabels(items) {
|
||||
const placed = [];
|
||||
const visible = new Set();
|
||||
const sorted = [...items].sort((a, b) => b.prio - a.prio);
|
||||
const cell = 80;
|
||||
const grid = new Map();
|
||||
const key = (gx, gy) => gx + "," + gy;
|
||||
for (const it of sorted) {
|
||||
const box = { x0: it.x - it.w / 2, x1: it.x + it.w / 2, y0: it.y, y1: it.y + it.h };
|
||||
let hit = false;
|
||||
const gx0 = Math.floor(box.x0 / cell), gx1 = Math.floor(box.x1 / cell);
|
||||
const gy0 = Math.floor(box.y0 / cell), gy1 = Math.floor(box.y1 / cell);
|
||||
outer: for (let gx = gx0; gx <= gx1; gx++) {
|
||||
for (let gy = gy0; gy <= gy1; gy++) {
|
||||
for (const o of grid.get(key(gx, gy)) || []) {
|
||||
if (box.x0 < o.x1 && box.x1 > o.x0 && box.y0 < o.y1 && box.y1 > o.y0) { hit = true; break outer; }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hit && !it.force) continue;
|
||||
visible.add(it.id);
|
||||
placed.push(box);
|
||||
for (let gx = gx0; gx <= gx1; gx++) for (let gy = gy0; gy <= gy1; gy++) {
|
||||
const k = key(gx, gy);
|
||||
if (!grid.has(k)) grid.set(k, []);
|
||||
grid.get(k).push(box);
|
||||
}
|
||||
}
|
||||
return visible;
|
||||
}
|
||||
|
||||
export function labelWidth(text, px = 12) {
|
||||
let w = 0;
|
||||
for (const ch of text) w += /[ㄱ-힣]/.test(ch) ? px * 1.1 : px * 0.6;
|
||||
return w + 16;
|
||||
}
|
||||
|
||||
/** 등록된 관계로 최단 경로 (방향 무시) — [{term, rel, forward}] 또는 null */
|
||||
export function shortestPath(adj, from, to, { maxDepth = 8, allow = () => true } = {}) {
|
||||
if (from === to) return [];
|
||||
const prev = new Map([[from, null]]);
|
||||
let frontier = [from];
|
||||
for (let depth = 0; depth < maxDepth && frontier.length; depth++) {
|
||||
const next = [];
|
||||
for (const id of frontier) {
|
||||
for (const r of adj.get(id) || []) {
|
||||
if (!allow(r)) continue;
|
||||
const other = r.s === id ? r.t : r.s;
|
||||
if (prev.has(other)) continue;
|
||||
prev.set(other, { id, r });
|
||||
if (other === to) {
|
||||
const path = [];
|
||||
let cur = to;
|
||||
while (prev.get(cur)) {
|
||||
const p = prev.get(cur);
|
||||
path.unshift({ from: p.id, to: cur, rel: p.r, forward: p.r.s === p.id });
|
||||
cur = p.id;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
next.push(other);
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// 로그인: 학생·교수자 공용. 교수자 아이디면 관리 화면으로.
|
||||
import { api, esc, toast } from "./common.js";
|
||||
|
||||
const main = document.getElementById("main");
|
||||
const params = new URLSearchParams(location.search);
|
||||
const next = params.get("next") || "";
|
||||
const safe = (u) => (u && u.startsWith("/") && !u.startsWith("//") ? u : "");
|
||||
|
||||
main.innerHTML = `<h1>로그인</h1>
|
||||
<p class="acc-note">사례와 문제는 회원만 볼 수 있습니다.</p>
|
||||
<form class="card" id="f">
|
||||
<label class="field"><span>아이디</span><input class="input" name="username" autocomplete="username" required autofocus></label>
|
||||
<label class="field"><span>비밀번호</span><input class="input" name="password" type="password" autocomplete="current-password" required></label>
|
||||
<button class="btn primary" type="submit">로그인</button>
|
||||
</form>
|
||||
<p class="auth-alt">아직 회원이 아니신가요? <a href="/signup${next ? "?next=" + encodeURIComponent(next) : ""}">가입 코드로 회원 가입</a></p>
|
||||
<p class="auth-alt" style="font-size:12px">아이디·비밀번호를 잊었다면 담당 교수자에게 문의해 주세요.</p>`;
|
||||
|
||||
document.getElementById("f").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const v = Object.fromEntries(new FormData(e.target).entries());
|
||||
const btn = e.target.querySelector("button");
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const r = await api("/api/login", { method: "POST", body: v });
|
||||
if (r.kind === "admin") {
|
||||
const n = safe(next);
|
||||
location.href = n.startsWith("/admin") || n.startsWith("/members") ? n : "/admin";
|
||||
} else {
|
||||
location.href = safe(next) || "/";
|
||||
}
|
||||
} catch (err) {
|
||||
toast(err.status === 429 ? "시도가 너무 많습니다. 잠시 후 다시 해 주세요." : err.message, true);
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// 이미 로그인한 상태면 알려 줌
|
||||
api("/api/members/me").then((me) => {
|
||||
if (me.logged_in || me.admin) {
|
||||
main.insertAdjacentHTML("afterbegin", `<p class="acc-note">이미 ${esc(me.logged_in ? me.name : me.admin)} 님으로 로그인되어 있습니다. <a href="${me.admin ? "/admin" : "/account"}">${me.admin ? "관리 화면" : "내 정보"}</a></p>`);
|
||||
}
|
||||
}).catch(() => {});
|
||||
@@ -0,0 +1,131 @@
|
||||
// 규칙 기반 용어 검색 (AI 없음). 등록된 이름·동의어·검색 표현·설명 문자열만 찾는다.
|
||||
|
||||
const PUNCT = /[\s·\-–_/.,()()'"’‘“”「」『』\[\]:;!?~]/g;
|
||||
const CHO = "ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ";
|
||||
|
||||
export function normalize(s) {
|
||||
return String(s ?? "").normalize("NFC").toLowerCase().replace(PUNCT, "");
|
||||
}
|
||||
|
||||
export function choseong(s) {
|
||||
let out = "";
|
||||
for (const ch of String(s ?? "")) {
|
||||
const c = ch.charCodeAt(0);
|
||||
if (c >= 0xac00 && c <= 0xd7a3) out += CHO[Math.floor((c - 0xac00) / 588)];
|
||||
else if (!PUNCT.test(ch)) out += ch.toLowerCase();
|
||||
PUNCT.lastIndex = 0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const isChoseongQuery = (q) => /^[ㄱ-ㅎ]{2,}$/.test(q.replace(/\s/g, ""));
|
||||
|
||||
function editDistance(a, b, max) {
|
||||
if (Math.abs(a.length - b.length) > max) return max + 1;
|
||||
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
const cur = [i];
|
||||
let rowMin = i;
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
||||
rowMin = Math.min(rowMin, cur[j]);
|
||||
}
|
||||
if (rowMin > max) return max + 1;
|
||||
prev = cur;
|
||||
}
|
||||
return prev[b.length];
|
||||
}
|
||||
|
||||
/** terms: [{i, n, a:[], q:[], x}] → 검색용 색인 */
|
||||
export function buildIndex(terms) {
|
||||
return terms.map((t) => ({
|
||||
t,
|
||||
name: normalize(t.n),
|
||||
cho: choseong(t.n),
|
||||
aliases: (t.a || []).map((a) => [a, normalize(a)]),
|
||||
extra: (t.q || []).map((a) => [a, normalize(a)]),
|
||||
text: normalize(t.x || ""),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 반환: [{t, score, reason}] 점수순.
|
||||
* reason: 사용자에게 보여줄 일치 이유 (예: "동의어 ‘HBM’ 일치")
|
||||
*/
|
||||
export function search(index, query, { limit = Infinity, fuzzy = true } = {}) {
|
||||
const raw = String(query ?? "").trim();
|
||||
if (!raw) return [];
|
||||
const q = normalize(raw);
|
||||
const words = raw.split(/[\s,]+/).map(normalize).filter(Boolean);
|
||||
const cho = isChoseongQuery(raw) ? raw.replace(/\s/g, "") : null;
|
||||
const hits = [];
|
||||
for (const e of index) {
|
||||
let score = 0;
|
||||
let reason = "";
|
||||
if (q) {
|
||||
if (e.name === q) { score = 100; reason = "용어 일치"; }
|
||||
else if (e.name.startsWith(q)) { score = 80; reason = "용어 앞부분 일치"; }
|
||||
else if (e.name.includes(q)) { score = 62; reason = "용어 일부 일치"; }
|
||||
if (score < 70) {
|
||||
for (const [orig, a] of e.aliases) {
|
||||
if (a === q) { score = 72; reason = `동의어 ‘${orig}’ 일치`; break; }
|
||||
if (a.includes(q) && score < 46) { score = 46; reason = `동의어 ‘${orig}’에 포함`; }
|
||||
}
|
||||
}
|
||||
if (score < 42) {
|
||||
for (const [orig, a] of e.extra) {
|
||||
if (a.includes(q) || (a.length >= 3 && q.includes(a))) { score = 42; reason = `검색 표현 ‘${orig}’`; break; }
|
||||
}
|
||||
}
|
||||
if (!score && e.text.includes(q)) { score = 16; reason = "설명 속 단어"; }
|
||||
}
|
||||
if (words.length > 1) {
|
||||
let matched = 0;
|
||||
let sub = 0;
|
||||
for (const w of words) {
|
||||
if (e.name.includes(w)) { sub += 8; matched++; }
|
||||
else if (e.aliases.some(([, a]) => a.includes(w)) || e.extra.some(([, a]) => a.includes(w))) { sub += 5; matched++; }
|
||||
else if (e.text.includes(w)) { sub += 2; matched++; }
|
||||
}
|
||||
if (matched) {
|
||||
const all = matched === words.length;
|
||||
const s = sub + (all ? 12 : 0);
|
||||
if (s > score) {
|
||||
score = s;
|
||||
reason = all ? "모든 단어 포함" : `${words.length}개 단어 중 ${matched}개 포함`;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cho && !score) {
|
||||
if (e.cho.startsWith(cho)) { score = 50; reason = "초성 일치"; }
|
||||
else if (e.cho.includes(cho)) { score = 34; reason = "초성 포함"; }
|
||||
}
|
||||
if (score) hits.push({ t: e.t, score: score - Math.min(e.name.length, 30) * 0.05, reason });
|
||||
}
|
||||
if (fuzzy && hits.length < 3 && q.length >= 2 && !cho) {
|
||||
const max = q.length <= 4 ? 1 : 2;
|
||||
const seen = new Set(hits.map((h) => h.t.i));
|
||||
for (const e of index) {
|
||||
if (seen.has(e.t.i)) continue;
|
||||
let d = editDistance(q, e.name, max);
|
||||
if (d > max && e.name.length > q.length) {
|
||||
// 긴 이름 앞부분과 비교 (예: '우선순이' → '우선순위 설정')
|
||||
d = editDistance(q, e.name.slice(0, q.length), max);
|
||||
}
|
||||
if (d <= max) hits.push({ t: e.t, score: 8 - d, reason: "비슷한 표기" });
|
||||
}
|
||||
}
|
||||
hits.sort((a, b) => b.score - a.score || a.t.n.localeCompare(b.t.n, "ko"));
|
||||
return hits.slice(0, limit);
|
||||
}
|
||||
|
||||
/** 가나다 색인용 첫 글자 묶음 */
|
||||
export function initialGroup(name) {
|
||||
const c = String(name).charCodeAt(0);
|
||||
if (c >= 0xac00 && c <= 0xd7a3) {
|
||||
const ch = CHO[Math.floor((c - 0xac00) / 588)];
|
||||
return { ㄲ: "ㄱ", ㄸ: "ㄷ", ㅃ: "ㅂ", ㅆ: "ㅅ", ㅉ: "ㅈ" }[ch] || ch;
|
||||
}
|
||||
if (/[a-z]/i.test(name[0])) return "A–Z";
|
||||
return "기타";
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// 회원 가입: 수업에서 받은 가입 코드가 있어야 함. 가입하면 바로 이용.
|
||||
import { api, toast } from "./common.js";
|
||||
|
||||
const main = document.getElementById("main");
|
||||
const next = new URLSearchParams(location.search).get("next") || "";
|
||||
const safe = (u) => (u && u.startsWith("/") && !u.startsWith("//") ? u : "");
|
||||
|
||||
main.innerHTML = `<h1>회원 가입</h1>
|
||||
<p class="acc-note">수업에서 교수자에게 받은 <b>가입 코드</b>가 있어야 가입할 수 있습니다. 가입하면 바로 사례와 문제를 볼 수 있어요.</p>
|
||||
<form class="card" id="f">
|
||||
<label class="field"><span>가입 코드</span><input class="input" name="code" required maxlength="40" autocomplete="off" placeholder="예: K7P-2QX" style="text-transform:uppercase"></label>
|
||||
<label class="field"><span>아이디 (영문·숫자 3~30자)</span><input class="input" name="username" autocomplete="username" required minlength="3" maxlength="30" pattern="[A-Za-z0-9_.\\-]{3,30}"></label>
|
||||
<label class="field"><span>비밀번호 (6자 이상)</span><input class="input" name="password" type="password" autocomplete="new-password" required minlength="6"></label>
|
||||
<label class="field"><span>비밀번호 확인</span><input class="input" name="password2" type="password" autocomplete="new-password" required minlength="6"></label>
|
||||
<label class="field"><span>이름</span><input class="input" name="name" required maxlength="40"></label>
|
||||
<label class="field"><span>학번 (선택)</span><input class="input" name="student_no" maxlength="30"></label>
|
||||
<input class="hp" name="website" tabindex="-1" autocomplete="off" aria-hidden="true">
|
||||
<button class="btn primary" type="submit">가입하기</button>
|
||||
</form>
|
||||
<p class="auth-alt">이미 회원인가요? <a href="/login${next ? "?next=" + encodeURIComponent(next) : ""}">로그인</a></p>`;
|
||||
|
||||
document.getElementById("f").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const v = Object.fromEntries(new FormData(e.target).entries());
|
||||
if (v.password !== v.password2) return toast("비밀번호 확인이 일치하지 않습니다.", true);
|
||||
delete v.password2;
|
||||
const btn = e.target.querySelector("button");
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await api("/api/members/signup", { method: "POST", body: v });
|
||||
await api("/api/members/login", { method: "POST", body: { username: v.username, password: v.password } });
|
||||
toast("가입했습니다!");
|
||||
location.href = safe(next) || "/";
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user