Files

366 lines
66 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================================
* 웹메이커 공통 모듈 런타임 (v1.0.3, v1.3.10 다국어 — 페이지 언어 날짜 형식·번역표 LT(), v1.4.18 일정관리 캘린더 R.calendar)
* 생성 사이트에 삽입된 모듈 위젯(data-wm-module)을 찾아 API 와 통신하고 내용을 그린다.
* 사이트별 설정은 모두 data-* 속성과 관리자 설정(API 응답)으로 들어오므로 이 파일에는 사이트 고유 값이 없다.
* - data-site : 사이트 ID - data-api : 모듈 API 기본 주소 (…/api/m/<siteId>)
* 회원 토큰은 localStorage 'wm_member_<siteId>' 에 저장되어 게시판·후기·예약·설문이 함께 쓴다.
* 이 파일을 사이트 안에 복사해 쓰려면 그대로 js/wm-modules.js 로 두고 <script src> 만 바꾸면 된다.
* ========================================================================== */
(function () {
if (window.__wmModulesLoaded) return; window.__wmModulesLoaded = true;
var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]; }); };
var nl2br = function (s) { return esc(s).replace(/\n/g, '<br>'); };
// v1.3.10 다국어: 페이지 언어(<html lang>)로 날짜를 쓰고, 화면 문구는 페이지에 들어 있는 번역표(<script type="application/json" data-wm-i18n>)로 바꾼다.
// 번역표는 다국어 사이트의 다른 언어 페이지에 서비스가 넣는다(lib/multilang.js). 없으면(기본 언어) 원문 그대로.
var PAGE_LANG = (document.documentElement.getAttribute('lang') || 'ko');
var DICT = (function () { try { var el = document.querySelector('script[data-wm-i18n]'); return el ? JSON.parse(el.textContent) : {}; } catch (e) { return {}; } })();
// v1.3.16: 문구는 코드에 직접 쓰지 않고 id 로만 쓴다. BASE 는 서비스가 이 파일을 보낼 때 채우는 원문(한국어) 표
// (server.js GET /wm-modules.js ← locales/site/ko.json). 페이지 번역표(DICT)에 그 언어 글자가 있으면 그것을 쓴다.
var BASE = /*@WM_BASE_MESSAGES@*/{};
/** LT('board.empty') / LT('members.welcome', { name: '홍길동' }) — {이름} 자리를 채운다 */
// v1.3.17 (B01): 1.3.16 에서는 이 함수 이름이 L 이라 지도 모듈의 Leaflet(window.L)을 가려 L.map 이 오류가 났다 → LT 로 바꿈
// v1.4.5 숫자 값(조회 수·댓글 수·남은 자리 등)은 3자리마다 쉼표 (페이지 언어 형식)
var numStr = function (v) { return typeof v === 'number' && isFinite(v) ? v.toLocaleString(document.documentElement.lang || undefined) : String(v); };
var LT = function (k, vars) { var s = DICT[k] || BASE[k] || k; return vars ? String(s).replace(/\{(\w+)\}/g, function (m, n) { return vars[n] !== undefined ? numStr(vars[n]) : m; }) : s; };
/** 관리자가 정한 설정 값(문의 유형·후기 분류 등)의 그 언어 번역 — 없으면 그대로 */
var LV = function (v) { return (v && DICT['v:' + v]) || v; };
/** v1.5.36 (요구 2): 위젯 속성(data-title 등)의 표시 글자. 속성이 없으면 문구표, 속성이 한국어 원문과 같은데 이 언어 번역이 있으면 번역 — 번역 단계를 거치지 않은 옛 페이지도 다른 언어에서 바르게 보이게 */
var AT = function (w, name, id) { var v = w.getAttribute(name); if (!v || !v.trim()) return LT(id); if (BASE[id] === v && DICT[id]) return DICT[id]; return v; };
/** v1.5.36: <input type=file> 의 브라우저 기본 글자("파일 선택")는 페이지 언어와 무관하게 브라우저 언어를 따르므로, 보이는 버튼을 따로 만들어 문구표 글자를 쓴다 */
var fileButton = function (input) {
if (!input || input.__wmFile) return; input.__wmFile = 1; var wrap = document.createElement('span'); wrap.className = 'wm-file'; var btn = document.createElement('button'); btn.type = 'button'; btn.className = 'wm-btn wm-file-btn'; btn.textContent = LT('inquiry.chooseFile'); var lab = document.createElement('span'); lab.className = 'wm-file-names'; lab.textContent = LT('inquiry.noFile');
input.parentNode.insertBefore(wrap, input); wrap.appendChild(btn); wrap.appendChild(lab); wrap.appendChild(input); input.style.position = 'absolute'; input.style.width = '1px'; input.style.height = '1px'; input.style.opacity = '0'; input.style.overflow = 'hidden'; input.tabIndex = -1;
btn.onclick = function () { input.click(); }; input.addEventListener('change', function () { var n = input.files ? input.files.length : 0; lab.textContent = n ? (n === 1 ? input.files[0].name : LT('inquiry.filesChosen', { n: n })) : LT('inquiry.noFile'); });
};
var fmtDate = function (s) { try { return new Date(s).toLocaleString(PAGE_LANG, { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); } catch (e) { return s; } };
var tokenKey = function (site) { return 'wm_member_' + site; };
var getToken = function (site) { try { return localStorage.getItem(tokenKey(site)) || ''; } catch (e) { return ''; } };
var setToken = function (site, t) { try { if (t) localStorage.setItem(tokenKey(site), t); else localStorage.removeItem(tokenKey(site)); } catch (e) {} };
/** API 호출: JSON 또는 FormData. 회원 토큰이 있으면 Authorization 헤더로 보낸다 */
function call(w, method, pathname, body, opts) {
var api = w.getAttribute('data-api'); var site = w.getAttribute('data-site'); var headers = {}; var tok = getToken(site);
if (tok) headers['Authorization'] = 'Bearer ' + tok;
headers['X-WM-Lang'] = PAGE_LANG; // v1.3.16 서버가 오류·안내 문구를 이 페이지 언어로 돌려준다
var init = { method: method, headers: headers, mode: 'cors', credentials: 'omit' };
if (body instanceof FormData) init.body = body; else if (body !== undefined) { headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(body); }
return fetch(api + pathname, init).then(function (r) { return r.json().catch(function () { return {}; }).then(function (j) { if (!r.ok) { var e = new Error((j.error && LT(j.error)) || ('HTTP ' + r.status)); e.status = r.status; throw e; } return j; }); });
}
/** 폼 안의 name 필드 찾기 (form.elements 가 아닌 하위 어디에 있어도) */
var fld = function (form, name) { return form ? form.querySelector('[name="' + name + '"]') : null; };
var msg = function (w, text, ok) { var m = w.querySelector('.wm-msg'); if (m) { m.textContent = text || ''; m.className = 'wm-msg' + (ok ? ' ok' : (text ? ' err' : '')); } };
var baseCss = '.wm-btn{cursor:pointer}.wm-ghost{background:transparent}.wm-msg.err{color:#b91c1c}.wm-msg.ok{color:#047857}.wm-row{display:flex;gap:8px;flex-wrap:wrap}.wm-row>*{flex:1;min-width:140px}.wm-board table{width:100%;border-collapse:collapse}.wm-board td,.wm-board th{padding:8px 6px;border-bottom:1px solid #e5e7eb;text-align:left}.wm-board .wm-post-card{border:1px solid #e5e7eb;border-radius:10px;padding:14px;margin:10px 0}.wm-board .wm-comment{border-top:1px solid #eee;padding:8px 0}.wm-board .wm-comment.reply{margin-left:24px}.wm-pager button{margin:0 2px}.wm-pager .on{font-weight:700;text-decoration:underline}.wm-gallery-grid{display:grid;gap:10px}.wm-gallery-grid img{width:100%;aspect-ratio:4/3;object-fit:cover;border-radius:8px;cursor:zoom-in}.wm-lightbox{position:fixed;inset:0;background:rgba(0,0,0,.85);display:flex;align-items:center;justify-content:center;z-index:9999;flex-direction:column;color:#fff}.wm-lightbox img{max-width:92vw;max-height:80vh}.wm-slots{display:flex;flex-wrap:wrap;gap:6px;margin:8px 0}.wm-slot{padding:6px 10px;border:1px solid #cbd5e1;border-radius:8px;background:#fff;cursor:pointer}.wm-slot.full{opacity:.4;cursor:not-allowed;text-decoration:line-through}.wm-slot.on{outline:2px solid #2563eb}.wm-kanban{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(220px,1fr);gap:10px;overflow:auto}.wm-kanban-col{background:#f8fafc;border-radius:10px;padding:10px}.wm-kanban-col h4{margin:0 0 8px}.wm-review{border:1px solid #e5e7eb;border-radius:10px;padding:10px;margin:8px 0;background:#fff}.wm-stars{color:#f59e0b}.wm-faq details{border-bottom:1px solid #e5e7eb;padding:6px 0}.wm-faq summary{cursor:pointer;font-weight:600}.wm-map-canvas{width:100%;border-radius:10px;overflow:hidden}.wm-survey .wm-q{margin:12px 0}.wm-survey .wm-q label{display:block;font-weight:600;margin-bottom:4px}.wm-survey .wm-opt{display:block;font-weight:400}.wm-cats button{margin:0 4px 6px 0}.wm-cats .on{font-weight:700;text-decoration:underline}.wm-blog-card{border-bottom:1px solid #e5e7eb;padding:14px 0}.wm-blog-cover{max-width:100%;border-radius:8px;display:block;margin:6px 0}.wm-blog-search,.wm-library-search{display:flex;gap:6px;margin:8px 0}.wm-blog-search input,.wm-library-search input{flex:1}.wm-blog-body p{margin:0 0 1em}.wm-blog-tag{margin-right:4px;opacity:.8}.wm-library-item{display:flex;gap:12px;align-items:center;justify-content:space-between;border-bottom:1px solid #e5e7eb;padding:10px 0}.wm-library-info{min-width:0;flex:1}.wm-library-desc{margin:4px 0 0}.wm-lang-select{font:inherit;padding:2px 6px}.wm-blog-view .wm-cform{display:grid;gap:8px;max-width:640px;margin-top:10px}.wm-blog-view .wm-cform input,.wm-blog-view .wm-cform textarea{font:inherit;padding:6px 8px;width:100%;box-sizing:border-box}.wm-blog-view .wm-cform button{justify-self:start}.wm-blog-view .wm-meta{margin:4px 0 8px}' +
/* v1.4.1 게시판 목록 [검색][글쓰기] 한 줄: 버튼 글자가 줄바꿈되며(글쓰/기) 두 줄이 되던 것 — 버튼은 줄이지 않고 검색칸이 남은 폭을 쓴다 (블로그·자료실 검색 줄도 같게) */
'.wm-board-tools{display:flex;align-items:center;flex-wrap:nowrap;gap:8px;min-width:0;max-width:100%}.wm-board-tools .wm-board-search{flex:1 1 auto;min-width:0;width:auto}.wm-board-tools .wm-btn,.wm-blog-search .wm-btn,.wm-library-search .wm-btn{white-space:nowrap;flex:0 0 auto;width:auto;word-break:keep-all}.wm-blog-search,.wm-library-search{align-items:center}' +
/* v1.4.18 일정관리 캘린더: 월간 격자·주간/일간 시간표·일정 상세 팝업. 색은 .wm-cal-c-<색> 으로, 사이트는 .wm-cal 아래 규칙만 덧씌우면 된다 */
'.wm-cal-tools{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin:8px 0}.wm-cal-tools .wm-cal-caption{font-weight:700;margin:0 8px;flex:1;min-width:120px}.wm-cal-tools .wm-cal-view.on{font-weight:700;text-decoration:underline}.wm-cal-grid{display:grid;grid-template-columns:repeat(7,1fr);border-left:1px solid #e5e7eb;border-top:1px solid #e5e7eb}.wm-cal-wd{padding:6px 4px;text-align:center;font-weight:600;font-size:13px;border-right:1px solid #e5e7eb;border-bottom:1px solid #e5e7eb;background:#f8fafc}.wm-cal-cell{min-height:92px;padding:4px;border-right:1px solid #e5e7eb;border-bottom:1px solid #e5e7eb;font-size:12px;overflow:hidden;cursor:pointer;min-width:0}.wm-cal-cell.other{opacity:.45}.wm-cal-cell.today .wm-cal-num,.wm-cal-head .today .wm-cal-num{background:#2563eb;color:#fff;border-radius:999px;padding:0 6px}.wm-cal-sun .wm-cal-num{color:#dc2626}.wm-cal-sat .wm-cal-num{color:#2563eb}.wm-cal-cell.today.wm-cal-sun .wm-cal-num,.wm-cal-cell.today.wm-cal-sat .wm-cal-num{color:#fff}.wm-cal-date{display:flex;align-items:center;gap:4px;flex-wrap:wrap}.wm-cal-num{font-weight:600}.wm-cal-term{font-size:10px;color:#059669;border:1px solid #a7f3d0;border-radius:4px;padding:0 3px;white-space:nowrap}.wm-cal-sub{color:#6b7280;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.wm-cal-ev{display:block;width:100%;text-align:left;border:0;border-left:3px solid #2563eb;background:#eff6ff;color:inherit;font:inherit;font-size:11px;padding:1px 4px;margin:2px 0;border-radius:3px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.wm-cal-c-green{border-left-color:#059669;background:#ecfdf5}.wm-cal-c-red{border-left-color:#dc2626;background:#fef2f2}.wm-cal-c-orange{border-left-color:#ea580c;background:#fff7ed}.wm-cal-c-purple{border-left-color:#7c3aed;background:#f5f3ff}.wm-cal-c-gray{border-left-color:#6b7280;background:#f3f4f6}.wm-cal-more{background:none;border:0;color:#2563eb;font-size:11px;cursor:pointer;padding:0 4px}.wm-cal-week{overflow:auto}.wm-cal-week .wm-cal-row{display:grid;grid-template-columns:56px repeat(var(--wm-cal-cols,7),minmax(0,1fr));min-width:560px}.wm-cal-week.one .wm-cal-row{min-width:0}.wm-cal-head>div,.wm-cal-allday>div{border-right:1px solid #e5e7eb;border-bottom:1px solid #e5e7eb;padding:4px;font-size:12px;min-height:28px;min-width:0}.wm-cal-head{border-top:1px solid #e5e7eb}.wm-cal-head .wm-cal-sub{white-space:normal}.wm-cal-timegrid>div{border-right:1px solid #e5e7eb;position:relative;min-width:0}.wm-cal-hour{height:44px;border-bottom:1px solid #f1f5f9;box-sizing:border-box;font-size:10px;color:#6b7280;padding:2px 4px}.wm-cal-tev{position:absolute;box-sizing:border-box;overflow:hidden;white-space:normal;line-height:1.25;margin:0}.wm-cal-list{margin-top:10px}.wm-cal-list .wm-cal-ev{white-space:normal;padding:6px 8px;font-size:13px}.wm-cal-info{font-size:13px;color:#374151;margin:6px 0}.wm-cal-pop{position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;z-index:9999}.wm-cal-pop .wm-cal-card{background:#fff;color:#111;border-radius:12px;padding:18px;max-width:420px;width:92vw;max-height:80vh;overflow:auto}.wm-cal-card h3{margin:0 0 8px}.wm-cal-card .wm-meta{color:#6b7280;font-size:13px;margin:4px 0}.wm-cal-card .wm-body{margin-top:8px}.wm-cal-empty{color:#6b7280;padding:12px 0}';
var st = document.createElement('style'); st.textContent = baseCss; document.head.appendChild(st);
var R = {};
// ---------------- 회원 ----------------
R.members = function (w) {
var site = w.getAttribute('data-site'); var status = w.querySelector('.wm-members-status'); var fl = w.querySelector('.wm-members-login'); var fs = w.querySelector('.wm-members-signup'); if (!status || !fl || !fs) { console.warn('wm members: 표준 마크업(.wm-members-status/.wm-members-login/.wm-members-signup)이 필요합니다'); return; }
function show(me) {
if (me) { status.innerHTML = '<span>' + esc(LT('members.welcome', { name: me.name })) + '</span> <button type="button" class="wm-btn wm-ghost wm-logout">' + LT('members.logout') + '</button>'; fl.hidden = true; fs.hidden = true; status.querySelector('.wm-logout').onclick = function () { setToken(site, ''); show(null); document.dispatchEvent(new CustomEvent('wm:member', { detail: null })); }; }
else { status.innerHTML = ''; fl.hidden = false; fs.hidden = true; }
}
function me() { if (!getToken(site)) return show(null); call(w, 'GET', '/members/me').then(function (r) { show(r.member); }).catch(function () { setToken(site, ''); show(null); }); }
w.querySelector('.wm-to-signup').onclick = function () { fl.hidden = true; fs.hidden = false; }; w.querySelector('.wm-to-login').onclick = function () { fs.hidden = true; fl.hidden = false; };
fl.onsubmit = function (e) { e.preventDefault(); var d = Object.fromEntries(new FormData(fl)); call(w, 'POST', '/members/login', d).then(function (r) { setToken(site, r.token); show(r.member); document.dispatchEvent(new CustomEvent('wm:member', { detail: r.member })); }).catch(function (err) { msg(fl, err.message); }); };
fs.onsubmit = function (e) { e.preventDefault(); var d = Object.fromEntries(new FormData(fs)); call(w, 'POST', '/members/signup', d).then(function (r) { if (r.pending) { msg(fs, LT('members.pending'), true); fs.reset(); } else { setToken(site, r.token); show(r.member); document.dispatchEvent(new CustomEvent('wm:member', { detail: r.member })); } }).catch(function (err) { msg(fs, err.message); }); };
me();
};
// ---------------- 게시판 ----------------
R.board = function (w) {
var site = w.getAttribute('data-site'); var style = w.getAttribute('data-style'); var fixedCat = w.getAttribute('data-category') || ''; var page = 1; var cat = fixedCat; var q = ''; var settings = null; var canWrite = false;
var bt = w.querySelector('.wm-board-title'); if (bt && !bt.textContent.trim()) bt.textContent = AT(w, 'data-title', 'board.title');
var mk = function (cls) { var d = document.createElement('div'); d.className = cls; w.appendChild(d); return d; };
var list = w.querySelector('.wm-board-list') || mk('wm-board-list'), pager = w.querySelector('.wm-board-pager') || mk('wm-board-pager'), view = w.querySelector('.wm-board-view') || mk('wm-board-view'), form = w.querySelector('.wm-board-form') || w.querySelector('form') || mk('wm-board-form'), cats = w.querySelector('.wm-board-cats') || mk('wm-board-cats');
function load() {
var qs = '?page=' + page + (cat ? '&cat=' + encodeURIComponent(cat) : '') + (q ? '&q=' + encodeURIComponent(q) : '') + (w.getAttribute('data-size') ? '&size=' + w.getAttribute('data-size') : '');
call(w, 'GET', '/board/posts' + qs).then(function (r) {
settings = r.settings; canWrite = r.canWrite; if (!style) style = settings.style || 'list';
if (wb) wb.style.display = canWrite ? '' : 'none';
if (!fixedCat && settings.categories && settings.categories.length) { cats.className = 'wm-board-cats wm-cats'; cats.innerHTML = '<button type="button" class="wm-btn wm-ghost' + (!cat ? ' on' : '') + '" data-c="">' + LT('common.all') + '</button>' + settings.categories.map(function (c) { return '<button type="button" class="wm-btn wm-ghost' + (cat === c ? ' on' : '') + '" data-c="' + esc(c) + '">' + esc(LV(c)) + '</button>'; }).join(''); cats.querySelectorAll('button').forEach(function (b) { b.onclick = function () { cat = b.getAttribute('data-c'); page = 1; load(); }; }); }
if (!r.items.length) list.innerHTML = '<p class="wm-empty">' + LT('board.empty') + '</p>';
else if (style === 'forum') list.innerHTML = r.items.map(function (p) { return '<article class="wm-post-card" data-id="' + p.id + '">' + (p.pinned ? '<span class="wm-pin">📌</span> ' : '') + '<h3 class="wm-post-title">' + (p.category ? '<span class="wm-cat">[' + esc(p.category) + ']</span> ' : '') + esc(p.title) + '</h3><p class="wm-excerpt">' + esc(p.excerpt) + '</p><div class="wm-meta">' + esc(p.author) + ' · ' + fmtDate(p.createdAt) + ' ' + esc(LT('board.metaViews', { n: p.views })) + ' ' + esc(LT('board.metaComments', { n: p.comments })) + '</div></article>'; }).join('');
else list.innerHTML = '<table><thead><tr><th>' + LT('common.subject') + '</th><th>' + LT('board.author') + '</th><th>' + LT('common.date') + '</th><th>' + LT('board.views') + '</th></tr></thead><tbody>' + r.items.map(function (p) { return '<tr data-id="' + p.id + '"><td>' + (p.pinned ? '📌 ' : '') + (p.category ? '<span class="wm-cat">[' + esc(p.category) + ']</span> ' : '') + esc(p.title) + (p.comments ? ' <span class="wm-cc">[' + p.comments + ']</span>' : '') + '</td><td>' + esc(p.author) + '</td><td>' + fmtDate(p.createdAt).slice(0, 12) + '</td><td>' + numStr(p.views || 0) + '</td></tr>'; }).join('') + '</tbody></table>';
list.querySelectorAll('[data-id]').forEach(function (el) { el.style.cursor = 'pointer'; el.onclick = function () { open(el.getAttribute('data-id')); }; });
var pages = Math.max(1, Math.ceil(r.total / r.size)); pager.className = 'wm-board-pager wm-pager'; pager.innerHTML = ''; for (var i = 1; i <= pages && i <= 50; i++) { (function (i) { var b = document.createElement('button'); b.type = 'button'; b.className = 'wm-btn wm-ghost' + (i === page ? ' on' : ''); b.textContent = i; b.onclick = function () { page = i; load(); }; pager.appendChild(b); })(i); }
}).catch(function (err) { list.innerHTML = '<p class="wm-msg err">' + esc(err.message) + '</p>'; if (err.status === 401) list.innerHTML += '<p>' + LT('members.loginRequired') + '</p>'; });
}
function open(id, pw) {
call(w, 'GET', '/board/posts/' + id + (pw ? '?pw=' + encodeURIComponent(pw) : '')).then(function (r) {
list.hidden = true; pager.hidden = true; cats.hidden = true; form.hidden = true; view.hidden = false;
if (r.locked) { view.innerHTML = '<h3>' + esc(r.post.title) + '</h3><p>' + (r.needPassword ? LT('board.secretAskPw') : LT('board.secretOnlyOwner')) + '</p>' + (r.needPassword ? '<form class="wm-pw"><input name="pw" type="password" placeholder="' + LT('common.password') + '"><button class="wm-btn">' + LT('common.confirm') + '</button></form>' : '') + '<button type="button" class="wm-btn wm-ghost wm-back">' + LT('common.list') + '</button>'; var f = view.querySelector('.wm-pw'); if (f) f.onsubmit = function (e) { e.preventDefault(); open(id, f.pw.value); }; view.querySelector('.wm-back').onclick = back; return; }
var p = r.post;
var cm = r.comments.map(function (c) { return '<div class="wm-comment' + (c.parentId ? ' reply' : '') + '" data-id="' + c.id + '"><b>' + esc(c.author) + '</b> <span class="wm-meta">' + fmtDate(c.createdAt) + '</span><div>' + nl2br(c.body) + '</div>' + (r.canComment ? '<button type="button" class="wm-link wm-reply">' + LT('board.reply') + '</button> ' : '') + '<button type="button" class="wm-link wm-del-c">' + LT('common.delete') + '</button></div>'; }).join('');
view.innerHTML = '<article class="wm-post"><h3>' + (p.category ? '[' + esc(p.category) + '] ' : '') + esc(p.title) + '</h3><div class="wm-meta">' + esc(p.author) + ' · ' + fmtDate(p.createdAt) + ' ' + esc(LT('board.metaViews', { n: p.views })) + '</div><div class="wm-body">' + nl2br(p.body) + '</div></article><div class="wm-row"><button type="button" class="wm-btn wm-ghost wm-back">' + LT('common.list') + '</button>' + (r.canEdit ? '<button type="button" class="wm-btn wm-ghost wm-del">' + LT('common.delete') + '</button>' : '<button type="button" class="wm-btn wm-ghost wm-del">' + LT('board.deleteWithPw') + '</button>') + '</div><section class="wm-comments"><h4>' + esc(LT('board.comments', { n: r.comments.length })) + '</h4>' + cm + (r.canComment ? '<form class="wm-cform"><input type="hidden" name="parentId"><textarea name="body" rows="3" placeholder="' + LT('board.commentPlaceholder') + '" required></textarea><div class="wm-row wm-guest"><input name="name" placeholder="' + LT('common.name') + '"><input name="password" type="password" placeholder="' + LT('board.guestPwDelete') + '"></div><label class="wm-check"><input type="checkbox" name="secret"> ' + LT('board.commentSecret') + '</label><button class="wm-btn">' + LT('board.commentSubmit') + '</button><p class="wm-msg"></p></form>' : '') + '</section>';
view.querySelector('.wm-back').onclick = back;
view.querySelector('.wm-del').onclick = function () { var pwd = r.canEdit ? (pw || '') : prompt(LT('board.deleteAskPw')); /* v1.3.17 (B02): 비밀번호로 연 비회원 글은 그 비밀번호로 삭제 (회원·관리자는 빈 값) */ if (!r.canEdit && !pwd) return; if (!confirm(LT('board.deleteConfirm'))) return; call(w, 'DELETE', '/board/posts/' + id, { password: pwd }).then(function () { back(); load(); }).catch(function (e) { alert(e.message); }); };
var cf = view.querySelector('.wm-cform'); if (cf) { if (getToken(site)) cf.querySelector('.wm-guest').style.display = 'none'; cf.onsubmit = function (e) { e.preventDefault(); var d = Object.fromEntries(new FormData(cf)); d.secret = !!cf.secret.checked; call(w, 'POST', '/board/posts/' + id + '/comments', d).then(function () { open(id, pw); }).catch(function (err) { msg(cf, err.message); }); }; }
view.querySelectorAll('.wm-reply').forEach(function (b) { b.onclick = function () { cf.parentId.value = b.closest('.wm-comment').getAttribute('data-id'); cf.body.focus(); cf.body.placeholder = LT('board.replyPlaceholder'); }; });
view.querySelectorAll('.wm-del-c').forEach(function (b) { b.onclick = function () { var cid = b.closest('.wm-comment').getAttribute('data-id'); var pwd = getToken(site) ? '' : prompt(LT('board.commentPw')); if (!getToken(site) && !pwd) return; call(w, 'DELETE', '/board/comments/' + cid, { password: pwd }).then(function () { open(id, pw); }).catch(function (e) { alert(e.message); }); }; });
}).catch(function (err) { alert(err.message); });
}
function back() { view.hidden = true; form.hidden = true; list.hidden = false; pager.hidden = false; cats.hidden = false; }
var wb = w.querySelector('.wm-board-write'); if (wb) wb.onclick = function () { list.hidden = true; pager.hidden = true; cats.hidden = true; view.hidden = true; form.hidden = false; var sel = fld(form, 'category'); if (sel) sel.innerHTML = (settings && settings.categories || []).map(function (c) { return '<option value="' + esc(c) + '">' + esc(LV(c)) + '</option>'; }).join(''); var g = form.querySelector('.wm-board-guest'); if (g) g.style.display = getToken(site) ? 'none' : ''; var ck = form.querySelector('.wm-check'); if (ck) ck.style.display = settings && settings.allowSecret ? '' : 'none'; };
var cb = w.querySelector('.wm-board-cancel'); if (cb) cb.onclick = back;
form.onsubmit = function (e) { e.preventDefault(); var d = Object.fromEntries(new FormData(form)); var sc = fld(form, 'secret'); d.secret = !!(sc && sc.checked); call(w, 'POST', '/board/posts', d).then(function () { form.reset(); back(); page = 1; load(); }).catch(function (err) { msg(form, err.message); }); };
var search = w.querySelector('.wm-board-search'); if (search) search.onkeydown = function (e) { if (e.key === 'Enter') { q = search.value.trim(); page = 1; load(); } };
document.addEventListener('wm:member', function () { load(); });
load();
};
// ---------------- 문의폼 ----------------
R.inquiry = function (w) {
var it = w.querySelector('.wm-inquiry-title'); if (it && !it.textContent.trim()) it.textContent = AT(w, 'data-title', 'inquiry.title'); var sb = w.querySelector('.wm-inquiry-submit'); if (sb && !sb.textContent.trim()) sb.textContent = AT(w, 'data-button', 'common.send');
var form = w.querySelector('.wm-inquiry-form') || w.querySelector('form'); if (!form) return; var catEl = fld(form, 'category'), filesEl = fld(form, 'files');
call(w, 'GET', '/inquiry/settings').then(function (s) { if (catEl) catEl.innerHTML = (s.categories || []).map(function (c) { return '<option value="' + esc(c) + '">' + esc(LV(c)) + '</option>'; }).join(''); if (!s.allowFiles) { var fw = w.querySelector('.wm-inquiry-files'); if (fw) fw.style.display = 'none'; } else if (filesEl) { filesEl.setAttribute('data-max', s.maxFileMb || 10); fileButton(filesEl); } }).catch(function () {});
form.onsubmit = function (e) {
e.preventDefault(); if (!form.checkValidity()) { msg(form, LT('common.requiredCheck')); return; }
var fd = new FormData(form); fd.append('kind', w.getAttribute('data-kind') || 'contact'); fd.append('page', location.href);
var max = (parseInt(filesEl && filesEl.getAttribute('data-max'), 10) || 10) * 1048576; if (filesEl) for (var i = 0; i < filesEl.files.length; i++) if (filesEl.files[i].size > max) { msg(form, LT('inquiry.fileTooBig', { mb: max / 1048576 })); return; }
var b = form.querySelector('.wm-inquiry-submit') || form.querySelector('button[type=submit]') || form.querySelector('button'); if (b) b.disabled = true; msg(form, LT('common.sending'));
call(w, 'POST', '/inquiry/submit', fd).then(function (r) { msg(form, (r.message && LT(r.message)) || LT('common.receivedContact'), true); form.reset(); }).catch(function (err) { msg(form, err.message); }).then(function () { if (b) b.disabled = false; });
};
};
// ---------------- 설문 ----------------
R.survey = function (w) {
var form = w.querySelector('.wm-survey-form') || w.querySelector('form'); if (!form) return; var box = w.querySelector('.wm-survey-questions') || (function () { var d = document.createElement('div'); d.className = 'wm-survey-questions'; form.insertBefore(d, form.firstChild); return d; })(); var id = w.getAttribute('data-survey');
(id ? call(w, 'GET', '/survey/forms/' + id) : call(w, 'GET', '/survey/forms').then(function (r) { if (!r.items || !r.items.length) throw new Error(LT('survey.none')); return call(w, 'GET', '/survey/forms/' + r.items[0].id); })).then(function (r) {
var f = r.survey; var stt = w.querySelector('.wm-survey-title'); if (stt && !stt.textContent.trim()) stt.textContent = w.getAttribute('data-title') || f.title; var sd = w.querySelector('.wm-survey-desc'); if (sd && !sd.textContent.trim()) sd.textContent = f.description || '';
box.innerHTML = f.questions.map(function (q) {
var req = q.required ? ' <span class="wm-req">*</span>' : ''; var inner = '';
if (q.type === 'long') inner = '<textarea name="' + q.id + '" rows="3"' + (q.required ? ' required' : '') + '></textarea>';
else if (q.type === 'single') inner = q.options.map(function (o) { return '<label class="wm-opt"><input type="radio" name="' + q.id + '" value="' + esc(o) + '"' + (q.required ? ' required' : '') + '> ' + esc(o) + '</label>'; }).join('');
else if (q.type === 'multi') inner = q.options.map(function (o) { return '<label class="wm-opt"><input type="checkbox" name="' + q.id + '" value="' + esc(o) + '"> ' + esc(o) + '</label>'; }).join('');
else if (q.type === 'scale') { inner = '<div class="wm-row wm-scale">'; for (var i = q.min; i <= q.max; i++) inner += '<label class="wm-opt"><input type="radio" name="' + q.id + '" value="' + i + '"' + (q.required ? ' required' : '') + '> ' + i + '</label>'; inner += '</div>'; }
else inner = '<input name="' + q.id + '" type="' + (q.type === 'date' ? 'date' : q.type === 'email' ? 'email' : q.type === 'number' ? 'number' : 'text') + '"' + (q.required ? ' required' : '') + '>';
return '<div class="wm-q"><label>' + esc(q.label) + req + '</label>' + inner + '</div>';
}).join('');
form.onsubmit = function (e) { e.preventDefault(); var answers = {}; f.questions.forEach(function (q) { if (q.type === 'multi') answers[q.id] = Array.from(form.querySelectorAll('[name="' + q.id + '"]:checked')).map(function (x) { return x.value; }); else { var el = form.querySelector('[name="' + q.id + '"]' + (q.type === 'single' || q.type === 'scale' ? ':checked' : '')); answers[q.id] = el ? el.value : ''; } }); call(w, 'POST', '/survey/forms/' + f.id + '/responses', { answers: answers, respondent: (fld(form, 'respondent') || {}).value || '' }).then(function (r) { form.innerHTML = '<p class="wm-msg ok">' + esc((r.message && LT(r.message)) || f.thanks) + '</p>'; }).catch(function (err) { msg(form, err.message); }); };
}).catch(function (err) { var t = w.querySelector('.wm-survey-title'); if (t) t.textContent = err.message; form.hidden = true; });
};
// ---------------- 예약 ----------------
R.booking = function (w) {
var form = w.querySelector('.wm-booking-form') || w.querySelector('form'); if (!form) return; var slots = w.querySelector('.wm-booking-slots') || (function () { var d = document.createElement('div'); d.className = 'wm-booking-slots'; form.insertBefore(d, form.firstChild); return d; })(); var tt = w.querySelector('.wm-booking-title'); if (tt && !tt.textContent.trim()) tt.textContent = AT(w, 'data-title', 'booking.title'); var nl = w.querySelector('.wm-booking-note-label'); if (nl && !nl.textContent.trim()) nl.insertBefore(document.createTextNode(AT(w, 'data-note-label', 'booking.note')), nl.firstChild);
var dateEl = fld(form, 'date'), timeEl = fld(form, 'time'); if (!dateEl) { console.warn('wm booking: name="date" 입력이 없습니다'); return; } if (!timeEl) { timeEl = document.createElement('input'); timeEl.type = 'hidden'; timeEl.name = 'time'; form.appendChild(timeEl); }
var submit = form.querySelector('button[type=submit]') || form.querySelector('button');
call(w, 'GET', '/booking/settings').then(function (s) { var d = new Date(); d.setDate(d.getDate() + (s.leadDays || 0)); dateEl.min = d.toISOString().slice(0, 10); var m = new Date(); m.setDate(m.getDate() + (s.maxDays || 60)); dateEl.max = m.toISOString().slice(0, 10); }).catch(function () {});
dateEl.onchange = function () { timeEl.value = ''; submit.disabled = true; slots.innerHTML = LT('common.loading'); call(w, 'GET', '/booking/slots?date=' + dateEl.value).then(function (r) { slots.className = 'wm-booking-slots wm-slots'; if (r.closed) { slots.innerHTML = '<p>' + LT('booking.closedDate') + '</p>'; return; } if (!r.slots.length) { slots.innerHTML = '<p>' + LT('booking.noSlots') + '</p>'; return; } slots.innerHTML = r.slots.map(function (s) { return '<button type="button" class="wm-slot' + (s.left ? '' : ' full') + '" data-t="' + s.time + '"' + (s.left ? '' : ' disabled') + '>' + s.time + (s.left && s.left < 3 ? ' <small>' + esc(LT('booking.seatsLeft', { n: s.left })) + '</small>' : '') + '</button>'; }).join(''); slots.querySelectorAll('.wm-slot:not(.full)').forEach(function (b) { b.onclick = function () { slots.querySelectorAll('.wm-slot').forEach(function (x) { x.classList.remove('on'); }); b.classList.add('on'); timeEl.value = b.getAttribute('data-t'); submit.disabled = false; }; }); }).catch(function (err) { slots.innerHTML = '<p class="wm-msg err">' + esc(err.message) + '</p>'; }); };
form.onsubmit = function (e) { e.preventDefault(); var d = Object.fromEntries(new FormData(form)); submit.disabled = true; call(w, 'POST', '/booking/reserve', d).then(function (r) { msg(form, (r.message && LT(r.message)) || (r.booking.status === 'confirmed' ? LT('booking.confirmed') : LT('booking.received')), true); form.reset(); slots.innerHTML = ''; }).catch(function (err) { msg(form, err.message); submit.disabled = false; }); };
};
// ---------------- 갤러리 ----------------
R.gallery = function (w) {
var grid = w.querySelector('.wm-gallery-grid'); if (!grid) return; var tagsBox = w.querySelector('.wm-gallery-tags') || document.createElement('div'); var gt = w.querySelector('.wm-gallery-title'); if (gt && !gt.textContent.trim()) gt.textContent = AT(w, 'data-title', 'gallery.title'); var tag = w.getAttribute('data-tag') || '';
call(w, 'GET', '/gallery/list').then(function (r) {
var cols = parseInt(w.getAttribute('data-columns'), 10) || parseInt(r.settings.columns, 10) || 3; grid.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)';
var all = r.items; var tags = []; all.forEach(function (i) { (i.tags || []).forEach(function (t) { if (tags.indexOf(t) < 0) tags.push(t); }); });
function draw() { var items = tag ? all.filter(function (i) { return (i.tags || []).indexOf(tag) >= 0; }) : all; grid.innerHTML = items.length ? items.map(function (i, idx) { return '<figure class="wm-gallery-item" data-i="' + idx + '"><img src="' + esc(i.thumb || i.src) + '" alt="' + esc(i.title) + '" loading="lazy">' + (i.title ? '<figcaption>' + esc(i.title) + (i.caption ? '<small> ' + esc(i.caption) + '</small>' : '') + '</figcaption>' : '') + '</figure>'; }).join('') : '<p class="wm-empty">' + LT('gallery.empty') + '</p>'; if (r.settings.lightbox !== false) grid.querySelectorAll('img').forEach(function (img, idx) { img.onclick = function () { var it = items[idx]; var lb = document.createElement('div'); lb.className = 'wm-lightbox'; lb.innerHTML = '<img src="' + esc(it.src) + '" alt=""><p>' + esc(it.title || '') + ' ' + esc(it.caption || '') + '</p>'; lb.onclick = function () { lb.remove(); }; document.body.appendChild(lb); }; }); }
if (!w.getAttribute('data-tag') && tags.length) { tagsBox.className = 'wm-gallery-tags wm-cats'; tagsBox.innerHTML = '<button type="button" class="wm-btn wm-ghost on" data-t="">' + LT('common.all') + '</button>' + tags.map(function (t) { return '<button type="button" class="wm-btn wm-ghost" data-t="' + esc(t) + '">' + esc(t) + '</button>'; }).join(''); tagsBox.querySelectorAll('button').forEach(function (b) { b.onclick = function () { tag = b.getAttribute('data-t'); tagsBox.querySelectorAll('button').forEach(function (x) { x.classList.toggle('on', x === b); }); draw(); }; }); }
draw();
}).catch(function (err) { grid.innerHTML = '<p class="wm-msg err">' + esc(err.message) + '</p>'; });
};
// ---------------- 지도 (Leaflet + OSM) ----------------
R.map = function (w) {
var canvas = w.querySelector('.wm-map-canvas') || (function () { var d = document.createElement('div'); d.className = 'wm-map-canvas'; w.insertBefore(d, w.firstChild); return d; })(); canvas.style.height = (parseInt(w.getAttribute('data-height'), 10) || 360) + 'px';
function ensureLeaflet(cb) { if (window.L) return cb(); var css = document.createElement('link'); css.rel = 'stylesheet'; css.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'; document.head.appendChild(css); var s = document.createElement('script'); s.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'; s.onload = cb; document.head.appendChild(s); }
function draw(lat, lng, label, address) { ensureLeaflet(function () { var zoom = parseInt(w.getAttribute('data-zoom'), 10) || 16; var map = L.map(canvas).setView([lat, lng], zoom); L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '&copy; OpenStreetMap' }).addTo(map); var mk = L.marker([lat, lng]).addTo(map); if (label || address) mk.bindPopup(esc(label || address)).openPopup(); var ad = w.querySelector('.wm-map-address'); if (ad && !ad.textContent.trim()) ad.textContent = address || ''; }); }
var lat = parseFloat(w.getAttribute('data-lat')), lng = parseFloat(w.getAttribute('data-lng')); var address = w.getAttribute('data-address') || ''; var label = w.getAttribute('data-label') || '';
var go = function (a, la, ln, lb) { if (isFinite(la) && isFinite(ln)) draw(la, ln, lb, a); else if (a) call(w, 'GET', '/map/geocode?q=' + encodeURIComponent(a)).then(function (r) { if (r && r.lat) draw(r.lat, r.lng, lb, a); else canvas.textContent = LT('map.notFound', { address: a }); }).catch(function () { canvas.textContent = LT('map.loadFailed'); }); else canvas.textContent = LT('map.noAddress'); };
if (!address && !(isFinite(lat) && isFinite(lng))) call(w, 'GET', '/map/settings').then(function (s) { go(s.address, parseFloat(s.lat), parseFloat(s.lng), s.label); }).catch(function () { go('', NaN, NaN, ''); }); else go(address, lat, lng, label);
};
// ---------------- 후기 ----------------
R.reviews = function (w) {
var list = w.querySelector('.wm-reviews-list'); var form = w.querySelector('.wm-reviews-form') || w.querySelector('form'); var rt = w.querySelector('.wm-reviews-title'); if (rt && !rt.textContent.trim()) rt.textContent = AT(w, 'data-title', 'reviews.title'); if (form && w.getAttribute('data-form') === '0') form.hidden = true; if (!list) return;
var stars = function (n) { return '<span class="wm-stars">' + '★★★★★'.slice(0, n) + '<span style="color:#d1d5db">' + '★★★★★'.slice(n) + '</span></span>'; };
function load() { call(w, 'GET', '/reviews/list').then(function (r) { var style = w.getAttribute('data-style') || r.settings.style || 'list'; var rc = fld(form, 'category'); if (rc) rc.innerHTML = '<option value="">' + LT('common.category') + '</option>' + (r.settings.categories || []).map(function (c) { return '<option value="' + esc(c) + '">' + esc(LV(c)) + '</option>'; }).join(''); var card = function (x) { return '<div class="wm-review">' + stars(x.rating) + ' <b>' + esc(x.name) + '</b>' + (x.category ? ' <span class="wm-cat">' + esc(x.category) + '</span>' : '') + '<p>' + nl2br(x.text) + '</p><div class="wm-meta">' + fmtDate(x.createdAt).slice(0, 12) + '</div></div>'; };
if (!r.items.length) list.innerHTML = '<p class="wm-empty">' + LT('reviews.empty') + '</p>';
else if (style === 'kanban') { var by = r.settings.kanbanBy || 'rating'; var cols = by === 'rating' ? [['5', '★5'], ['4', '★4'], ['3', LT('reviews.low')]] : (r.settings.categories || []).map(function (c) { return [c, LV(c)]; }).concat([['', LT('common.other')]]); list.innerHTML = '<div class="wm-kanban">' + cols.map(function (c) { var items = r.items.filter(function (x) { return by === 'rating' ? (c[0] === '3' ? x.rating <= 3 : String(x.rating) === c[0]) : (x.category || '') === c[0]; }); return '<div class="wm-kanban-col"><h4>' + esc(c[1]) + ' <small>' + items.length + '</small></h4>' + items.map(card).join('') + '</div>'; }).join('') + '</div>'; }
else list.innerHTML = r.items.map(card).join(''); }).catch(function (err) { list.innerHTML = '<p class="wm-msg err">' + esc(err.message) + '</p>'; }); }
if (form) form.onsubmit = function (e) { e.preventDefault(); var d = Object.fromEntries(new FormData(form)); call(w, 'POST', '/reviews/submit', d).then(function (r) { msg(form, r.pending ? LT('reviews.pending') : LT('reviews.posted'), true); form.reset(); load(); }).catch(function (err) { msg(form, err.message); }); };
load();
};
// ---------------- 블로그 (v1.4.0) ----------------
/** 글 본문: 관리자가 쓴 글자를 그대로 보이되(HTML 해석 안 함) 빈 줄은 문단, 줄바꿈은 <br>, http(s) 주소는 링크로 */
var blogBody = function (t) { return String(t || '').split(/\n{2,}/).map(function (p) { return '<p>' + esc(p).replace(/\n/g, '<br>').replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" rel="noopener" target="_blank">$1</a>') + '</p>'; }).join(''); };
R.blog = function (w) {
var list = w.querySelector('.wm-blog-list'), pager = w.querySelector('.wm-blog-pager'), view = w.querySelector('.wm-blog-view'), cats = w.querySelector('.wm-blog-cats'), form = w.querySelector('.wm-blog-search'); if (!list || !view) return;
var bt = w.querySelector('.wm-blog-title'); if (bt && !bt.textContent.trim()) bt.textContent = AT(w, 'data-title', 'blog.title');
var fixedCat = w.getAttribute('data-category') || ''; var st = { q: '', cat: fixedCat, tag: '', page: 1 }; var settings = null;
function showList(on) { list.hidden = !on; if (pager) pager.hidden = !on; if (cats) cats.hidden = !on; if (form) form.hidden = !on; view.hidden = on; }
function load() {
var qs = '?page=' + st.page + (st.q ? '&q=' + encodeURIComponent(st.q) : '') + (st.cat ? '&cat=' + encodeURIComponent(st.cat) : '') + (st.tag ? '&tag=' + encodeURIComponent(st.tag) : '') + (w.getAttribute('data-size') ? '&size=' + encodeURIComponent(w.getAttribute('data-size')) : '');
call(w, 'GET', '/blog/posts' + qs).then(function (r) {
settings = r.settings;
if (cats && !fixedCat) { var c = (settings.categories || []).map(function (x) { return '<button type="button" class="wm-btn wm-ghost' + (st.cat === x ? ' on' : '') + '" data-c="' + esc(x) + '">' + esc(LV(x)) + '</button>'; }).join(''); var tg = (r.tags || []).slice(0, 12).map(function (x) { return '<button type="button" class="wm-btn wm-ghost wm-blog-tag' + (st.tag === x.tag ? ' on' : '') + '" data-t="' + esc(x.tag) + '">#' + esc(x.tag) + '</button>'; }).join(''); cats.innerHTML = (c || tg) ? '<button type="button" class="wm-btn wm-ghost' + (!st.cat && !st.tag ? ' on' : '') + '" data-c="">' + LT('common.all') + '</button>' + c + tg : ''; cats.querySelectorAll('button').forEach(function (b) { b.onclick = function () { if (b.hasAttribute('data-t')) { st.tag = b.getAttribute('data-t'); st.cat = ''; } else { st.cat = b.getAttribute('data-c'); st.tag = ''; } st.page = 1; load(); }; }); }
if (!r.items.length) { list.innerHTML = '<p class="wm-empty">' + LT(st.q || st.tag ? 'blog.noResults' : 'blog.empty') + '</p>'; }
else list.innerHTML = r.items.map(function (p) { return '<article class="wm-blog-card" data-id="' + p.id + '">' + (p.cover ? '<img class="wm-blog-cover" src="' + esc(p.cover) + '" alt="" loading="lazy">' : '') + '<h3 class="wm-blog-card-title"><a href="#post-' + p.id + '">' + esc(p.title) + '</a></h3><div class="wm-meta">' + (p.category ? '<span class="wm-cat">' + esc(LV(p.category)) + '</span> · ' : '') + (p.author ? esc(p.author) + ' · ' : '') + fmtDate(p.createdAt).slice(0, 12) + ' · ' + esc(LT('blog.views', { n: p.views })) + ' · ' + esc(LT('blog.comments', { n: p.comments })) + '</div><p class="wm-excerpt">' + esc(p.summary) + '</p><a class="wm-link" href="#post-' + p.id + '">' + LT('blog.readMore') + '</a></article>'; }).join('');
var pages = Math.max(1, Math.ceil(r.total / r.size)); if (pager) { pager.innerHTML = pages > 1 ? Array.from({ length: pages }, function (_, i) { return '<button type="button" class="wm-btn wm-ghost' + (i + 1 === r.page ? ' on' : '') + '" data-p="' + (i + 1) + '">' + (i + 1) + '</button>'; }).join('') : ''; pager.querySelectorAll('button').forEach(function (b) { b.onclick = function () { st.page = Number(b.getAttribute('data-p')); load(); }; }); }
}).catch(function (err) { list.innerHTML = '<p class="wm-msg err">' + esc(err.message) + '</p>'; });
}
function open(id) {
call(w, 'GET', '/blog/posts/' + encodeURIComponent(id)).then(function (r) {
var p = r.post; showList(false);
var cm = r.comments.map(function (c) { return '<div class="wm-comment"><b>' + esc(c.name) + '</b> <span class="wm-meta">' + fmtDate(c.createdAt) + '</span><div>' + esc(c.body).replace(/\n/g, '<br>') + '</div></div>'; }).join('');
var canWrite = r.canComment; var guest = !getToken(w.getAttribute('data-site'));
view.innerHTML = '<button type="button" class="wm-btn wm-ghost wm-blog-back">' + LT('blog.back') + '</button>' + (p.cover ? '<img class="wm-blog-cover" src="' + esc(p.cover) + '" alt="">' : '') + '<h3 class="wm-blog-view-title">' + esc(p.title) + '</h3><div class="wm-meta">' + (p.category ? '<span class="wm-cat">' + esc(LV(p.category)) + '</span> · ' : '') + (p.author ? esc(p.author) + ' · ' : '') + fmtDate(p.createdAt) + ' · ' + esc(LT('blog.views', { n: p.views })) + '</div>' + ((p.tags || []).length ? '<div class="wm-blog-tags">' + p.tags.map(function (t) { return '<span class="wm-blog-tag">#' + esc(t) + '</span>'; }).join(' ') + '</div>' : '') + '<div class="wm-blog-body">' + blogBody(p.body) + '</div>'
+ '<section class="wm-comments"><h4>' + esc(LT('blog.comments', { n: r.comments.length })) + '</h4>' + cm + (canWrite ? '<form class="wm-cform">' + (guest ? '<input name="name" placeholder="' + esc(LT('common.name')) + '" maxlength="40" required>' : '') + '<textarea name="body" rows="3" placeholder="' + esc(LT('blog.commentPh')) + '" maxlength="3000" required></textarea><button class="wm-btn">' + LT('blog.commentSubmit') + '</button><p class="wm-msg" aria-live="polite"></p></form>' : '<p class="wm-muted">' + LT('blog.commentsClosed') + '</p>') + '</section>';
view.querySelector('.wm-blog-back').onclick = function () { if (location.hash) history.pushState('', document.title, location.pathname + location.search); showList(true); load(); };
var cf = view.querySelector('.wm-cform'); if (cf) cf.onsubmit = function (e) { e.preventDefault(); var d = Object.fromEntries(new FormData(cf)); call(w, 'POST', '/blog/posts/' + encodeURIComponent(id) + '/comments', d).then(function (x) { if (x.pending) { msg(cf, x.message || LT('blog.commentPending'), true); cf.reset(); } else open(id); }).catch(function (err) { msg(cf, err.message); }); };
try { view.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (e) {}
}).catch(function (err) { showList(true); list.innerHTML = '<p class="wm-msg err">' + esc(err.message) + '</p>'; });
}
if (form) form.onsubmit = function (e) { e.preventDefault(); st.q = (form.q.value || '').trim(); st.page = 1; showList(true); load(); };
/** 주소의 #post-<ID> 로 글을 연다 (목록 링크·공유 주소·뒤로 가기) */
function route() { var m = /^#post-([a-f0-9]+)$/.exec(location.hash || ''); if (m) open(m[1]); else { showList(true); } }
window.addEventListener('hashchange', route); load(); route();
};
// ---------------- 자료실 (v1.4.0) ----------------
var fmtSize = function (n) { n = Number(n) || 0; if (n < 1024) return n + ' B'; if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'; if (n < 1073741824) return (n / 1048576).toFixed(1) + ' MB'; return (n / 1073741824).toFixed(2) + ' GB'; };
R.library = function (w) {
var list = w.querySelector('.wm-library-list'), cats = w.querySelector('.wm-library-cats'), form = w.querySelector('.wm-library-search'); if (!list) return;
var lt = w.querySelector('.wm-library-title'); if (lt && !lt.textContent.trim()) lt.textContent = AT(w, 'data-title', 'library.title');
var fixedCat = w.getAttribute('data-category') || ''; var st = { q: '', cat: fixedCat }; var site = w.getAttribute('data-site');
function load() {
call(w, 'GET', '/library/files?' + (st.q ? 'q=' + encodeURIComponent(st.q) + '&' : '') + (st.cat ? 'cat=' + encodeURIComponent(st.cat) : '')).then(function (r) {
var s = r.settings || {};
if (cats && !fixedCat) { cats.innerHTML = (s.categories || []).length ? '<button type="button" class="wm-btn wm-ghost' + (!st.cat ? ' on' : '') + '" data-c="">' + LT('common.all') + '</button>' + s.categories.map(function (x) { return '<button type="button" class="wm-btn wm-ghost' + (st.cat === x ? ' on' : '') + '" data-c="' + esc(x) + '">' + esc(LV(x)) + '</button>'; }).join('') : ''; cats.querySelectorAll('button').forEach(function (b) { b.onclick = function () { st.cat = b.getAttribute('data-c'); load(); }; }); }
if (!r.items.length) { list.innerHTML = '<p class="wm-empty">' + LT(st.q ? 'blog.noResults' : 'library.empty') + '</p>'; return; }
list.innerHTML = r.items.map(function (f) { return '<div class="wm-library-item" data-id="' + f.id + '"><div class="wm-library-info"><b class="wm-library-name">' + esc(f.title) + '</b><div class="wm-meta">' + esc(f.originalName) + ' · ' + fmtSize(f.size) + ' · ' + fmtDate(f.createdAt).slice(0, 12) + (f.downloads !== undefined ? ' · ' + esc(LT('library.downloads', { n: f.downloads })) : '') + (f.category ? ' · <span class="wm-cat">' + esc(LV(f.category)) + '</span>' : '') + '</div>' + (f.description ? '<p class="wm-library-desc">' + esc(f.description) + '</p>' : '') + '</div><button type="button" class="wm-btn wm-library-dl">' + LT('library.download') + '</button></div>'; }).join('');
list.querySelectorAll('.wm-library-dl').forEach(function (b) {
b.onclick = function () {
var id = b.closest('.wm-library-item').getAttribute('data-id'); var tok = getToken(site);
if (s.downloadRole === 'member' && !tok) { msg(w, LT('library.memberOnly')); return; }
// 링크로 내려받는다(큰 파일도 브라우저가 바로 저장). 회원 전용이면 토큰을 주소에 붙인다.
location.href = w.getAttribute('data-api') + '/library/files/' + encodeURIComponent(id) + '/download' + (tok ? '?t=' + encodeURIComponent(tok) : '');
};
});
}).catch(function (err) { list.innerHTML = '<p class="wm-msg err">' + esc(err.message) + '</p>'; });
}
if (form) form.onsubmit = function (e) { e.preventDefault(); st.q = (form.q.value || '').trim(); load(); };
load();
};
// ---------------- 일정관리 캘린더 (v1.4.18) ----------------
// 월간·주간·일간 보기. 날짜 정보(음력·간지·절기)와 일정 발생은 서버(/calendar/range)가 계산해 주므로 여기서는 그리기만 한다.
// 설정 우선순위: data-view / data-lunar / data-ganji 속성(사이트가 정함) → 관리자 설정(API 응답 settings).
var calPad = function (n) { return (n < 10 ? '0' : '') + n; };
var calYmd = function (d) { return d.getFullYear() + '-' + calPad(d.getMonth() + 1) + '-' + calPad(d.getDate()); };
var calParse = function (s) { var p = String(s).split('-'); return new Date(+p[0], +p[1] - 1, +p[2]); };
var calAdd = function (d, n) { var x = new Date(d.getTime()); x.setDate(x.getDate() + n); return x; };
var calMin = function (t) { var m = /^(\d\d):(\d\d)$/.exec(t || ''); return m ? +m[1] * 60 + +m[2] : null; };
R.calendar = function (w) {
var body = w.querySelector('.wm-cal-body');
// v1.5.7: 표준 마크업(.wm-cal-title/.wm-cal-tools/.wm-cal-body)이 없는 위젯(AI 가 임의로 만든 구조·별칭 id)은 안내문을 비우고 골격을 만들어 그린다
if (!body) { var keepTitle = w.getAttribute('data-title') || (w.querySelector('h1,h2,h3') || {}).textContent || ''; w.innerHTML = ''; w.classList.add('wm-cal'); var t0 = document.createElement('h2'); t0.className = 'wm-cal-title'; t0.textContent = keepTitle.trim(); w.appendChild(t0); var tl = document.createElement('div'); tl.className = 'wm-cal-tools'; w.appendChild(tl); body = document.createElement('div'); body.className = 'wm-cal-body'; w.appendChild(body); }
var tools = w.querySelector('.wm-cal-tools') || (function () { var d = document.createElement('div'); d.className = 'wm-cal-tools'; w.insertBefore(d, body); return d; })();
var tt = w.querySelector('.wm-cal-title'); if (tt && !tt.textContent.trim()) tt.textContent = AT(w, 'data-title', 'calendar.title');
var fixedCat = w.getAttribute('data-category') || ''; var attrView = w.getAttribute('data-view') || '';
var settings = null; var view = attrView; var cur = new Date(); cur.setHours(0, 0, 0, 0); var todayStr = calYmd(cur);
var HOUR_PX = 44; var isKo = /^ko/i.test(PAGE_LANG);
var flag = function (attr, key) { var a = w.getAttribute(attr); return a === '' || a == null ? !!settings[key] : a === '1' || a === 'true'; };
var weekStart = function () { return settings && settings.weekStart === 1 ? 1 : 0; };
/** 보기 종류에 따른 조회 기간 [시작, 끝] (월간은 6주 격자) */
function rangeOf() {
if (view === 'day') return [cur, cur];
if (view === 'week') { var s = calAdd(cur, -((cur.getDay() - weekStart() + 7) % 7)); return [s, calAdd(s, 6)]; }
var first = new Date(cur.getFullYear(), cur.getMonth(), 1); var gs = calAdd(first, -((first.getDay() - weekStart() + 7) % 7)); return [gs, calAdd(gs, 41)];
}
var caption = function (s, e) {
try {
if (view === 'month') return new Date(cur.getFullYear(), cur.getMonth(), 1).toLocaleDateString(PAGE_LANG, { year: 'numeric', month: 'long' });
if (view === 'week') return s.toLocaleDateString(PAGE_LANG, { year: 'numeric', month: 'short', day: 'numeric' }) + ' – ' + e.toLocaleDateString(PAGE_LANG, { month: 'short', day: 'numeric' });
return cur.toLocaleDateString(PAGE_LANG, { year: 'numeric', month: 'long', day: 'numeric', weekday: 'short' });
} catch (e2) { return calYmd(cur); }
};
var wdName = function (wd) { try { return new Date(2023, 0, 1 + wd).toLocaleDateString(PAGE_LANG, { weekday: 'short' }); } catch (e) { return ['일', '월', '화', '수', '목', '금', '토'][wd]; } };
var gText = function (g) { return g ? (isKo ? g.hangul : g.hanja) : ''; };
/** 날짜 칸 아래 줄: 음력(음 9.4) · 일진(신유) — 설정에 따라 */
var subLine = function (info, full) {
if (!info) return ''; var parts = [];
if (flag('data-lunar', 'showLunar') && info.lunar) parts.push(esc(LT('calendar.lunarPrefix') + ' ' + (info.lunar.leap ? LT('calendar.leap') : '') + info.lunar.month + '.' + info.lunar.day));
if (flag('data-ganji', 'showGanji') && info.ganji) parts.push('<span class="wm-cal-gj" title="' + esc(isKo ? info.ganji.text : info.ganji.textHanja) + '">' + esc(full ? (isKo ? info.ganji.text : info.ganji.textHanja) : gText(info.ganji.day)) + '</span>');
return parts.join(' · ');
};
var termBadge = function (info) { return settings.showTerms !== false && info && info.term ? '<span class="wm-cal-term">' + esc(isKo ? info.term : (info.termHanja || info.term)) + '</span>' : ''; };
var evLabel = function (o) { return (o.allDay ? '' : '<b>' + esc(o.time) + '</b> ') + esc(o.title); };
var evClass = function (o) { return 'wm-cal-ev' + (o.color ? ' wm-cal-c-' + o.color : ''); };
function toolbar(s, e) {
tools.innerHTML = '<button type="button" class="wm-btn wm-ghost wm-cal-prev" aria-label="' + esc(LT('calendar.prev')) + '">‹</button><button type="button" class="wm-btn wm-ghost wm-cal-today">' + esc(LT('calendar.today')) + '</button><button type="button" class="wm-btn wm-ghost wm-cal-next" aria-label="' + esc(LT('calendar.next')) + '">›</button><span class="wm-cal-caption">' + esc(caption(s, e)) + '</span>'
+ ['month', 'week', 'day'].map(function (v) { return '<button type="button" class="wm-btn wm-ghost wm-cal-view' + (view === v ? ' on' : '') + '" data-v="' + v + '">' + esc(LT('calendar.' + v)) + '</button>'; }).join('');
var step = function (dir) { if (view === 'month') cur = new Date(cur.getFullYear(), cur.getMonth() + dir, 1); else cur = calAdd(cur, dir * (view === 'week' ? 7 : 1)); load(); };
tools.querySelector('.wm-cal-prev').onclick = function () { step(-1); }; tools.querySelector('.wm-cal-next').onclick = function () { step(1); };
tools.querySelector('.wm-cal-today').onclick = function () { cur = calParse(todayStr); load(); };
tools.querySelectorAll('.wm-cal-view').forEach(function (b) { b.onclick = function () { view = b.getAttribute('data-v'); load(); }; });
}
/** 일정 상세 팝업 */
function popup(o) {
var d = document.createElement('div'); d.className = 'wm-cal-pop';
var when = calParse(o.start).toLocaleDateString(PAGE_LANG, { year: 'numeric', month: 'long', day: 'numeric', weekday: 'short' }) + (o.days > 1 ? ' ~ ' + calParse(o.end).toLocaleDateString(PAGE_LANG, { month: 'long', day: 'numeric', weekday: 'short' }) : '') + (o.allDay ? '' : ' ' + o.time + (o.endTime ? ' – ' + o.endTime : ''));
var unit = o.unit === 'time' ? LT('calendar.unitTime') : o.unit === 'week' ? LT('calendar.unitWeek', { n: Math.round(o.days / 7) }) : LT('calendar.unitDay', { n: o.days });
d.innerHTML = '<div class="wm-cal-card" role="dialog"><h3>' + esc(o.title) + '</h3><div class="wm-meta">' + esc(when) + '</div><div class="wm-meta">' + esc(unit) + (o.category ? ' · ' + esc(LV(o.category)) : '') + (o.cal === 'lunar' && o.lunarText ? ' · ' + esc(LT('calendar.lunarBased')) + ' (' + esc(LT('calendar.lunarPrefix') + ' ' + o.lunarText) + ')' : '') + '</div>' + (o.location ? '<div class="wm-meta">' + esc(LT('calendar.location')) + ': ' + esc(o.location) + '</div>' : '') + (o.body ? '<div class="wm-body">' + nl2br(o.body) + '</div>' : '') + '<p><button type="button" class="wm-btn wm-cal-close">' + esc(LT('calendar.close')) + '</button></p></div>';
d.onclick = function (e) { if (e.target === d || e.target.classList.contains('wm-cal-close')) d.remove(); }; document.body.appendChild(d);
}
function bindEvents(root, byId) { root.querySelectorAll('.wm-cal-ev[data-id]').forEach(function (b) { b.onclick = function (e) { e.stopPropagation(); var o = byId[b.getAttribute('data-id')]; if (o) popup(o); }; }); }
/** 시간 단위 일정을 겹치지 않게 열 나누기: 시작 순으로 보며 끝나지 않은 일정과 겹치면 다음 열 */
function layout(items) {
var cols = []; items.forEach(function (o) { o._s = calMin(o.time) || 0; o._e = Math.max(o._s + 30, calMin(o.endTime) || (o._s + 60)); var c = 0; while (cols[c] && cols[c] > o._s) c++; cols[c] = o._e; o._c = c; });
var n = cols.length || 1; items.forEach(function (o) { o._n = n; });
}
function renderMonth(r, s) {
var info = {}; r.days.forEach(function (d) { info[d.date] = d; }); var byDay = {}; var byId = {};
r.events.forEach(function (o) { byId[o.id] = o; var d = calParse(o.start); for (var i = 0; i < o.days; i++) { var k = calYmd(calAdd(d, i)); (byDay[k] = byDay[k] || []).push(o); } });
var html = '<div class="wm-cal-grid wm-cal-month">'; for (var i = 0; i < 7; i++) html += '<div class="wm-cal-wd">' + esc(wdName((weekStart() + i) % 7)) + '</div>';
for (var j = 0; j < 42; j++) {
var d = calAdd(s, j); var k = calYmd(d); var evs = byDay[k] || []; var wd = d.getDay();
html += '<div class="wm-cal-cell' + (d.getMonth() !== cur.getMonth() ? ' other' : '') + (k === todayStr ? ' today' : '') + (wd === 0 ? ' wm-cal-sun' : wd === 6 ? ' wm-cal-sat' : '') + '" data-date="' + k + '"><div class="wm-cal-date"><span class="wm-cal-num">' + d.getDate() + '</span>' + termBadge(info[k]) + '</div><div class="wm-cal-sub">' + subLine(info[k]) + '</div><div class="wm-cal-evs">'
+ evs.slice(0, 3).map(function (o) { return '<button type="button" class="' + evClass(o) + '" data-id="' + esc(o.id) + '">' + evLabel(o) + '</button>'; }).join('') + (evs.length > 3 ? '<button type="button" class="wm-cal-more">' + esc(LT('calendar.more', { n: evs.length - 3 })) + '</button>' : '') + '</div></div>';
}
body.innerHTML = html + '</div>' + (r.events.length ? '' : '<p class="wm-cal-empty">' + esc(LT('calendar.empty')) + '</p>');
bindEvents(body, byId);
body.querySelectorAll('.wm-cal-cell').forEach(function (c) { c.onclick = function () { cur = calParse(c.getAttribute('data-date')); view = 'day'; load(); }; });
}
function renderWeek(r, s, e) {
var days = []; for (var d = s; d <= e; d = calAdd(d, 1)) days.push(d); var n = days.length; var info = {}; r.days.forEach(function (x) { info[x.date] = x; }); var byId = {}; r.events.forEach(function (o) { byId[o.id] = o; });
var allOf = function (k) { return r.events.filter(function (o) { return o.allDay && o.start <= k && o.end >= k; }); };
var timedOf = function (k) { return r.events.filter(function (o) { return !o.allDay && o.start === k; }); };
var h0 = settings.dayStart || 0, h1 = settings.dayEnd || 24;
var html = '<div class="wm-cal-week' + (n === 1 ? ' one' : '') + '" style="--wm-cal-cols:' + n + '">';
if (n === 1) { var di = info[calYmd(cur)]; if (di) html += '<div class="wm-cal-info">' + subLine(di, true) + (di.term ? ' · ' + esc(LT('calendar.termLabel')) + ': ' + esc(isKo ? di.term : di.termHanja || di.term) : '') + '</div>'; }
html += '<div class="wm-cal-row wm-cal-head"><div></div>' + days.map(function (d) { var k = calYmd(d); var wd = d.getDay(); return '<div class="' + (k === todayStr ? 'today' : '') + (wd === 0 ? ' wm-cal-sun' : wd === 6 ? ' wm-cal-sat' : '') + '"><span class="wm-cal-num">' + esc(wdName(wd)) + ' ' + d.getDate() + '</span> ' + termBadge(info[k]) + (n > 1 ? '<div class="wm-cal-sub">' + subLine(info[k]) + '</div>' : '') + '</div>'; }).join('') + '</div>';
html += '<div class="wm-cal-row wm-cal-allday"><div class="wm-cal-sub">' + esc(LT('calendar.allDay')) + '</div>' + days.map(function (d) { return '<div>' + allOf(calYmd(d)).map(function (o) { return '<button type="button" class="' + evClass(o) + '" data-id="' + esc(o.id) + '">' + evLabel(o) + '</button>'; }).join('') + '</div>'; }).join('') + '</div>';
html += '<div class="wm-cal-row wm-cal-timegrid"><div class="wm-cal-hours">'; for (var h = h0; h < h1; h++) html += '<div class="wm-cal-hour">' + calPad(h) + ':00</div>'; html += '</div>';
days.forEach(function (d) {
var k = calYmd(d); var items = timedOf(k); layout(items); html += '<div class="wm-cal-col" data-date="' + k + '">'; for (var h = h0; h < h1; h++) html += '<div class="wm-cal-hour"></div>';
items.forEach(function (o) { var top = Math.max(0, (o._s - h0 * 60) / 60 * HOUR_PX); var bottom = Math.min((h1 - h0) * HOUR_PX, (o._e - h0 * 60) / 60 * HOUR_PX); var ht = Math.max(22, bottom - top); var wpct = 100 / o._n; html += '<button type="button" class="' + evClass(o) + ' wm-cal-tev" data-id="' + esc(o.id) + '" style="top:' + top + 'px;height:' + ht + 'px;left:calc(' + (o._c * wpct) + '% + 2px);width:calc(' + wpct + '% - 4px)">' + evLabel(o) + '</button>'; });
html += '</div>';
});
html += '</div></div>';
if (n === 1) { var list = r.events.filter(function (o) { return o.start <= calYmd(cur) && o.end >= calYmd(cur); }); html += '<div class="wm-cal-list">' + (list.length ? list.map(function (o) { return '<button type="button" class="' + evClass(o) + '" data-id="' + esc(o.id) + '">' + evLabel(o) + (o.location ? ' <small>' + esc(o.location) + '</small>' : '') + '</button>'; }).join('') : '<p class="wm-cal-empty">' + esc(LT('calendar.empty')) + '</p>') + '</div>'; }
body.innerHTML = html; bindEvents(body, byId);
}
function load() {
if (!view) view = (settings && settings.defaultView) || 'month';
var se = rangeOf(); toolbar(se[0], se[1]); body.innerHTML = '<p class="wm-cal-empty">' + esc(LT('common.loading')) + '</p>';
call(w, 'GET', '/calendar/range?from=' + calYmd(se[0]) + '&to=' + calYmd(se[1]) + (fixedCat ? '&cat=' + encodeURIComponent(fixedCat) : '')).then(function (r) {
settings = r.settings || settings || {}; if (view === 'month') renderMonth(r, se[0]); else renderWeek(r, se[0], se[1]);
}).catch(function (err) { body.innerHTML = '<p class="wm-msg err">' + esc(err.message || LT('calendar.loadFailed')) + '</p>'; });
}
// 설정을 먼저 받아 기본 보기·주 시작 요일을 알고 나서 그린다
call(w, 'GET', '/calendar/settings').then(function (s) { settings = s || {}; load(); }).catch(function () { settings = {}; load(); });
};
// ---------------- FAQ ----------------
R.faq = function (w) {
var list = w.querySelector('.wm-faq-list'); if (!list) return; var catsBox = w.querySelector('.wm-faq-cats') || document.createElement('div'); var ft = w.querySelector('.wm-faq-title'); if (ft && !ft.textContent.trim()) ft.textContent = AT(w, 'data-title', 'faq.title'); var cat = w.getAttribute('data-category') || '';
call(w, 'GET', '/faq/list').then(function (r) { var cats = []; r.items.forEach(function (i) { if (i.category && cats.indexOf(i.category) < 0) cats.push(i.category); }); function draw() { var items = cat ? r.items.filter(function (i) { return i.category === cat; }) : r.items; list.innerHTML = items.length ? items.map(function (i) { return '<details><summary>' + esc(i.q) + '</summary><div class="wm-faq-a">' + nl2br(i.a) + '</div></details>'; }).join('') : '<p class="wm-empty">' + LT('faq.empty') + '</p>'; } if (!w.getAttribute('data-category') && cats.length) { catsBox.className = 'wm-faq-cats wm-cats'; catsBox.innerHTML = '<button type="button" class="wm-btn wm-ghost on" data-c="">' + LT('common.all') + '</button>' + cats.map(function (c) { return '<button type="button" class="wm-btn wm-ghost" data-c="' + esc(c) + '">' + esc(LV(c)) + '</button>'; }).join(''); catsBox.querySelectorAll('button').forEach(function (b) { b.onclick = function () { cat = b.getAttribute('data-c'); catsBox.querySelectorAll('button').forEach(function (x) { x.classList.toggle('on', x === b); }); draw(); }; }); } draw(); }).catch(function (err) { list.innerHTML = '<p class="wm-msg err">' + esc(err.message) + '</p>'; });
};
/**
* v1.5.7: 모듈 id 별칭. AI 가 표준 코드 없이 비슷한 이름(schedule·events·일정)으로 위젯을 만든 페이지도 일정관리 런타임이 그려 준다
* (2026-10-14 운영 사이트에서 data-wm-module="schedule" 이 빈 안내문만 보이던 문제). 표준 id 가 우선이다.
*/
var ALIAS = { schedule: 'calendar', schedules: 'calendar', events: 'calendar', event: 'calendar', agenda: 'calendar', '일정': 'calendar', '일정관리': 'calendar', '캘린더': 'calendar', bbs: 'board', forum: 'board', photos: 'gallery', photo: 'gallery', contact: 'inquiry', 'contact-form': 'inquiry', review: 'reviews', faqs: 'faq', news: 'blog', posts: 'blog', downloads: 'library', files: 'library', reservation: 'booking', member: 'members', login: 'members' };
function boot() { document.querySelectorAll('[data-wm-module]').forEach(function (w) { if (w.__wm) return; w.__wm = 1; var id = String(w.getAttribute('data-wm-module') || '').trim().toLowerCase(); var fn = R[id] || R[ALIAS[id]]; if (fn) { try { fn(w); } catch (e) { console.error('wm module error', e); } } }); }
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
window.wmModules = { boot: boot, call: call };
})();