357 lines
53 KiB
JavaScript
357 lines
53 KiB
JavaScript
'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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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();
|
|||
|
|
}
|