Files

357 lines
53 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.
'use strict';
/* ============================================================================
* 사이트 관리자 페이지 (v1.0.3) — /site-admin/<사이트ID>/
* 켜진 모듈(회원·게시판·문의·설문·예약·갤러리·지도·후기·FAQ·블로그·자료실·일정관리)의 데이터와 설정을 관리한다.
* 인증: 빌더 세션 쿠키(소유자/운영자) 가 있으면 그대로, 없으면 사이트 관리자 계정(회원 role=admin)으로 로그인해 토큰(localStorage)으로 호출한다.
* 화면은 lib/modules.js 의 CATALOG(옵션 스키마·표 열) 로 대부분 범용 렌더링하고, 설문 양식 편집기·갤러리 업로드 같은 것만 전용 UI 를 둔다.
* ========================================================================== */
const $ = (id) => document.getElementById(id);
const SITE = (location.pathname.match(/\/site-admin\/([a-f0-9]{32})/) || [])[1];
const EMBED = new URLSearchParams(location.search).get('embed') === '1';
const TOKEN_KEY = 'wm_siteadmin_' + SITE;
let token = ''; try { token = localStorage.getItem(TOKEN_KEY) || ''; } catch (_) {}
let ov = null; let tab = 'home';
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const dt = (s) => (s ? new Date(s).toLocaleString(uiLocale(), { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '');
async function api(p, opt = {}) {
const headers = opt.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }; if (token) headers.Authorization = 'Bearer ' + token;
const r = await fetch(`/api/site-admin/${SITE}${p}`, { credentials: 'same-origin', ...opt, headers: { ...headers, ...(opt.headers || {}) } });
if (r.status === 401) { showLogin(); throw new Error('로그인이 필요합니다'); }
const ct = r.headers.get('content-type') || ''; const body = ct.includes('json') ? await r.json().catch(() => ({})) : await r.blob();
if (!r.ok) throw new Error((body && body.error) || `HTTP ${r.status}`);
return body;
}
function showLogin() { $('loginBox').classList.remove('hidden'); $('app').classList.add('hidden'); }
$('lGo').onclick = async () => {
$('lMsg').textContent = '확인 중…';
try { const r = await fetch(`/api/m/${SITE}/members/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: $('lId').value.trim(), password: $('lPw').value }) }); const j = await r.json(); if (!r.ok) throw new Error(j.error || '로그인 실패'); if (j.member.role !== 'admin') throw new Error('사이트 관리자 권한이 없는 계정입니다'); token = j.token; try { localStorage.setItem(TOKEN_KEY, token); } catch (_) {} await boot(); }
catch (e) { $('lMsg').textContent = e.message; $('lMsg').className = 'msg err'; }
};
$('btnLogout').onclick = () => { token = ''; try { localStorage.removeItem(TOKEN_KEY); } catch (_) {} showLogin(); };
// ---------- 공통 렌더 ----------
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; };
function btn(label, cls, fn) { const b = el('button', cls, label); b.type = 'button'; b.onclick = fn; return b; }
/** 설정 스키마(옵션) → 입력 폼 */
function settingsForm(mod, into) {
const cat = ov.catalog.find((c) => c.id === mod); const cur = ov.settings[mod] || {}; const kv = el('div', 'kv');
for (const o of cat.options || []) {
kv.appendChild(el('label', '', o.label)); let inp;
if (o.type === 'boolean') { inp = el('input'); inp.type = 'checkbox'; inp.checked = !!cur[o.key]; inp.style.width = 'auto'; }
else if (o.type === 'select') { inp = el('select'); for (const [v, l] of o.values) inp.appendChild(new Option(l, v)); inp.value = cur[o.key] == null ? o.def : cur[o.key]; }
else if (o.type === 'list') { inp = el('input'); inp.value = (cur[o.key] || []).join(', '); inp.placeholder = '쉼표로 구분'; }
else if (o.type === 'time') { inp = el('input'); inp.type = 'time'; inp.value = cur[o.key] || o.def; }
else { inp = el('input'); inp.type = o.type === 'number' ? 'number' : 'text'; inp.value = cur[o.key] == null ? '' : cur[o.key]; }
inp.dataset.key = o.key; inp.dataset.type = o.type; kv.appendChild(inp);
}
into.appendChild(kv);
const row = el('div', 'row'); const m = el('span', 'msg');
row.appendChild(btn('설정 저장', 'primary', async () => { const body = {}; kv.querySelectorAll('[data-key]').forEach((i) => { body[i.dataset.key] = i.dataset.type === 'boolean' ? i.checked : i.value; }); try { ov.settings[mod] = await api(`/settings/${mod}`, { method: 'PUT', body: JSON.stringify(body) }); m.textContent = '저장되었습니다'; m.className = 'msg ok'; } catch (e) { m.textContent = e.message; m.className = 'msg err'; } })); row.appendChild(m); into.appendChild(row);
}
/** 범용 표: 열 정의대로 표시, 수정/삭제 */
async function table(coll, into, opts = {}) {
const items = await api(`/c/${coll}`); const cols = opts.columns || []; const wrap = el('div', 'table-wrap'); const t = el('table'); const tr = el('tr');
for (const c of cols) tr.appendChild(el('th', '', LABEL[c] || c)); tr.appendChild(el('th', '', '')); t.appendChild(tr);
for (const it of items) {
const r = el('tr');
for (const c of cols) { const td = el('td'); let v = it[c]; if (opts.format && opts.format[c]) v = opts.format[c](v, it); /* v1.4.18 format 에 항목 전체도 넘긴다 */ else if (typeof v === 'number' && Number.isFinite(v)) v = v.toLocaleString(uiLocale()); /* v1.4.5 숫자 3자리 쉼표 */ else if (c === 'createdAt' || c === 'at') v = dt(v); else if (typeof v === 'boolean') { const p = el('span', 'pill ' + (v ? 'ok' : 'warn'), v ? (c === 'approved' ? '승인' : c === 'sent' ? '발송' : '예') : (c === 'approved' ? '대기' : c === 'sent' ? '미발송' : '아니오')); td.appendChild(p); r.appendChild(td); continue; } else if (Array.isArray(v)) v = v.join(', '); else if (c === 'status') { const p = el('span', 'pill ' + (v === 'confirmed' ? 'ok' : v === 'cancelled' ? 'off' : 'warn'), STATUS[v] || v); td.appendChild(p); r.appendChild(td); continue; } else if (c === 'role') v = v === 'admin' ? '관리자' : '회원'; else if (c === 'rating') v = '★'.repeat(v || 0); const d = el('div', 'wrap', v == null ? '' : String(v)); td.appendChild(d); r.appendChild(td); }
const act = el('td', 'act');
for (const a of (opts.actions || [])) act.appendChild(btn(a.label, 'small' + (a.danger ? ' danger' : ''), () => a.fn(it)));
if (opts.edit) act.appendChild(btn('수정', 'small', () => opts.edit(it))); /* v1.4.18 전용 편집기(일정) */
else if (opts.editable !== false) act.appendChild(btn('수정', 'small', () => editDialog(coll, it, opts)));
act.appendChild(btn('삭제', 'small danger', async () => { if (!confirm('삭제할까요?')) return; await api(`/c/${coll}/${it.id}`, { method: 'DELETE' }); render(); }));
r.appendChild(act); t.appendChild(r);
}
if (!items.length) { const r = el('tr'); const td = el('td', 'muted', '아직 항목이 없습니다'); td.colSpan = cols.length + 1; r.appendChild(td); t.appendChild(r); }
wrap.appendChild(t); into.appendChild(wrap); return items;
}
const LABEL = { when: '날짜·시간', unit: '단위', repeat: '반복', next: '다음 발생', location: '장소', published: '공개', pinned: '고정', summary: '요약', cover: '대표 이미지', description: '설명', originalName: '파일', size: '크기', downloads: '내려받음', mailAddress: '메일 주소', name: '이름', email: '이메일', role: '권한', approved: '승인', createdAt: '등록', title: '제목', author: '글쓴이', category: '분류', secret: '비밀', views: '조회', postId: '글', body: '내용', active: '진행', responses: '응답 수', surveyId: '설문', respondent: '응답자', date: '날짜', time: '시간', contact: '연락처', status: '상태', note: '요청', tags: '태그', src: '이미지', q: '질문', a: '답변', order: '순서', rating: '별점', text: '내용', subject: '제목', sent: '메일', at: '접수', loginId: '아이디', caption: '설명' };
const STATUS = { pending: '대기', confirmed: '확정', cancelled: '취소' };
/** 수정/추가 대화상자: 필드 정의에 따라 입력 생성 */
const FIELDS = {
members: [['name', '이름'], ['email', '이메일'], ['role', '권한', 'select', [['member', '회원'], ['admin', '관리자']]], ['approved', '승인', 'bool']],
posts: [['title', '제목'], ['category', '분류'], ['author', '글쓴이'], ['body', '내용', 'text'], ['secret', '비밀글', 'bool'], ['pinned', '상단 고정', 'bool']],
comments: [['body', '내용', 'text'], ['secret', '비밀 댓글', 'bool']],
bookings: [['date', '날짜', 'date'], ['time', '시간', 'time'], ['name', '이름'], ['contact', '연락처'], ['note', '요청', 'text'], ['status', '상태', 'select', [['pending', '대기'], ['confirmed', '확정'], ['cancelled', '취소']]]],
reviews: [['name', '이름'], ['rating', '별점(1~5)', 'number'], ['category', '분류'], ['text', '내용', 'text'], ['approved', '공개', 'bool']],
faq: [['q', '질문'], ['a', '답변', 'text'], ['category', '분류'], ['order', '순서', 'number']],
gallery: [['src', '이미지 주소'], ['thumb', '썸네일 주소(선택)'], ['title', '제목'], ['caption', '설명'], ['tags', '태그(쉼표)'], ['order', '순서', 'number']],
// v1.4.0 블로그·자료실
blogPosts: [['title', '제목'], ['summary', '요약(비우면 본문 앞부분)'], ['body', '본문 (빈 줄 = 문단, 주소는 자동 링크)', 'text'], ['cover', '대표 이미지 주소(선택)'], ['category', '분류'], ['tags', '태그(쉼표)'], ['author', '글쓴이'], ['published', '공개', 'bool'], ['pinned', '상단 고정', 'bool']],
blogComments: [['name', '이름'], ['body', '내용', 'text'], ['approved', '공개', 'bool']],
libraryFiles: [['title', '제목'], ['description', '설명', 'text'], ['category', '분류'], ['tags', '태그(쉼표)'], ['published', '공개', 'bool'], ['pinned', '상단 고정', 'bool']],
};
/** v1.4.0 바이트 → 읽기 쉬운 크기 */
const fmtSize = (n) => { n = Number(n) || 0; return n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : n < 1073741824 ? `${(n / 1048576).toFixed(1)} MB` : `${(n / 1073741824).toFixed(2)} GB`; };
function editDialog(coll, item, opts) {
const fields = FIELDS[coll]; if (!fields) return; const body = $('dlgBody'); body.innerHTML = ''; $('dlgTitle').textContent = (item ? '수정' : '추가') + ' — ' + ((opts && opts.label) || coll); $('dlgMsg').textContent = '';
const inputs = {};
for (const [k, label, type, values] of fields) { const lab = el('label', '', label); let inp; if (type === 'text') { inp = el('textarea'); inp.rows = 5; inp.value = item ? (item[k] || '') : ''; } else if (type === 'bool') { inp = el('input'); inp.type = 'checkbox'; inp.style.width = 'auto'; inp.checked = item ? !!item[k] : (k === 'approved'); } else if (type === 'select') { inp = el('select'); for (const [v, l] of values) inp.appendChild(new Option(l, v)); if (item) inp.value = item[k]; } else { inp = el('input'); inp.type = type === 'number' ? 'number' : type === 'date' ? 'date' : type === 'time' ? 'time' : 'text'; inp.value = item ? (Array.isArray(item[k]) ? item[k].join(', ') : (item[k] == null ? '' : item[k])) : ''; } inputs[k] = [inp, type]; lab.appendChild(inp); body.appendChild(lab); }
$('dlgSave').onclick = async () => { const data = {}; for (const [k, [inp, type]] of Object.entries(inputs)) data[k] = type === 'bool' ? inp.checked : inp.value; try { if (item) await api(`/c/${coll}/${item.id}`, { method: 'PUT', body: JSON.stringify(data) }); else await api(`/c/${coll}`, { method: 'POST', body: JSON.stringify(data) }); $('dlg').close(); render(); } catch (e) { $('dlgMsg').textContent = e.message; $('dlgMsg').className = 'msg err'; } };
$('dlgCancel').onclick = () => $('dlg').close(); $('dlg').showModal();
}
// ---------- 화면 ----------
async function boot() {
try { ov = await api('/overview'); } catch (e) { return; }
$('loginBox').classList.add('hidden'); $('app').classList.remove('hidden'); $('hSite').textContent = ov.site ? ov.site.title : SITE.slice(0, 8); $('hWho').textContent = ov.who || ''; $('btnLogout').hidden = !token;
if (EMBED) document.querySelector('header').style.display = 'none';
// v1.1.4: 이 페이지의 직접 접근 주소 (빌더 없이 들어오면 사이트 관리자 계정으로 로그인)
let bar = document.getElementById('saUrl'); if (!bar) { bar = document.createElement('div'); bar.id = 'saUrl'; bar.className = 'sa-url'; document.querySelector('header').insertAdjacentElement('afterend', bar); }
const direct = `${location.origin}/site-admin/${SITE}/`; bar.innerHTML = ''; bar.appendChild(Object.assign(document.createElement('span'), { textContent: '관리자 페이지 주소:' })); const code = document.createElement('code'); code.textContent = direct; bar.appendChild(code);
const cp = btn('복사', 'small', async () => { try { await navigator.clipboard.writeText(direct); cp.textContent = '복사됨'; setTimeout(() => (cp.textContent = '복사'), 1500); } catch (_) { prompt('주소', direct); } }); bar.appendChild(cp);
bar.appendChild(btn('새 창으로 열기', 'small', () => window.open(direct, '_blank', 'noopener')));
bar.appendChild(Object.assign(document.createElement('span'), { className: 'muted', textContent: '— 빌더를 거치지 않고 이 주소로 들어오면 "관리자 계정" 탭에서 만든 사이트 관리자 계정으로 로그인해야 합니다.' }));
renderTabs(); render();
}
function renderTabs() {
const tabs = $('tabs'); tabs.innerHTML = '';
const add = (id, label, n) => { const b = btn(label, tab === id ? 'active' : '', () => { tab = id; renderTabs(); render(); }); if (n) { const s = el('span', 'n', `(${n})`); b.appendChild(s); } tabs.appendChild(b); };
add('home', '모듈 · 설정');
for (const c of ov.catalog) if (ov.enabled[c.id] && Object.keys(c.collections).length) add(c.id, c.label, c.id === 'booking' ? ov.counts.pendingBookings : c.id === 'reviews' ? ov.counts.pendingReviews : c.id === 'members' ? ov.counts.pendingMembers : null);
if (ov.enabled.inquiry) add('inquiry', '문의', ov.counts.unsentInquiries || null);
if (ov.mail && ov.mail.systemEnabled) add('mail', '메일'); // v1.3.2: 운영자가 이 사이트의 메일 시스템을 켰을 때만 (설정중 포함)
add('account', '관리자 계정');
}
async function render() {
const m = $('main'); m.innerHTML = '';
try {
if (tab === 'home') return renderHome(m);
if (tab === 'account') return renderAccount(m);
if (tab === 'mail') return renderMail(m);
if (tab === 'inquiry') return renderInquiries(m);
const cat = ov.catalog.find((c) => c.id === tab); if (!cat) return;
const card = el('div', 'card'); card.appendChild(el('h2', '', cat.label + ' 설정')); settingsForm(tab, card); m.appendChild(card);
for (const [coll, def] of Object.entries(cat.collections)) {
const c2 = el('div', 'card'); const h = el('h2', '', def.label); c2.appendChild(h);
if (coll === 'surveys') { h.appendChild(btn('+ 설문 만들기', 'small primary', () => surveyEditor(null))); await table(coll, c2, { columns: def.columns, editable: false, actions: [{ label: '문항 편집', fn: (it) => surveyEditor(it) }, { label: '응답 보기', fn: (it) => { tab = 'survey'; renderResponses(m, it); } }, { label: 'xlsx 내려받기', fn: (it) => { window.open(`/api/site-admin/${SITE}/export/survey/${it.id}.xlsx${token ? '?token=' + encodeURIComponent(token) : ''}`, '_blank'); } }] }); }
else if (coll === 'responses') { c2.appendChild(el('p', 'muted small', '응답은 설문 표의 "응답 보기"에서 설문별로 확인하고 xlsx 로 내려받을 수 있습니다.')); }
else if (coll === 'gallery') { h.appendChild(btn('+ 이미지 주소로 추가', 'small', () => editDialog(coll, null, def))); const up = el('input'); up.type = 'file'; up.accept = 'image/*'; up.multiple = true; up.style.width = 'auto'; up.onchange = async () => { const fd = new FormData(); for (const f of up.files) fd.append('files', f, f.name); try { await api('/upload', { method: 'POST', body: fd }); render(); } catch (e) { alert(e.message); } }; const lab = el('label', '', '이미지 업로드(여러 장 가능): '); lab.appendChild(up); c2.appendChild(lab); await table(coll, c2, { columns: def.columns, label: def.label }); }
// v1.4.0 블로그 글: 추가 + 대표 이미지 올리기(올린 이미지 주소를 글의 "대표 이미지 주소" 에 붙여 넣는다)
else if (coll === 'blogPosts') {
h.appendChild(btn('+ 글쓰기', 'small primary', () => editDialog(coll, null, def)));
const up = el('input'); up.type = 'file'; up.accept = 'image/*'; up.style.width = 'auto'; const out = el('span', 'muted small');
up.onchange = async () => { const fd = new FormData(); for (const f of up.files) fd.append('files', f, f.name); try { const r = await api('/upload?to=blog', { method: 'POST', body: fd }); out.textContent = r.added.length ? `올린 이미지 주소: ${r.added.map((a) => a.url).join(' , ')} (글의 대표 이미지 주소에 붙여 넣으세요)` : '이미지 파일(15MB 이하)만 올릴 수 있습니다'; } catch (e) { alert(e.message); } };
const lab = el('label', '', '대표 이미지 올리기: '); lab.appendChild(up); c2.appendChild(lab); c2.appendChild(out);
await table(coll, c2, { columns: def.columns, label: def.label });
}
else if (coll === 'blogComments') { let titles = {}; try { titles = Object.fromEntries(((await api('/c/blogPosts')) || []).map((x) => [x.id, x.title])); } catch (_) {} await table(coll, c2, { columns: def.columns, label: def.label, format: { postId: (v) => titles[v] || v }, actions: [{ label: '공개', fn: async (it) => { await api(`/c/blogComments/${it.id}`, { method: 'PUT', body: JSON.stringify({ approved: true }) }); render(); } }] }); }
// v1.4.0 자료실: 파일 올리기(여러 개) — 크기 상한은 자료실 설정, 파일은 사이트 폴더 밖에 저장되어 API 로만 내려받는다
else if (coll === 'libraryFiles') {
const box = el('div', 'row'); const up = el('input'); up.type = 'file'; up.multiple = true; up.style.width = 'auto'; const cat = el('input'); cat.placeholder = '분류(선택)'; cat.style.width = '140px'; const out = el('span', 'muted small');
const go = btn('올리기', 'small primary', async () => { if (!up.files.length) { out.textContent = '파일을 고르세요'; return; } const fd = new FormData(); for (const f of up.files) fd.append('files', f, f.name); fd.append('category', cat.value.trim()); out.textContent = '올리는 중…'; try { const r = await api('/library', { method: 'POST', body: fd }); out.textContent = `${r.added.length}개 올림` + (r.skipped.length ? ` · 건너뜀: ${r.skipped.map((x) => `${x.name}(${x.reason})`).join(', ')}` : ''); setTimeout(render, 600); } catch (e) { out.textContent = e.message; } });
box.appendChild(up); box.appendChild(cat); box.appendChild(go); box.appendChild(out); c2.appendChild(box);
await table(coll, c2, { columns: def.columns, label: def.label, format: { size: fmtSize } });
}
else if (coll === 'events') { await renderCalendarEvents(c2, def, h); } // v1.4.18 일정관리
else if (coll === 'members') { await table(coll, c2, { columns: def.columns, label: def.label, actions: [{ label: '승인', fn: async (it) => { await api(`/c/members/${it.id}`, { method: 'PUT', body: JSON.stringify({ approved: true }) }); render(); } }] }); }
else if (coll === 'bookings') { h.appendChild(btn('+ 예약 추가', 'small', () => editDialog(coll, null, def))); await table(coll, c2, { columns: def.columns, label: def.label, actions: [{ label: '확정', fn: async (it) => { await api(`/c/bookings/${it.id}`, { method: 'PUT', body: JSON.stringify({ status: 'confirmed' }) }); render(); } }, { label: '취소', fn: async (it) => { await api(`/c/bookings/${it.id}`, { method: 'PUT', body: JSON.stringify({ status: 'cancelled' }) }); render(); } }] }); }
else if (coll === 'reviews') { await table(coll, c2, { columns: def.columns, label: def.label, actions: [{ label: '공개', fn: async (it) => { await api(`/c/reviews/${it.id}`, { method: 'PUT', body: JSON.stringify({ approved: true }) }); render(); } }] }); }
else { if (FIELDS[coll] && coll !== 'comments') h.appendChild(btn('+ 추가', 'small', () => editDialog(coll, null, def))); await table(coll, c2, { columns: def.columns, label: def.label }); }
m.appendChild(c2);
}
} catch (e) { m.appendChild(el('p', 'msg err', e.message)); }
}
function renderHome(m) {
const card = el('div', 'card'); card.appendChild(el('h2', '', '기능 모듈 켜기 / 끄기')); card.appendChild(el('p', 'muted small', '켜면 이 관리자 페이지에 해당 탭이 생기고, 빌더가 사이트를 만들거나 수정할 때 표준 코드가 삽입됩니다.'));
const grid = el('div', 'mod-grid');
for (const c of ov.catalog) { const box = el('div', 'mod'); box.appendChild(el('b', '', c.label)); const sw = el('button', 'switch' + (ov.enabled[c.id] ? ' on' : '')); sw.type = 'button'; sw.onclick = async () => { try { ov.enabled = await api('/enabled', { method: 'PUT', body: JSON.stringify({ module: c.id, on: !ov.enabled[c.id] }) }); sw.classList.toggle('on', !!ov.enabled[c.id]); renderTabs(); } catch (e) { alert(e.message); } }; box.appendChild(sw); box.appendChild(el('div', 'd', c.desc)); grid.appendChild(box); }
card.appendChild(grid); m.appendChild(card);
const c2 = el('div', 'card'); c2.appendChild(el('h2', '', '사이트 주소')); c2.appendChild(el('p', 'small', ov.site && ov.site.liveUrl ? `운영 주소: ${ov.site.liveUrl}` : '아직 배포되지 않았습니다.')); c2.appendChild(el('p', 'muted small', `모듈 API: ${location.origin}/api/m/${SITE}/… · 이 관리자 페이지: ${location.origin}/site-admin/${SITE}/`)); m.appendChild(c2);
}
async function renderInquiries(m) {
const card = el('div', 'card'); card.appendChild(el('h2', '', '문의 접수')); settingsForm('inquiry', card); m.appendChild(card);
const c2 = el('div', 'card'); c2.appendChild(el('h2', '', '접수 내역')); const items = await api('/inquiries');
const t = el('table'); const tr = el('tr'); for (const h of ['접수', '유형', '이름', '이메일', '제목', '내용', '첨부', '메일']) tr.appendChild(el('th', '', h)); t.appendChild(tr);
for (const q of items) { const r = el('tr'); for (const v of [dt(q.at), q.category || (q.extra && q.extra.category) || '-', q.name, q.email + (q.phone ? ' / ' + q.phone : ''), q.subject || '-', q.message]) { const td = el('td'); td.appendChild(el('div', 'wrap', v)); r.appendChild(td); } const fa = el('td'); for (const f of q.files || []) { const a = el('a', '', f.name); a.href = `/api/site-admin/${SITE}/files/${encodeURIComponent(f.stored)}${token ? '?token=' + encodeURIComponent(token) : ''}`; a.target = '_blank'; a.style.display = 'block'; fa.appendChild(a); } r.appendChild(fa); const td = el('td'); td.appendChild(el('span', 'pill ' + (q.sent ? 'ok' : 'warn'), q.sent ? '발송' : (q.error ? '미발송' : '기록'))); td.title = q.error || ''; r.appendChild(td); t.appendChild(r); }
if (!items.length) { const r = el('tr'); const td = el('td', 'muted', '접수된 문의가 없습니다'); td.colSpan = 8; r.appendChild(td); t.appendChild(r); }
const w = el('div', 'table-wrap'); w.appendChild(t); c2.appendChild(w); m.appendChild(c2);
}
async function renderResponses(m, survey) {
m.innerHTML = ''; const card = el('div', 'card'); card.appendChild(el('h2', '', `응답 — ${survey.title}`)); const row = el('div', 'row'); row.appendChild(btn('← 설문 목록', '', () => render())); row.appendChild(btn('xlsx 내려받기', 'primary', () => window.open(`/api/site-admin/${SITE}/export/survey/${survey.id}.xlsx${token ? '?token=' + encodeURIComponent(token) : ''}`, '_blank'))); card.appendChild(row);
const items = (await api('/c/responses')).filter((r) => r.surveyId === survey.id); const t = el('table'); const tr = el('tr'); for (const h of ['응답 일시', '응답자', ...survey.questions.map((q) => q.label)]) tr.appendChild(el('th', '', h)); tr.appendChild(el('th')); t.appendChild(tr);
for (const r of items) { const row2 = el('tr'); row2.appendChild(el('td', '', dt(r.createdAt))); row2.appendChild(el('td', '', r.respondent)); for (const q of survey.questions) { const v = r.answers[q.id]; const td = el('td'); td.appendChild(el('div', 'wrap', Array.isArray(v) ? v.join(', ') : (v == null ? '' : String(v)))); row2.appendChild(td); } const act = el('td', 'act'); act.appendChild(btn('삭제', 'small danger', async () => { if (confirm('이 응답을 삭제할까요?')) { await api(`/c/responses/${r.id}`, { method: 'DELETE' }); renderResponses(m, survey); } })); row2.appendChild(act); t.appendChild(row2); }
if (!items.length) { const r = el('tr'); const td = el('td', 'muted', '응답이 없습니다'); td.colSpan = survey.questions.length + 3; r.appendChild(td); t.appendChild(r); }
const w = el('div', 'table-wrap'); w.appendChild(t); card.appendChild(w); m.appendChild(card);
}
/** 설문 양식 편집기 (구글폼식 문항) */
function surveyEditor(item) {
const body = $('dlgBody'); body.innerHTML = ''; $('dlgTitle').textContent = item ? '설문 문항 편집' : '새 설문'; $('dlgMsg').textContent = '';
const title = el('input'); title.placeholder = '설문 제목'; title.value = item ? item.title : ''; const desc = el('textarea'); desc.rows = 2; desc.placeholder = '설명(선택)'; desc.value = item ? item.description : ''; const thanks = el('input'); thanks.placeholder = '제출 후 안내 문구'; thanks.value = item ? item.thanks : '응답해 주셔서 감사합니다.'; const active = el('input'); active.type = 'checkbox'; active.style.width = 'auto'; active.checked = item ? item.active !== false : true;
body.appendChild(el('label', '', '제목')).appendChild(title); body.appendChild(el('label', '', '설명')).appendChild(desc); body.appendChild(el('label', '', '제출 후 문구')).appendChild(thanks); const al = el('label', '', '진행 중 (끄면 사이트에서 응답을 받지 않음) '); al.appendChild(active); body.appendChild(al);
body.appendChild(el('h3', '', '문항')); const qbox = el('div'); body.appendChild(qbox); const qs = item ? JSON.parse(JSON.stringify(item.questions)) : [];
const TYPES = [['short', '단답'], ['long', '장문'], ['single', '객관식(하나)'], ['multi', '객관식(여러 개)'], ['scale', '척도(1~5)'], ['date', '날짜'], ['email', '이메일'], ['number', '숫자']];
const draw = () => { qbox.innerHTML = ''; qs.forEach((q, i) => { const r = el('div', 'q-row'); const lab = el('input'); lab.placeholder = '질문'; lab.value = q.label || ''; lab.oninput = () => { q.label = lab.value; }; const ty = el('select'); for (const [v, l] of TYPES) ty.appendChild(new Option(l, v)); ty.value = q.type || 'short'; ty.onchange = () => { q.type = ty.value; draw(); }; const req = el('label', '', '필수 '); const rc = el('input'); rc.type = 'checkbox'; rc.style.width = 'auto'; rc.checked = !!q.required; rc.onchange = () => { q.required = rc.checked; }; req.appendChild(rc); const rm = btn('×', 'small danger', () => { qs.splice(i, 1); draw(); }); r.appendChild(lab); r.appendChild(ty); r.appendChild(req); r.appendChild(rm); if (q.type === 'single' || q.type === 'multi') { const op = el('textarea'); op.rows = 3; op.placeholder = '선택지 (줄바꿈으로 구분)'; op.value = (q.options || []).join('\n'); op.oninput = () => { q.options = op.value.split('\n').map((x) => x.trim()).filter(Boolean); }; r.appendChild(op); } qbox.appendChild(r); }); qbox.appendChild(btn('+ 문항 추가', 'small', () => { qs.push({ type: 'short', label: '', required: false, options: [] }); draw(); })); };
draw();
$('dlgSave').onclick = async () => { const data = { title: title.value, description: desc.value, thanks: thanks.value, active: active.checked, questions: qs }; try { if (item) await api(`/c/surveys/${item.id}`, { method: 'PUT', body: JSON.stringify(data) }); else await api('/c/surveys', { method: 'POST', body: JSON.stringify(data) }); $('dlg').close(); render(); } catch (e) { $('dlgMsg').textContent = e.message; $('dlgMsg').className = 'msg err'; } };
$('dlgCancel').onclick = () => $('dlg').close(); $('dlg').showModal();
}
async function renderAccount(m) {
const card = el('div', 'card'); card.appendChild(el('h2', '', '사이트 관리자 계정')); card.appendChild(el('p', 'muted small', '이 관리자 페이지에 빌더 로그인 없이 들어올 수 있는 계정입니다(회원 목록에 "관리자" 권한으로 표시). 사이트 소유자가 직원 등에게 운영을 맡길 때 만듭니다.'));
const id = el('input'); id.placeholder = '아이디 (영문·숫자, 로그인에 사용)'; const pw = el('input'); pw.type = 'password'; pw.placeholder = window.pwRuleText ? `비밀번호(${window.pwRuleText})` : '비밀번호(6자 이상)'; const name = el('input'); name.placeholder = '표시 이름'; const msg = el('span', 'msg');
const row = el('div', 'row'); row.appendChild(id); row.appendChild(pw); row.appendChild(name); row.appendChild(btn('만들기', 'primary', async () => { try { const r = await api('/admin-account', { method: 'POST', body: JSON.stringify({ loginId: id.value.trim(), password: pw.value, name: name.value.trim() }) }); id.value = pw.value = name.value = ''; await render(); const m2 = document.getElementById('saAccMsg'); if (m2) { m2.textContent = `관리자 계정 "${r.name}" 을(를) 만들었습니다. 위 주소로 들어와 아이디·비밀번호로 로그인할 수 있습니다.`; m2.className = 'msg ok'; } } catch (e) { msg.textContent = e.message; msg.className = 'msg err'; } })); card.appendChild(row); msg.id = 'saAccMsg'; card.appendChild(msg); m.appendChild(card);
const c2 = el('div', 'card'); c2.appendChild(el('h2', '', '관리자 권한 계정 목록'));
// v1.3.0: 메일 시스템이 켜져 있으면 계정마다 "메일 사용" 을 바꿀 수 있다(같은 아이디·비밀번호로 웹메일 로그인)
const mailOn = ov.mail && ov.mail.systemEnabled;
if (mailOn) c2.appendChild(el('p', 'muted small', '"메일 사용" 을 켠 계정은 같은 아이디·비밀번호로 웹메일(아이디@사이트도메인)을 씁니다. 끈 계정은 관리용으로만 쓰입니다. 먼저 "메일" 탭에서 이 사이트의 웹메일을 켜야 합니다.'));
await table('members', c2, { columns: ['name', 'loginId', 'email', 'role', ...(mailOn ? ['mailAddress'] : []), 'createdAt'], label: '회원', actions: mailOn ? [{ label: '메일 사용 켜기/끄기', fn: (it) => (it.loginId ? toggleMemberMail(it) : alert('사이트 관리자 계정(아이디가 있는 계정)만 메일을 쓸 수 있습니다')) }] : [] }); m.appendChild(c2);
}
if (!SITE) { document.body.innerHTML = '<p style="padding:40px">잘못된 주소입니다.</p>'; } else boot();
// ---------- 메일 (v1.3.0) ----------
/**
* 메일 탭: 이 사이트 웹메일 켜기/끄기, DNS 등록 안내·확인, 계정별 메일 사용.
* 서버 API: GET/POST /api/site-admin/<id>/mail[/enable|/disable|/check], PUT /mail/account/<회원ID>
*/
const MAIL_LEVEL_CLS = { ready: 'ok', 'send-only': 'ok', 'setting-up': 'warn', off: 'off' };
async function renderMail(m) {
const v = await api('/mail'); const st = v.status;
const top = el('div', 'card'); top.appendChild(el('h2', '', '웹메일'));
const banner = el('p', 'mail-banner ' + (MAIL_LEVEL_CLS[st.level] || ''), st.message); top.appendChild(banner);
// v1.3.2: 켜기·끄기와 도메인 설정은 운영자가 관리자 콘솔에서 한다. 여기서는 상태·DNS 안내·계정만
top.insertBefore(el('div', 'mail-state', `메일 시스템: ${st.label}`), banner);
if (st.enabled) {
const info = el('div', 'kv');
info.appendChild(el('label', '', '메일 도메인')); info.appendChild(el('div', '', st.domain));
info.appendChild(el('label', '', '웹메일 주소')); const links = el('div'); const a1 = el('a', '', v.siteWebmailUrl || v.webmailUrl); a1.href = v.siteWebmailUrl || v.webmailUrl; a1.target = '_blank'; a1.rel = 'noopener'; links.appendChild(a1);
if (v.siteWebmailUrl) links.appendChild(el('div', 'muted small', '사이트 주소/webmail 은 메일 시스템이 켜진 뒤 한 번 다시 배포해야 운영 주소에 생깁니다. 바로 쓰려면: ' + v.webmailUrl));
info.appendChild(links); top.appendChild(info);
const row = el('div', 'row');
row.appendChild(btn('웹메일 열기', 'primary', () => window.open(v.webmailUrl, '_blank', 'noopener')));
row.appendChild(btn('DNS 다시 확인', '', async () => { try { await api('/mail/check', { method: 'POST', body: '{}' }); render(); } catch (e) { alert(e.message); } }));
top.appendChild(row);
}
m.appendChild(top);
// DNS 안내 표
if (v.records || v.recordsError) {
const c = el('div', 'card'); c.appendChild(el('h2', '', `DNS 등록 안내${v.previewDomain ? ' — ' + v.previewDomain : ''}`));
if (v.recordsError) c.appendChild(el('p', 'msg err', v.recordsError));
else {
const dnsMap = {}; for (const r of ((st.dns && st.dns.records) || [])) dnsMap[r.key] = r;
c.appendChild(el('p', 'muted small', `도메인 관리 화면에서 아래 레코드를 추가하세요. 반영에는 몇 분~최대 하루가 걸릴 수 있습니다.${st.dns ? ' 마지막 확인: ' + dt(st.dns.checkedAt) : ''}`));
const t = el('table'); const tr = el('tr'); for (const h of ['종류', '호스트(이름)', '값', '우선순위', '필수', '확인 결과']) tr.appendChild(el('th', '', h)); t.appendChild(tr);
for (const r of v.records) {
const row = el('tr'); const chk = dnsMap[r.key];
for (const val of [r.type, r.host, r.value, r.priority || '', r.required ? '필수' : '권장']) { const td = el('td'); td.appendChild(el('div', 'wrap mono', String(val))); row.appendChild(td); }
const td = el('td'); if (chk) { td.appendChild(el('span', 'pill ' + (chk.ok ? 'ok' : (r.required ? 'off' : 'warn')), chk.ok ? '확인됨' : '미확인')); td.appendChild(el('div', 'muted small wrap', '현재: ' + chk.found)); } else td.appendChild(el('span', 'muted small', '켠 뒤 확인')); row.appendChild(td);
row.title = r.why; t.appendChild(row);
}
const w = el('div', 'table-wrap'); w.appendChild(t); c.appendChild(w);
c.appendChild(el('p', 'muted small', st.resend && st.resend.error ? `보내기용 도메인 등록 실패: ${st.resend.error}` : '보내기용 레코드(DKIM 등, Resend)는 웹메일을 켜면 함께 표시되며 Resend 가 확인합니다. 받기용 MX·SPF 는 이 서비스가 직접 조회합니다.'));
}
m.appendChild(c);
}
// 계정별 메일 사용
const ac = el('div', 'card'); ac.appendChild(el('h2', '', '메일 계정'));
ac.appendChild(el('p', 'muted small', '"관리자 계정" 탭에서 만든 사이트 관리자 계정만 메일을 쓸 수 있습니다. 메일 사용을 켜면 같은 아이디·비밀번호로 웹메일에 로그인합니다(관리자 계정과 메일 계정은 같은 계정). 끄면 관리용으로만 쓰이고, 그 계정의 메일은 모두 지워집니다.'));
// v1.6.39 (요구 3): 메일 서버(Arimail)가 다른 서버에 있어도 비밀번호가 같게 유지된다 — 관리자가 이 페이지에 로그인할 때마다 메일 서버 비밀번호를 맞춘다. 그 상태를 열로 보여 준다
const arimail = st.backend === 'arimail';
if (arimail) ac.appendChild(el('p', 'muted small', `메일 서버(${st.mailServer && st.mailServer.host ? st.mailServer.host : 'Arimail'})의 비밀번호는 이 계정의 사이트 관리자 비밀번호와 같게 유지됩니다 — 관리자가 이 페이지에 로그인하면 자동으로 맞춰지므로 비밀번호를 따로 기억할 필요가 없습니다. "동기화" 가 "다음 로그인 때" 이면 그 관리자가 한 번 로그인하면 됩니다.`));
const syncText = (a) => { const s = a.sync || { state: 'n/a' }; if (!arimail || !a.mail) return '-'; if (s.state === 'synced') return `관리자 비밀번호와 같음${s.at ? ' · ' + new Date(s.at).toLocaleString() : ''}`; if (s.state === 'pending') return '다음 로그인 때 맞춰짐 (지금은 켤 때 넣은 임시 비밀번호)'; if (s.state === 'error') return `실패: ${s.error || ''} — 관리자 비밀번호를 메일 서버 규칙에 맞게 바꾸거나 운영자에게 알리세요`; return '-'; };
const t = el('table'); const tr = el('tr'); for (const h of ['이름', '아이디', '메일 주소', '메일 사용', '비밀번호 동기화']) tr.appendChild(el('th', '', h)); t.appendChild(tr);
for (const a of v.accounts) {
const row = el('tr'); row.appendChild(el('td', '', a.name)); row.appendChild(el('td', '', a.loginId)); row.appendChild(el('td', '', a.mailAddress || (st.domain ? `${a.loginId}@${st.domain} (사용 안 함)` : '-')));
const syncTd = el('td', a.sync && a.sync.state === 'error' ? 'msg err small' : 'muted small', syncText(a)); row.appendChild(syncTd);
const td = el('td'); const sw = el('button', 'switch' + (a.mail ? ' on' : '')); sw.type = 'button'; sw.title = a.mail ? '메일 사용 중 — 눌러서 끄기' : '관리용 계정 — 눌러서 메일 사용 켜기'; sw.disabled = !st.enabled && !a.mail; sw.onclick = () => toggleMemberMail(a, st); td.appendChild(sw); row.appendChild(td); t.appendChild(row);
}
if (!v.accounts.length) { const r = el('tr'); const td = el('td', 'muted', '사이트 관리자 계정이 없습니다. "관리자 계정" 탭에서 먼저 만드세요.'); td.colSpan = 5; r.appendChild(td); t.appendChild(r); }
const w = el('div', 'table-wrap'); w.appendChild(t); ac.appendChild(w); m.appendChild(ac);
}
/**
* 계정 메일 사용 켜기/끄기.
* 끌 때는 메일함이 지워지므로 ① 삭제 안내 확인 ② "삭제" 입력 ③ 지금 로그인한 관리자의 비밀번호 재입력을 받는다.
* @param {{id, loginId, mail}} acc 계정
*/
async function toggleMemberMail(acc, st) {
if (!acc.mail) {
// v1.5.7: 받는 메일 서버(Arimail)가 연결된 사이트는 켤 때 메일 비밀번호를 받아 Arimail 계정을 만든다(같은 로그인 ID).
// v1.6.39 (요구 3): 메일 비밀번호 = 사이트 관리자 비밀번호. 본인 계정이면 지금 로그인한 비밀번호를 넣어야 하고(다르면 서버가 거절),
// 소유자·운영자가 다른 관리자의 계정을 대신 켤 때는 임시 비밀번호를 넣는다 — 그 관리자가 다음에 로그인하면 자동으로 관리자 비밀번호로 맞춰진다.
if (st && st.backend === 'arimail') {
const body = $('dlgBody'); body.innerHTML = ''; $('dlgTitle').textContent = `메일 사용 켜기 — ${acc.loginId}`; $('dlgMsg').textContent = '';
body.appendChild(el('p', 'small', `메일 서버(${st.mailServer && st.mailServer.host ? st.mailServer.host : 'Arimail'})에 ${acc.loginId}@${st.domain} 계정을 만듭니다. 메일 비밀번호는 이 계정의 사이트 관리자 비밀번호와 같게 유지됩니다 — 본인 계정이면 지금 로그인한 비밀번호를 그대로 입력하세요. 다른 관리자의 계정을 대신 켜는 경우에는 임시 비밀번호를 넣으면 되고, 그 관리자가 다음에 로그인할 때 자동으로 맞춰집니다.`));
const p1 = el('input'); p1.type = 'password'; p1.placeholder = '사이트 관리자 비밀번호 (대신 켤 때는 임시 비밀번호)'; p1.autocomplete = 'current-password'; const p2 = el('input'); p2.type = 'password'; p2.placeholder = '비밀번호 다시 입력'; p2.autocomplete = 'current-password';
body.appendChild(el('label', '', '비밀번호')).appendChild(p1); body.appendChild(el('label', '', '다시 입력')).appendChild(p2);
$('dlgSave').textContent = '계정 만들고 켜기';
$('dlgSave').onclick = async () => { if (!p1.value) { $('dlgMsg').textContent = '메일 비밀번호를 입력하세요'; return; } if (p1.value !== p2.value) { $('dlgMsg').textContent = '두 비밀번호가 다릅니다'; return; } try { const r = await api(`/mail/account/${acc.id}`, { method: 'PUT', body: JSON.stringify({ on: true, password: p1.value }) }); $('dlg').close(); $('dlgSave').textContent = '저장'; if (r.note) alert(`메일 사용을 켰습니다.\n${r.note}`); render(); } catch (e) { $('dlgMsg').textContent = e.message; } };
$('dlgCancel').onclick = () => { $('dlg').close(); $('dlgSave').textContent = '저장'; }; $('dlg').showModal(); return;
}
try { const r = await api(`/mail/account/${acc.id}`, { method: 'PUT', body: JSON.stringify({ on: true }) }); if (r.note) alert(`메일 사용을 켰습니다.\n${r.note}`); render(); } catch (e) { alert(e.message); } return;
}
if (!confirm(`"${acc.loginId}" 계정의 메일 사용을 끕니다.\n\n이 계정의 메일(${acc.mailAddress || ''})과 메일함이 모두 지워지며 되돌릴 수 없습니다. 관리자 계정은 그대로 남아 관리용으로만 쓰입니다.\n\n계속할까요?`)) return;
const body = $('dlgBody'); body.innerHTML = ''; $('dlgTitle').textContent = '메일 사용 끄기 — 다시 확인'; $('dlgMsg').textContent = '';
body.appendChild(el('p', 'small', `${acc.mailAddress || acc.loginId} 의 메일이 모두 삭제됩니다. 확인란에 "삭제" 를 입력하고, 지금 로그인한 관리자의 비밀번호를 입력하세요.`));
const c = el('input'); c.placeholder = '삭제'; const pw = el('input'); pw.type = 'password'; pw.placeholder = '관리자 비밀번호'; pw.autocomplete = 'current-password';
body.appendChild(el('label', '', '확인 문구')).appendChild(c); body.appendChild(el('label', '', '관리자 비밀번호')).appendChild(pw);
$('dlgSave').textContent = '메일 삭제 후 끄기';
$('dlgSave').onclick = async () => { try { const r = await api(`/mail/account/${acc.id}`, { method: 'PUT', body: JSON.stringify({ on: false, confirm: c.value, password: pw.value }) }); $('dlg').close(); $('dlgSave').textContent = '저장'; alert(`메일 사용을 껐습니다. 지운 메일 ${r.deletedMessages || 0}통.`); render(); } catch (e) { $('dlgMsg').textContent = e.message; $('dlgMsg').className = 'msg err'; } };
$('dlgCancel').onclick = () => { $('dlg').close(); $('dlgSave').textContent = '저장'; }; $('dlg').showModal();
}
// ---------- 일정관리 (v1.4.18) ----------
/**
* 일정관리 탭의 일정 카드: 날짜 조회(양력 → 음력·간지·절기), 일정 표(다음 발생일 포함), 일정 편집기.
* 일정 데이터 규칙은 lib/calendar.js(normalizeEvent) — 시·분/일/주 단위, 양력·음력 시작일, 반복(매일·매주·매월·매년).
*/
const CAL_UNIT = { time: '시간 단위', day: '일 단위', week: '주 단위' };
const CAL_FREQ = { none: '반복 없음', daily: '매일', weekly: '매주', monthly: '매월', yearly: '매년' };
const CAL_WD = ['일', '월', '화', '수', '목', '금', '토'];
const CAL_COLORS = [['', '기본(파랑)'], ['green', '초록'], ['red', '빨강'], ['orange', '주황'], ['purple', '보라'], ['gray', '회색']];
/** 일정 한 건의 날짜 표시: 음력 일정이면 "음력 2026.9.4 (양 2026-10-14)" */
const calWhen = (it) => (it.cal === 'lunar' && it.lunar ? `음력 ${it.lunar.year}.${it.lunar.leap ? '윤' : ''}${it.lunar.month}.${it.lunar.day} (양 ${it.date})` : it.date) + (it.unit === 'time' ? ` ${it.time}${it.endTime ? '–' + it.endTime : ''}` : it.length > 1 ? ` · ${it.length}${it.unit === 'week' ? '주' : '일'}` : it.unit === 'week' ? ' · 1주' : '');
const calRepeat = (it) => { const r = it.repeat || {}; if (!r.freq || r.freq === 'none') return '-'; let s = (r.interval > 1 ? `${r.interval}${{ daily: '일', weekly: '주', monthly: '개월', yearly: '년' }[r.freq]}마다` : CAL_FREQ[r.freq]) + (it.cal === 'lunar' && (r.freq === 'monthly' || r.freq === 'yearly') ? '(음력)' : ''); if (r.freq === 'weekly' && (r.weekdays || []).length) s += ' ' + r.weekdays.map((d) => CAL_WD[d]).join('·'); if (r.until) s += ` ~${r.until}`; return s; };
async function renderCalendarEvents(card, def, h) {
h.appendChild(btn('+ 일정 추가', 'small primary', () => eventEditor(null)));
// 날짜 조회 도구: 캘린더에 보이는 음력·간지가 맞는지 바로 확인할 수 있다 (공개 API /api/m/<사이트>/calendar/day)
const tool = el('div', 'row'); const di = el('input'); di.type = 'date'; di.style.width = '170px'; di.value = new Date().toISOString().slice(0, 10); const out = el('span', 'small');
const look = async () => { out.textContent = '조회 중…'; try { const r = await fetch(`/api/m/${SITE}/calendar/day?date=${encodeURIComponent(di.value)}`); const j = await r.json(); if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`); out.textContent = `음력 ${j.lunar.year}.${j.lunar.leap ? '윤' : ''}${j.lunar.month}.${j.lunar.day} (${j.lunar.monthDays}일까지) · ${j.ganji.text} (${j.ganji.textHanja}, ${j.ganji.zodiac}띠)` + (j.term ? ` · 절기: ${j.term}` : ''); out.className = 'small ok'; } catch (e) { out.textContent = e.message; out.className = 'small msg err'; } };
const lab = el('label', '', '날짜 조회(양력 → 음력·만세력 간지·절기): '); lab.appendChild(di); tool.appendChild(lab); tool.appendChild(btn('조회', 'small', look)); tool.appendChild(out); card.appendChild(tool);
card.appendChild(el('p', 'muted small', '일정은 시·분 단위(하루 안의 시작~종료 시각) / 일 단위(며칠) / 주 단위(몇 주)로 등록하고, 반복(매일·매주·매월·매년)과 음력 기준(음력 생일·제사·명절 등)을 정할 수 있습니다. "다음 발생" 은 오늘 이후 처음 캘린더에 나타나는 날입니다.'));
await table('events', card, { columns: ['title', 'when', 'unit', 'repeat', 'category', 'next', 'published', 'createdAt'], label: def.label, edit: (it) => eventEditor(it), format: { when: (v, it) => calWhen(it), unit: (v) => CAL_UNIT[v] || v, repeat: (v, it) => calRepeat(it), next: (v) => v || '(2년 안에 없음)' } });
}
/** 일정 추가·수정 대화상자. 단위·달력·반복 선택에 따라 보이는 입력이 달라진다. */
function eventEditor(item) {
const body = $('dlgBody'); body.innerHTML = ''; $('dlgTitle').textContent = (item ? '일정 수정' : '일정 추가'); $('dlgMsg').textContent = '';
const cats = ((ov.settings.calendar || {}).categories || []);
const field = (label, inp, hint) => { const l = el('label', '', label); l.appendChild(inp); if (hint) l.appendChild(el('span', 'muted small', ' ' + hint)); body.appendChild(l); return l; };
// 숫자 칸(연도·월·일·기간·간격)은 3자리 쉼표를 붙이지 않는다(numfmt.js 의 data-plain) — 2026 이 2,026 으로 보이면 안 된다
const inp = (type, val, w) => { const i = el('input'); i.type = type; if (type === 'number') i.setAttribute('data-plain', ''); if (val != null) i.value = val; if (w) i.style.width = w; return i; };
const sel = (pairs, val) => { const s = el('select'); for (const [v, l] of pairs) s.appendChild(new Option(l, v)); s.value = val; return s; };
const title = inp('text', item ? item.title : ''); title.maxLength = 120; field('제목 *', title);
const cat = inp('text', item ? item.category : ''); cat.setAttribute('list', 'calCats'); let dl = document.getElementById('calCats'); if (!dl) { dl = document.createElement('datalist'); dl.id = 'calCats'; document.body.appendChild(dl); } dl.innerHTML = cats.map((c) => `<option value="${esc(c)}">`).join(''); field('분류', cat, cats.length ? `(설정의 분류: ${cats.join(', ')})` : '');
const color = sel(CAL_COLORS, item ? item.color || '' : ''); field('색', color);
const unit = sel([['time', '시간 단위(시·분)'], ['day', '일 단위'], ['week', '주 단위']], item ? item.unit : 'day'); field('일정 단위', unit);
const cal = sel([['solar', '양력'], ['lunar', '음력(만세력)']], item ? item.cal : 'solar'); field('달력', cal);
const date = inp('date', item ? item.date : new Date().toISOString().slice(0, 10)); const dateLab = field('시작일(양력)', date);
const ln = item && item.lunar ? item.lunar : { year: new Date().getFullYear(), month: 1, day: 1, leap: false };
const ly = inp('number', ln.year, '90px'); ly.min = 1900; ly.max = 2100; const lm = inp('number', ln.month, '64px'); lm.min = 1; lm.max = 12; const ld = inp('number', ln.day, '64px'); ld.min = 1; ld.max = 30; const leap = inp('checkbox'); leap.checked = !!ln.leap; leap.style.width = 'auto';
const lrow = el('div', 'row'); lrow.appendChild(ly); lrow.appendChild(el('span', '', '년')); lrow.appendChild(lm); lrow.appendChild(el('span', '', '월')); lrow.appendChild(ld); lrow.appendChild(el('span', '', '일')); const ll = el('label', '', '윤달 '); ll.appendChild(leap); ll.style.width = 'auto'; lrow.appendChild(ll); const lprev = el('span', 'muted small'); lrow.appendChild(lprev);
const lunLab = el('label', '', '시작일(음력)'); lunLab.appendChild(lrow); body.appendChild(lunLab);
const time = inp('time', item ? item.time : '10:00'); const endTime = inp('time', item ? item.endTime : ''); const trow = el('div', 'row'); trow.appendChild(time); trow.appendChild(el('span', '', '~')); trow.appendChild(endTime); const timeLab = el('label', '', '시작 ~ 종료 시각 (종료는 선택)'); timeLab.appendChild(trow); body.appendChild(timeLab);
const len = inp('number', item ? item.length || 1 : 1, '90px'); len.min = 1; const lenLab = field('기간', len);
const freq = sel(Object.entries(CAL_FREQ), item && item.repeat ? item.repeat.freq : 'none'); field('반복', freq);
const iv = inp('number', item && item.repeat ? item.repeat.interval || 1 : 1, '80px'); iv.min = 1; iv.max = 99; const ivLab = field('반복 간격', iv);
const wdBox = el('div', 'row'); const wdInputs = CAL_WD.map((n, i) => { const l = el('label', '', n + ' '); l.style.width = 'auto'; const c = inp('checkbox'); c.style.width = 'auto'; c.checked = !!(item && item.repeat && (item.repeat.weekdays || []).includes(i)); l.appendChild(c); wdBox.appendChild(l); return c; }); const wdLab = el('label', '', '반복 요일 (비우면 시작일의 요일)'); wdLab.appendChild(wdBox); body.appendChild(wdLab);
const until = inp('date', item && item.repeat ? item.repeat.until || '' : ''); const untilLab = field('반복 종료일 (비우면 계속)', until);
const loc = inp('text', item ? item.location : ''); loc.maxLength = 200; field('장소', loc);
const desc = el('textarea'); desc.rows = 4; desc.value = item ? item.body || '' : ''; field('설명', desc);
const pub = inp('checkbox'); pub.style.width = 'auto'; pub.checked = item ? item.published !== false : true; const pl = el('label', '', '사이트에 공개 '); pl.appendChild(pub); body.appendChild(pl);
const hint = el('p', 'muted small'); body.appendChild(hint);
/** 음력 입력 → 양력 미리보기 (그 해 윤달 여부·달 길이도 함께) */
let lt = null; const preview = () => { if (cal.value !== 'lunar') return; clearTimeout(lt); lt = setTimeout(async () => { try { const r = await fetch(`/api/m/${SITE}/calendar/lunar?year=${ly.value}&month=${lm.value}&day=${ld.value}&leap=${leap.checked ? 1 : 0}`); const j = await r.json(); if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`); lprev.textContent = (j.solar ? `→ 양력 ${j.solar}` : `→ 그 날이 없습니다(이 달은 ${j.monthDays}일까지)`) + (leap.checked && !j.hasLeap ? ` · ${ly.value}년에는 윤${lm.value}월이 없어 평달로 봅니다` : '') + (j.leapMonths.length ? ` · ${ly.value}년 윤달: ${j.leapMonths.map((m) => '윤' + m + '월').join(', ')}` : ''); lprev.className = 'small ok'; } catch (e) { lprev.textContent = e.message; lprev.className = 'small msg err'; } }, 250); };
const sync = () => {
const u = unit.value; timeLab.style.display = u === 'time' ? '' : 'none'; lenLab.style.display = u === 'time' ? 'none' : ''; lenLab.firstChild.textContent = u === 'week' ? '기간(몇 주)' : '기간(며칠)';
const lunar = cal.value === 'lunar'; dateLab.style.display = lunar ? 'none' : ''; lunLab.style.display = lunar ? '' : 'none';
const f = freq.value; ivLab.style.display = f === 'none' ? 'none' : ''; untilLab.style.display = f === 'none' ? 'none' : ''; wdLab.style.display = f === 'weekly' ? '' : 'none';
ivLab.firstChild.textContent = `반복 간격(${{ daily: '일', weekly: '주', monthly: '개월', yearly: '년' }[f] || ''} 단위, 1 = 매번)`;
hint.textContent = lunar && (f === 'monthly' || f === 'yearly') ? (f === 'yearly' ? '음력 매년: 해마다 같은 음력 달·날에 표시됩니다(그 해에 같은 윤달이 없으면 평달, 30일이 없으면 29일). 양력 날짜는 해마다 달라집니다.' : '음력 매월: 음력 달마다 같은 날에 표시됩니다(윤달도 한 달로 셉니다).') : lunar ? '음력 시작일은 양력으로 바꿔 저장되고, 매일·매주 반복은 그 양력 날짜부터 이어집니다.' : f === 'monthly' || f === 'yearly' ? '31일·2월 29일처럼 없는 날은 그 달의 마지막 날에 표시됩니다.' : '';
if (lunar) preview();
};
[unit, cal, freq].forEach((s) => { s.onchange = sync; }); [ly, lm, ld].forEach((i) => { i.oninput = preview; }); leap.onchange = preview; sync();
$('dlgSave').onclick = async () => {
const data = { title: title.value, category: cat.value, color: color.value, unit: unit.value, cal: cal.value, date: date.value, lunar: { year: ly.value, month: lm.value, day: ld.value, leap: leap.checked }, time: time.value, endTime: endTime.value, length: len.value, repeat: { freq: freq.value, interval: iv.value, until: until.value, weekdays: wdInputs.map((c, i) => (c.checked ? i : -1)).filter((i) => i >= 0) }, location: loc.value, body: desc.value, published: pub.checked };
try { if (item) await api(`/c/events/${item.id}`, { method: 'PUT', body: JSON.stringify(data) }); else await api('/c/events', { method: 'POST', body: JSON.stringify(data) }); $('dlg').close(); render(); } catch (e) { $('dlgMsg').textContent = e.message; $('dlgMsg').className = 'msg err'; }
};
$('dlgCancel').onclick = () => $('dlg').close(); $('dlg').showModal();
}