Files
AriWeb-Suite/modules/webmaker/public/flow-ui.js

240 lines
26 KiB
JavaScript

'use strict';
/* ============================================================================
* 작업 흐름 지도 · 레일 상태 배지 · 배포 점검표 (v1.6.40, 요구 1)
*
* 웹메이커의 일은 서로 이어져 있다 — 그러나 모두 한 줄로 늘어선 것은 아니다:
* 만들기 ──▶ 수정 ──▶ 배포 ──▶ 운영 (사이트 축: 배포는 사이트 내용이 있어야 한다)
* 도메인 ──▶ DNS ──▶ (배포의 도메인 연결) · (메일) (주소 축: 도메인은 다른 일과 무관하게 먼저 준비할 수 있고, DNS 는 배포·메일이 의존한다)
* 캐시 (모든 AI 작업·도메인 구매의 비용)
* 이 모듈은 현재 사이트(app.js 의 current)와 도메인 목록(/api/domains), 캐시 잔액을 읽어 각 단계의 상태를 계산하고
* - 레일(#mainTabs) 버튼마다 상태 점(data-state)과 짧은 배지(.nb)를 붙이고,
* - 홈 화면(#homeBody)에 단계별 상태·다음 할 일·의존 관계를 그리고,
* - 배포 화면 맨 위(#deployChecklist)에 "배포 준비 점검표" 를 그린다.
* 상태 값: done(완료) · active(진행 중) · wait(기다리는 중: 전파·배포 단계) · warn(주의: 실패·확인 필요) · todo(아직) · na(이 사이트에 해당 없음)
* 전역 의존(app.js): $, api, T, TF, krwFmt, current, credit, config, showTab, inProgress, uiLocale
* ========================================================================== */
(() => {
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined && text !== null) e.textContent = text; return e; };
const btn = (text, cls, onclick) => { const b = el('button', cls || 'ghost small-btn', text); b.type = 'button'; b.onclick = onclick; return b; };
const go = (tab) => () => window.showTab(tab);
/** 상태 → 홈 단계 머리의 짧은 배지 문구 (배지가 따로 없을 때) */
const STATE_LABEL = { done: '완료', active: '진행 중', wait: '대기', warn: '확인 필요', todo: '아직', na: '해당 없음' };
/** 배포 단계 이름 (wizard.js 의 PHASE 와 같은 뜻, 짧게) */
const PHASE = { 'vhost-wait': '웹서버 설정 대기', 'dns-wait': 'DNS 설정 대기', 'cert-wait': '인증서 준비 중', 'check-failed': '접속 확인 실패', 'vhost-failed': '도메인 연결 실패', failed: '배포 실패' };
const MODE_LABEL = { ai: 'AI 에게 맡기기', manual: '내가 고르기', template: '템플릿', copy: '사이트 복사', import: '가져옴', quick: '빠른 생성' };
// ---------- 도메인 목록 (60초 캐시 — 레일 배지·홈·점검표가 같이 쓴다) ----------
let domData = null, domAt = 0, domP = null;
async function domains(force) {
if (!force && domData && Date.now() - domAt < 60000) return domData;
if (!domP) domP = api('/api/domains').then((d) => { domData = d; domAt = Date.now(); return d; }).catch(() => domData).finally(() => { domP = null; });
return domP;
}
/** 도메인 화면에서 연결·등록이 바뀌면 호출 — 다음 그리기 때 새로 받는다 */
function invalidateDomains() { domAt = 0; }
// ---------- 단계 계산 ----------
/**
* 현재 사이트와 도메인 자료로 7 단계의 상태를 만든다.
* @param {object|null} site app.js current (publicView 응답)
* @param {object|null} dom /api/domains 응답 (아직 못 받았으면 null)
* @returns {Array<{id, title, state, badge, detail, dep, optional, actions}>}
*/
function stages(site, dom) {
const out = [];
const siteDone = !!site && !inProgress(site) && site.status !== 'importing';
// 1. 만들기
{
let st = 'todo', badge = '', detail = '', actions = [];
if (!site) { detail = '아직 고른 사이트가 없습니다. AI 에게 맡기기 · 내가 고르기 · 템플릿 · 기존 사이트 복사 중 하나로 시작하세요.'; actions = [btn('사이트 만들기', 'primary small-btn', go('create'))]; }
// 빠른 생성(plan 없음)도 생성 명령이 도는 동안은 "생성 중"
else if (site.status === 'importing' || (site.running && (!siteDone || (site.commands || []).some((c) => c.kind === 'create' && c.status === 'running')))) { st = 'active'; badge = site.mode === 'copy' ? '복사 중' : '생성 중'; detail = '작업이 진행 중입니다. 위 진행 상태에서 확인하세요.'; actions = [btn('만들기 화면', 'ghost small-btn', go('create'))]; }
else if (inProgress(site)) { st = 'active'; badge = '생성 중'; const p = site.plan || {}; detail = `기획 단계 "${p.stage || ''}" 에서 멈춰 있습니다. 만들기 화면에서 이어서 진행하세요.`; actions = [btn('이어서 만들기', 'primary small-btn', go('create'))]; }
else if (site.importError && !site.copyInfo) { st = 'warn'; badge = '실패'; detail = site.importError; actions = [btn('만들기 화면', 'ghost small-btn', go('create'))]; }
else { st = 'done'; badge = '완성'; detail = `${T(MODE_LABEL[site.mode] || site.mode || '')} · ${new Date(site.createdAt).toLocaleDateString(uiLocale())} 에 만든 사이트입니다.`; actions = [btn('새 사이트 만들기', 'ghost small-btn', () => { if (window.resetCreate) resetCreate(); window.showTab('create'); })]; }
out.push({ id: 'create', title: '만들기', state: st, badge, detail, dep: '다른 작업과 무관하게 바로 시작할 수 있습니다.', actions });
}
// 2. 수정
{
let st = 'na', badge = '', detail = '사이트가 완성되면 수정 지시를 보낼 수 있습니다.', actions = [];
if (siteDone) {
const ups = (site.commands || []).filter((c) => c.kind === 'update'); const last = ups[ups.length - 1]; const lim = site.editLimit; const v = site.plan && site.plan.verification;
const running = !!site.running && (site.commands || []).some((c) => c.kind === 'update' && c.status === 'running'); // 수정 명령이 도는 중일 때만 "수정 중" (생성 중에는 아님)
st = running ? 'active' : (lim && lim.locked ? 'warn' : (ups.length ? 'done' : 'todo'));
badge = running ? '수정 중' : (lim && lim.locked ? '발행 대기' : (lim && lim.max ? `수정 ${lim.used}/${lim.max}` : (ups.length ? `수정 ${ups.length}회` : '')));
detail = lim && lim.locked ? `수정 횟수 한도(${lim.max}회)에 도달했습니다. 배포(발행)한 뒤 다시 수정할 수 있습니다.` : (last ? `마지막 수정 ${new Date(last.finishedAt || last.startedAt).toLocaleString(uiLocale(), { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}${v && v.stale ? ' · 수정 뒤 재검증 필요' : ''}` : '아직 수정 지시가 없습니다. 미리보기를 보고 바꿀 점을 문장으로 적어 보내세요.');
actions = [btn(lim && lim.locked ? '배포로 이동' : '수정하기', 'primary small-btn', go(lim && lim.locked ? 'deploy' : 'edit'))];
}
out.push({ id: 'edit', title: '수정', state: st, badge, detail, dep: '배포와 무관하게 계속할 수 있습니다. 배포 뒤의 수정은 "재배포" 로 올립니다.', actions });
}
// 3. 도메인 (선택)
{
let st = 'todo', badge = '', detail = '', actions = [btn('도메인 준비', 'ghost small-btn', go('domains'))];
const mine = dom ? dom.domains || [] : null; const connected = site ? (site.domains || []) : [];
if (!dom) detail = '도메인 정보를 불러오는 중…';
else if (connected.length) { st = 'done'; badge = TF('{1}개 연결', connected.length); detail = `연결된 도메인: ${connected.join(', ')}`; actions = [btn('도메인 관리', 'ghost small-btn', go('domains'))]; }
else if (mine.length) { badge = TF('{1}개 등록', mine.length); detail = site ? `등록된 도메인(${mine.map((d) => d.name).join(', ')})이 있습니다. 이 사이트에 연결하면 그 주소로 배포됩니다.` : `등록된 도메인: ${mine.map((d) => d.name).join(', ')}`; actions = [btn(site ? '사이트에 연결' : '도메인 관리', 'primary small-btn', go('domains'))]; }
else detail = dom.accountType === 'subdomain' ? `플랫폼 도메인(${dom.platform && dom.platform.domain ? dom.platform.domain : ''}) 아래 서브 도메인을 무료로 등록할 수 있습니다.` : '도메인을 사거나(장바구니) 이미 가진 도메인을 등록하세요. 도메인 없이도 서버 주소로 배포할 수 있습니다.';
out.push({ id: 'domains', title: '도메인', state: st, badge, detail, dep: '사이트 제작과 무관하게 먼저 준비해 둘 수 있습니다. 도메인만 사 두고 사이트는 나중에 연결해도 됩니다.', optional: true, actions });
}
// 4. DNS
{
let st = 'na', badge = '', detail = '사이트에 도메인을 연결하면 그 도메인의 DNS 준비 상태가 여기에 보입니다. 서브 도메인·여기서 구매한 도메인은 자동으로 준비됩니다.', actions = [btn('DNS 보기', 'ghost small-btn', go('dns'))];
const connected = site ? (site.domains || []) : []; const mine = dom ? dom.domains || [] : [];
if (connected.length && dom) {
// v1.7.2: 하위 이름(shop.example.com)으로 연결한 도메인도 찾는다 — siteNames 또는 '.도메인' 으로 끝나는 이름
const findDom = (n) => mine.find((d) => d.name === n || (d.siteNames || []).includes(n) || n.endsWith('.' + d.name));
const recs = [...new Set(connected.map(findDom).filter(Boolean))];
const waiting = recs.filter((d) => d.dnsLocked || d.pending || (d.transfer && d.transfer.step && d.transfer.step !== 'done'));
const manualUnverified = recs.filter((d) => d.type === 'external' && d.mode === 'manual' && !(d.verified && d.verified.siteOk));
const unknown = connected.filter((n) => !findDom(n));
if (waiting.length) { st = 'wait'; badge = '전파 대기'; detail = `${waiting.map((d) => d.name).join(', ')} — 네임서버 변경·DNS 전파를 확인하는 중입니다 (최대 48시간).`; }
else if (manualUnverified.length) { st = 'warn'; badge = '확인 필요'; detail = `${manualUnverified.map((d) => d.name).join(', ')} — 직접 관리하는 DNS 에 필요한 레코드를 넣고 "설정 완료 — 확인" 을 눌러야 배포가 됩니다.`; actions = [btn('필요한 설정 확인', 'primary small-btn', go('dns'))]; }
else if (unknown.length && !recs.length) { st = 'todo'; badge = '직접 입력'; detail = `${unknown.join(', ')} — 배포 카드에 직접 적은 도메인입니다. 그 도메인의 A 레코드가 배포 서버를 가리켜야 합니다.`; }
else { st = 'done'; badge = '준비됨'; detail = `${recs.map((d) => d.name).join(', ')} 의 DNS 가 준비되어 배포·메일에 쓸 수 있습니다.`; }
} else if (!site && mine.length) { st = mine.some((d) => d.dnsLocked || d.pending) ? 'wait' : 'done'; badge = st === 'wait' ? '전파 대기' : TF('{1}개', mine.length); detail = '등록된 도메인의 DNS 레코드·전파 상태를 봅니다.'; }
out.push({ id: 'dns', title: 'DNS', state: st, badge, detail, dep: '웹 제작과는 무관하지만 배포(웹서버 연결)와 메일(MX)이 이 설정에 달려 있습니다.', actions });
}
// 5. 메일 (선택)
{
let st = 'todo', badge = '', detail = '', actions = [btn('메일 설정', 'ghost small-btn', go('mail'))];
const m = site && site.mail;
if (!site) { st = 'na'; detail = '사이트를 고르면 그 사이트의 메일 시스템 상태가 보입니다. 도메인별 메일(MX) 자동 설정은 메일 화면에서 합니다.'; }
else if (!m || m.level === 'off') { badge = '꺼짐'; detail = '도메인에 메일(MX·SPF·DMARC)을 자동 설정하면 그 도메인으로 메일을 받을 준비가 되고, 운영팀이 사이트 메일 시스템을 켜면 사이트 관리자 페이지에서 메일 계정을 쓸 수 있습니다.'; }
else if (m.level === 'setting-up') { st = 'wait'; badge = m.label || '설정 중'; detail = `${m.domain} — 도메인 연결(DNS) 확인을 기다리는 중입니다.`; }
else { st = 'done'; badge = m.label || '사용 가능'; detail = `${m.domain} — 아이디@${m.domain} 로 메일을 쓸 수 있습니다.`; if (m.webmailUrl) { const a = el('a', 'btn ghost small-btn', '웹메일 열기'); a.href = m.webmailUrl; a.target = '_blank'; a.rel = 'noopener'; actions = [a, btn('메일 화면', 'ghost small-btn', go('mail'))]; } }
out.push({ id: 'mail', title: '메일', state: st, badge, detail, dep: 'DNS 가 준비된 도메인에서 동작합니다. 사이트 제작·배포와는 무관합니다.', optional: true, actions });
}
// 6. 배포
{
let st = 'na', badge = '', detail = '사이트가 완성되면 운영 주소로 올릴 수 있습니다.', actions = [];
if (site) {
const ds = site.deployState || {}; const stg = site.plan && site.plan.stage;
const changed = !!(ds.lastChangeAt && ds.lastDeployAt && ds.lastChangeAt > ds.lastDeployAt);
if (!siteDone) { st = 'na'; detail = '만들기가 끝나야 배포할 수 있습니다.'; }
else if (ds.state === 'empty') { st = 'todo'; badge = '내용 없음'; detail = '배포할 페이지가 아직 없습니다.'; }
else if (ds.state === 'working') { st = 'active'; badge = '작업 중'; detail = 'AI 작업이 끝나면 배포할 수 있습니다.'; }
else if (ds.state === 'deploying') { st = 'active'; badge = '배포 중'; detail = '운영 서버로 전송하고 있습니다.'; }
else if (site.unpublishedAt) { st = 'warn'; badge = '발행 취소됨'; detail = `운영 주소에는 "사이트 수정 중" 안내만 보입니다. 수정을 마친 뒤 다시 발행하세요.`; }
else if (ds.phase && PHASE[ds.phase] && ds.phase !== 'failed') { st = /failed/.test(ds.phase) ? 'warn' : 'wait'; badge = PHASE[ds.phase]; detail = ds.phaseDetail || PHASE[ds.phase]; }
else if (ds.phase === 'failed' || ds.state === 'failed') { st = 'warn'; badge = '배포 실패'; detail = ds.phaseDetail || '배포 서버 전송에 실패했습니다. 메시지창의 원인을 확인하세요.'; }
else if (stg === 'published' && ds.lastDeployAt && !(ds.state === 'pending' && changed)) { st = 'done'; badge = '발행됨'; detail = site.liveUrl ? `운영 주소 ${site.liveUrl}` : '배포되었습니다.'; }
else if (ds.lastDeployAt) { st = 'wait'; badge = '변경 있음'; detail = '마지막 배포 뒤 바뀐 내용이 있습니다. 재배포하면 운영 주소에 반영됩니다.'; }
else { st = 'todo'; badge = '발행 전'; detail = (site.domains || []).length ? `연결 도메인 ${site.domains.join(', ')} 으로 발행합니다.` : '도메인을 연결하지 않으면 서버 주소로 발행됩니다.'; }
actions = siteDone ? [btn(st === 'done' ? '배포 보기' : (st === 'wait' && badge === '변경 있음' ? '재배포' : '배포하기'), st === 'done' ? 'ghost small-btn' : 'primary small-btn', go('deploy'))] : [];
}
out.push({ id: 'deploy', title: '배포', state: st, badge, detail, dep: '사이트 내용이 있어야 하고, 도메인을 연결했다면 그 DNS 가 준비되어야 합니다. 한 번 배포된 뒤의 수정·운영은 도메인·DNS 와 직접 묶이지 않습니다.', actions });
}
// 7. 운영
{
let st = 'na', badge = '', detail = '배포한 사이트의 기능 모듈·사이트 관리자 페이지·문의·접속 통계를 여기서 다룹니다.', actions = [];
if (siteDone) { const live = !!site.liveUrl && site.plan && site.plan.stage === 'published'; st = live ? 'active' : 'todo'; badge = live ? '운영 중' : ''; detail = live ? '문의 접수·접속 통계·기능 모듈·사이트 관리자 페이지를 봅니다.' : '배포 전에도 기능 모듈을 켜고 사이트 관리자 페이지를 준비할 수 있습니다.'; actions = [btn('운영 보기', 'ghost small-btn', go('manage'))]; }
out.push({ id: 'manage', title: '운영', state: st, badge, detail, dep: '완성된 사이트의 기능·상태 관리는 "수정" 과 이어집니다.', actions });
}
return out;
}
// ---------- 레일 배지 ----------
/** 레일 버튼에 상태 점·배지를 붙인다. 캐시 버튼은 잔액(부족하면 빨강) */
function navBadges(list) {
const byId = Object.fromEntries(list.map((s) => [s.id, s]));
document.querySelectorAll('#mainTabs button').forEach((b) => {
const s = byId[b.dataset.tab]; const nb = b.querySelector('.nb'); if (!nb) return;
if (s) { if (s.state === 'na') { b.removeAttribute('data-state'); nb.textContent = ''; } else { b.dataset.state = s.state; nb.textContent = T(s.badge || ''); } }
else if (b.dataset.tab === 'credits') { const c = typeof credit !== 'undefined' && credit; const isAdmin = config && config.user && config.user.role === 'admin'; if (c && c.balance != null) { const low = c.balance <= 0 && !isAdmin; b.dataset.state = low ? 'warn' : 'done'; nb.textContent = low ? T('부족') : krwFmt(c.balance); } }
});
}
// ---------- 홈 ----------
/** 단계 한 줄 (아이콘 번호 · 제목 · 배지 · 설명 · 의존 · 실행 버튼) */
function stepRow(s, no) {
const row = el('div', 'flow-step' + (s.optional ? ' optional' : '')); row.dataset.state = s.state;
row.appendChild(el('div', 'fs-icon', s.state === 'done' ? '✓' : String(no)));
const mid = el('div');
const t = el('div', 'fs-title'); t.appendChild(el('span', null, T(s.title)));
const pill = el('span', 'pill small ' + ({ done: 'done', active: 'running', wait: 'waiting', warn: 'error', todo: 'idle', na: 'idle' }[s.state] || 'idle'), T(s.badge || STATE_LABEL[s.state]));
t.appendChild(pill); if (s.optional) t.appendChild(el('span', 'muted small', '(선택)')); mid.appendChild(t);
if (s.detail) mid.appendChild(el('div', 'fs-detail', s.detail));
if (s.dep) mid.appendChild(el('div', 'fs-dep', s.dep));
row.appendChild(mid);
const act = el('div', 'fs-act'); for (const a of s.actions || []) act.appendChild(a); row.appendChild(act);
return row;
}
/** 의존 관계 지도 (글자로): 사이트 축 · 주소 축 · 캐시 */
function flowMap() {
const c = el('div', 'flow-map');
const line = (nodes, note) => { const r = el('div', 'fm-row'); nodes.forEach((n, i) => { if (i) r.appendChild(el('span', 'fm-arrow', '→')); r.appendChild(el('span', 'fm-node' + (n.dim ? ' dim' : ''), n.t)); }); c.appendChild(r); if (note) c.appendChild(el('div', 'fm-note', note)); };
line([{ t: '만들기' }, { t: '수정' }, { t: '배포' }, { t: '운영' }], '사이트 축 — 배포는 사이트 내용이 있어야 하는 마지막 작업이고, 배포 뒤의 수정·운영은 도메인·DNS 와 직접 묶이지 않습니다.');
line([{ t: '도메인', dim: true }, { t: 'DNS' }, { t: '배포의 도메인 연결 · 메일', dim: true }], '주소 축 — 도메인 구매·등록은 다른 일과 무관하게 먼저 할 수 있고, DNS 는 배포(웹서버 연결)와 메일(MX)이 의존합니다.');
line([{ t: '캐시' }, { t: 'AI 작업 · 도메인 구매 비용', dim: true }], '비용 — AI 생성·수정·검증과 도메인 구매·갱신은 선불 캐시에서 차감됩니다.');
return c;
}
async function renderHome() {
const box = $('homeBody'); if (!box) return;
const site = typeof current !== 'undefined' ? current : null;
const dom = await domains();
if (!$('homeBody') || $('pane-home').classList.contains('hidden')) { navBadges(stages(site, dom)); return; } // 그 사이 다른 화면으로 옮겼으면 배지만
const list = stages(site, dom); navBadges(list);
box.innerHTML = ''; const wrap = el('div', 'flow');
const hero = el('div', 'flow-hero');
if (!site) {
hero.appendChild(el('h3', null, '무엇부터 할까요?'));
hero.appendChild(el('p', null, '사이트 만들기, 도메인 준비, 캐시 충전은 서로 무관하게 시작할 수 있습니다. 만들어 둔 사이트가 있으면 왼쪽 레일에서 고르세요 — 그 사이트의 진행 상태가 여기에 보입니다.'));
wrap.appendChild(hero);
const entry = el('div', 'flow-entry');
const ent = (t, d, tab, primary) => { const b = el('button', primary ? 'primary' : ''); b.type = 'button'; b.appendChild(el('span', 'fe-t', t)); b.appendChild(el('span', 'fe-d', d)); b.onclick = go(tab); entry.appendChild(b); };
ent('사이트 만들기', 'AI 에게 맡기기 · 내가 고르기 · 템플릿 · 기존 사이트 복사', 'create');
ent('도메인 준비', dom && dom.accountType === 'subdomain' ? '서브 도메인 무료 등록' : '검색·구매(장바구니) 또는 이미 가진 도메인 등록', 'domains');
ent('캐시 충전', '선불 충전 상품을 골라 충전 신청', 'credits');
wrap.appendChild(entry);
} else {
const pill = $('siteStatePill'); hero.appendChild(el('h3', null, site.title));
const next = list.find((s) => !s.optional && ['todo', 'active', 'wait', 'warn'].includes(s.state));
hero.appendChild(el('p', null, (pill && pill.textContent ? pill.textContent + ' · ' : '') + (site.liveUrl ? TF('운영 주소: {1}', site.liveUrl) : T('아직 배포되지 않았습니다 (미리보기 주소만 사용 가능)'))));
if (next) { const r = el('div', 'row'); r.appendChild(el('span', 'small', TF('다음 할 일: {1}', T(next.title) + (next.badge ? ` (${T(next.badge)})` : '')))); if (next.actions && next.actions[0]) { const a = next.actions[0].cloneNode(true); a.onclick = next.actions[0].onclick; r.appendChild(a); } hero.appendChild(r); }
wrap.appendChild(hero);
}
const steps = el('div', 'flow-steps'); list.forEach((s, i) => steps.appendChild(stepRow(s, i + 1))); wrap.appendChild(steps);
wrap.appendChild(flowMap());
box.appendChild(wrap);
}
// ---------- 배포 점검표 ----------
async function renderDeployChecklist() {
const box = $('deployChecklist'); if (!box) return;
const site = typeof current !== 'undefined' ? current : null; const dom = await domains();
if (!$('deployChecklist') || $('pane-deploy').classList.contains('hidden')) return;
box.innerHTML = '';
const card = el('section', 'card'); card.appendChild(el('h2', null, '배포 준비 점검'));
if (!site) { card.appendChild(el('p', 'muted small', '왼쪽 레일에서 사이트를 고르면 배포에 필요한 항목을 점검하고 발행할 수 있습니다. 배포는 사이트 내용이 있어야 하고, 도메인을 연결했다면 그 DNS 가 준비되어야 합니다.')); box.appendChild(card); return; }
const list = stages(site, dom); const by = Object.fromEntries(list.map((s) => [s.id, s]));
const ul = el('ul', 'chk-list');
const item = (state, label, detail, action) => { const li = el('li'); li.dataset.state = state; li.appendChild(el('span', 'ci', state === 'done' ? '✓' : state === 'warn' ? '!' : state === 'wait' ? '…' : '·')); const m = el('div'); m.appendChild(el('span', null, T(label))); if (detail) m.appendChild(el('span', 'cd', detail)); li.appendChild(m); li.appendChild(action || el('span')); ul.appendChild(li); };
const c = by.create; item(c.state === 'done' ? 'done' : (c.state === 'active' ? 'wait' : 'warn'), '사이트 내용', c.state === 'done' ? '완성된 사이트입니다' : c.detail, c.state === 'done' ? null : btn('만들기', 'ghost small-btn', go('create')));
const v = site.plan && site.plan.verification; const s = v && v.rules && v.rules.summary; const issues = s ? (s.pagesMissing + s.extraneous + s.regulated) : 0;
item(!v ? 'todo' : (v.stale ? 'wait' : (issues ? 'warn' : 'done')), '검증', !v ? '아직 검증하지 않았습니다 (발행 때 자동 점검)' : v.stale ? '수정 뒤 재검증이 필요합니다' : issues ? TF('점검 항목 {1}건 — 그대로 발행할 수도 있습니다', issues) : '이상 없음', null);
const d = by.domains; item(d.state === 'done' ? 'done' : 'todo', '도메인 연결 (선택)', d.state === 'done' ? d.detail : '연결하지 않으면 서버 주소로 발행됩니다', d.state === 'done' ? null : btn('도메인', 'ghost small-btn', go('domains')));
const n = by.dns; if (n.state !== 'na') item(n.state, 'DNS 준비', n.detail, n.state === 'done' ? null : btn('DNS', 'ghost small-btn', go('dns')));
const isAdmin = config && config.user && config.user.role === 'admin'; const bal = site.credit ? site.credit.balance : (typeof credit !== 'undefined' && credit ? credit.balance : null);
if (bal != null) item(bal > 0 || isAdmin ? 'done' : 'warn', '캐시', bal > 0 || isAdmin ? TF('잔액 {1}', krwFmt(bal)) : '캐시가 없습니다 — 발행은 가능하지만 AI 수정은 할 수 없습니다', bal > 0 || isAdmin ? null : btn('충전', 'ghost small-btn', go('credits')));
card.appendChild(ul);
const dep = by.deploy; card.appendChild(el('p', 'muted small', dep.badge ? `${T('배포 상태')}: ${T(dep.badge)} — ${dep.detail}` : dep.detail));
box.appendChild(card);
}
// ---------- app.js 와의 연결점 ----------
let timer = null;
/** 사이트가 열리거나 바뀌면(작업 완료 포함) 배지와 보이는 화면을 다시 그린다. 연달아 오는 이벤트는 150ms 로 모은다 */
function onSite() { clearTimeout(timer); timer = setTimeout(refresh, 150); }
async function refresh() {
const site = typeof current !== 'undefined' ? current : null; const dom = await domains();
navBadges(stages(site, dom));
if (!$('pane-home').classList.contains('hidden')) renderHome();
if (!$('pane-deploy').classList.contains('hidden')) renderDeployChecklist();
}
function onTab(name) { if (name === 'home') renderHome(); else if (name === 'deploy') renderDeployChecklist(); else navBadges(stages(typeof current !== 'undefined' ? current : null, domData)); }
function onCredit() { navBadges(stages(typeof current !== 'undefined' ? current : null, domData)); }
window.FlowUI = { renderHome, renderDeployChecklist, onSite, onTab, onCredit, invalidateDomains: () => { invalidateDomains(); onSite(); }, stages };
})();