Files
AriWeb-Suite/modules/webmaker/public/app.js
T

1185 lines
112 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
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 (!ed.dirty || (current && targetId === current.id)) 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; }
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': '직접 편집', restore: '복원', deploy: '배포본', import: '가져옴' };
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);
$('emptyState').classList.toggle('hidden', hasIndex);
$('frame').classList.toggle('hidden', !hasIndex);
if (hasIndex && reload) loadFrame();
}
/**
* 미리보기할 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;
$('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 };
try {
const r = await api(`/api/sites/${sent.siteId}/files/${encodeURIComponent(sent.file)}`, { method: 'PUT', body: JSON.stringify({ content: sent.content }) });
if (!current || current.id !== sent.siteId || ed.file !== sent.file) return; // 그 사이 다른 사이트·파일로 바뀜
ed.saved = sent.content; 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) { alert('저장 실패: ' + e.message); $('btnSave').disabled = false; }
}
$('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 = () => setEditorOpen(!ed.open);
$('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) { e.preventDefault(); e.returnValue = ''; } });
// ---------- 사이트 로드 / 이벤트 ----------
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 = {}) {
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);
}
/** 위쪽 사이트 선택 아래: 사이트 상태 배지와 운영 주소 (모든 탭 공통, 높이 고정) */
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 = '소스 보기';
$('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 = loadFrame;
$('fileSelect').onchange = loadFrame;
/**
* 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 (_) {}
});