Files

1206 lines
116 KiB
JavaScript
Raw Permalink Normal View History

'use strict';
const $ = (id) => document.getElementById(id);
const fmt = (n) => (n || 0).toLocaleString(uiLocale());
const fmtCost = (c) => '$' + (c || 0).toFixed(4);
const fmtDur = (ms) => ms ? (ms / 1000).toFixed(1) + 's' : '';
let current = null; // 현재 사이트 (서버 응답)
let es = null; // EventSource
let config = { publicBaseUrl: location.origin, tiers: [] }; let credit = null;
/**
* 서버 API 호출 공통 함수.
* v1.2.0: 화면 언어(I18N.lang)를 X-UI-Lang 헤더로 보낸다. 서버는 사이트를 만들거나 수정 지시를 받을 때
* 이 값을 site.uiLang 에 저장하고, AI 가 생성 코드에 다는 주석을 그 언어로 쓰게 한다.
*/
/**
* 생성 옵션 "검색·AI 최적화" 체크 값 읽기 (v1.3.5). 각 시작 카드(AI·내가 고르기·복사) 안의 [data-seo-opts] 블록.
* @param {string} cardId 시작 카드 요소 ID
* @returns {{seo: boolean, aeo: boolean, geo: boolean}}
*/
function seoOptsOf(cardId) { const box = document.querySelector(`#${cardId} [data-seo-opts]`); const on = (k) => !!(box && box.querySelector(`input[data-opt="${k}"]`) && box.querySelector(`input[data-opt="${k}"]`).checked); return { seo: on('seo'), aeo: on('aeo'), geo: on('geo') }; }
/**
* v1.3.10 다국어 생성 옵션: 생성 카드의 [data-lang-opts] 에 기본 언어 선택과 추가 언어 체크를 그린다 (언어 목록은 /api/config 의 siteLocales).
* 언어 이름은 그 언어로 쓴 이름(English, 日本語 …)과 화면 언어 이름을 같이 보인다.
*/
function fillLangOpts() {
const locs = (config && config.siteLocales) || []; if (!locs.length) return;
for (const box of document.querySelectorAll('[data-lang-opts]')) {
const sel = box.querySelector('[data-lang-default]'); const ex = box.querySelector('[data-lang-extra]'); const keepDef = sel.value || 'ko'; const keepOn = new Set([...ex.querySelectorAll('input:checked')].map((i) => i.value));
sel.innerHTML = ''; ex.innerHTML = '';
for (const l of locs) {
sel.appendChild(new Option(`${l.name} — ${T(l.label)}`, l.code));
const lab = document.createElement('label'); lab.className = 'chk lang-chip'; const c = document.createElement('input'); c.type = 'checkbox'; c.value = l.code; c.checked = keepOn.has(l.code);
lab.appendChild(c); lab.appendChild(document.createTextNode(` ${l.name}`)); lab.title = T(l.label) + (l.dir === 'rtl' ? ' · ' + T('오른쪽→왼쪽') : ''); ex.appendChild(lab);
}
sel.value = keepDef;
// v1.3.16 x-default 선택: 고른 언어들 + 언어 선택 페이지 (추가 언어가 있을 때만 보인다)
const xs = box.querySelector('[data-lang-xdefault]'); const xwrap = box.querySelector('.lang-xdef');
const fillX = () => { if (!xs) return; const keep = xs.value; const chosen = [sel.value, ...[...ex.querySelectorAll('input:checked')].map((i) => i.value).filter((c) => c !== sel.value)]; xs.innerHTML = ''; for (const c of chosen) { const l = locs.find((x) => x.code === c); xs.appendChild(new Option(`${l ? l.name : c} (${c})`, c)); } xs.appendChild(new Option(T('언어 선택 페이지 (자동 이동 없음)'), 'root')); xs.value = [...xs.options].some((o) => o.value === keep) ? keep : sel.value; xwrap.classList.toggle('hidden', chosen.length < 2); };
const sync = () => { for (const i of ex.querySelectorAll('input')) { const isDef = i.value === sel.value; i.disabled = isDef; if (isDef) i.checked = false; i.parentElement.classList.toggle('off', isDef); } fillX(); };
sel.onchange = sync; ex.onchange = fillX; sync();
}
}
/** 생성 카드의 다국어 옵션 값: { default, list } — 추가 언어가 없으면 단일 언어 */
function langOptsOf(cardId) {
const box = document.querySelector(`#${cardId} [data-lang-opts]`); if (!box) return null;
const def = box.querySelector('[data-lang-default]').value || 'ko'; const extra = [...box.querySelectorAll('[data-lang-extra] input:checked')].map((i) => i.value).filter((c) => c !== def);
const xs = box.querySelector('[data-lang-xdefault]');
return { default: def, list: [def, ...extra], xDefault: xs && xs.value ? xs.value : def };
}
/**
* v1.6.31 (요구 4): 서비스 API 는 세션 쿠키만으로는 쓸 수 없고, 출처가 이 화면(Referer)이거나 X-WM-CSRF 헤더가 있어야 한다.
* 토큰은 /api/auth/csrf 가 "출처가 빌더·관리자 화면인 요청" 에만 준다 — 같은 주소에서 열린 생성 사이트 페이지는 받을 수 없다.
*/
let _csrf = null; let _csrfP = null;
async function csrf() { if (_csrf) return _csrf; if (!_csrfP) _csrfP = fetch('/api/auth/csrf', { headers: { 'X-UI-Lang': (window.I18N && window.I18N.lang) || 'ko' } }).then((r) => r.ok ? r.json() : {}).then((b) => { _csrf = (b && b.csrf) || ''; return _csrf; }).catch(() => ''); return _csrfP; }
async function api(path, opt = {}) {
const headers = opt.body instanceof FormData ? {} : { 'Content-Type': 'application/json' };
if (!/^\/api\/auth\/csrf/.test(path)) { const tok = await csrf(); if (tok) headers['X-WM-CSRF'] = tok; }
headers['X-UI-Lang'] = (window.I18N && window.I18N.lang) || 'ko';
const r = await fetch(path, { headers, ...opt });
const body = await r.json().catch(() => ({}));
if (r.status === 401) { location.href = '/login'; throw new Error('로그인이 필요합니다'); }
if (!r.ok) throw Object.assign(new Error(body.error || `HTTP ${r.status}`), { status: r.status, body }); // v1.4.0: 오류 응답 본문(예: 바뀐 견적 quote)을 화면 코드가 쓸 수 있게
return body;
}
// ---------- 탭 (생성 / 수정 / 관리 / 도메인 / 설정) ----------
/**
* 좌측 패널의 탭 (v1.4.14 에서 "수정" 탭 신설, 순서 고정: 생성 → 수정 → 관리 → 도메인 → 설정).
* - 생성 : 새 사이트 생성 ~ 생성 완료
* - 수정 : 사이트 고치기 — 수정 지시·기본 사항·발행(배포)·다국어·검색 최적화·검색 등록·버전 되돌리기·첨부 (1.4.5 까지 관리 탭에 있던 것)
* - 관리 : 사이트 운영 — 기능 모듈·사이트 관리자 페이지·메일·문의 접수·사이트 현황·현황 분석·캐시 사용·사이트 설정(폐기).
* 이 탭에서는 오른콴 미리보기 창이 "사이트 관리 창"(사이트 관리자 페이지 + 현황)으로 바뀐다 (public/manage-ui.js)
* - 도메인: 서브 도메인·도메인 구매·DNS (public/domains-ui.js) - 설정: 계정
* 진행 상태 상자는 어느 탭에서나 위쪽에 그대로 보인다.
*/
/**
* v1.6.40 (요구 1): 다섯 탭을 "작업 흐름" 내비게이션(왼쪽 레일)으로 재구성했다. 값은 레일 버튼의 data-tab 과 같다.
* - home : 작업 흐름 지도 — 만들기 → 수정 → 도메인 → DNS·메일 → 배포 → 운영 의 상태와 다음 할 일 (public/flow-ui.js)
* - create : 만들기(1.6.39 까지 "생성") - edit : 수정(발행·배포 카드는 deploy 로 옮김)
* - deploy : 배포 — 점검표 + 발행·배포 · 원본 서버 반영 · 검색사이트 등록 (이전 단계가 갖춰져야 하는 마지막 작업)
* - manage : 운영(1.6.39 까지 "관리") — 오른쪽 캔버스가 사이트 관리 창으로 바뀐다
* - domains / dns / mail : 도메인(구매·등록·연결) · DNS(레코드·전파·확인) · 메일(MX 자동 설정·웹메일) — 서로 독립적으로 열 수 있지만 배포·메일은 DNS 에 의존한다 (public/domains-ui.js)
* - credits : 캐시·충전 — 잔액·선불 충전 상품·주문 (public/credits-ui.js) - settings : 계정
*/
const TABS = ['home', 'create', 'edit', 'deploy', 'manage', 'domains', 'dns', 'mail', 'credits', 'settings'];
/** 사이트를 "고른" 상태로 다루는 화면 — 수정·배포·운영은 같은 사이트(manageId)를 가리킨다 */
const SITE_TABS = ['edit', 'deploy', 'manage'];
/** 레일에서 고른 화면의 이름·설명 (작업 칸 머리 #paneTitle · #paneDesc). 사이트와 무관한 설명은 짧게, 의존 관계는 한 줄로 */
const PANE_META = {
home: ['홈', '사이트 만들기부터 배포·운영까지, 지금 어디까지 되었고 다음에 무엇을 하면 되는지 한눈에 봅니다.'],
create: ['만들기', 'AI 에게 맡기거나 직접 고르거나 템플릿·기존 사이트로 새 사이트를 만듭니다. 다른 작업과 무관하게 바로 시작할 수 있습니다.'],
edit: ['수정', '수정 지시·기본 사항·다국어·검색 최적화·버전 되돌리기. 수정은 배포와 무관하게 계속할 수 있고, 배포는 "배포" 화면에서 합니다.'],
deploy: ['배포', '사이트 내용·도메인·DNS 가 갖춰졌을 때 운영 주소로 올립니다. 배포 뒤의 수정은 "수정" 에서, 운영은 "운영" 에서 이어집니다.'],
manage: ['운영', '기능 모듈·사이트 관리자 페이지·문의·접속 통계·캐시 사용. 오른쪽에 사이트 관리 창이 열립니다.'],
domains: ['도메인', '도메인을 사거나(장바구니) 이미 가진 도메인을 등록하고 사이트에 연결합니다. 사이트 제작과 무관하게 먼저 준비해 둘 수 있습니다.'],
dns: ['DNS', '도메인의 DNS 레코드와 전파 상태입니다. 웹 제작과는 무관하지만 배포(웹서버 연결)와 메일(MX)이 이 설정에 달려 있습니다.'],
mail: ['메일', '도메인에 메일(MX·SPF·DMARC·DKIM)을 자동 설정하고 사이트 메일 시스템·웹메일을 확인합니다. DNS 가 준비된 도메인에서 동작합니다.'],
credits: ['캐시 · 충전', '작업 비용은 선불 적립 캐시에서 차감됩니다. 잔액·사용 내역을 보고 선불 충전 상품을 골라 충전을 신청합니다.'],
settings: ['설정', '계정 정보 · 내 문의 내역 · 비밀번호'],
};
let activeTab = 'home';
/**
* v1.1.0: 생성 탭과 수정·관리 탭은 서로 다른 사이트를 가리킬 수 있다.
* - createId : 생성 탭에서 만들고 있는(아직 생성이 끝나지 않은) 사이트
* - manageId : 수정·관리 탭에서 고른 사이트 (두 탭은 같은 사이트를 공유한다 — 고친 사이트를 바로 관리하는 흐름)
* 탭을 바꾸면 그 탭의 사이트를 다시 열어(`openSite`) 화면과 진행 상태 스트림을 그 사이트로 맞춘다.
*/
let createId = null, manageId = null; try { createId = localStorage.getItem('createSiteId') || null; manageId = localStorage.getItem('currentSiteId') || null; } catch (_) {}
// [Ariweb Suite 변경] 포털 SSO 진입(/api/auth/sso → /?site=<프로젝트 id>): 지정한 프로젝트를 바로 연다
try { const sp = new URLSearchParams(location.search).get('site'); if (sp && /^[a-f0-9]{32}$/.test(sp)) { manageId = sp; localStorage.setItem('currentSiteId', sp); history.replaceState(null, '', location.pathname); } } catch (_) {}
// 가져온(import)·복사한(copy) 사이트는 기획 단계가 없으므로 "생성 중" 으로 보지 않는다 (복사 중에는 관리 탭의 진행 상태 상자로 표시)
const inProgress = (site) => !!(site && site.plan && !['generated', 'published'].includes(site.plan.stage) && !['import', 'copy', 'template'].includes(site.mode));
let switching = false;
/**
* v1.3.17 (B21): 소스 편집기에 저장하지 않은 변경이 있으면 다른 사이트로 바꾸기 전에 묻는다
* (파일을 바꿀 때만 묻고, 사이트를 바꾸면 말없이 지워지던 문제). 같은 사이트면 묻지 않는다.
* @param {string|null} targetId 열려는 사이트 (null = 새 사이트 만들기 화면)
*/
function discardOk(targetId) {
if (current && targetId === current.id) return true;
// v1.8.0: 직접 편집(시각 편집기)의 저장하지 않은 변경도 같은 자리에서 묻는다
if (window.VisualEditor && VisualEditor.isDirty() && !VisualEditor.discardOk(T('직접 편집에서 저장하지 않은 변경이 있습니다. 버리고 다른 사이트로 바꿀까요?'))) return false;
if (!ed.dirty) return true;
if (!confirm(T('저장하지 않은 소스 변경이 있습니다. 버리고 다른 사이트로 바꿀까요?'))) return false;
ed.dirty = false; return true;
}
/**
* 탭 버튼·왼쪽 패널·오른쪽 창을 탭에 맞춘다 (화면만 바꾸고 사이트는 열지 않는다).
* 오른쪽: 관리 탭이면 사이트 관리 창(#manageView), 그 밖에는 미리보기(도구 줄 + #previewArea).
*/
function applyTabView(name) {
activeTab = name;
document.querySelectorAll('#mainTabs button').forEach((b) => b.classList.toggle('active', b.dataset.tab === name));
for (const t of TABS) $('pane-' + t).classList.toggle('hidden', t !== name);
const manage = name === 'manage';
$('manageView').classList.toggle('hidden', !manage); $('previewArea').classList.toggle('hidden', manage); document.querySelector('.right > .toolbar').classList.toggle('hidden', manage);
if (manage && window.ManageUI) ManageUI.show(current);
// v1.6.40: 작업 칸 머리 — 화면 이름·설명·(사이트 화면이면) 사이트 이름
const meta = PANE_META[name] || [name, '']; if ($('paneTitle')) { $('paneTitle').textContent = T(meta[0]); $('paneDesc').textContent = T(meta[1]); $('paneSite').textContent = current && (SITE_TABS.includes(name) || name === 'home') ? current.title : ''; }
if (window.FlowUI) FlowUI.onTab(name);
}
async function showTab(name) {
if (!TABS.includes(name)) name = 'create';
{ const target = name === 'create' ? createId : SITE_TABS.includes(name) ? manageId : (current && current.id); if (!['settings', 'domains', 'dns', 'mail', 'credits', 'home'].includes(name) && !discardOk(target || null)) return; }
// v1.8.0 검토 수정: 홈 화면도 고른 사이트(manageId)가 지금 사이트와 다르면 그 사이트를 여므로, 직접 편집의 저장하지 않은 변경을 먼저 묻는다(취소하면 화면을 바꾸지 않는다)
if (name === 'home' && manageId && current && current.id !== manageId && !discardOk(manageId)) return;
applyTabView(name);
try { localStorage.setItem('activeTab', name); } catch (_) {}
if (switching) return; switching = true;
try {
if (name === 'create') {
if (createId && (!current || current.id !== createId)) await openSite(createId, { tab: 'create' }).catch(() => { createId = null; try { localStorage.removeItem('createSiteId'); } catch (_) {} showCreateChooser(); });
else if (!createId) showCreateChooser();
} else if (SITE_TABS.includes(name)) {
// 수정·관리: 고른 사이트가 아직 열려 있지 않으면 연다. 둘은 같은 사이트를 공유하므로 탭만 바꾸면 다시 열지 않는다
if (manageId && (!current || current.id !== manageId)) await openSite(manageId, { tab: name }).catch(() => { manageId = null; resetToNew(false); });
renderManageHint(); loadLedger();
if (name === 'manage' && window.ManageUI) ManageUI.show(current);
} else if (['domains', 'dns', 'mail'].includes(name) && window.DomainsUI) DomainsUI.load(name); // v1.4.0 도메인 탭 → v1.6.40 도메인·DNS·메일 세 화면: 열 때마다 최신 상태(전파·잔액)로
else if (name === 'credits' && window.CreditsUI) CreditsUI.load(); // v1.6.40 캐시·충전
else if (name === 'home') { if (manageId && (!current || current.id !== manageId)) await openSite(manageId, { tab: 'home' }).catch(() => { manageId = null; resetToNew(false); }); if (window.FlowUI) FlowUI.renderHome(); } // v1.6.40 홈: 고른 사이트의 작업 흐름
} finally { switching = false; syncPicker(); }
}
/**
* 상단 사이트 선택 상자를 지금 보이는 사이트에 맞춘다 (v1.2.0).
* 생성 탭에서 새 사이트 방식 선택 화면이면 "+ 새 사이트 만들기", 그 밖에는 현재 사이트.
*/
function syncPicker() {
const sel = $('siteSelect'); if (!sel) return;
const v = activeTab === 'create' ? (createId && current && current.id === createId ? createId : '') : (SITE_TABS.includes(activeTab) || activeTab === 'home' ? (manageId && current && current.id === manageId ? manageId : '') : (current ? current.id : ''));
sel.value = [...sel.options].some((o) => o.value === v) ? v : '';
// v1.4.14 (요구 4): 운영 주소 줄 상자는 숨기지 않는다 — 고른 사이트가 없으면 안내 한 줄만 보여 높이를 유지한다
if (!sel.value) renderSiteMetaEmpty();
}
/**
* 사이트를 고르지 않은 상태의 상태·운영 주소 줄 (같은 높이 유지).
* v1.4.15: 표시 클래스는 `no-site` — 1.4.14 에서 `empty` 를 썼는데 이는 오른쪽 미리보기의 "빈 화면" 규칙(.empty = position:absolute; inset:0)과
* 이름이 같아 이 줄이 화면 전체를 덮는 투명한 층이 되어 모든 클릭을 삼켰다(빌더가 멈춘 것처럼 보임).
*/
function renderSiteMetaEmpty() { $('siteStatePill').textContent = ''; $('siteLiveLine').textContent = T('사이트를 고르면 상태와 운영 주소가 여기에 표시됩니다'); $('siteMetaBox').classList.add('no-site'); }
/** 생성 탭을 "새 사이트 만들기" 초기 화면으로 (수정·관리 탭의 선택은 그대로) */
function showCreateChooser() { if (window.Wizard) Wizard.showModeChooser(); $('planCard').classList.add('hidden'); }
/** 생성이 끝난 사이트를 수정 탭으로 넘기고 생성 탭은 비운다 (v1.4.14: 1.4.5 까지는 관리 탭으로) */
function finishCreate(id) { manageId = id; try { localStorage.setItem('currentSiteId', id); } catch (_) {} createId = null; try { localStorage.removeItem('createSiteId'); } catch (_) {} showTab('edit'); }
/** 진행 중 생성을 버리고(실행 중이면 중단) 새로 시작 */
async function resetCreate() {
if (createId) { try { const s = await api(`/api/sites/${createId}`); if (s.running) await api(`/api/sites/${createId}/cancel`, { method: 'POST', body: '{}' }); } catch (_) {} }
createId = null; try { localStorage.removeItem('createSiteId'); } catch (_) {}
if (current && (!manageId || current.id !== manageId)) { if (es) es.close(); current = null; prog.state = 'idle'; prog.label = ''; setStatus('idle'); }
showCreateChooser(); refreshSiteList();
}
window.finishCreate = finishCreate; window.resetCreate = resetCreate;
/** 수정 중인 사이트를 생성 탭에서 다시 손보기(선택값 수정 후 재생성) 위해 생성 컨텍스트로 지정 */
window.setCreating = (id) => { createId = id; try { localStorage.setItem('createSiteId', id); } catch (_) {} };
document.querySelectorAll('#mainTabs button').forEach((b) => (b.onclick = () => showTab(b.dataset.tab)));
{ const cp = $('creditPill'); if (cp) cp.onclick = () => showTab('credits'); } // v1.6.40 잔여 캐시 → 캐시·충전 화면
$('btnAbortCreate').onclick = async () => { if (!confirm('진행 중인 생성을 중단하고 새 사이트 만들기로 돌아갈까요? 지금까지 만들어진 내용은 위쪽 사이트 목록에 남습니다.')) return; await resetCreate(); };
/**
* ? 도움말 풍선 (v1.1.4): 하나의 #helpTip 요소를 공유하며, 버튼 위치에 따라 위/아래로 뒤집고
* 좌우로 밀어 항상 화면(뷰포트) 안에 들어오게 한다. PC 는 마우스오버, 터치는 눌러서 열고 닫는다.
*/
let tipFor = null;
function showTip(h) {
const tip = $('helpTip'); const text = h.getAttribute('data-tip'); if (!text) return; tipFor = h;
tip.textContent = text; tip.classList.remove('hidden', 'above', 'below');
const r = h.getBoundingClientRect(); const vw = window.innerWidth, vh = window.innerHeight; const w = Math.min(300, vw - 16);
tip.style.width = w + 'px';
let left = r.left + r.width / 2 - w / 2; left = Math.max(8, Math.min(left, vw - w - 8)); tip.style.left = left + 'px';
const th = tip.offsetHeight || 60; const spaceAbove = r.top - 10; const below = spaceAbove < th + 8 && (vh - r.bottom) >= spaceAbove;
let top = below ? r.bottom + 10 : r.top - th - 10; top = Math.max(8, Math.min(top, vh - th - 8)); tip.style.top = top + 'px';
tip.classList.add(below ? 'below' : 'above'); tip.style.setProperty('--ax', Math.max(12, Math.min(w - 12, r.left + r.width / 2 - left)) + 'px');
}
function hideTip() { $('helpTip').classList.add('hidden'); tipFor = null; document.querySelectorAll('.help.open').forEach((x) => x.classList.remove('open')); }
document.addEventListener('mouseover', (e) => { const h = e.target.closest('.help'); if (h) showTip(h); else if (tipFor && !tipFor.classList.contains('open')) hideTip(); });
document.addEventListener('click', (e) => { const h = e.target.closest('.help'); if (h) { e.preventDefault(); const open = h.classList.contains('open'); hideTip(); if (!open) { h.classList.add('open'); showTip(h); } } else hideTip(); });
window.addEventListener('scroll', () => { if (tipFor) showTip(tipFor); }, true); window.addEventListener('resize', () => { if (tipFor) showTip(tipFor); });
/**
* 수정·관리 탭 맨 위 안내 한 줄 (v1.4.14: 두 탭에 각각).
* - 사이트를 고르지 않았으면 무엇을 할 수 있는 탭인지, 생성이 끝나지 않은 사이트면 생성 탭으로 안내한다.
*/
function renderManageHint() {
const eh = $('editHint'); const mh = $('manageHint');
if (!current) {
eh.textContent = '왼쪽 레일에서 사이트를 고르면 수정 지시·기본 사항·다국어·검색 최적화·버전 되돌리기를 할 수 있습니다. 새 사이트는 "만들기" 에서 만들고, 발행·배포는 "배포" 에서 합니다.';
mh.textContent = '왼쪽 레일에서 사이트를 고르면 기능 모듈·사이트 관리자 페이지·문의 접수·접속 통계·캐시 사용을 관리할 수 있습니다. 오른쪽 창에 사이트 관리자 페이지와 현황이 열립니다.';
return;
}
const p = current.plan; const generated = !p || ['generated', 'published'].includes(p.stage) || current.mode === 'import' || current.mode === 'copy';
eh.textContent = generated ? '' : '이 사이트는 아직 생성이 끝나지 않았습니다. "만들기" 에서 기획을 이어서 진행하세요.';
mh.textContent = generated ? '' : '이 사이트는 아직 생성이 끝나지 않았습니다. 생성이 끝나면 기능 모듈·관리자 페이지를 쓸 수 있습니다.';
}
/** 캐시 원장(모든 사이트·충전 포함)을 받아 둔다. 화면에는 요약(잔액)만 두고, 전체 내역과 기간별 사용량은 "상세 보기" 대화상자에서 본다 (v1.1.4) */
let ledgerData = null;
async function loadLedger() {
try { ledgerData = await api('/api/me/credit'); } catch (_) { return; }
if (ledgerData) renderCredit({ ...credit, ...ledgerData });
}
/** KST 기준 일자/주(월요일 시작)/월 키 */
const kstDay = (at) => new Date(new Date(at).getTime() + 9 * 3600000).toISOString().slice(0, 10);
const weekKey = (day) => { const d = new Date(day + 'T00:00:00Z'); const dow = (d.getUTCDay() + 6) % 7; d.setUTCDate(d.getUTCDate() - dow); return d.toISOString().slice(0, 10) + ' 주'; };
/** 캐시 상세 보기: 이 사이트 작업 내역(전체) + 일간/주간/월간 사용량 + 전체 원장(모든 사이트·충전) */
async function showUsageDetail() {
// v1.1.6: 대화상자를 넓히고 열 폭을 고정(table-layout: fixed)해 좌우 스크롤 없이 보이게, 기간별 표는 3열로 나란히
if (!ledgerData) { try { ledgerData = await api('/api/me/credit'); } catch (e) { return alert(e.message); } }
// v1.2.0 (사용자 제안 반영): 열은 그대로 두고 간격만 줄여 가로 스크롤을 없앴다.
// 대화상자를 세 부분으로 나눈다 — 위(고정): 잔액 요약·일/주/월 합계, 가운데(세로 스크롤): 작업 내역·전체 히스토리, 아래(고정): 닫기.
const root = $('usageDialogBody'); root.innerHTML = '';
const headBox = document.createElement('div'); headBox.className = 'ud-head'; root.appendChild(headBox);
const scrollBox = document.createElement('div'); scrollBox.className = 'ud-scroll'; root.appendChild(scrollBox);
let body = headBox; // sec/table 의 기본 출력 위치 (요약 부분이 끝나면 scrollBox 로 바꾼다)
const sec = (title, into) => { const h = document.createElement('div'); h.className = 'field-label'; h.textContent = title; (into || body).appendChild(h); };
/** heads: [{text, num, col}] col = 열 폭 클래스(c-time/c-amt/c-kind/c-st), 없으면 남는 폭 */
const table = (heads, rows, into) => { const w = document.createElement('div'); w.className = 'table-wrap usage-detail'; const t = document.createElement('table'); const cg = document.createElement('colgroup'); heads.forEach((h) => { const c = document.createElement('col'); if (h.col) c.className = h.col; cg.appendChild(c); }); t.appendChild(cg); const tr = document.createElement('tr'); heads.forEach((h) => { const th = document.createElement('th'); th.textContent = h.text; if (h.num) th.className = 'num'; tr.appendChild(th); }); t.appendChild(tr); for (const r of rows) { const row = document.createElement('tr'); r.forEach((c, i) => { const td = document.createElement('td'); td.textContent = c; if (heads[i] && heads[i].num) td.className = 'num'; row.appendChild(td); }); t.appendChild(row); } if (!rows.length) { const row = document.createElement('tr'); const td = document.createElement('td'); td.colSpan = heads.length; td.className = 'muted'; td.textContent = '내역이 없습니다'; row.appendChild(td); t.appendChild(row); } w.appendChild(t); (into || body).appendChild(w); };
// v1.3.17 (B20): 기간 합계는 서버가 준 전체 사용 기록(usageAll, 최근 400일)으로 — 최근 30건 원장만 더하던 문제
const usage = ledgerData.usageAll || (ledgerData.ledger || []).filter((e) => e.type === 'usage');
const agg = (keyFn) => { const m = {}; for (const e of usage) { const k = keyFn(kstDay(e.at)); m[k] = (m[k] || 0) + (-e.amount); } return Object.entries(m).sort((a, b) => b[0].localeCompare(a[0])); };
const info = document.createElement('div'); info.className = 'small'; info.textContent = `잔액 ${krwFmt(ledgerData.balance)} · 누적 사용 ${krwFmt(ledgerData.used)} · 누적 지급 ${krwFmt(ledgerData.granted)}`; body.appendChild(info);
const grid = document.createElement('div'); grid.className = 'usage-periods'; body.appendChild(grid);
const col = (title, heads, rows) => { const d = document.createElement('div'); sec(title, d); table(heads, rows, d); grid.appendChild(d); };
col('일간 (최근 14일)', [{ text: '일자' }, { text: '사용', num: true, col: 'c-amt' }], agg((d) => d).slice(0, 14).map(([k, v]) => [k.slice(5), krwFmt(v)]));
col('주간 (최근 8주)', [{ text: '주 시작(월)' }, { text: '사용', num: true, col: 'c-amt' }], agg(weekKey).slice(0, 8).map(([k, v]) => [k.replace(' 주', '').slice(5), krwFmt(v)]));
col('월간', [{ text: '월' }, { text: '사용', num: true, col: 'c-amt' }], agg((d) => d.slice(0, 7)).slice(0, 12).map(([k, v]) => [k, krwFmt(v)]));
body = scrollBox;
if (current) { sec(`이 사이트 작업 내역 전체 (${current.title})`); const groups = groupCommands(current.commands || []); table([{ text: '종류', col: 'c-kind' }, { text: '지시' }, { text: '캐시', num: true, col: 'c-amt' }, { text: '상태', col: 'c-st' }], groups.map((g) => [g.label, g.instruction || '', g.krw ? krwFmt(g.krw) + (g.estimated ? '*' : '') : '-', g.hasRunning ? '실행 중' : (CMD_STATUS_LABEL[g.status] || fmtDur(g.ms))])); }
sec('전체 캐시 히스토리 (모든 사이트 · 충전 포함)'); const TYPE = { set: '설정', add: '충전', adjust: '조정', usage: '사용', refund: '환불', migrate: '이관' };
table([{ text: '일시', col: 'c-time' }, { text: '내용' }, { text: '금액', num: true, col: 'c-amt' }, { text: '잔액', num: true, col: 'c-amt' }], (ledgerData.ledger || []).map((e) => [new Date(e.at).toLocaleString(uiLocale(), { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }), `[${TYPE[e.type] || e.type}] ${e.memo || ''}`, `${e.amount > 0 ? '+' : ''}${krwFmt(e.amount)}`, krwFmt(e.balanceAfter)]));
const dlg = $('usageDialog'); if (typeof dlg.showModal === 'function') dlg.showModal(); else dlg.setAttribute('open', '');
}
$('btnUsageDetail').onclick = showUsageDetail; $('usageDialogClose').onclick = () => $('usageDialog').close();
/** 하단 요약 푸터는 1.1.4 에서 제거됨 — 계정 탭 한 줄만 유지 */
function renderCreditFooter(info) { if (!info) return; $('pfCreditLine').textContent = `캐시 잔액 ${krwFmt(info.balance)} · 누적 사용 ${krwFmt(info.used)} · 누적 지급 ${krwFmt(info.granted)}`; }
// ---------- 진행 표시기 ----------
// 상세 진행 내용은 고객에게 보이지 않는다. 진행 중/완료/오류만 시각적으로 표시하고, 오류 메시지 한 줄만 노출한다.
const prog = { state: 'idle', label: '', startedAt: null, timer: null, finishedIn: null, deploy: false, deployResult: null };
function fmtElapsed(ms) { const s = Math.max(0, Math.round(ms / 1000)); return s < 60 ? `${s}초` : `${Math.floor(s / 60)}분 ${s % 60}초`; }
function renderProgress() {
const box = $('progress'); box.className = 'progress ' + prog.state;
const t = $('progressText'), sub = $('progressSub');
if (prog.state === 'running') { t.textContent = `${prog.label || '작업'} 진행 중…`; sub.textContent = prog.startedAt ? `경과 ${fmtElapsed(Date.now() - prog.startedAt)}` : ''; }
else if (prog.state === 'done') { t.textContent = `${prog.label || '작업'} 완료`; sub.textContent = prog.deploy && prog.deployResult ? `${prog.deployResult.redeploy ? '재배포' : '배포'} 성공${prog.finishedIn != null ? ' · 소요 ' + fmtElapsed(prog.finishedIn) : ''}` : (prog.finishedIn != null ? `소요 ${fmtElapsed(prog.finishedIn)}` : ''); }
else if (prog.state === 'error') { t.textContent = prog.deploy ? `${prog.label || '배포'} 실패` : `${prog.label || '작업'} 중 문제가 발생했습니다`; sub.textContent = prog.deploy ? '아래 안내를 확인한 뒤 다시 배포해 주세요' : '아래 안내를 확인하거나 다시 시도해 주세요'; }
else if (prog.state === 'waiting-credit') { t.textContent = '캐시 소진 — 충전 대기 중'; sub.textContent = '지금까지의 작업은 저장되었습니다. 캐시가 충전되면 남은 작업을 자동으로 이어서 진행합니다.'; }
else if (prog.state === 'cancelled') { t.textContent = `${prog.label || '작업'} 중단됨`; sub.textContent = prog.finishedIn != null ? `${fmtElapsed(prog.finishedIn)} 진행 후 중단 · 지금까지 만들어진 내용은 유지됩니다` : ''; }
else { t.textContent = '대기 중'; sub.textContent = '지시를 보내면 여기에 진행 상태가 표시됩니다'; }
$('btnCancel').classList.toggle('hidden', prog.state !== 'running' || prog.deploy);
if (prog.state !== 'running') { clearTimeout(prog.msgTimer); $('progressMsg').textContent = ''; }
renderDeployResult();
}
/** v1.5.31 (요구 1·3): 배포 결과 상자는 없앴다 — 결과는 deploy 이벤트에서 메시지창(pushMsg)으로 들어간다. 호환용 빈 함수 */
function renderDeployResult() {}
// v1.3.10: 통화 단위도 번역 사전("{1}원")을 따른다. v1.4.1: 관리자가 캐시 표시 단위를 바꾸면(/api/config cashUnit) 그 글자를 쓴다(번역하지 않음)
const krwFmt = (n) => { if (n == null) return '-'; const u = (typeof config !== 'undefined' && config && config.cashUnit) || '원'; const v = Math.round(n).toLocaleString(uiLocale()); return u === '원' ? TF('{1}원', v) : `${v}${/^[가-힣]+$/.test(u) ? '' : ' '}${u}`; };
/**
* 작업 중 잔여 캐시 실시간 표시 (v1.2.0).
* 1.1.x 의 "이번 회차 캐시" 상자는 없애고, 헤더(버전 줄 오른쪽)의 잔여 캐시만 실시간으로 갱신한다.
* @param {object|null} u 토큰 사용량 (표시에는 쓰지 않음, 호출 호환용)
* @param {boolean} final 작업이 끝났으면 true (실시간 표시 강조 해제)
* @param {number|null} krw 이번 작업 추정/확정 캐시 (표시하지 않음)
* @param {number|undefined} left 남은 캐시. undefined 면 갱신하지 않음
*/
function renderRunTokens(u, final, krw, left) {
const pill = $('creditBadge').parentElement; pill.classList.toggle('live', !final && !!u);
if (left === undefined || left === null) return;
const next = krwFmt(left); const el = $('creditBadge');
if (el.textContent !== next) { el.textContent = next; pill.classList.add('bump'); setTimeout(() => pill.classList.remove('bump'), 160); }
pill.classList.toggle('neg', left <= 0);
}
/** 헤더·사용 내역의 캐시 잔액 표시 */
function renderCredit(info) {
if (!info) return; credit = info;
$('creditBadge').textContent = krwFmt(info.balance); $('creditBadge').parentElement.classList.toggle('neg', info.balance <= 0);
// v1.4.1 관리자는 캐시 한도를 받지 않으므로 "충전 요청" 안내를 보이지 않는다. 금액(-)은 누적 사용액이라 그대로 표시
const isAdmin = typeof config !== 'undefined' && config && config.user && config.user.role === 'admin';
$('creditNote').textContent = info.balance <= 0 && !isAdmin ? '캐시가 없습니다. 캐시·충전 화면에서 충전하세요.' : ''; // v1.6.40: 사용자가 스스로 충전 신청
$('tBalance').textContent = krwFmt(info.balance); if (info.used != null) renderCreditFooter(info);
if (window.FlowUI) FlowUI.onCredit(info); if (window.CreditsUI) CreditsUI.onCredit(info); // v1.6.40 레일 배지·캐시 화면 갱신
}
/** 작업 단계 띠(지시 전달 → AI 작업 → 버전 저장 → 완료). 배포에는 쓰지 않는다 */
function setStep(n) {
const ol = $('progressSteps'); if (n === 0) { ol.classList.add('hidden'); return; } ol.classList.remove('hidden');
[...ol.children].forEach((li, i) => { li.className = i + 1 < n ? 'done' : (i + 1 === n ? 'now' : ''); });
}
function setProgressMsg(text) {
if (prog.state === 'running' && !prog.deploy && text) { if (/버전을 저장/.test(text)) setStep(3); else if (/연결됨|구상|작성|수정|읽|확인|파일/.test(text)) setStep(2); }
const el = $('progressMsg'); if (el.textContent === text || prog.state !== 'running') return;
clearTimeout(prog.msgTimer); el.classList.add('flash'); prog.msgTimer = setTimeout(() => { if (prog.state === 'running') el.textContent = text; el.classList.remove('flash'); }, 120);
}
/**
* v1.5.27 (요구 1·2) 메시지창. 아래 작업 영역의 결과(경고·배포 결과·발행 취소·문의 메일 등)를 위쪽 상태 영역의 고정 크기 창에 쌓는다.
* - 최신 메시지가 맨 위, 지난 메시지는 아래로 밀려 창 안에서 스크롤된다(최대 MSG_MAX 개 보관).
* - 메시지마다 × 로 지우고, "모두 지우기" 로 비운다. 사이트를 바꾸면 그 사이트의 메시지만 보이도록 비운다.
* - 종류(kind): warn(오류·경고, 빨강) / ok(성공, 초록) / info(안내). 서버 로그 이벤트 중 level=warn 은 항상, info 는 notify 표시가 있을 때만 넣는다
* (AI 작업 중 흐르는 진행 로그로 창이 넘치지 않게).
*/
const MSG_MAX = 50; let msgs = []; let msgSeq = 0;
function pushMsg(kind, text, extra) {
if (!text) return;
const top = msgs[0]; if (top && top.text === text && top.kind === kind && Date.now() - top.at < 3000) return; // 같은 문구가 연달아 오면 한 번만
msgs.unshift({ id: ++msgSeq, kind, text, at: Date.now(), links: (extra && extra.links) || [], lines: (extra && extra.lines) || [] }); if (msgs.length > MSG_MAX) msgs.length = MSG_MAX;
renderMsgs(true);
}
function renderMsgs(flash) {
const box = $('msgBox'); const ul = $('msgList'); if (!box || !ul) return;
box.classList.toggle('hidden', !msgs.length); ul.innerHTML = ''; $('msgCount').textContent = msgs.length ? TF('{1}개', msgs.length) : '';
msgs.forEach((m, i) => {
const li = document.createElement('li'); li.className = m.kind + (flash && i === 0 ? ' new' : '');
const t = document.createElement('span'); t.className = 't'; t.textContent = new Date(m.at).toLocaleTimeString(uiLocale(), { hour: '2-digit', minute: '2-digit' }); li.appendChild(t);
const x = document.createElement('span'); x.className = 'x'; x.textContent = m.text;
for (const u of m.links) { x.appendChild(document.createTextNode(' ')); const a = document.createElement('a'); a.href = u; a.target = '_blank'; a.rel = 'noopener'; a.textContent = u; x.appendChild(a); }
for (const ln of m.lines || []) { const d = document.createElement('div'); d.className = 'ln'; if (ln.url) { const a = document.createElement('a'); a.href = ln.url; a.target = '_blank'; a.rel = 'noopener'; a.textContent = ln.url; d.appendChild(a); } d.appendChild(document.createTextNode(ln.text || '')); x.appendChild(d); } // v1.5.31 여러 줄(배포 주소별 판정)
li.appendChild(x);
const del = document.createElement('button'); del.type = 'button'; del.className = 'del'; del.title = T('이 메시지 지우기'); del.textContent = '×'; del.onclick = () => { msgs = msgs.filter((y) => y.id !== m.id); renderMsgs(false); }; li.appendChild(del);
ul.appendChild(li);
});
ul.scrollTop = 0;
}
function clearMsgs() { msgs = []; renderMsgs(false); }
/**
* v1.5.31 (요구 1·3) 배포 결과를 메시지창에 한 건으로: 첫 줄 "배포 완료 (재배포)" / "배포 실패 — 원인", 다음 줄부터 주소마다 "주소 — 판정" (주소는 링크).
* 1.5.27 까지 진행 상자 아래 별도 상자(progressDeploy)에 보이던 내용이며, 이제 시각과 함께 쌓이고 지울 수 있다.
*/
function pushDeployMsg(ev) {
const items = (ev.access && ev.access.items) || []; const bad = items.filter((i) => !i.ok);
const head = ev.ok ? `${ev.label || '배포'} 완료 (${ev.redeploy ? '재배포' : '첫 배포'})` : `${ev.label || '배포'} 실패 — ${(ev.detail || '').slice(0, 300)}`;
const lines = items.length ? items.map((i) => ({ url: i.url, text: ` — ${i.note || (i.ok ? '정상' : '확인 필요')}` })) : (ev.ok ? (ev.urls || []).slice(0, 3).map((u) => ({ url: u, text: '' })) : []);
if (!ev.ok) lines.push({ text: '자세한 원인은 관리자에게 전달되었습니다. 접속 정보나 배포 서버 설정을 확인한 뒤 다시 시도해 주세요.' });
pushMsg(ev.ok ? (bad.length ? 'warn' : 'ok') : 'warn', head, { lines });
}
document.addEventListener('DOMContentLoaded', () => { const b = $('msgClear'); if (b) b.onclick = clearMsgs; });
function logLine(ev) {
// 경고는 항상, 안내는 서버가 notify 로 표시한 것만 메시지창으로. 그 밖의 상세 로그는 표시하지 않는다.
if (!ev) return;
if (ev.level === 'warn') pushMsg('warn', ev.text);
else if (ev.notify && (ev.level === 'info' || !ev.level)) pushMsg(ev.notifyKind || 'ok', ev.text);
}
function setStatus(s, label, startedAt, deploy) {
if (s === 'running') { prog.deploy = !!deploy; if (deploy) prog.deployResult = null; }
else if (s === 'idle') { prog.deploy = false; prog.deployResult = null; }
else if (!deploy) prog.deploy = false;
const p = $('statusPill');
const map = { idle: ['idle', '대기'], running: ['running', '실행 중'], error: ['error', '오류'], done: ['done', '완료'], cancelled: ['cancelled', '중단됨'], 'waiting-credit': ['waiting', '캐시 충전 대기'] };
const [cls, txt] = map[s] || map.idle;
p.className = 'pill ' + cls; p.textContent = txt;
const running = s === 'running';
if (running) { if (prog.state !== 'running') { prog.startedAt = startedAt ? new Date(startedAt).getTime() : Date.now(); renderRunTokens({ total: 0 }, false, null); if (!deploy) setStep(1); setProgressMsg('작업을 준비하고 있습니다'); } if (label) prog.label = label; if (deploy) setStep(0); }
else if (s === 'done' && !deploy && prog.state === 'running') setStep(4); else if (s !== 'done') setStep(0);
else if (prog.state === 'running') prog.finishedIn = Date.now() - (prog.startedAt || Date.now());
prog.state = s === 'idle' && prog.state === 'done' ? 'done' : (map[s] ? s : 'idle');
clearInterval(prog.timer); if (running) prog.timer = setInterval(renderProgress, 1000);
renderProgress();
$('btnUpdate').disabled = running || s === 'waiting-credit' || !!(current && current.editLimit && current.editLimit.locked); if (window.Wizard) Wizard.setRunning(running);
}
// ---------- 토큰 사용량 ----------
const CMD_KIND = { create: '생성', update: '수정', 'plan-analyze': '이해 확인', 'plan-structure': '구조 제안', 'plan-design': '디자인 3안', 'plan-verify': '검증', 'site-analyze': '사이트 분석', 'seo-draft': '검색·AI 최적화 초안' };
const CMD_STATUS_LABEL = { running: '실행 중', cancelled: '중단', interrupted: '끊김', 'paused-credit': '캐시 대기' };
/**
* 캐시 사용 내역 (v1.1.2): 사용자에게는 "작업 단위" 합계만 보여 준다.
* - 생성: 이해 확인·구조 제안·디자인·생성·검증 등 생성에 들어간 단계들을 한 묶음으로
* - 수정: 수정 지시 1건(+뒤따르는 자동 검증)을 한 묶음으로
* 세부 단계별 금액은 관리자 콘솔에서만 본다. 묶음 행을 누르면 단계가 펼쳐진다(금액 없이 단계 이름만).
*/
function groupCommands(cmds) {
const groups = []; let cur = null;
const start = (label, c) => { cur = { label, items: [c], krw: 0, status: c.status, at: c.startedAt, instruction: c.instruction }; groups.push(cur); };
for (const c of cmds) {
const ins = String(c.instruction || '');
if (c.kind === 'create') { if (cur && cur.label === '생성' && !cur.items.some((x) => x.kind === 'create')) cur.items.push(c); else start('생성', c); cur.status = c.status; cur.instruction = '사이트 생성'; }
else if (c.kind === 'update' && /^\(검증 결과 자동 수정\)/.test(ins)) { start('검증 반영', c); cur.instruction = '검증에서 발견된 항목 자동 수정'; }
else if (c.kind === 'update' && /^\(이어서\)/.test(ins)) start('수정(이어서)', c);
else if (c.kind === 'update' && /^\(Brief 기반 재생성\)/.test(ins)) { start('재생성', c); cur.instruction = '선택값 변경 후 재생성'; }
else if (c.kind === 'update') start('수정', c);
else if (c.kind === 'plan-verify') { start('검증', c); cur.instruction = '검증 리포트 작성 (Brief 대조)'; } // 검증은 항상 별도 행으로 (v1.1.4)
else if (c.kind.startsWith('plan-')) { if (!cur || cur.label !== '생성' || cur.items.some((x) => x.kind === 'create')) start('생성', c); else cur.items.push(c); }
else start(CMD_KIND[c.kind] || c.kind, c);
}
for (const g of groups) { g.krw = g.items.reduce((a, c) => a + (c.chargeKrw || 0), 0); g.estimated = g.items.some((c) => c.usageEstimated); const last = g.items[g.items.length - 1]; g.status = g.items.some((c) => c.status === 'running') ? 'running' : last.status; g.ms = g.items.reduce((a, c) => a + (c.durationMs || 0), 0); g.hasRunning = g.items.some((c) => c.status === 'running'); }
return groups;
}
function renderUsage(site) {
const t = site.totals || {};
$('tKrw').textContent = krwFmt(t.chargeKrw || 0); if (site.credit) renderCredit(site.credit);
const tb = $('usageTable').querySelector('tbody'); tb.innerHTML = '';
const groups = groupCommands(site.commands || []); const older = groups.slice(0, Math.max(0, groups.length - 15));
if (older.length) { const tr = document.createElement('tr'); tr.className = 'muted'; const td = document.createElement('td'); td.colSpan = 5; td.textContent = `이전 작업 ${older.length}건 — 캐시 ${krwFmt(older.reduce((a, g) => a + g.krw, 0))} (총액만 표시)`; tr.appendChild(td); tb.appendChild(tr); }
groups.slice(-15).forEach((g, gi) => {
const tr = document.createElement('tr'); tr.className = 'usage-group' + (g.status === 'error' || g.status === 'interrupted' ? ' err' : (g.status === 'cancelled' || g.status === 'paused-credit' ? ' cancelled' : ''));
const cells = [String(groups.length - Math.min(15, groups.length) + gi + 1), g.label, g.instruction || '', g.hasRunning ? '실행 중' : (g.krw ? krwFmt(g.krw) + (g.estimated ? '*' : '') : '-'), g.hasRunning ? '실행 중' : (CMD_STATUS_LABEL[g.status] || fmtDur(g.ms))];
cells.forEach((v, i) => { const td = document.createElement('td'); td.textContent = v; if (i === 2) { td.className = 'instr'; td.title = g.instruction || ''; } tr.appendChild(td); });
tr.title = '누르면 단계를 펼칩니다'; tb.appendChild(tr);
const subs = g.items.map((c) => { const sr = document.createElement('tr'); sr.className = 'usage-sub hidden'; [``, `└ ${CMD_KIND[c.kind] || c.kind}`, c.kind === 'update' || c.kind === 'create' ? '' : (c.instruction || ''), '', CMD_STATUS_LABEL[c.status] || fmtDur(c.durationMs)].forEach((v) => { const td = document.createElement('td'); td.textContent = v; sr.appendChild(td); }); tb.appendChild(sr); return sr; });
tr.onclick = () => subs.forEach((sr) => sr.classList.toggle('hidden'));
});
let tf = $('usageTable').querySelector('tfoot');
if (!tf) { tf = document.createElement('tfoot'); $('usageTable').appendChild(tf); }
tf.innerHTML = '';
const tr = document.createElement('tr');
['', '합계', `작업 ${groups.length}건`, krwFmt(t.chargeKrw || 0), ''].forEach((v) => { const td = document.createElement('td'); td.textContent = v; tr.appendChild(td); });
tf.appendChild(tr);
}
// ---------- 모델 등급 (기본 · 중급 · 고급) ----------
function fillTiers() {
const sel = $('tierSelect'); const prev = sel.value; sel.innerHTML = '';
for (const t of config.tiers || []) sel.appendChild(new Option(`${T(t.label)} — ${T(t.desc)}`, t.id)); // 서버 목록 문구를 조각별로 번역
const want = current ? current.tier : (localStorage.getItem('preferredTier') || config.defaultTier);
sel.value = [...sel.options].some((o) => o.value === want) ? want : (prev || config.defaultTier || 'standard');
if (![...sel.options].some((o) => o.value === sel.value)) sel.selectedIndex = 0;
renderTierGuide();
}
/** 등급 선택 상자 공통: 생성(tierSelect)·수정(editTierSelect) */
function fillEditTier(site) {
const sel = $('editTierSelect'); sel.innerHTML = '';
for (const t of config.tiers || []) sel.appendChild(new Option(t.label, t.id));
sel.value = (site && site.tier) || config.defaultTier || 'standard'; renderEditCost();
}
const krwRange = (n) => (n == null ? T('산출 불가') : TF('약 {1} ~ {2}', krwFmt(Math.round(n * 0.6 / 100) * 100), krwFmt(Math.round(n * 1.6 / 100) * 100)));
/** 수정 카드: 선택한 등급의 수정 1회 예상 비용 */
function renderEditCost() {
const g = (config.tierGuide || []).find((t) => t.id === $('editTierSelect').value); const el = $('editCostLine');
el.innerHTML = g ? `${TF('수정 1회 예상 {1}', krwRange(g.editKrw))} <button type="button" class="help" data-tip="${(T(g.title) + ': ' + T(g.summary) + ' ' + T('실제 금액은 사이트 크기와 지시 범위에 따라 달라지며 작업이 끝날 때 확정됩니다.')).replace(/"/g, '&quot;')}">?</button>` : '';
}
$('editTierSelect').onchange = renderEditCost;
/** 생성 탭: 등급별 특징·예상 비용 표 */
function renderTierGuide() {
const box = $('tierGuideBox'); const list = config.tierGuide || []; if (!list.length) { box.classList.add('hidden'); return; }
const cur = $('tierSelect').value;
// 짧은 한 줄만 표에 두고, 자세한 설명은 ? 풍선으로 (화면이 복잡해지지 않게)
// v1.5.19 (요구 1): 특징 열 문구 — 서버의 등급 설명(tierGuide.summary)과 같은 내용
const short = { basic: 'HTML5, CSS3, 자바스크립트 구조의 정적 웹페이지 생성', standard: '웹페이지의 시각적 완성도(UI/UX)와 심미성 우수', premium: '복잡한 UI/UX의 프런트엔드 아키텍쳐 구현' };
box.innerHTML = `<table><thead><tr><th class="tg-name">등급</th><th>특징</th><th class="cost tg-cost">생성 1회</th><th class="cost tg-cost">수정 1회</th></tr></thead><tbody>${list.map((t) => `<tr class="${t.id === cur ? 'on' : ''}"><td><b>${T(t.title)}</b></td><td class="tg-feat">${T(short[t.id] || '')} <button type="button" class="help" data-tip="${(T(t.summary) + ' ' + T('적합:') + ' ' + T(t.fit)).replace(/"/g, '&quot;')}">?</button></td><td class="cost">${krwRange(t.createKrw)}</td><td class="cost">${krwRange(t.editKrw)}</td></tr>`).join('')}</tbody></table><div class="note">예상 비용(현재 환율·수수료 반영) <button type="button" class="help right" data-tip="일반적인 사이트 기준의 추정 범위입니다. 실제 금액은 작업이 끝날 때 확정되어 캐시에서 차감되며, 페이지가 많거나 지시가 길면 더 들 수 있습니다. 생성에는 이해 확인·구조·디자인·생성·검증 단계 비용이 모두 포함됩니다.">?</button></div>`;
}
$('tierSelect').onchange = async () => {
const tier = $('tierSelect').value; try { localStorage.setItem('preferredTier', tier); } catch (_) {}
if (current) { try { await api(`/api/sites/${current.id}/model`, { method: 'PUT', body: JSON.stringify({ tier }) }); current.tier = tier; } catch (e) { alert('등급 변경 실패: ' + e.message); fillTiers(); } }
renderTierGuide();
};
// ---------- 기본 사항 수정 ----------
const BASICS = [['title', 'bTitle'], ['purpose', 'bPurpose'], ['content', 'bContent'], ['design', 'bDesign'], ['extra', 'bExtra'], ['contactEmail', 'bEmail']];
function renderBasics(site) {
for (const [k, id] of BASICS) $(id).value = site[k] || '';
updateBasicsState();
}
function basicsDirty() { return !!current && BASICS.some(([k, id]) => $(id).value.trim() !== (current[k] || '')); }
function updateBasicsState() {
const dirty = basicsDirty();
$('btnSaveBasics').disabled = !dirty; $('btnRevertBasics').disabled = !dirty;
const st = $('basicsState');
if (dirty) { st.textContent = '변경됨 (미저장)'; st.className = 'basics-state'; }
else if (current && current.basicsChanged && current.basicsChanged.length) { st.textContent = '다음 지시에 반영 예정'; st.className = 'basics-state pending'; }
else { st.textContent = ''; }
}
for (const [, id] of BASICS) $(id).addEventListener('input', updateBasicsState);
$('btnSaveBasics').onclick = async () => {
if (!current) return;
const body = {}; for (const [k, id] of BASICS) body[k] = $(id).value;
if (!body.title.trim()) { alert('페이지 제목은 비울 수 없습니다.'); return; }
$('btnSaveBasics').disabled = true;
try {
const r = await api(`/api/sites/${current.id}`, { method: 'PATCH', body: JSON.stringify(body) });
Object.assign(current, r); $('siteTitle').textContent = current.title; renderBasics(current); refreshSiteList();
} catch (e) { alert('저장 실패: ' + e.message); $('btnSaveBasics').disabled = false; }
};
$('btnRevertBasics').onclick = () => { if (current) renderBasics(current); };
// ---------- 첨부 파일 ----------
function renderAttachments(site) {
const list = (site.attachments || []);
$('attachCard').classList.remove('hidden');
$('attachCount').textContent = list.length ? `${list.length}개` : '없음';
const ul = $('attachList'); ul.innerHTML = '';
for (const a of list) {
const li = document.createElement('li');
const url = site.url + a.rel;
if (a.kind === 'image') { const img = document.createElement('img'); img.className = 'thumb'; img.src = url + '?t=' + Date.now(); img.alt = a.name; li.appendChild(img); }
else { const d = document.createElement('div'); d.className = 'doc'; d.textContent = (a.name.split('.').pop() || 'DOC').toUpperCase().slice(0, 4); li.appendChild(d); }
const info = document.createElement('div'); info.className = 'info';
const name = document.createElement('span'); name.className = 'name';
const link = document.createElement('a'); link.href = url; link.target = '_blank'; link.rel = 'noopener'; link.textContent = a.name; name.appendChild(link);
if (!a.usedInCommands || !a.usedInCommands.length) { const tag = document.createElement('span'); tag.className = 'tag'; tag.textContent = '다음 지시에 사용'; name.appendChild(tag); }
const meta = document.createElement('span'); meta.className = 'meta';
meta.textContent = `${a.kind === 'image' ? '이미지' : '문서'} · ${fmtSize(a.size)} · ${a.rel}` + (a.usedInCommands && a.usedInCommands.length ? ` · 명령 #${a.usedInCommands.join(', #')}` : '');
info.appendChild(name); info.appendChild(meta); li.appendChild(info);
const del = document.createElement('button'); del.className = 'del'; del.title = '삭제'; del.textContent = '×';
del.onclick = async () => {
if (!confirm(`${a.name} 을(를) 삭제할까요?`)) return;
try { const r = await api(`/api/sites/${site.id}/uploads/${encodeURIComponent(a.name)}`, { method: 'DELETE' }); current.attachments = r.attachments; renderAttachments(current); }
catch (e) { alert('삭제 실패: ' + e.message); }
};
li.appendChild(del); ul.appendChild(li);
}
}
function fmtSize(n) { return n < 1024 ? n + 'B' : n < 1048576 ? (n / 1024).toFixed(0) + 'KB' : (n / 1048576).toFixed(1) + 'MB'; }
// 첨부 대기 목록 (생성 폼 / 수정 폼). 드롭·선택한 파일을 모아 두고 요청 시 함께 보낸다.
const pending = { create: [], update: [] };
function acceptedExt(name) { return !config.upload || config.upload.extensions.includes((name.split('.').pop() || '').toLowerCase()); }
function addPending(target, files) {
const rejected = [];
for (const f of files) {
if (!acceptedExt(f.name)) { rejected.push(f.name); continue; }
if (config.upload && f.size > config.upload.maxFileBytes) { rejected.push(f.name + ' (25MB 초과)'); continue; }
if (!pending[target].some((p) => p.name === f.name && p.size === f.size)) pending[target].push(f);
}
renderPending(target);
if (rejected.length) alert('첨부할 수 없는 파일: ' + rejected.join(', '));
}
function renderPending(target) {
const ul = $(target === 'create' ? 'pendingCreate' : 'pendingUpdate'); ul.innerHTML = '';
pending[target].forEach((f, i) => {
const li = document.createElement('li');
const n = document.createElement('span'); n.className = 'pn'; n.textContent = f.name; n.title = f.name;
const sz = document.createElement('span'); sz.className = 'ps'; sz.textContent = fmtSize(f.size);
const x = document.createElement('button'); x.type = 'button'; x.textContent = '×'; x.title = '제거'; x.onclick = () => { pending[target].splice(i, 1); renderPending(target); };
li.appendChild(n); li.appendChild(sz); li.appendChild(x); ul.appendChild(li);
});
}
function appendFiles(fd, target) { for (const f of pending[target]) fd.append('files', f, f.name); }
function clearPending(target) { pending[target] = []; renderPending(target); }
async function uploadNow(files) {
if (!current || !files.length) return;
const fd = new FormData(); for (const f of files) fd.append('files', f, f.name);
try { const r = await api(`/api/sites/${current.id}/uploads`, { method: 'POST', body: fd }); current.attachments = r.attachments; renderAttachments(current); }
catch (err) { alert('업로드 실패: ' + err.message); }
}
function routeFiles(target, files) {
if (target === 'now') uploadNow([...files]);
else addPending(target, [...files]);
}
// 파일 선택 input → 대기 목록/즉시 업로드
$('fFiles').onchange = (e) => { addPending('create', [...e.target.files]); e.target.value = ''; };
$('fUpdFiles').onchange = (e) => { addPending('update', [...e.target.files]); e.target.value = ''; };
// 드롭존
for (const dz of document.querySelectorAll('.dropzone')) {
dz.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); dz.classList.add('dragover'); });
dz.addEventListener('dragleave', () => dz.classList.remove('dragover'));
dz.addEventListener('drop', (e) => { e.preventDefault(); e.stopPropagation(); dz.classList.remove('dragover'); hideOverlay(); routeFiles(dz.dataset.target, e.dataTransfer.files); });
}
// 화면 전체 드롭: 사이트가 열려 있으면 즉시 업로드, 아니면 생성 폼 대기 목록에 추가
let dragDepth = 0;
function hideOverlay() { dragDepth = 0; $('dropOverlay').classList.add('hidden'); }
document.addEventListener('dragenter', (e) => {
if (![...(e.dataTransfer.types || [])].includes('Files')) return;
dragDepth += 1;
$('dropOverlayText').textContent = current ? '파일을 놓으면 이 사이트에 바로 업로드됩니다' : '파일을 놓으면 생성 폼의 첨부 목록에 추가됩니다';
$('dropOverlay').classList.remove('hidden');
});
document.addEventListener('dragleave', () => { dragDepth = Math.max(0, dragDepth - 1); if (!dragDepth) $('dropOverlay').classList.add('hidden'); });
document.addEventListener('dragover', (e) => e.preventDefault());
document.addEventListener('drop', (e) => {
e.preventDefault(); hideOverlay();
if (!e.dataTransfer.files.length) return;
if (current) routeFiles('now', e.dataTransfer.files); else routeFiles('create', e.dataTransfer.files);
});
window.addEventListener('blur', hideOverlay);
// ---------- 수정 완료 안내 + 상세 내역 (v1.1.2) ----------
const FILE_ST = { A: '추가', M: '수정', D: '삭제', R: '이름 변경' };
/** 마지막 수정 작업 결과를 배너로. 상세는 대화상자(지시·AI 요약·바뀐 파일) */
function renderDoneBanner(site, cmd) {
const b = $('doneBanner'); if (!cmd || cmd.kind !== 'update') { b.classList.add('hidden'); return; }
const ok = cmd.status === 'done'; b.classList.remove('hidden'); b.classList.toggle('fail', !ok);
$('dbTitle').textContent = ok ? '수정 완료' : (cmd.status === 'cancelled' ? '수정 중단됨' : cmd.status === 'paused-credit' ? '캐시 소진으로 일시 중지' : '수정 실패');
const files = (cmd.git && cmd.git.files) || [];
$('dbMeta').textContent = `${new Date(cmd.finishedAt || cmd.startedAt).toLocaleString(uiLocale())} · ${files.length ? TF('파일 {1}개 변경', files.length) : T('파일 변경 없음')}${cmd.chargeKrw != null ? ` · ${T('캐시')} ${krwFmt(cmd.chargeKrw)}` : ''}`; // v1.3.10 조각 번역
const summary = String(cmd.resultText || '').replace(/<brief>[\s\S]*?<\/brief>/, '').trim();
$('dbSummary').textContent = ok ? (summary.split('\n').slice(0, 3).join('\n') || '요청한 내용을 반영했습니다. 우측 미리보기에서 확인하세요.') : (cmd.error || '');
$('dbDetail').onclick = () => showCommandDetail(cmd);
}
$('dbClose').onclick = () => $('doneBanner').classList.add('hidden');
function showCommandDetail(cmd) {
const d = $('detailDialog'); const body = $('ddBody2'); body.innerHTML = '';
$('ddTitle2').textContent = `${cmd.kind === 'create' ? '생성' : '수정'} #${cmd.no} 상세 내역`;
const sec = (title, node) => { const h = document.createElement('div'); h.className = 'field-label'; h.textContent = title; body.appendChild(h); body.appendChild(node); };
const ins = document.createElement('div'); ins.className = 'small'; ins.style.whiteSpace = 'pre-wrap'; ins.textContent = cmd.instruction || ''; sec('지시 내용', ins);
const res = document.createElement('div'); res.className = 'small'; res.style.whiteSpace = 'pre-wrap'; res.textContent = String(cmd.resultText || '').replace(/<brief>[\s\S]*?<\/brief>/, '').trim() || '(요약 없음)'; sec('AI 작업 요약 (어느 페이지를 어떻게 바꿨는지)', res);
const files = (cmd.git && cmd.git.files) || []; const ul = document.createElement('ul'); ul.className = 'detail-files'; ul.style.listStyle = 'none'; ul.style.padding = '0';
for (const f of files) { const li = document.createElement('li'); const st = document.createElement('span'); st.className = 'fs ' + f.status; st.textContent = FILE_ST[f.status] || f.status; const pth = document.createElement('span'); pth.textContent = f.path; li.appendChild(st); li.appendChild(pth); ul.appendChild(li); }
if (!files.length) { const li = document.createElement('li'); li.className = 'muted'; li.textContent = '변경된 파일이 기록되지 않았습니다'; ul.appendChild(li); }
sec(`변경된 파일 ${files.length}개${cmd.git && cmd.git.stat ? ' — ' + cmd.git.stat : ''}`, ul);
const meta = document.createElement('div'); meta.className = 'muted small'; meta.textContent = `상태 ${CMD_STATUS_LABEL[cmd.status] || cmd.status} · 소요 ${fmtDur(cmd.durationMs)} · 캐시 ${cmd.chargeKrw != null ? krwFmt(cmd.chargeKrw) : '-'}${cmd.git && cmd.git.no ? ` · 버전 #${cmd.git.no}` : ''}`; body.appendChild(meta);
if (typeof d.showModal === 'function') d.showModal(); else d.setAttribute('open', '');
}
$('ddClose2').onclick = () => $('detailDialog').close();
// ---------- 제안 · 오류 신고 (v1.1.2) ----------
/**
* 제안·오류 신고에 함께 보내는 사용 환경 (v1.1.6). 문제 재현에 필요한 정보만, 입력 내용·비밀번호는 넣지 않는다.
* 대화상자에 "보낼 정보 보기" 로 그대로 보여 준다(사전 고지).
*/
function collectEnv() {
const ua = navigator.userAgent || ''; const nav = navigator.userAgentData;
const browser = (() => { const m = ua.match(/(Edg|OPR|Whale|SamsungBrowser|Firefox|Chrome|Safari)\/([\d.]+)/); if (!m) return ''; const name = { Edg: 'Edge', OPR: 'Opera' }[m[1]] || m[1]; const ver = m[1] === 'Safari' ? ((ua.match(/Version\/([\d.]+)/) || [])[1] || m[2]) : m[2]; return `${name} ${ver}`; })();
const os = (() => { if (/Windows NT ([\d.]+)/.test(ua)) return 'Windows ' + RegExp.$1; if (/Mac OS X ([\d_]+)/.test(ua)) return 'macOS ' + RegExp.$1.replace(/_/g, '.'); if (/Android ([\d.]+)/.test(ua)) return 'Android ' + RegExp.$1; if (/(iPhone|iPad).*OS ([\d_]+)/.test(ua)) return 'iOS ' + RegExp.$2.replace(/_/g, '.'); if (/Linux/.test(ua)) return 'Linux'; return nav && nav.platform ? nav.platform : (navigator.platform || ''); })();
let tz = ''; try { tz = Intl.DateTimeFormat().resolvedOptions().timeZone; } catch (_) {}
return {
browser, os, userAgent: ua, mobile: nav ? !!nav.mobile : /Mobi|Android|iPhone/.test(ua),
screen: `${screen.width}x${screen.height}`, viewport: `${window.innerWidth}x${window.innerHeight}`, devicePixelRatio: window.devicePixelRatio || 1, colorDepth: screen.colorDepth,
language: navigator.language || '', uiLanguage: window.I18N ? I18N.lang : 'ko', timezone: tz, utcOffsetMin: -new Date().getTimezoneOffset(),
online: navigator.onLine, cookieEnabled: navigator.cookieEnabled, touchPoints: navigator.maxTouchPoints || 0,
appVersion: config.version || '', tab: activeTab, siteId: current ? current.id : '', siteMode: current ? (current.mode || '') : '', siteStage: current && current.plan ? current.plan.stage : '', siteStatus: current ? (current.status || '') : '',
progressState: prog.state, previewMode: $('previewArea').classList.contains('mobile') ? 'mobile' : 'pc', path: location.pathname, time: new Date().toISOString(),
};
}
/**
* 지금 탭의 화면 이름 (v1.3.2). 1.3.1 까지는 탭 ID(create/manage/settings)를 그대로 써서 "manage 탭" 처럼 보였고 번역도 되지 않았다.
* 한국어 이름을 쓰면 번역 사전에 있는 탭 이름("관리 탭" 등)으로 바뀐다.
*/
const TAB_LABEL = { home: '홈 화면', create: '만들기 화면', edit: '수정 화면', deploy: '배포 화면', manage: '운영 화면', domains: '도메인 화면', dns: 'DNS 화면', mail: '메일 화면', credits: '캐시·충전 화면', settings: '설정 화면' }; // v1.6.40 새 화면 이름
function tabLabel() { return TAB_LABEL[activeTab] || activeTab; }
function openFeedback() {
$('fbEnvPreview').classList.add('hidden'); $('fbEnvToggle').textContent = '보낼 정보 보기'; const d = $('fbDialog'); $('fbMsg').textContent = ''; $('fbContext').textContent = current ? `현재 사이트: ${current.title} · 화면: ${tabLabel()} (함께 전달됩니다)` : `화면: ${tabLabel()} (함께 전달됩니다)`; if (typeof d.showModal === 'function') d.showModal(); else d.setAttribute('open', ''); }
$('fbEnvToggle').onclick = () => { const pre = $('fbEnvPreview'); const show = pre.classList.contains('hidden'); if (show) pre.textContent = Object.entries(collectEnv()).map(([k, v]) => `${k}: ${v}`).join('\n'); pre.classList.toggle('hidden', !show); $('fbEnvToggle').textContent = show ? '닫기' : '보낼 정보 보기'; };
$('btnFeedback').onclick = openFeedback; $('btnFeedback2').onclick = openFeedback; $('fbCancel').onclick = () => $('fbDialog').close();
$('fbSend').onclick = async () => {
const title = $('fbTitle').value.trim(), body = $('fbBody').value.trim(); if (!title && !body) return ($('fbMsg').textContent = '내용을 입력하세요');
$('fbSend').disabled = true;
try { await api('/api/feedback', { method: 'POST', body: JSON.stringify({ type: $('fbType').value, title, body, siteId: current ? current.id : null, page: `${activeTab} 탭 (${tabLabel()})`, env: collectEnv() }) }); $('fbTitle').value = ''; $('fbBody').value = ''; $('fbDialog').close(); alert('보냈습니다. AI 가 내용을 분석해 운영팀에 전달하며, 답변은 설정 탭 "내 문의 내역"에서 볼 수 있습니다.'); loadMyFeedback(); }
catch (e) { $('fbMsg').textContent = e.message; }
$('fbSend').disabled = false;
};
const FB_TYPE = { inquiry: '문의', suggestion: '제안', bug: '오류', request: '요구' }; const FB_STATUS = { new: '접수', reviewing: '검토 중', done: '처리 완료', rejected: '처리 완료' }; // v1.2.0: 반려 없음 (예전 반려 글은 처리 완료로 표시)
/**
* 설정 탭 "내 제안 · 오류 신고" 목록 (v1.2.0 흐름 정리).
* 상태: 접수(new) → 검토 중(reviewing, AI 분석 완료) → 처리 완료(done, 운영팀 답변 저장 시 자동).
* 처리 완료된 글은 운영팀 답변을 목록에 바로 펼쳐 보여 준다 (예전에는 마우스를 올려야 보였음).
*/
async function loadMyFeedback() {
const ul = $('myFeedbackList'); let r; try { r = await api('/api/feedback'); } catch (_) { return; }
ul.innerHTML = '';
for (const x of r.items.slice(0, 30)) {
const li = document.createElement('li'); li.className = 'fb-item';
const head = document.createElement('div'); head.className = 'fb-head';
const k = document.createElement('span'); k.className = 'fb-st ' + (x.status || 'new'); k.textContent = FB_STATUS[x.status] || x.status;
const t = document.createElement('span'); t.className = 'fb-title'; t.textContent = `[${FB_TYPE[x.type] || x.type}] ${x.title}`; t.title = x.body || '';
const d = document.createElement('span'); d.className = 'muted small'; d.textContent = new Date(x.at).toLocaleDateString(uiLocale());
head.appendChild(k); head.appendChild(t); head.appendChild(d); li.appendChild(head);
const admin = (x.comments || []).filter((c) => c.by === 'admin');
if (admin.length) { for (const c of admin.slice(-2)) { const a = document.createElement('div'); a.className = 'fb-answer'; a.textContent = `운영팀 답변 (${new Date(c.at).toLocaleDateString(uiLocale())}): ${c.text}`; li.appendChild(a); } }
else if (x.status === 'reviewing') { const a = document.createElement('div'); a.className = 'muted small'; a.textContent = 'AI 검토를 마치고 운영팀이 확인하고 있습니다.'; li.appendChild(a); }
ul.appendChild(li);
}
if (!r.items.length) { const li = document.createElement('li'); li.className = 'muted'; li.textContent = '보낸 글이 없습니다'; ul.appendChild(li); }
}
// ---------- 사이트 현황 분석 (v1.2.0, 사이트 관리하기·복사하기) ----------
/**
* site.analysis 를 "사이트 현황 분석" 카드에 그린다.
* - stats : 파일 수·크기·페이지 수·확장자별 분포·감지된 기술(markers)
* - server : (사이트 관리하기) 웹서버 종류·가상 호스트(도메인/설정 파일)·문서 루트
* - copy : (사이트 복사하기) 복사한 페이지·파일 수, 건너뛴 수, 서버 실행 페이지 여부
* - ai : AI 요약 {summary, structure[], tech[], editability, issues[], suggestions[]} 또는 {error}/{skipped}
* 가져오거나 복사한 사이트가 아니면 카드를 숨긴다.
*/
function renderAnalysis(site) {
const card = $('analysisCard'); const existing = site && (site.mode === 'import' || site.mode === 'copy');
if (!existing) { card.classList.add('hidden'); return; }
card.classList.remove('hidden'); const body = $('anBody'); body.innerHTML = '';
const a = site.analysis; const busy = site.status === 'importing';
$('anAt').textContent = a && a.at ? `(${new Date(a.at).toLocaleString(uiLocale(), { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })})` : '';
$('btnReanalyze').disabled = busy || !!site.running;
const line = (label, text) => { if (!text) return; const d = document.createElement('div'); d.className = 'an-line'; const b = document.createElement('b'); b.textContent = label; d.appendChild(b); d.appendChild(document.createTextNode(' ' + text)); body.appendChild(d); };
const list = (label, items) => { if (!items || !items.length) return; const h = document.createElement('div'); h.className = 'field-label'; h.textContent = label; body.appendChild(h); const ul = document.createElement('ul'); ul.className = 'an-list'; for (const it of items) { const li = document.createElement('li'); li.textContent = String(it); ul.appendChild(li); } body.appendChild(ul); };
if (!a) { line('', busy ? (site.mode === 'copy' ? '사이트를 복사하는 중입니다. 끝나면 현황을 분석합니다.' : '사이트를 가져오는 중입니다. 끝나면 현황을 분석합니다.') : '아직 분석하지 않았습니다. "다시 분석"을 누르세요.'); return; }
const st = a.stats || {};
line('파일', [TF('{1}개', fmt(st.files)), fmtBytes(st.bytes), TF('HTML 페이지 {1}개', fmt(st.pageCount)), st.newestAt ? TF('최근 수정 {1}', new Date(st.newestAt).toLocaleDateString(uiLocale())) : ''].filter(Boolean).join(' · '));
if ((st.byExt || []).length) line('종류', st.byExt.slice(0, 8).map((x) => `${x.ext} ${x.n}`).join(', '));
if ((st.markers || []).length) line('감지된 기술', st.markers.join(', '));
if (a.server) line('서버', [a.server.webServers && a.server.webServers.length ? '웹서버 ' + a.server.webServers.join(', ') : '', a.server.vhost && a.server.vhost.names && a.server.vhost.names.length ? '도메인 ' + a.server.vhost.names.join(', ') : '', a.server.vhost && a.server.vhost.conf ? '설정 ' + a.server.vhost.conf : '', a.server.docroot ? '경로 ' + a.server.docroot : ''].filter(Boolean).join(' · '));
if (a.copy) line('복사', [TF('{1} 에서 페이지 {2}개', a.copy.from, a.copy.pages), TF('파일 {1}개', a.copy.assets), a.copy.skipped ? TF('상한으로 {1}개 건너뜀', a.copy.skipped) : '', a.copy.errors ? TF('가져오지 못한 주소 {1}개', a.copy.errors) : '', a.copy.dynamic ? T('서버 실행 페이지는 결과 화면만 복사됨') : ''].filter(Boolean).join(' · '));
const ai = a.ai;
if (!ai) { const d = document.createElement('div'); d.className = 'muted an-wait'; d.textContent = 'AI 가 사이트 구성을 요약하는 중입니다…'; body.appendChild(d); return; }
if (ai.error || ai.skipped) { const d = document.createElement('div'); d.className = 'muted'; d.textContent = ai.skipped || ('AI 요약을 만들지 못했습니다: ' + ai.error); body.appendChild(d); return; }
if (ai.summary) { const p = document.createElement('p'); p.className = 'an-summary'; p.textContent = ai.summary; body.appendChild(p); }
line('수정 난이도', ai.editability);
list('구성', ai.structure); list('사용 기술', ai.tech); list('눈에 띄는 문제', ai.issues); list('개선 제안', ai.suggestions);
}
$('btnReanalyze').onclick = async () => {
if (!current) return; $('btnReanalyze').disabled = true;
try { const r = await api(`/api/sites/${current.id}/analyze`, { method: 'POST', body: '{}' }); if (r && r.site) { current = r.site; } renderAnalysis(current); }
catch (e) { alert('분석 실패: ' + e.message); $('btnReanalyze').disabled = false; }
};
// ---------- 사이트 정보: 접속 통계 · 네트워크 · 저장 공간 ----------
/** 바이트를 읽기 쉬운 단위로 */
function fmtBytes(n) { n = n || 0; if (n < 1024) return `${n} B`; if (n < 1048576) return `${(n / 1024).toFixed(1)} KB`; if (n < 1073741824) return `${(n / 1048576).toFixed(1)} MB`; return `${(n / 1073741824).toFixed(2)} GB`; }
let statsData = null;
/**
* GET /api/sites/:id/stats 결과. v1.7.2 (요구 2): 자세한 표·그래프는 오른쪽 캔버스의 사이트 정보 페이지(public/site-info.js)가 그리고,
* 가운데 칸의 카드에는 요약 타일(오늘·최근 7일·이번 달 송신·디스크)만 남긴다. 통계 자료는 statsData 에 두고 ManageUI.onStats 로 페이지를 갱신한다.
*/
function renderStats(st) {
if (!current) return; statsData = st; $('siteInfoCard').classList.remove('hidden');
const useLog = st.sources.log; const pick = (a) => (useLog ? { v: a.visitors, pv: a.pv } : { v: a.beaconVisitors, pv: a.beaconPv });
$('statsUpdated').textContent = st.updatedAt ? `(집계 ${new Date(st.updatedAt).toLocaleString(uiLocale(), { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })})` : '(아직 집계된 접속이 없습니다)';
const grid = $('statVisits'); grid.innerHTML = '';
const t = (label, v, d) => { const x = document.createElement('div'); x.className = 'tot'; const k = document.createElement('span'); k.className = 'k'; k.textContent = label; const vv = document.createElement('span'); vv.className = 'v'; vv.textContent = v; const dd = document.createElement('span'); dd.className = 'd'; dd.textContent = d || ''; x.appendChild(k); x.appendChild(vv); x.appendChild(dd); grid.appendChild(x); };
const today = pick(st.today || {}); const l7 = pick(st.last7 || {}); const mo = st.month || {};
t(T('오늘'), `${fmt(today.v)}${T('명')}`, `${T('페이지뷰')} ${fmt(today.pv)}`); t(T('최근 7일'), `${fmt(l7.v)}${T('명')}`, `${T('페이지뷰')} ${fmt(l7.pv)}`);
t(T('이번 달 송신'), useLog ? fmtBytes(mo.bytesOut) : (mo.beaconBytesOut ? fmtBytes(mo.beaconBytesOut) + ' ' + T('(추정)') : '-'), useLog ? `${T('수신')} ${fmtBytes(mo.bytesIn)}` : '');
const d = st.disk || {}; t(T('디스크'), fmtBytes((d.web != null ? d.web : d.total || 0) + ((st.db || {}).total || 0) + (d.mail || 0)), `${T('웹')} ${fmtBytes(d.web != null ? d.web : d.total || 0)} · DB ${fmtBytes((st.db || {}).total || 0)}${d.mail != null ? ' · ' + T('메일') + ' ' + fmtBytes(d.mail) : ''}`);
}
async function loadStats() { if (!current) return; try { renderStats(await api(`/api/sites/${current.id}/stats`)); if (window.ManageUI) ManageUI.onStats(statsData); } catch (e) { $('statsUpdated').textContent = '(통계를 불러오지 못했습니다)'; } }
$('btnStatsReload').onclick = loadStats;
// v1.7.2: 가운데 요약 카드 → 운영 화면의 오른쪽 사이트 정보 페이지
{ const b = $('btnSiteInfoOpen'); if (b) b.onclick = () => { showTab('manage').then(() => { if (window.ManageUI) ManageUI.openInfo(); }); }; }
// ---------- 문의 접수 내역 ----------
async function renderInquiries() {
if (!current) return; $('inquiryCard').classList.remove('hidden');
try {
const r = await api(`/api/sites/${current.id}/inquiries`); const ul = $('inquiryList'); ul.innerHTML = '';
$('inquiryCount').textContent = r.inquiries.length ? `${r.inquiries.length}건` : '없음';
if (window.ManageUI) ManageUI.onInquiries(r); // v1.4.14 관리 창 현황(문의 건수·미발송)
$('inquiryNote').textContent = r.contactEmail ? `수신 이메일: ${r.contactEmail} · 사이트의 문의 폼에서 보낸 내용이 이 주소로 메일 발송됩니다.` : '문의 수신 이메일이 없습니다. "홈페이지 기본 사항"에서 설정해야 문의 메일이 발송됩니다(접수 자체는 여기에 기록됩니다).';
for (const q of r.inquiries.slice(0, 50)) { const li = document.createElement('li'); const k = document.createElement('span'); k.className = 'kind ' + (q.sent ? 'create' : 'manual-edit'); k.textContent = q.sent ? '발송됨' : (q.error ? '미발송' : '접수'); const b = document.createElement('span'); b.className = 'bname'; b.style.fontFamily = 'inherit'; b.style.whiteSpace = 'normal'; b.textContent = `${new Date(q.at).toLocaleString(uiLocale(), { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })} · ${q.name} (${q.email}${q.phone ? ', ' + q.phone : ''}) · ${q.subject || (q.kind === 'booking' ? '예약 문의' : '문의')}: ${q.message.slice(0, 80)}${q.message.length > 80 ? '…' : ''}`; b.title = q.message + (q.error ? '\n[미발송] ' + q.error : ''); li.appendChild(k); li.appendChild(b); if (!q.sent) { const idx = r.inquiries.length - 1 - r.inquiries.indexOf(q); const rb = document.createElement('button'); rb.className = 'ghost small-btn'; rb.textContent = '재발송'; rb.title = q.error || '메일이 발송되지 않았습니다'; rb.onclick = async () => { rb.disabled = true; try { await api(`/api/sites/${current.id}/inquiries/${idx}/resend`, { method: 'POST', body: '{}' }); renderInquiries(); } catch (e) { alert(e.message); rb.disabled = false; } }; li.appendChild(rb); } ul.appendChild(li); }
} catch (e) { $('inquiryNote').textContent = '접수 내역을 불러오지 못했습니다: ' + e.message; }
}
$('btnInquiryReload').onclick = renderInquiries;
// ---------- 버전(되돌리기) ----------
const VER_KIND = { create: '생성', 'ai-update': 'AI 수정', 'manual-edit': '소스 편집', 'visual-edit': '직접 편집', restore: '복원', deploy: '배포본', import: '가져옴' }; // v1.8.0 visual-edit: 시각 편집기 저장
const CMD_STATUS = { running: '실행 중', done: '완료', error: '오류', cancelled: '중단', interrupted: '끊김' };
function renderGit(site) {
const list = site.versions || []; $('versionsCard').classList.remove('hidden');
$('versionsCount').textContent = list.length ? `${list.length}개` : '없음';
const ul = $('versionsList'); ul.innerHTML = '';
for (const v of [...list].reverse()) {
const li = document.createElement('li');
const k = document.createElement('span'); k.className = 'kind ' + v.kind; k.textContent = VER_KIND[v.kind] || v.kind;
const b = document.createElement('span'); b.className = 'bname'; b.style.fontFamily = 'inherit'; b.textContent = `#${v.no} · ${new Date(v.at).toLocaleString(uiLocale(), { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })} · ${v.message}`; b.title = v.message;
const r = document.createElement('button'); r.className = 'ghost small-btn'; r.textContent = '되돌리기'; r.disabled = v.no === list[list.length - 1].no;
r.onclick = async () => {
if (!confirm(`버전 #${v.no} (${v.message}) 시점으로 되돌릴까요? 현재 상태도 버전으로 남습니다.`)) return;
r.disabled = true;
try { const s = await api(`/api/sites/${current.id}/versions/${v.no}/restore`, { method: 'POST', body: '{}' }); Object.assign(current, s); renderGit(current); renderPreview(current, true); renderUsage(current); if (window.Wizard) Wizard.onSiteOpened(current); if (ed.open && ed.file) loadEditFile(ed.file); }
catch (e) { alert('되돌리기 실패: ' + e.message); r.disabled = false; }
};
li.appendChild(k); li.appendChild(b); li.appendChild(r); ul.appendChild(li);
}
}
// ---------- 미리보기 ----------
function renderPreview(site, reload) {
const url = site.url; const live = site.liveUrl && site.liveUrl !== url ? site.liveUrl : null;
// 배포가 끝난 사이트는 32자리 미리보기 주소 대신 운영 서버 주소를 보여 준다 (미리보기 iframe 은 계속 내부 주소를 사용)
$('siteUrl').textContent = live ? `운영 주소 ${live}` : url; $('siteUrl').classList.toggle('live', !!live); $('siteUrl').title = live ? `미리보기 주소: ${url}` : '';
$('btnOpen').href = live || url; $('btnOpen').textContent = live ? '운영 주소 열기' : '새 탭에서 열기'; $('btnOpen').classList.remove('disabled');
$('btnReload').disabled = false; $('btnCopy').disabled = false; $('btnMobile').disabled = false;
const sel = $('fileSelect'); const prev = sel.value; sel.innerHTML = '';
const htmls = (site.files || []).filter((f) => f.endsWith('.html'));
for (const f of htmls) { const o = document.createElement('option'); o.value = f; o.textContent = f; sel.appendChild(o); }
if (htmls.includes(prev)) sel.value = prev; else if (htmls.includes('index.html')) sel.value = 'index.html';
sel.classList.toggle('hidden', htmls.length < 2);
const hasIndex = htmls.length > 0;
renderEmptyReason(site, hasIndex);
// v1.8.0: 직접 편집 중이면 미리보기를 덮어쓰지 않고 편집기에 알린다(저장하지 않은 변경 보호). 아니면 예전처럼 다시 읽는다.
// v1.8.0 검토 수정: HTML 페이지가 없어진 갱신도 편집기가 스스로 판단한다(onSiteUpdated — 변경이 있으면 묻고, 취소하면 캔버스를 그대로 둔다). 강제로 끝내지 않는다.
const veActive = !!(window.VisualEditor && VisualEditor.isActive());
if (veActive && (reload || !hasIndex)) VisualEditor.onSiteUpdated(site); else if (hasIndex && reload) loadFrame();
const veStill = !!(window.VisualEditor && VisualEditor.isActive());
$('emptyState').classList.toggle('hidden', hasIndex || veStill);
$('frame').classList.toggle('hidden', !hasIndex && !veStill);
$('btnVisual').disabled = !hasIndex && !veStill; // v1.8.0 직접 편집은 HTML 페이지가 있을 때만(편집 중이면 끝내기 버튼으로 남는다)
}
/**
* 미리보기할 HTML 이 없을 때의 안내 (v1.3.3). 1.3.2 까지는 가져오기·복사가 실패했거나 진행 중이어도 "새 사이트를 만들면…"
* 기본 안내만 보여, 무엇이 문제인지 알 수 없었다. 가져온·복사한 사이트는 상태에 맞는 이유를 보여 준다.
*/
let emptyDefaultHtml = null;
/**
* 사용자 첫 화면 페이지 (v1.4.5, v1.4.15 정리).
* "첫 화면" 은 서비스 홈페이지(로그인 화면)가 아니라 **빌더 오른쪽 창의 빈 화면** 이다 — 로그인한 사용자가 아직 아무 사이트도
* 고르지(만들지) 않아 왼쪽 작업 영역이 비어 있을 때, 오른쪽에 "미리보기 없음" 대신 관리자가 고른 페이지를 보인다.
* 사이트를 고르면 그 사이트 미리보기로 바뀌고, 관리 탭에서는 사이트 관리 창이 대신 보인다. 로그인·관리자 콘솔·생성 사이트에는 영향이 없다.
* 1.4.14 까지는 빈 화면 안내(#emptyState)의 내용을 iframe 으로 바꿔치기했는데, 이제 전용 상자 #welcomeBox 에 두고 켜고 끈다
* (sandbox: allow-same-origin 없음 — 그 페이지의 스크립트가 빌더 세션·저장소를 쓰지 못한다, v1.3.17 S02 와 같은 원칙).
* @param {boolean} on 보일지 여부
*/
function showWelcome(on) {
const box = $('welcomeBox'); const fr = $('welcomeFrame'); const wp = config && config.welcomePage;
if (!on || !wp || !wp.url) { box.classList.add('hidden'); if (fr.getAttribute('src')) fr.removeAttribute('src'); return; }
fr.title = wp.title || T('첫 화면'); if (fr.getAttribute('src') !== wp.url) fr.src = wp.url; box.classList.remove('hidden');
}
function renderEmptyReason(site, hasIndex) {
const box = $('emptyState'); if (emptyDefaultHtml === null) emptyDefaultHtml = box.innerHTML;
// 사이트를 고르기 전(첫 접속·새 사이트 만들기 화면)에만 첫 화면 페이지. 사이트가 있으면 그 사이트의 빈 화면 이유를 보인다
const welcome = !site && !hasIndex && !!(config && config.welcomePage && config.welcomePage.url);
showWelcome(welcome);
if (welcome) { box.innerHTML = ''; return; }
if (hasIndex || !site || !['import', 'copy'].includes(site.mode)) { box.innerHTML = emptyDefaultHtml; return; }
const what = site.mode === 'copy' ? '복사' : '가져오기';
const files = site.files || []; const php = files.find((f) => /^index\.(php|jsp|aspx?)$/i.test(f));
let title, msg;
if (site.status === 'importing' || site.running) { title = `사이트 ${what} 중`; msg = '끝나면 여기에 미리보기가 표시됩니다. 진행 상황은 왼쪽 위 진행 상태에서 볼 수 있습니다.'; }
else if (site.importError) { title = `사이트 ${what}에 실패했습니다`; msg = site.importError; }
else if (php) { title = '미리보기할 수 없는 첫 페이지'; msg = `첫 페이지가 ${php} (서버에서 실행되는 프로그램)라 이 미리보기에서는 실행되지 않습니다. 위쪽 "운영 주소 열기" 로 원래 사이트에서 확인하세요. 파일 수정과 원본 서버 반영은 그대로 할 수 있습니다.`; }
else { title = '미리보기할 HTML 파일이 없습니다'; msg = files.length ? `가져온 파일 ${files.length}개 중 HTML 이 없습니다. 사이트 경로(문서 루트)가 맞는지 확인하세요.` : `가져온 파일이 없습니다. 사이트 경로(문서 루트)가 맞는지 확인하고 다시 ${what} 해 보세요.`; }
box.innerHTML = ''; const inner = document.createElement('div'); inner.className = 'empty-inner'; const h = document.createElement('h3'); h.textContent = title; const p = document.createElement('p'); p.textContent = msg; inner.appendChild(h); inner.appendChild(p); box.appendChild(inner);
}
function loadFrame() {
if (!current) return;
const f = $('fileSelect').value || 'index.html';
$('frame').removeAttribute('srcdoc');
$('frame').src = current.url + f + '?t=' + Date.now();
}
// ---------- 소스 편집기 ----------
const TEXT_EXT = ['html', 'htm', 'css', 'js', 'mjs', 'json', 'txt', 'md', 'svg', 'xml', 'csv', 'yaml', 'yml'];
const ed = { open: false, file: null, saved: '', dirty: false, timer: null };
const isText = (f) => TEXT_EXT.includes((f.split('.').pop() || '').toLowerCase());
function setEditorOpen(open) {
ed.open = open;
$('editorPane').classList.toggle('hidden', !open); $('splitter').classList.toggle('hidden', !open);
$('btnToggleSource').classList.toggle('active', open);
$('btnToggleSource').textContent = open ? '소스 닫기' : '소스 보기';
try { localStorage.setItem('sourceOpen', open ? '1' : '0'); } catch (_) {}
if (open && current) { fillEditFiles(); if (!ed.file) loadEditFile($('fileSelect').value || 'index.html'); }
}
function fillEditFiles() {
const sel = $('editFile'); const prev = ed.file || sel.value; sel.innerHTML = '';
const files = (current.files || []).filter(isText);
for (const f of files) { const o = document.createElement('option'); o.value = f; o.textContent = f; sel.appendChild(o); }
if (files.includes(prev)) sel.value = prev;
}
async function loadEditFile(rel) {
if (!current || !rel) return;
if (ed.dirty && !confirm('저장하지 않은 변경이 있습니다. 버리고 다른 파일을 열까요?')) { $('editFile').value = ed.file; return; }
try {
const r = await api(`/api/sites/${current.id}/files/${encodeURIComponent(rel)}`);
ed.file = rel; ed.saved = r.content; ed.dirty = false; ed.hash = r.hash || null; // v1.8.0 검토 수정: 저장 기준 hash — 다른 곳이 먼저 저장했으면 서버가 409 로 알린다
$('editor').value = r.content; $('editFile').value = rel; updateLineNos(); setDirty(false);
} catch (e) { alert('파일 열기 실패: ' + e.message); }
}
function setDirty(d) {
ed.dirty = d;
const el = $('editState'); el.textContent = d ? '변경됨 (미저장)' : '저장됨'; el.classList.toggle('dirty', d);
$('btnSave').disabled = !d; $('btnRevert').disabled = !d;
}
function updateLineNos() {
const n = $('editor').value.split('\n').length;
let s = ''; for (let i = 1; i <= n; i++) s += i + '\n';
$('lineNos').textContent = s; $('lineNos').scrollTop = $('editor').scrollTop;
}
/** 편집 중인 HTML 을 저장 없이 미리보기에 반영 (srcdoc + <base>) */
function applyLivePreview() {
if (!current || !ed.file || !$('liveApply').checked) return;
const ext = (ed.file.split('.').pop() || '').toLowerCase();
if (ext !== 'html' && ext !== 'htm') return; // css/js 는 저장 후 새로고침으로 반영
if ($('fileSelect').value !== ed.file) { $('fileSelect').value = ed.file; }
// v1.3.17 (B23): 기준 주소는 편집 중인 파일이 있는 폴더 (en/about.html → /<id>/en/) — 상대 경로 자원·링크가 저장된 페이지와 같게 풀린다
const dir = ed.file.includes('/') ? ed.file.slice(0, ed.file.lastIndexOf('/') + 1) : '';
const base = `<base href="${current.url}${dir.split('/').map(encodeURIComponent).join('/')}">`;
let html = $('editor').value;
if (/<head[^>]*>/i.test(html)) html = html.replace(/<head[^>]*>/i, (m) => m + base);
else html = base + html;
$('frame').removeAttribute('src'); $('frame').srcdoc = html;
}
async function saveEditFile() {
if (!current || !ed.file || !ed.dirty) return;
$('btnSave').disabled = true;
// v1.3.17 (B22): 보낸 내용·사이트·파일을 붙잡아 두고, 응답 뒤에는 그 내용만 "저장됨" 으로 본다 (저장 중에 더 친 글자는 저장 안 됨으로 남김)
const sent = { siteId: current.id, file: ed.file, content: $('editor').value, baseHash: ed.hash || undefined };
try {
// v1.8.0 검토 수정: baseHash(파일을 열 때 받은 hash)를 함께 보낸다 — 직접 편집·AI 수정·다른 창이 먼저 저장했으면 서버가 409(stale) 로 거절해 조용히 덮어쓰지 않는다
const r = await api(`/api/sites/${sent.siteId}/files/${encodeURIComponent(sent.file)}`, { method: 'PUT', body: JSON.stringify({ content: sent.content, baseHash: sent.baseHash }) });
if (!current || current.id !== sent.siteId || ed.file !== sent.file) return; // 그 사이 다른 사이트·파일로 바뀜
ed.saved = sent.content; if (r.hash) ed.hash = r.hash; setDirty($('editor').value !== sent.content); if (ed.dirty) $('btnSave').disabled = false;
if (r.files) { current.files = r.files; fillEditFiles(); }
if (r.git) { current.git = r.git; renderGit(current); }
$('frame').removeAttribute('srcdoc'); loadFrame();
} catch (e) {
$('btnSave').disabled = false;
if (e.status === 409 && e.body && e.body.stale) { if (confirm(T('다른 곳(직접 편집·AI 수정·다른 창)에서 이 파일을 먼저 저장했습니다. 최신 내용을 다시 불러올까요? (지금 편집기의 내용은 사라집니다. "취소" 를 누르면 편집기에 남겨 둡니다)'))) { ed.dirty = false; loadEditFile(sent.file); } return; }
alert('저장 실패: ' + e.message);
}
}
$('editor').addEventListener('input', () => {
setDirty($('editor').value !== ed.saved); updateLineNos();
clearTimeout(ed.timer); ed.timer = setTimeout(applyLivePreview, 400);
});
$('editor').addEventListener('scroll', () => { $('lineNos').scrollTop = $('editor').scrollTop; });
$('editor').addEventListener('keydown', (e) => {
if (e.key === 'Tab') { e.preventDefault(); const t = e.target, st = t.selectionStart, en = t.selectionEnd; t.value = t.value.slice(0, st) + ' ' + t.value.slice(en); t.selectionStart = t.selectionEnd = st + 2; t.dispatchEvent(new Event('input')); }
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') { e.preventDefault(); saveEditFile(); }
});
$('btnSave').onclick = saveEditFile;
$('btnRevert').onclick = () => { if (!ed.dirty || confirm('변경 내용을 버리고 저장된 내용으로 되돌릴까요?')) { $('editor').value = ed.saved; setDirty(false); updateLineNos(); $('frame').removeAttribute('srcdoc'); loadFrame(); } };
$('editFile').onchange = (e) => loadEditFile(e.target.value);
$('btnToggleSource').onclick = () => { if (!ed.open && window.VisualEditor && VisualEditor.isActive() && !VisualEditor.close(false)) return; setEditorOpen(!ed.open); }; // v1.8.0 소스 편집기와 직접 편집은 한 번에 하나만 (미리보기 iframe 을 함께 쓴다)
$('liveApply').onchange = () => { if ($('liveApply').checked) applyLivePreview(); else { $('frame').removeAttribute('srcdoc'); loadFrame(); } };
// 분할 크기 드래그
(() => {
const sp = $('splitter'), pane = $('editorPane');
let dragging = false;
sp.addEventListener('mousedown', (e) => { dragging = true; e.preventDefault(); document.body.style.cursor = 'row-resize'; $('frame').style.pointerEvents = 'none'; });
window.addEventListener('mousemove', (e) => { if (!dragging) return; const box = $('frame').parentElement.getBoundingClientRect(); const h = Math.min(Math.max(box.bottom - e.clientY, 120), box.height - 120); pane.style.height = h + 'px'; });
window.addEventListener('mouseup', () => { if (!dragging) return; dragging = false; document.body.style.cursor = ''; $('frame').style.pointerEvents = ''; });
})();
window.addEventListener('beforeunload', (e) => { if (ed.dirty || (window.VisualEditor && VisualEditor.isDirty())) { e.preventDefault(); e.returnValue = ''; } }); // v1.8.0 직접 편집 변경도 보호
// ---------- 사이트 로드 / 이벤트 ----------
function connectEvents(id) {
if (es) es.close();
es = new EventSource(`/api/sites/${id}/events`);
es.onmessage = (m) => {
const ev = JSON.parse(m.data);
if (ev.type === 'log') { logLine(ev); if (/문의 접수|문의가 접수/.test(ev.text || '')) renderInquiries(); }
else if (ev.type === 'progress') setProgressMsg(ev.text);
else if (ev.type === 'usage') renderRunTokens(ev.usage, false, ev.krwEstimate, ev.creditLeft);
else if (ev.type === 'status') setStatus(ev.status, ev.label, ev.startedAt, ev.deploy);
else if (ev.type === 'deploy') { prog.deploy = true; prog.deployResult = ev; renderProgress(); pushDeployMsg(ev); } // v1.5.31: 결과 상자 대신 메시지창
else if (ev.type === 'plan') { if (window.Wizard) Wizard.onPlanEvent(ev); }
else if (ev.type === 'done') {
current = ev.site;
if (ev.deploy) setStatus(ev.deploy.ok ? 'done' : 'error', prog.label, null, true); else setStatus(!ev.command || ev.command.status === 'done' ? 'done' : (ev.command.status === 'cancelled' ? 'cancelled' : 'error'));
if (ev.command && ev.command.usage) renderRunTokens(ev.command.usage, true, ev.command.chargeKrw, ev.site && ev.site.credit ? ev.site.credit.balance : undefined);
if (ev.site && ev.site.credit) renderCredit(ev.site.credit);
if (ev.site && ev.site.status === 'waiting-credit') setStatus('waiting-credit', prog.label);
if (ev.command && ev.command.status === 'done') setStep(4);
fillEditTier(current); if (ev.command && ev.command.kind === 'update') renderDoneBanner(current, ev.command);
renderUsage(current); renderAttachments(current); renderGit(current); renderPreview(current, true); refreshSiteList(); loadStats(); loadLedger(); renderSiteMeta(current); renderEditLimit(current); renderAnalysis(current); renderMailLine(current);
if (!basicsDirty()) renderBasics(current); else updateBasicsState();
if (window.Wizard) Wizard.onSiteOpened(current);
if (window.Importer) Importer.onSiteOpened(current);
if (window.OptimizeUI) OptimizeUI.onSiteOpened(current);
if (window.ManageUI) ManageUI.onSiteOpened(current); // v1.4.14 관리 창(현황·관리자 페이지) 갱신
if (window.FlowUI) FlowUI.onSite(current); // v1.6.40 작업 흐름·배지 갱신
if (ed.open) { fillEditFiles(); if (ed.file && !ed.dirty) loadEditFile(ed.file); else if (ed.file && ed.dirty) logLine({ level: 'warn', text: `AI 가 파일을 수정했습니다. 편집기의 ${ed.file} 은 저장되지 않은 이전 내용입니다. 되돌리기로 최신 내용을 불러올 수 있습니다.` }); }
}
};
es.onerror = () => { /* 브라우저가 자동 재접속 */ };
}
async function openSite(id, opts = {}) {
// v1.8.0 검토 수정: 다른 사이트로 바꾸기 **전에** 직접 편집의 저장하지 않은 변경을 묻는다(여기가 모든 호출 경로의 마지막 방어선 — 홈·탭·선택 상자·다른 모듈의 openSite 포함).
// 취소하면 아무것도 바꾸지 않고(현재 사이트·편집기 유지) 지금 사이트를 돌려준다. 승낙하면 편집기를 끝낸 뒤 진행한다.
if (window.VisualEditor && VisualEditor.isActive() && VisualEditor.state().siteId !== id) { if (!VisualEditor.close(false)) { syncPicker(); return current; } }
const site = await api(`/api/sites/${id}`);
current = site;
const prog_ = inProgress(site);
if (prog_) { createId = id; try { localStorage.setItem('createSiteId', id); } catch (_) {} }
else if (opts.tab !== 'create') { manageId = id; try { localStorage.setItem('currentSiteId', id); } catch (_) {} }
if (window.Wizard) Wizard.hideStart();
$('siteTitle').textContent = site.title; $('siteId').textContent = site.id;
renderSiteMeta(site); renderEditLimit(site); renderAnalysis(site); renderMailLine(site);
// 열 탭: 지정 > 생성 중이면 생성 > 관리 탭을 보고 있었으면 관리 > 그 밖은 수정 (v1.4.14: 완성 사이트의 기본 탭은 "수정")
// v1.6.40: 완성 사이트는 보고 있던 사이트 화면(홈·수정·배포·운영)에 머무르고, 그 밖의 화면에서 골랐으면 "수정" 으로
const wantTab = opts.tab || (prog_ ? 'create' : (['manage', 'deploy', 'home'].includes(activeTab) ? activeTab : 'edit'));
if (activeTab !== wantTab) { applyTabView(wantTab); try { localStorage.setItem('activeTab', wantTab); } catch (_) {} }
for (const id of ['attachCard', 'versionsCard', 'usageCard', 'siteInfoCard', 'inquiryCard', 'moduleCard']) $(id).classList.remove('hidden');
$('updateCard').classList.toggle('hidden', !!prog_); $('btnDeleteSite').classList.remove('hidden'); $('siteDangerCard').classList.remove('hidden');
fillEditTier(site);
prog.state = 'idle'; prog.label = ''; prog.deploy = false; prog.deployResult = null; clearMsgs(); /* v1.5.27: 사이트가 바뀌면 메시지창 비움 */
if (site.deploying) setStatus('running', site.deploying.label, site.deploying.startedAt, true); else setStatus(site.running ? 'running' : (site.status === 'waiting-credit' ? 'waiting-credit' : 'idle'));
if (site.credit) renderCredit(site.credit);
if (!site.running && $('btnCancel')) $('btnCancel').classList.add('hidden');
const lastCmd = [...(site.commands || [])].reverse().find((c) => c.usage); renderRunTokens(lastCmd ? lastCmd.usage : null, true, lastCmd ? lastCmd.chargeKrw : null, site.credit ? site.credit.balance : undefined);
renderUsage(site); renderAttachments(site); renderGit(site); renderBasics(site); renderPreview(site, true); connectEvents(id); fillTiers(); renderInquiries(); loadStats();
{ const lastUpd = [...(site.commands || [])].reverse().find((c) => c.kind === 'update'); if (lastUpd && !site.running && Date.now() - new Date(lastUpd.finishedAt || 0).getTime() < 6 * 3600000) renderDoneBanner(site, lastUpd); else $('doneBanner').classList.add('hidden'); }
if (window.Wizard) Wizard.onSiteOpened(site);
if (window.Importer) Importer.onSiteOpened(site);
syncPicker();
if (window.ModulesUI) ModulesUI.onSiteOpened(site);
if (window.OptimizeUI) OptimizeUI.onSiteOpened(site); // v1.3.5 검색·AI 최적화·검색사이트 등록
if (window.TemplatesUI) TemplatesUI.onSiteOpened(site); // v1.3.10 관리자: 템플릿으로 등록
if (window.ManageUI) ManageUI.onSiteOpened(site); // v1.4.14 관리 탭 오른쪽 사이트 관리 창
if (window.FlowUI) FlowUI.onSite(site); // v1.6.40 레일 상태 배지·홈 작업 흐름·배포 점검표
if ($('paneSite')) $('paneSite').textContent = SITE_TABS.includes(activeTab) || activeTab === 'home' ? site.title : '';
$('btnToggleSource').disabled = false; clearPending('update');
ed.file = null; ed.dirty = false; $('editor').value = ''; setDirty(false); updateLineNos();
let wantOpen = false; try { wantOpen = localStorage.getItem('sourceOpen') === '1'; } catch (_) {}
setEditorOpen(wantOpen);
// v1.8.0: 사이트가 바뀌면 직접 편집은 끝낸다(저장하지 않은 변경은 위에서 close(false) 로 이미 물었다 — 여기 남아 있으면 변경이 없는 경우뿐). 같은 사이트를 다시 열 때는 그대로 둔다
if (window.VisualEditor && VisualEditor.isActive() && VisualEditor.state().siteId !== site.id) VisualEditor.close(false);
}
/** 위쪽 사이트 선택 아래: 사이트 상태 배지와 운영 주소 (모든 탭 공통, 높이 고정) */
function renderSiteMeta(site) {
$('ssSite').textContent = site ? `${site.title}` : '';
const pill = $('siteStatePill'); const p = site.plan;
let txt = '생성 완료', cls = 'done';
if (site.mode === 'import') { txt = site.status === 'importing' ? '가져오는 중' : (site.source && site.source.lastPushAt ? '원본 반영됨' : '가져옴'); if (site.status === 'importing') cls = 'running'; }
else if (site.mode === 'copy') { txt = site.status === 'importing' ? '복사 중' : (site.importError && !site.copyInfo ? '복사 실패' : (p && p.stage === 'published' ? '발행됨' : '복사함')); if (site.status === 'importing') cls = 'running'; }
else if (p && !['generated', 'published'].includes(p.stage)) { txt = '생성 중'; cls = 'running'; }
else if (p && p.stage === 'published') { txt = '발행됨'; }
else if (site.editLimit && site.editLimit.locked) { txt = '발행 대기'; cls = 'waiting'; }
pill.textContent = txt; pill.className = 'pill small ' + cls;
$('ssSite').textContent = `${site.title} · ${T(txt)}`;
$('siteLiveLine').textContent = (site.liveUrl ? TF('운영 주소: {1}', site.liveUrl) : T('아직 배포되지 않았습니다 (미리보기 주소만 사용 가능)')) + (site.mode === 'copy' && site.copyFrom ? ' · ' + TF('복사 원본: {1}', site.copyFrom) : '');
$('siteMetaBox').classList.remove('hidden', 'no-site');
renderManageHint();
}
window.showTab = showTab; // v1.6.40: flow-ui.js·credits-ui.js 가 화면을 옮길 때 쓴다 (wizard.js 는 이미 전역으로 호출)
/**
* 메일 시스템 상태 줄 (v1.3.2, 관리 탭 공통 기능 카드). 운영자가 이 사이트의 메일 시스템을 켜지 않았으면 숨긴다.
* setting-up 은 "메일시스템 설정중"(DNS 전파·인증 대기), ready/send-only 는 "사용 가능" 과 웹메일 열기.
*/
function renderMailLine(site) {
const m = site && site.mail; const box = $('mailLine'); if (!box) return;
if (!m || m.level === 'off') { box.classList.add('hidden'); return; }
box.classList.remove('hidden'); $('mailLabel').textContent = m.label; $('mailLabel').className = 'pill small ' + (m.level === 'setting-up' ? 'waiting' : 'done');
$('mailMsg').textContent = m.level === 'setting-up' ? `${m.domain} — 도메인 연결(DNS) 확인을 기다리는 중입니다` : `${m.domain} — 아이디@${m.domain}`;
$('mailOpen').classList.toggle('hidden', !m.webmailUrl || m.level === 'setting-up'); if (m.webmailUrl) $('mailOpen').href = m.webmailUrl;
}
/** AI에게 맡기기 수정 횟수 한도 표시·잠금 */
function renderEditLimit(site) {
const lim = site.editLimit; const note = $('editLimitNote'); const box = $('editLockBox');
if (!lim || !lim.max) { note.textContent = ''; box.classList.add('hidden'); $('btnUpdate').disabled = false; $('fInstruction').disabled = false; return; }
note.textContent = `수정 ${lim.used}/${lim.max}회`;
box.classList.toggle('hidden', !lim.locked); $('fInstruction').disabled = !!lim.locked; if (lim.locked) $('btnUpdate').disabled = true;
box.textContent = lim.locked ? `수정 횟수 한도(${lim.max}회)에 도달해 발행 대기 상태입니다. 아래 "발행 · 배포"에서 발행한 뒤 다시 수정할 수 있습니다.` : '';
}
function resetToNew(clearManage = true) {
current = null; if (es) es.close();
if (clearManage) { manageId = null; localStorage.removeItem('currentSiteId'); }
if (window.Wizard) Wizard.showModeChooser(); $('updateCard').classList.add('hidden'); $('planCard').classList.add('hidden'); $('importCard').classList.add('hidden'); $('publishCard').classList.add('hidden'); $('moduleCard').classList.add('hidden'); $('usageCard').classList.add('hidden'); $('optimizeCard').classList.add('hidden'); $('searchCard').classList.add('hidden'); $('tplRegisterCard').classList.add('hidden');
$('siteTitle').textContent = ''; $('siteId').textContent = ''; $('ssSite').textContent = ''; $('btnDeleteSite').classList.add('hidden'); $('siteDangerCard').classList.add('hidden'); $('analysisCard').classList.add('hidden'); renderSiteMetaEmpty(); renderManageHint(); $('btnMobile').disabled = true;
$('langCard').classList.add('hidden'); if (window.ManageUI) ManageUI.onSiteOpened(null); // v1.4.14 관리 창 비우기
if (window.FlowUI) FlowUI.onSite(null); if ($('paneSite')) $('paneSite').textContent = ''; // v1.6.40
prog.state = 'idle'; prog.label = ''; prog.deploy = false; prog.deployResult = null; clearMsgs(); /* v1.5.27: 사이트가 바뀌면 메시지창 비움 */ setStatus('idle'); renderRunTokens(null, true, null, credit ? credit.balance : undefined); fillTiers();
renderUsage({ totals: {}, commands: [] }); $('attachCard').classList.add('hidden'); $('versionsCard').classList.add('hidden'); $('inquiryCard').classList.add('hidden'); $('siteInfoCard').classList.add('hidden');
$('siteUrl').classList.remove('live'); $('btnOpen').textContent = '새 탭에서 열기';
ed.file = null; ed.dirty = false; ed.open = false; $('editorPane').classList.add('hidden'); $('splitter').classList.add('hidden'); $('btnToggleSource').disabled = true; $('btnToggleSource').classList.remove('active'); $('btnToggleSource').textContent = '소스 보기';
if (window.VisualEditor && VisualEditor.isActive()) VisualEditor.close(true); $('btnVisual').disabled = true; // v1.8.0
$('siteUrl').textContent = '아직 생성된 페이지가 없습니다'; $('btnOpen').classList.add('disabled'); $('btnReload').disabled = true; $('btnCopy').disabled = true;
$('fileSelect').classList.add('hidden'); renderEmptyReason(null, false); $('emptyState').classList.remove('hidden'); $('frame').classList.add('hidden'); $('frame').src = 'about:blank';
$('siteSelect').value = '';
}
/**
* 상단 사이트 선택 상자 채우기 (v1.2.0).
* 맨 위는 "+ 새 사이트 만들기"(값 ''), 그 아래로 "생성 중 / 완성된 사이트 / 기존 사이트(가져옴·복사)" 묶음.
* 생성 중 여부는 목록 응답의 plan 단계(stage)로 판단한다 (inProgress 와 같은 기준).
*/
async function refreshSiteList() {
const list = await api('/api/sites').catch(() => []);
const sel = $('siteSelect');
sel.innerHTML = '<option value="">+ 새 사이트 만들기</option>';
const groups = [['생성 중', []], ['완성된 사이트', []], ['기존 사이트 (가져옴·복사)', []]];
for (const s of list) {
const existing = s.mode === 'import' || s.mode === 'copy';
const making = !existing && s.stage && !['generated', 'published'].includes(s.stage);
const state = existing ? (s.status === 'importing' ? (s.mode === 'copy' ? '복사 중' : '가져오는 중') : (s.mode === 'copy' ? '복사함' : '가져옴')) : (making ? '생성 중' : (s.stage === 'published' ? '발행됨' : '완성'));
const o = document.createElement('option'); o.value = s.id;
o.textContent = `${s.title} · ${T(state)} (${new Date(s.createdAt).toLocaleDateString(uiLocale())})`; // v1.3.10: 사이트 이름(사용자 데이터)과 섞이므로 상태 문구를 먼저 번역
groups[existing ? 2 : (making ? 0 : 1)][1].push(o);
}
for (const [label, opts] of groups) { if (!opts.length) continue; const g = document.createElement('optgroup'); g.label = label; opts.forEach((o) => g.appendChild(o)); sel.appendChild(g); }
syncPicker();
}
// ---------- 버튼 ----------
$('btnUpdate').onclick = async () => {
const instruction = $('fInstruction').value.trim();
if (!instruction) { alert('수정 지시를 입력하세요.'); return; }
$('btnUpdate').disabled = true;
try {
const fd = new FormData(); fd.append('instruction', instruction); fd.append('tier', $('editTierSelect').value || ''); appendFiles(fd, 'update');
const r = await api(`/api/sites/${current.id}/commands`, { method: 'POST', body: fd });
$('fInstruction').value = ''; clearPending('update'); setStatus('running', '수정 반영'); setProgressMsg('수정 지시를 보냈습니다. AI 가 파일을 확인하고 있습니다'); setStep(1);
if (current) current.tier = $('editTierSelect').value || current.tier;
if (r.attachments) { current.attachments = r.attachments; renderAttachments(current); }
} catch (e) { alert('수정 요청 실패: ' + e.message); $('btnUpdate').disabled = false; }
};
$('pfSave').onclick = async () => {
const name = $('pfName').value.trim(); if (!name) return ($('pfMsg').textContent = '이름(회사명)을 입력하세요');
try { const r = await api('/api/auth/me', { method: 'PUT', body: JSON.stringify({ name }) }); config.user = r.user; $('userName').textContent = r.user.name; $('pfMsg').textContent = '저장되었습니다'; } catch (e) { $('pfMsg').textContent = e.message; }
};
$('pwSave').onclick = async () => {
const cur = $('pwCur').value, n1 = $('pwNew').value, n2 = $('pwNew2').value;
// v1.3.17: 길이 규칙은 관리자 콘솔의 비밀번호 규칙을 서버가 검사한다(화면에 6자를 박아 두지 않음) — 비어 있을 때만 먼저 알린다
if (!n1) return ($('pwMsg').textContent = T('새 비밀번호를 입력하세요'));
if (n1 !== n2) return ($('pwMsg').textContent = '새 비밀번호 확인이 일치하지 않습니다');
try { await api('/api/auth/password', { method: 'POST', body: JSON.stringify({ current: cur, next: n1 }) }); $('pwMsg').textContent = '비밀번호가 변경되었습니다'; $('pwCur').value = $('pwNew').value = $('pwNew2').value = ''; }
catch (e) { $('pwMsg').textContent = e.message; }
};
$('btnCancel').onclick = async () => {
if (!current || !confirm('실행 중인 작업을 중단할까요? 지금까지 만들어진 파일은 유지되고, 사용한 토큰은 추정치로 기록됩니다.')) return;
$('btnCancel').disabled = true;
try { await api(`/api/sites/${current.id}/cancel`, { method: 'POST', body: '{}' }); }
catch (e) { if (/실행 중인 작업이 없습니다/.test(e.message)) { prog.state = 'idle'; setStatus('idle'); try { await openSite(current.id); } catch (_) {} } else alert('중단 실패: ' + e.message); }
$('btnCancel').disabled = false;
};
$('fAttachNow').onchange = (e) => { uploadNow([...e.target.files]); e.target.value = ''; };
$('btnReload').onclick = () => { if (window.VisualEditor && VisualEditor.isActive()) { VisualEditor.reload(false); return; } loadFrame(); }; // v1.8.0 직접 편집 중 새로고침 = 저장된 페이지 다시 불러오기(변경이 있으면 묻는다)
$('fileSelect').onchange = () => { if (window.VisualEditor && VisualEditor.isActive() && VisualEditor.onPageChange($('fileSelect').value)) return; loadFrame(); }; // v1.8.0 페이지 전환 때 저장하지 않은 직접 편집 보호
/**
* v1.4.17 (요구 1) 미리보기 안에서 페이지를 옮겨 다닐 때 위 도구 줄의 페이지 선택(#fileSelect)과 소스 편집기를 따라가게 한다.
* 미리보기 iframe 은 sandbox(allow-same-origin 없음)라 빌더가 iframe 의 주소를 읽을 수 없다. 대신 모든 생성 페이지에 들어 있는
* 접속 통계 런타임(/wm-stats.js)이 페이지를 열 때 부모 창에 {type:'wm-page', site, path, title} 을 보낸다. 여기서
* ① 보낸 창이 지금의 미리보기 iframe 인지(e.source), ② 사이트 ID 와 경로가 현재 사이트인지 확인한 뒤
* ③ 경로를 사이트 기준 상대 파일명으로 바꿔(/<ID>/ko/contact.html → ko/contact.html, 폴더 주소는 index.html, 확장자 없는 주소는 .html 보충)
* ④ 페이지 선택 상자 값을 바꾼다(값만 바꾸므로 onchange 가 다시 불리지 않아 iframe 을 또 읽지 않는다).
* ⑤ 소스 편집기가 열려 있고 저장하지 않은 변경이 없으면 그 파일을 편집기에도 연다(저장 안 한 변경이 있으면 편집기는 그대로 둔다).
* 운영 주소에서 열린 페이지나 첫 화면 페이지(welcomeFrame)가 보내는 메시지는 ①에서 걸러진다. 직접 편집 미리보기(srcdoc)는 경로가 사이트가 아니라 무시된다.
*/
window.addEventListener('message', (e) => {
const d = e.data; if (!d || d.type !== 'wm-page' || !current) return;
const fr = $('frame'); if (!fr || e.source !== fr.contentWindow) return;
if (d.site && d.site !== current.id) return;
let base; try { base = new URL(current.url, location.href).pathname; } catch (_) { return; }
const path = String(d.path || ''); if (!path.startsWith(base)) return;
let rel = decodeURIComponent(path.slice(base.length)); if (!rel || rel.endsWith('/')) rel += 'index.html';
const htmls = (current.files || []).filter((f) => f.endsWith('.html'));
if (!htmls.includes(rel)) { if (htmls.includes(rel + '.html')) rel += '.html'; else if (htmls.includes(rel + '/index.html')) rel += '/index.html'; else return; }
const sel = $('fileSelect'); if (sel.value !== rel) sel.value = rel;
if (ed.open && ed.file !== rel && !ed.dirty) loadEditFile(rel);
});
$('btnCopy').onclick = async () => { try { await navigator.clipboard.writeText(current.liveUrl || current.url); $('btnCopy').textContent = '복사됨'; setTimeout(() => ($('btnCopy').textContent = 'URL 복사'), 1500); } catch (_) { prompt('URL', current.url); } };
/**
* 상단 사이트 선택 (v1.2.0): 고른 사이트를 열면 아래 작업 영역과 오른쪽 미리보기가 함께 바뀐다.
* 생성 중인 사이트는 생성 탭, 완성·가져온·복사한 사이트는 수정 탭(관리 탭을 보고 있었으면 관리 탭)으로 자동 이동한다 (openSite 가 판단).
* "+ 새 사이트 만들기"는 진행 중인 생성을 지우지 않고(목록에 남음) 생성 탭의 방식 선택 화면만 연다.
*/
$('siteSelect').onchange = (e) => {
const id = e.target.value;
if (!discardOk(id || null)) { syncPicker(); return; }
if (id) { openSite(id).catch((err) => { alert(err.message); syncPicker(); }); return; }
createId = null; try { localStorage.removeItem('createSiteId'); } catch (_) {}
resetToNew(false); manageId = null; try { localStorage.removeItem('currentSiteId'); } catch (_) {}
showTab('create');
};
// ---- 사이트 폐기 (v1.1.0) ----
$('btnDeleteSite').onclick = async () => {
if (!current) return; const title = current.title || '';
const typed = prompt(`"${title}" 사이트를 완전히 폐기합니다. 배포된 파일·도메인 설정·버전·통계·문의 데이터가 모두 지워지며 되돌릴 수 없습니다.\n\n계속하려면 사이트 제목을 그대로 입력하세요:`);
if (typed === null) return; if (typed.trim() !== title.trim()) return alert('입력한 제목이 다릅니다. 폐기를 취소했습니다.');
try { await api(`/api/sites/${current.id}`, { method: 'DELETE', body: JSON.stringify({ confirm: typed }) }); if (createId === current.id) { createId = null; localStorage.removeItem('createSiteId'); } resetToNew(); await refreshSiteList(); alert('사이트를 폐기했습니다.'); }
catch (e) { alert('폐기 실패: ' + e.message); }
};
// ---- 모바일 미리보기 (v1.1.0) ----
$('btnMobile').onclick = () => { const on = !$('previewArea').classList.contains('mobile'); $('previewArea').classList.toggle('mobile', on); $('btnMobile').classList.toggle('active', on); $('btnMobile').textContent = on ? 'PC 미리보기' : '모바일 미리보기'; };
// ---------- 초기화 ----------
(async () => {
config = await api('/api/config').catch(() => config);
if (!current) renderEmptyReason(null, false); // v1.4.5 설정을 받은 뒤 첫 화면 페이지 표시
fillTiers(); fillLangOpts(); if (config.credit) renderCredit(config.credit); $('verBadge').textContent = config.version ? `v${config.version}` : ''; document.title = (config.product || 'AI Webmaker') + (config.version ? ` v${config.version}` : ''); fillEditTier(null);
loadMyFeedback();
// [Ariweb Suite 변경] 포털 컨텍스트 배너: 그룹·운영 사이트 이름과 "이 화면의 사이트 = Webmaker 프로젝트" 구분 안내, 포털로 돌아가는 링크
try { const a = config.ariweb; const bn = $('ariwebBanner'); if (a && bn) { const pu = a.portalUrl || ''; bn.innerHTML = `<b>Ariweb Suite</b><span>그룹: ${esc(a.groupName || a.groupId || '-')}</span>${a.siteName ? `<span>사이트: ${esc(a.siteName)}</span>` : ''}<span class="muted" style="color:#dbe6fb">이 화면의 "사이트" 는 Webmaker 프로젝트입니다. 운영 사이트·배포·도메인·메일은 포털에서 관리합니다.</span>${pu ? `<a href="${esc(pu)}${a.siteId ? '/sites/' + encodeURIComponent(a.siteId) : ''}">포털로 돌아가기</a>` : ''}`; bn.classList.remove('hidden'); } } catch (_) {}
if (config.user) { $('userName').textContent = config.user.name; $('userId').textContent = `(${config.user.id})`; if (config.user.role === 'admin') $('adminLink').classList.remove('hidden'); $('pfId').value = config.user.id; $('pfName').value = config.user.name || ''; }
loadLedger();
$('btnLogout').onclick = async () => { await fetch('/api/auth/logout', { method: 'POST' }); localStorage.removeItem('currentSiteId'); location.href = '/login'; };
if (config.upload) {
const accept = config.upload.extensions.map((x) => '.' + x).join(',');
for (const id of ['fFiles', 'fUpdFiles', 'fAttachNow']) $(id).accept = accept;
$('attachExts').textContent = config.upload.extensions.join(', ');
}
await refreshSiteList();
// v1.6.40: 처음 열면 홈(작업 흐름 지도). 저장된 화면이 옛 이름이면 홈으로
let tab = localStorage.getItem('activeTab') || 'home'; if (!TABS.includes(tab)) tab = 'home';
if ((SITE_TABS.includes(tab) || tab === 'home') && manageId) openSite(manageId, { tab }).then(() => showTab(tab)).catch(() => { resetToNew(); showTab('home'); });
else if (tab === 'create' && createId) openSite(createId, { tab: 'create' }).catch(() => { createId = null; showCreateChooser(); });
else { renderManageHint(); showTab(tab); }
})();
/**
* 번역 사전이 준비되면(처음 받거나 바뀌면) 조각 번역(T/TF)으로 만든 목록·문장을 다시 그린다 (v1.3.10).
* 사이트 이름 같은 사용자 데이터와 섞인 문장은 화면 런타임이 통째로 맞출 수 없어, 그릴 때 조각을 번역하기 때문이다.
*/
window.addEventListener('wm:i18n', () => {
try { refreshSiteList(); fillTiers(); fillLangOpts(); renderManageHint(); if (current) { renderSiteMeta(current); renderAnalysis(current); renderEditLimit(current); fillEditTier(current); if (window.OptimizeUI) OptimizeUI.onSiteOpened(current); if (window.ModulesUI) ModulesUI.onSiteOpened(current); if (window.ManageUI) ManageUI.onSiteOpened(current); } else renderSiteMetaEmpty(); applyTabView(activeTab); if (window.FlowUI) FlowUI.onSite(current); } catch (_) {}
});