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:
jsahn
2026-10-06 23:14:45 +09:00
parent 04158ac442
commit 4e943700fc
62 changed files with 158771 additions and 0 deletions
+59
View File
@@ -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
+86
View File
@@ -0,0 +1,86 @@
// 모든 페이지 공통: API 호출, 오류 자동 보고, 작은 도우미.
export const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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);
}
+333
View File
@@ -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);
+154
View File
@@ -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;
}
+43
View File
@@ -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(() => {});
+131
View File
@@ -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 "기타";
}
+38
View File
@@ -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;
}
});