2026-10-26 11:02:58 +00:00
'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 ( _ ) {}
2026-10-26 11:56:38 +00:00
// [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 ( _ ) {}
2026-10-26 11:02:58 +00:00
// 가져온(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 , '"' ) } ">?</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 , '"' ) } ">?</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 ();
2026-10-26 11:56:38 +00:00
// [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 ( _ ) {}
2026-10-26 11:02:58 +00:00
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 ( _ ) {}
});