Ariweb Suite 0.1.1 기반: 공통 라이브러리(db·http·세션·작업 엔진·할당/예약·알림·감사·보관소), 포털 뼈대, Webmaker/AriMail 도입 소스와 manifest
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
9ca4cee341
commit
c7aea8d419
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>페이지를 찾을 수 없습니다</title><style>body{margin:0;min-height:100vh;display:grid;place-items:center;font-family:-apple-system,"Segoe UI","Noto Sans KR",sans-serif;background:#f4f5f7;color:#1f2933}main{text-align:center;padding:32px}h1{font-size:22px;margin:0 0 8px}p{color:#6b7684;margin:0}</style></head><body><main><h1>페이지를 찾을 수 없습니다</h1><p>요청하신 페이지가 존재하지 않거나 이동되었습니다.</p></main></body></html>
|
||||
@@ -0,0 +1,122 @@
|
||||
/* 관리자 콘솔: 일반 사이트(밝은 배경·파란 포인트)와 대비되는 다크 테마(짙은 남색 배경·호박색 포인트) */
|
||||
:root { --bg: #0b1220; --panel: #121a2b; --panel2: #18223a; --line: #263353; --text: #e6ebf5; --muted: #8b98b8; --accent: #f5a524; --accent2: #ffc457; --ok: #34d399; --warn: #fb923c; --bad: #f87171; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; padding: 16px; font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif; font-size: 13px; color: var(--text); background: var(--bg); }
|
||||
.adm { max-width: 1480px; margin: 0 auto; display: flex; flex-direction: column; gap: 12px; }
|
||||
.adm-hdr { display: flex; align-items: center; gap: 14px; padding: 10px 14px; background: var(--panel); border: 1px solid var(--line); border-radius: 12px; }
|
||||
.brand { display: flex; align-items: center; gap: 10px; } .brand h1 { margin: 0; font-size: 17px; letter-spacing: .3px; } .logo { width: 30px; height: 30px; border-radius: 8px; background: var(--accent); color: #0b1220; display: grid; place-items: center; font-weight: 700; }
|
||||
.spacer { flex: 1; } .muted { color: var(--muted); font-size: 12px; }
|
||||
.tabs { display: flex; gap: 4px; } .tabs button { padding: 7px 14px; border-radius: 8px; border: 1px solid transparent; background: transparent; color: var(--muted); font: inherit; cursor: pointer; } .tabs button.active { background: var(--panel2); color: var(--accent2); border-color: var(--line); } .tabs button:hover { color: var(--text); }
|
||||
button, .btn { font: inherit; padding: 7px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--panel2); color: var(--text); cursor: pointer; text-decoration: none; display: inline-block; }
|
||||
button:hover, .btn:hover { border-color: var(--accent); } button:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.primary { background: var(--accent); border-color: var(--accent); color: #0b1220; font-weight: 700; } .primary:hover { background: var(--accent2); }
|
||||
.ghost.danger { color: var(--bad); border-color: #7f1d1d; } .ghost.warn { color: var(--warn); }
|
||||
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 14px; }
|
||||
h2 { font-size: 14px; margin: 0 0 10px; color: var(--accent2); } h3 { font-size: 13px; margin: 12px 0 6px; }
|
||||
.hidden { display: none !important; }
|
||||
.kpi { display: flex; gap: 8px; flex-wrap: wrap; } .kpi .tot { min-width: 140px; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 8px 12px; display: flex; flex-direction: column; } .kpi .k { font-size: 11px; color: var(--muted); } .kpi .v { font-size: 18px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.table-wrap { overflow-x: auto; } table.adm-t { width: 100%; border-collapse: collapse; font-size: 12px; } table.adm-t th, table.adm-t td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--line); white-space: nowrap; vertical-align: top; } table.adm-t th { color: var(--muted); font-weight: 600; }
|
||||
table.adm-t td.wrap { white-space: normal; max-width: 560px; } table.adm-t td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
tr.clickable { cursor: pointer; } tr.clickable:hover td { background: var(--panel2); }
|
||||
.st-error, .st-interrupted { color: var(--bad); font-weight: 600; } .st-cancelled { color: var(--warn); } .st-running { color: var(--accent2); } .st-done { color: var(--ok); } .st-idle { color: var(--muted); }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } @media (max-width: 1000px) { .grid { grid-template-columns: 1fr; } }
|
||||
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.field-label { font-size: 12px; color: var(--muted); margin: 10px 0 4px; }
|
||||
.log { margin: 0; background: #060b16; color: #cbd5e1; border: 1px solid var(--line); border-radius: 8px; padding: 10px; height: 480px; overflow: auto; font-family: var(--mono); font-size: 12px; white-space: pre-wrap; word-break: break-word; }
|
||||
.log .info { color: #93c5fd; } .log .tool { color: #a7f3d0; } .log .assistant { color: #fde68a; } .log .warn { color: #fca5a5; } .log .debug { color: #f9a8d4; } .log .t { color: #64748b; }
|
||||
.json { max-height: 260px; overflow: auto; background: #060b16; color: #cbd5e1; border: 1px solid var(--line); border-radius: 8px; padding: 8px; font-family: var(--mono); font-size: 11px; white-space: pre-wrap; }
|
||||
input, select { font: inherit; padding: 7px 10px; border: 1px solid var(--line); border-radius: 8px; background: #0f172a; color: var(--text); width: 100%; margin: 4px 0 8px; } input:focus, select:focus { outline: 2px solid #3b4a75; border-color: var(--accent); }
|
||||
.radio { display: block; margin: 8px 0 4px; } .radio input { width: auto; margin: 0 6px 0 0; }
|
||||
.inline { display: flex; align-items: center; gap: 8px; margin: 4px 0 8px 22px; } .inline input { margin: 0; }
|
||||
code { font-family: var(--mono); background: #0f172a; padding: 1px 5px; border-radius: 4px; }
|
||||
.fx-line { margin: 8px 0; font-size: 12px; color: var(--muted); }
|
||||
.charge-form { display: grid; grid-template-columns: 1fr 1fr auto; gap: 6px; margin-top: 8px; } .charge-form input { margin: 0; }
|
||||
.pos { color: var(--ok); } .neg { color: var(--bad); font-weight: 600; }
|
||||
|
||||
.two { display: grid; grid-template-columns: 2fr 1fr; gap: 8px; }
|
||||
textarea { font: inherit; padding: 7px 10px; border: 1px solid var(--line); border-radius: 8px; background: #0f172a; color: var(--text); width: 100%; margin: 4px 0 8px; font-family: var(--mono); font-size: 11px; }
|
||||
.deploy-box { border: 1px solid var(--line); border-radius: 10px; padding: 8px 12px; margin-bottom: 12px; background: var(--panel2); }
|
||||
.star { color: var(--accent); }
|
||||
|
||||
.deploy-result { border-radius: 10px; padding: 10px 12px; margin: 6px 0 10px; font-size: 12px; border: 1px solid var(--line); background: #0f172a; }
|
||||
.deploy-result.ok { border-color: #065f46; background: #052e2b; } .deploy-result.fail { border-color: #7f1d1d; background: #2a1215; } .deploy-result.busy { border-color: var(--accent); }
|
||||
.deploy-result h4 { margin: 0 0 6px; font-size: 13px; }
|
||||
.deploy-result ul { margin: 6px 0 0; padding-left: 18px; } .deploy-result li { margin: 3px 0; }
|
||||
.deploy-result a { color: var(--accent2); }
|
||||
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 700; white-space: nowrap; }
|
||||
.badge.deploying { background: #dbeafe; color: #1e40af; }
|
||||
.deploy-status { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 8px 0; padding: 8px 12px; border-radius: 8px; font-size: 13px; }
|
||||
.deploy-status.running { background: #1e3a8a; color: #dbeafe; } .deploy-status.ok { background: #064e3b; color: #d1fae5; } .deploy-status.fail { background: #7f1d1d; color: #fee2e2; }
|
||||
.deploy-status a { color: inherit; text-decoration: underline; }
|
||||
.deploy-status.running .ds-icon { width: 14px; height: 14px; border-radius: 50%; border: 2px solid #93c5fd; border-top-color: #fff; animation: spin .9s linear infinite; display: inline-block; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.badge.pending { background: #fef3c7; color: #92400e; } .badge.deployed { background: #d1fae5; color: #065f46; } .badge.working { background: #e0e7ff; color: #3730a3; } .badge.empty { background: #1f2937; color: var(--muted); }
|
||||
h2 .tabs button { padding: 4px 10px; font-size: 12px; }
|
||||
|
||||
/* 표가 화면 밖으로 나가지 않도록: flex/grid 자식의 최소 너비 해제, 표는 컨테이너 안에서 가로 스크롤 */
|
||||
.adm > * , .card, .grid > * { min-width: 0; max-width: 100%; }
|
||||
.table-wrap { max-width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
table.adm-t td:not(.num):not(.wrap) { white-space: normal; }
|
||||
table.adm-t td.act { white-space: nowrap; } table.adm-t td.act button, table.adm-t td.act .btn { padding: 4px 8px; font-size: 11px; margin: 1px 0; }
|
||||
|
||||
.dlg { position: fixed; inset: 0; background: rgba(0,0,0,.55); display: grid; place-items: center; z-index: 50; }
|
||||
.dlg-box { width: 520px; max-width: calc(100vw - 32px); background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 16px; }
|
||||
|
||||
.ledger-scroll { max-height: 360px; overflow-y: auto; } .ledger-scroll thead th { position: sticky; top: 0; background: var(--panel, #111827); }
|
||||
.badge.planned { background: #1e3a8a; color: #dbeafe; }
|
||||
.badge-n { display: inline-block; min-width: 16px; padding: 0 5px; border-radius: 999px; background: #ef4444; color: #fff; font-size: 10px; margin-left: 4px; }
|
||||
|
||||
/* v1.2.0: Webmaker 화면 외형 편집기 */
|
||||
.th-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 6px 10px; margin: 8px 0; }
|
||||
.th-grid label { font-size: 12px; color: var(--muted); }
|
||||
.th-preview { display: grid; gap: 0; height: 150px; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; margin: 6px 0 10px; background: var(--tp-bg); font-size: var(--tp-fs); color: var(--tp-text); }
|
||||
.th-preview .tp-left { background: var(--tp-panel); display: flex; flex-direction: column; gap: var(--tp-pad); border-right: 1px solid var(--tp-line); }
|
||||
.th-preview .tp-top { background: var(--tp-top); color: var(--tp-top-text); padding: 6px 8px; font-weight: 700; display: flex; justify-content: space-between; align-items: center; gap: 6px; font-size: 12px; white-space: nowrap; }
|
||||
.th-preview .tp-pill { white-space: nowrap; font-weight: 600; font-size: 11px; border: 1px solid var(--tp-accent); border-radius: 999px; padding: 1px 6px; }
|
||||
.th-preview .tp-card { margin: 0 8px; padding: var(--tp-pad); border: 1px solid var(--tp-line); border-radius: var(--tp-radius); font-size: 12px; }
|
||||
.th-preview .tp-muted { color: var(--tp-muted); }
|
||||
.th-preview .tp-btn { margin: 0 8px; background: var(--tp-accent); border-color: var(--tp-accent); color: #fff; border-radius: var(--tp-radius); padding: 4px 10px; align-self: flex-start; font-size: 12px; }
|
||||
.th-preview .tp-right { display: grid; place-items: center; color: var(--tp-muted); font-size: 12px; }
|
||||
|
||||
/* v1.3.2 로그 보기 */
|
||||
.lg-filters { display: flex; flex-direction: column; gap: 8px; margin-bottom: 10px; }
|
||||
.lg-kinds { display: flex; flex-wrap: wrap; gap: 6px; } .lg-kind { padding: 5px 10px; border-radius: 999px; font-size: 12px; opacity: .55; } .lg-kind.on { opacity: 1; border-color: var(--accent); }
|
||||
.lg-kind.error.on { color: #fca5a5; } .lg-kind.warn.on { color: #fcd34d; } .lg-kind.deploy.on, .lg-kind.done.on { color: #6ee7b7; }
|
||||
.st-warn { color: #fcd34d; }
|
||||
.lg-wrap { max-height: 64vh; overflow: auto; }
|
||||
/* v1.3.2 메일 시스템 탭 */
|
||||
.ms-status { padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; font-size: 13px; line-height: 1.6; } .ms-status b { color: var(--accent2); }
|
||||
.ms-pill { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: 11px; border: 1px solid var(--line); } .ms-pill.ready, .ms-pill.send-only { color: #6ee7b7; border-color: #065f46; } .ms-pill.setting-up { color: #fcd34d; border-color: #92400e; } .ms-pill.off { color: var(--muted); }
|
||||
/* v1.3.2 화면 설정 탭: 왼쪽 조정, 오른쪽 넓은 미리보기 */
|
||||
.th-layout { display: grid; grid-template-columns: minmax(300px, 380px) minmax(0, 1fr); gap: 16px; align-items: start; }
|
||||
.th-stage { min-width: 0; } /* 1280px iframe 가 칸을 넓히지 않게 (그래야 축소 비율이 맞음) */
|
||||
@media (max-width: 1100px) { .th-layout { grid-template-columns: 1fr; } }
|
||||
.th-controls { position: sticky; top: 8px; }
|
||||
.th-views { gap: 4px; margin-bottom: 8px; } .th-views button.active { border-color: var(--accent); color: var(--accent2); }
|
||||
.th-frames.one .th-frame-wrap { width: 100%; } .th-frames.all { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
.th-frame-wrap .th-vp { position: relative; overflow: hidden; border: 1px solid var(--line); border-radius: 8px; background: #fff; width: 100%; }
|
||||
.th-frame-wrap[data-view="mobile"] .th-vp { max-width: 390px; }
|
||||
.th-frame-wrap iframe { border: 0; transform-origin: 0 0; display: block; }
|
||||
|
||||
/* v1.3.2: 탭이 9개로 늘어 한 줄에 다 안 들어가면 단어가 잘리지 않게 줄 단위로 넘김 */
|
||||
.adm-hdr { flex-wrap: wrap; } .tabs { flex-wrap: wrap; } .tabs button { white-space: nowrap; padding: 7px 10px; } .brand { white-space: nowrap; }
|
||||
|
||||
/* v1.3.5 버전 변경 알림 띠 */
|
||||
.ver-bar { margin: 10px 0; padding: 8px 12px; border: 1px solid #92400e; border-radius: 10px; color: #fcd34d; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
/* v1.3.10: 관리자 콘솔 다국어 — 번역문이 길어도 칸 안에서 줄바꿈 */
|
||||
.adm, .card, .kpi .tot { min-width: 0; max-width: 100%; } .tabs button { white-space: normal; } td, th, label, p { overflow-wrap: anywhere; } .table-wrap { max-width: 100%; overflow-x: auto; }
|
||||
|
||||
/* v1.3.10 템플릿 트리 */
|
||||
.tp-tree { border: 1px solid var(--line); border-radius: 8px; padding: 4px; max-height: 360px; overflow: auto; } .tp-node { display: flex; align-items: center; gap: 4px; } .tp-node.on { background: var(--panel2); border-radius: 6px; } .tp-node .link { background: transparent; border: 0; color: var(--text); text-align: left; padding: 3px 4px; flex: 1; } .tp-node .ghost { padding: 1px 6px; font-size: 11px; }
|
||||
/* v1.3.16 저장하지 않은 변경 표시 (data-dirty-watch 입력칸) */
|
||||
input.dirty, select.dirty { outline: 2px solid #f59e0b; outline-offset: 1px; }
|
||||
input[type=radio].dirty { outline-offset: 2px; }
|
||||
.muted.ok { color: #059669; }
|
||||
|
||||
/* v1.4.0 회원 목록의 계정 종류 (선택 + 변경 버튼 한 줄) */
|
||||
.acct-type { display: inline-flex; align-items: center; gap: 4px; margin-left: 4px; vertical-align: middle; }
|
||||
.acct-type select { width: auto; margin: 0; padding: 3px 6px; font-size: 12px; }
|
||||
/* v1.6.40 선불 충전 상품 폼: 칸이 많아 3열 → 자동 채움 */
|
||||
.charge-form.pp-form { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); align-items: center; } .charge-form.pp-form select { margin: 0; } .charge-form.pp-form button { white-space: nowrap; }
|
||||
@@ -0,0 +1,572 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>관리자 콘솔 — AI Webmaker</title>
|
||||
<link rel="stylesheet" href="/admin.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="adm">
|
||||
<header class="adm-hdr">
|
||||
<div class="brand"><span class="logo">⚙</span><h1>관리자 콘솔</h1><span class="muted" id="ver"></span><span class="muted" id="who"></span></div>
|
||||
<nav class="tabs">
|
||||
<button type="button" class="active" data-tab="sites">사이트 관리</button>
|
||||
<button type="button" data-tab="errors">로그 보기</button>
|
||||
<button type="button" data-tab="billing">회원 · 청구 <span id="apBadge" class="badge-n hidden" title="가입 승인 대기"></span></button>
|
||||
<button type="button" data-tab="deploy">배포 서버</button>
|
||||
<button type="button" data-tab="feedback">사용자 문의 <span id="fbBadge" class="badge-n hidden"></span></button>
|
||||
<button type="button" data-tab="recovery" title="배포·도메인 구매·메일 계정·인증서·데이터 파일 중 정상 완료되지 않은 작업 (v1.6.31)">운영 복구 <span id="rcBadge" class="badge-n hidden"></span></button>
|
||||
<button type="button" data-tab="audit" title="설정 변경·계정·접속 정보·비용 작업 기록 (v1.6.31)">작업 기록</button>
|
||||
<button type="button" data-tab="templates">템플릿</button>
|
||||
<button type="button" data-tab="mail">메일 시스템</button>
|
||||
<button type="button" data-tab="search">검색 등록 <span id="srBadge" class="badge-n hidden"></span></button>
|
||||
<button type="button" data-tab="i18n">언어</button>
|
||||
<button type="button" data-tab="theme">화면 설정</button>
|
||||
<button type="button" data-tab="ai" title="LLM 연결(서버 로그인·OAuth 토큰·API 키)·사용할 모델 선택·연결 테스트·AI 제공자 — 여기서 켠 모델만 등급·내부 호출에 지정할 수 있습니다">AI 연결</button>
|
||||
<button type="button" data-tab="settings">설정</button>
|
||||
<button type="button" data-tab="system" title="설정 파일(data/config.json)·연동 서비스(Gitea·Arimail)·AI 엔진 상태 — 초기 설정 페이지와 같은 화면">시스템 설정</button>
|
||||
</nav>
|
||||
<div class="spacer"></div>
|
||||
<span class="muted" id="reloadInfo"></span><button type="button" id="btnReload" class="ghost" title="지금 보고 있는 탭을 벗어나지 않고 화면의 모든 데이터를 다시 불러옵니다">새로고침</button>
|
||||
<span class="lang-switch" style="display:none"><select id="langSelect" title="Language" style="width:auto;margin:0"></select></span>
|
||||
<button type="button" id="btnLogout" class="ghost">로그아웃</button>
|
||||
</header>
|
||||
|
||||
<div class="ver-bar hidden" id="verBar"></div>
|
||||
<div class="kpi" id="kpi"></div>
|
||||
|
||||
<section class="card tab" id="tab-sites">
|
||||
<h2>모든 사이트 <span class="muted" id="siteCount"></span>
|
||||
<span class="spacer"></span>
|
||||
<span class="tabs" style="display:inline-flex"><button type="button" class="active" data-filter="all">전체</button><button type="button" data-filter="pending">배포 대기중</button><button type="button" data-filter="deployed">배포 완료</button><button type="button" data-filter="working">작업 중</button></span>
|
||||
</h2>
|
||||
<div id="deployStatus" class="deploy-status hidden"></div>
|
||||
<div class="table-wrap"><table class="adm-t" id="sitesTable"><thead><tr><th>배포 상태</th><th>마지막 변경</th><th>사이트</th><th>소유자</th><th>도메인</th><th>배포 위치</th><th>방식</th><th>단계</th><th>작업 상태</th><th>명령</th><th>오류</th><th>토큰</th><th>비용(USD)</th><th>작업</th></tr></thead><tbody></tbody></table></div>
|
||||
<p class="muted">배포 위치: 사이트마다 배포할 서버를 지정합니다(★ 는 기본 배포 서버). 사용자 화면에는 서버 이름이 보이지 않으며, 사용자의 "최종 확정(발행)"과 재배포는 여기서 지정한 서버로 갑니다. <span id="statsCollectLine"></span> <button type="button" class="ghost" id="btnStatsCollect">접속 통계 지금 수집</button></p>
|
||||
</section>
|
||||
|
||||
<div id="deployDialog" class="dlg hidden"><div class="dlg-box">
|
||||
<h2 id="ddTitle">첫 배포 정보 입력</h2>
|
||||
<div class="muted" id="ddSite"></div>
|
||||
<div class="field-label">배포 서버</div><select id="ddTarget"></select>
|
||||
<div class="field-label">연결할 도메인 (선택, 쉼표로 여러 개 — 예: www.example.com, example.com)</div><input id="ddDomains" placeholder="도메인 없이 배포하려면 비워 두세요">
|
||||
<label class="radio"><input type="checkbox" id="ddSsl" checked> Let's Encrypt SSL 자동 발급 (DNS 가 배포 서버를 가리키는 도메인만)</label>
|
||||
<p class="muted" id="ddNote">배포 후에는 같은 서버로의 재배포가 정보 입력 없이 바로 진행됩니다.</p>
|
||||
<div class="row"><button type="button" id="ddGo" class="primary">이 정보로 배포</button><button type="button" id="ddCancel" class="ghost">취소</button><span id="ddMsg" class="muted"></span></div>
|
||||
</div></div>
|
||||
|
||||
<section class="card tab hidden" id="tab-detail">
|
||||
<h2><span id="dTitle"></span></h2>
|
||||
<div class="row">
|
||||
<a id="dOpen" class="btn ghost" target="_blank" rel="noopener">사이트 열기</a>
|
||||
<a id="dReport" class="btn ghost" target="_blank" rel="noopener">검증 리포트</a>
|
||||
<button type="button" id="dCancel" class="ghost warn hidden">실행 중 작업 중단</button>
|
||||
<button type="button" id="dDelete" class="ghost danger">사이트 삭제</button>
|
||||
<span class="spacer"></span>
|
||||
<button type="button" id="dBack" class="ghost">← 목록</button>
|
||||
</div>
|
||||
<div class="deploy-box">
|
||||
<div class="field-label">배포</div>
|
||||
<div class="row">
|
||||
<span class="muted">배포 서버</span><select id="dTarget" style="width:auto;margin:0"></select>
|
||||
<span class="muted">도메인</span><input id="dDomains" placeholder="www.example.com, example.com" style="width:260px;margin:0">
|
||||
<button type="button" id="dDeploy" class="primary">이 서버로 지금 배포</button>
|
||||
<button type="button" id="dAccess" class="ghost">접속 확인</button>
|
||||
<button type="button" id="dSsl" class="ghost">SSL 발급/갱신</button>
|
||||
<button type="button" id="dRollback" class="ghost warn" title="배포 서버에 보관된 직전 배포본으로 운영 주소를 되돌립니다 (v1.6.31)">이전 배포본으로</button>
|
||||
</div>
|
||||
<div id="dDeployResult" class="deploy-result hidden"></div>
|
||||
<div class="table-wrap"><table class="adm-t" id="depTable"><thead><tr><th>일시</th><th>서버</th><th>결과</th><th>URL</th><th>내용</th><th>처리자</th></tr></thead><tbody></tbody></table></div>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div>
|
||||
<div class="field-label">명령 이력 (클릭하면 상세 로그)</div>
|
||||
<div class="table-wrap"><table class="adm-t" id="cmdTable"><thead><tr><th>#</th><th>종류</th><th>모델</th><th>상태</th><th>시작</th><th>토큰</th><th>비용</th><th>오류</th></tr></thead><tbody></tbody></table></div>
|
||||
<div class="field-label">버전</div>
|
||||
<div class="table-wrap"><table class="adm-t" id="verTable"><thead><tr><th>#</th><th>종류</th><th>시각</th><th>내용</th></tr></thead><tbody></tbody></table></div>
|
||||
<div class="field-label">기획 상태 / Brief</div>
|
||||
<pre class="json" id="dPlan"></pre>
|
||||
</div>
|
||||
<div>
|
||||
<div class="field-label">상세 로그 <span class="muted" id="dLogHint"></span></div>
|
||||
<pre id="dLog" class="log"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- v1.3.2: "오류 로그" → "로그 보기". 종류(오류·경고·중단·배포·완료·상세)·기간·회사(회원)·사이트·검색어로 찾아본다 -->
|
||||
<section class="card tab hidden" id="tab-errors">
|
||||
<h2>로그 보기 <span class="muted" id="lgSummary"></span> <span class="muted">(사용자에게는 보이지 않는 상세 원인 포함)</span></h2>
|
||||
<div class="lg-filters">
|
||||
<div class="lg-kinds" id="lgKinds"></div>
|
||||
<div class="row">
|
||||
<select id="lgRange" style="width:auto;margin:0"><option value="1">오늘</option><option value="7" selected>최근 7일</option><option value="30">최근 30일</option><option value="90">최근 90일</option><option value="all">전체</option><option value="custom">직접 지정</option></select>
|
||||
<input id="lgFrom" type="date" class="hidden" style="width:auto;margin:0"><input id="lgTo" type="date" class="hidden" style="width:auto;margin:0">
|
||||
<select id="lgOwner" style="width:auto;margin:0"><option value="">모든 회사(회원)</option></select>
|
||||
<select id="lgSite" style="width:auto;margin:0;max-width:260px"><option value="">모든 사이트</option></select>
|
||||
<input id="lgQ" placeholder="내용 검색 (예: 시간 초과, 403)" style="flex:1;min-width:180px;margin:0">
|
||||
<button type="button" id="lgReset" class="ghost">조건 초기화</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap lg-wrap"><table class="adm-t" id="errTable"><thead><tr><th>시각</th><th>종류</th><th>회사(회원)</th><th>사이트</th><th>작업</th><th>내용</th></tr></thead><tbody></tbody></table></div>
|
||||
<p class="muted" id="lgMore"></p>
|
||||
</section>
|
||||
|
||||
<section class="card tab hidden" id="tab-billing">
|
||||
<h2>모델별 사용량 · 비용</h2>
|
||||
<div class="table-wrap"><table class="adm-t" id="modelTable"><thead><tr><th>모델</th><th>호출</th><th>입력</th><th>출력</th><th>캐시 생성</th><th>캐시 읽기</th><th>토큰 합계</th><th>비용(USD)</th><th>단가(입력/출력, $/1M)</th></tr></thead><tbody></tbody></table></div>
|
||||
<div class="fx-line" id="fxLine"></div>
|
||||
<!-- v1.6.40 (요구 1): 선불 충전 상품 · 주문 — 사용자 화면 "캐시·충전" 에서 고르는 상품(금액 + 인센티브)과, PG 미연동 상태에서 접수된 결제 대기 주문의 입금 확인(적립)·취소 -->
|
||||
<h2 style="margin-top:16px">선불 충전 상품 · 주문 <span id="ppBadge" class="badge-n hidden" title="결제 대기 주문"></span></h2>
|
||||
<p class="muted">사용자 화면 "캐시 · 충전" 에 보이는 선불 충전 상품입니다. 적립 캐시 = 결제 금액 + 추가 인센티브(정률 % 또는 정액). 결제 대행(PG)이 아직 연동되지 않아 사용자의 "결제 진행" 은 <b>결제 대기 주문</b>만 만듭니다 — 입금(결제)을 확인한 뒤 아래 "입금 확인 → 적립" 을 누르면 적립 금액이 캐시 원장에 더해집니다(메모에 비밀값은 적지 마세요).</p>
|
||||
<div class="grid">
|
||||
<div>
|
||||
<div class="field-label">상품</div>
|
||||
<div class="table-wrap"><table class="adm-t" id="ppTable"><thead><tr><th>이름</th><th>결제 금액</th><th>인센티브</th><th>적립 캐시</th><th>표시</th><th>순서</th><th>작업</th></tr></thead><tbody></tbody></table></div>
|
||||
<div class="field-label" style="margin-top:8px"><span id="ppFormTitle">새 상품</span></div>
|
||||
<div class="charge-form pp-form">
|
||||
<input type="hidden" id="ppId">
|
||||
<input id="ppName" placeholder="이름 (예: 베이직 1만원)" maxlength="40">
|
||||
<input id="ppAmount" type="number" min="1000" step="1000" placeholder="결제 금액(원)">
|
||||
<select id="ppBonusType"><option value="percent">정률 인센티브 (%)</option><option value="fixed">정액 인센티브 (원)</option></select>
|
||||
<input id="ppBonusValue" type="number" min="0" step="1" placeholder="인센티브 값">
|
||||
<input id="ppSort" type="number" step="1" placeholder="표시 순서 (작을수록 먼저)" style="width:150px">
|
||||
<label class="inline" style="margin:0;display:inline-flex;align-items:center;gap:4px"><input type="checkbox" id="ppActive" checked style="width:auto;margin:0"> 사용자에게 표시</label>
|
||||
<input id="ppNote" placeholder="설명 (선택, 상품 카드에 보임)" maxlength="200">
|
||||
<button type="button" id="ppSave" class="primary">저장</button><button type="button" id="ppReset" class="ghost">새 상품</button>
|
||||
</div>
|
||||
<p class="muted" id="ppMsg"></p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="field-label">충전 주문 <select id="ppOrderFilter" style="width:auto;margin:0 0 0 6px"><option value="">모든 상태</option><option value="pending" selected>결제 대기</option><option value="paid">적립 완료</option><option value="cancelled">취소</option></select></div>
|
||||
<div class="table-wrap ledger-scroll"><table class="adm-t" id="ppOrders"><thead><tr><th>일시</th><th>회원</th><th>상품</th><th>결제 금액</th><th>적립</th><th>상태</th><th>작업</th></tr></thead><tbody></tbody></table></div>
|
||||
<p class="muted" id="ppOrderMsg"></p>
|
||||
</div>
|
||||
</div>
|
||||
<h2 style="margin-top:16px">회원별 캐시 · 사용</h2>
|
||||
<p class="muted">캐시(<span class="cash-unit">원</span>)는 관리자가 설정·추가하며 작업이 끝날 때마다 사용액이 차감됩니다. 캐시가 0 이하이면 새 작업을 시작할 수 없고, 진행 중 소진되면 작업이 멈췄다가 충전 시 자동으로 이어집니다. 외부 서비스도 서비스 키로 조정할 수 있습니다.</p>
|
||||
<div class="table-wrap"><table class="adm-t" id="billTable"><thead><tr><th>회원</th><th>역할</th><th>사이트</th><th>토큰</th><th>비용(USD)</th><th>사용 캐시(누적)</th><th>캐시 잔액</th><th>충전 대기</th><th>작업</th></tr></thead><tbody></tbody></table></div>
|
||||
<div id="billDetail" class="hidden">
|
||||
<h3 id="billDetailTitle"></h3>
|
||||
<div class="grid">
|
||||
<div><div class="field-label">일자별 정산</div><div class="table-wrap"><table class="adm-t" id="dayTable"><thead><tr><th>일자</th><th>명령</th><th>적용 환율</th><th>USD</th><th>원화 정산</th><th>청구</th></tr></thead><tbody></tbody></table></div></div>
|
||||
<div><div class="field-label">캐시 충전 · 조정</div>
|
||||
<div class="charge-form"><input id="chargeAmount" type="number" placeholder="금액(원)"><input id="chargeMemo" placeholder="메모 (예: 계좌이체 확인, 월 기본 제공)"><button type="button" id="btnCharge" class="primary">캐시 추가</button><button type="button" id="btnSetCredit" class="ghost">이 값으로 설정</button></div>
|
||||
<p class="muted">"캐시 추가"는 현재 잔액에 더합니다(음수면 차감). "이 값으로 설정"은 잔액을 입력한 값으로 맞춥니다. 캐시 소진으로 멈춘 작업은 충전 즉시 이어서 진행됩니다.</p>
|
||||
<div class="field-label">캐시 원장</div><div class="table-wrap ledger-scroll"><table class="adm-t" id="chargeTable"><thead><tr><th>일시</th><th>종류</th><th>금액(<span class="cash-unit">원</span>)</th><th>잔액</th><th>메모</th><th>처리자</th></tr></thead><tbody></tbody></table></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card tab hidden" id="tab-deploy">
|
||||
<h2>배포 서버 <span class="muted">— 하나의 서비스 서버가 여러 웹호스팅 서버를 관리합니다. 기본 배포 서버는 사용자가 "최종 확정(발행)"할 때 자동으로 사용됩니다.</span></h2>
|
||||
<div class="table-wrap"><table class="adm-t" id="targetTable"><thead><tr><th>기본</th><th>이름</th><th>종류</th><th>접속</th><th>사이트 저장 경로</th><th>공개 URL(선택)</th><th>키</th><th>sudo</th><th>SSL</th><th>마지막 테스트</th><th>작업</th></tr></thead><tbody></tbody></table></div>
|
||||
<!-- v1.5.13 (요구 4): 추가·수정 폼은 화면에 늘 보이지 않고 "배포 서버 추가" 또는 행의 "수정" 을 눌렀을 때만 대화상자(tDialog)로 열린다 — 실수로 고치는 일을 막고 목록이 넓게 보이도록 -->
|
||||
<div class="row" style="margin-top:12px"><button type="button" id="tNew" class="primary">배포 서버 추가</button><span id="tListMsg" class="muted"></span></div>
|
||||
<div style="margin-top:14px">
|
||||
<h2>등록 안내</h2>
|
||||
<p class="muted">1. 원격 서버를 저장한 뒤 행의 <b>키 생성</b>을 누르면 이 서비스 서버에 전용 SSH 키가 만들어지고, 공개키가 별도 창에 보입니다. <b>복사</b> 버튼으로 복사해 배포 서버의 <code>~/.ssh/authorized_keys</code> 에 한 줄로 등록하세요(v1.5.13). 이미 쓰는 키가 있으면 수정 창에서 개인키를 붙여넣어도 됩니다.</p>
|
||||
<p class="muted">2. <b>연결 테스트</b>로 접속과 저장 경로 쓰기 권한을 확인합니다.</p>
|
||||
<p class="muted">3. 배포 서버의 웹서버(nginx 등)가 저장 경로를 <code>공개URL/<사이트ID>/</code> 로 서비스하도록 설정합니다. 예시 nginx 설정은 README 를 참고하세요.</p>
|
||||
<p class="muted">4. <b>기본으로</b> 지정한 서버가 사용자의 최종 확정(발행) 시 자동 배포 대상이 됩니다. 사이트별로 다른 서버를 지정하려면 사이트 상세 화면에서 선택하세요.</p>
|
||||
</div>
|
||||
<!-- 배포 서버 추가·수정 대화상자 (입력칸 id 는 1.5.11 까지의 인라인 폼과 같다 — admin.js fillTargetForm/tSave 가 그대로 쓴다) -->
|
||||
<dialog id="tDialog" class="wm-dialog wide"><form method="dialog" id="tForm">
|
||||
<h2 id="tFormTitle">배포 서버 추가</h2>
|
||||
<input type="hidden" id="tId">
|
||||
<label class="radio"><input type="radio" name="tType" value="ssh" checked> 원격 서버 (SSH)</label>
|
||||
<label class="radio"><input type="radio" name="tType" value="local"> 이 서비스 서버의 디렉터리 (로컬)</label>
|
||||
<input id="tName" placeholder="이름 (예: 호스팅 1호기) — 필수" title="비워 두면 '이름을 입력하세요' 오류로 저장되지 않습니다">
|
||||
<div class="two"><input id="tHost" placeholder="서버 주소 (IP 또는 호스트명) — SSH 서버는 필수" title="비워 두면 '서버 주소(host)를 입력하세요' 오류가 나고, 잘못 적으면 연결 테스트에서 'Could not resolve' 또는 'Connection refused' 로 실패합니다"><input id="tPort" type="number" placeholder="SSH 포트 (기본 22)" data-plain></div>
|
||||
<input id="tUser" placeholder="SSH 사용자 (예: deploy) — 비우면 root" title="공개키를 등록한 계정과 다르면 'Permission denied (publickey)' 로 실패합니다">
|
||||
<input id="tBaseDir" placeholder="사이트 저장 경로 (예: /var/www/sites) — 필수, / 로 시작" title="비워 두거나 / 로 시작하지 않으면 저장되지 않습니다. 접속 사용자에게 쓰기 권한이 없으면 연결 테스트가 '쓰기 권한 없음' 으로 실패하고 배포가 되지 않습니다">
|
||||
<input id="tPublicUrl" placeholder="공개 URL (선택, 예: https://host.example.com) — 있으면 배포 후 공개URL/사이트ID/ 링크를 표시합니다">
|
||||
<input id="tNote" placeholder="메모 (선택)">
|
||||
<label class="radio"><input type="checkbox" id="tVhost" checked> 배포 시 도메인 vhost 자동 설정 (배포 서버에 nginx 필요)</label>
|
||||
<label class="radio"><input type="checkbox" id="tSsl" checked> 배포 시 Let's Encrypt SSL 인증서 자동 발급 (certbot, DNS 가 이 서버를 가리키는 도메인만)</label>
|
||||
<label class="radio"><input type="checkbox" id="tSudo"> 이 서버에서 sudo 명령 사용 허용 (nginx 설정 쓰기·reload, certbot 실행에 필요 — 허용하지 않으면 파일만 배포)</label>
|
||||
<div class="field-label">SSH 개인키 (선택) — 비워 두고 저장한 뒤 "키 생성"으로 이 서버가 키를 만들게 할 수도 있습니다</div>
|
||||
<textarea id="tKey" rows="4" placeholder="-----BEGIN OPENSSH PRIVATE KEY----- … 붙여넣기"></textarea>
|
||||
<div class="row"><button type="button" id="tSave" class="primary">저장</button><button type="button" id="tCancel" class="ghost">취소</button><span id="tMsg" class="muted"></span></div>
|
||||
</form></dialog>
|
||||
<!-- v1.5.13 (요구 3): 생성된 공개키는 별도 창에 한 줄로 보이고 버튼으로 복사한다 — 화면에서 직접 긁어 복사하다 줄이 잘리거나 앞뒤가 섞이는 실수를 막는다 -->
|
||||
<dialog id="tKeyDialog" class="wm-dialog wide"><form method="dialog">
|
||||
<h2 id="tKeyTitle">공개키</h2>
|
||||
<p class="muted" id="tKeyNote">배포 서버의 <code>~/.ssh/authorized_keys</code> 에 아래 한 줄을 그대로 추가하세요 (접속 사용자의 홈). 줄바꿈 없이 한 줄입니다.</p>
|
||||
<textarea id="tKeyText" rows="4" readonly style="width:100%;font-family:ui-monospace,monospace;font-size:12px;white-space:pre;overflow-x:auto"></textarea>
|
||||
<div class="row"><button type="button" id="tKeyCopy" class="primary">공개키 복사</button><button type="button" id="tKeyClose" class="ghost">닫기</button><span id="tKeyMsg" class="muted"></span></div>
|
||||
</form></dialog>
|
||||
</section>
|
||||
|
||||
<!-- v1.6.31 (요구 20): 외부 작업 복구 — 정상 완료되지 않은 배포·구매·메일 계정·인증서·데이터 파일 -->
|
||||
<section class="card tab hidden" id="tab-recovery">
|
||||
<h2>운영 복구 <span class="muted">— 정상 완료되지 않은 외부 작업과 손상 파일</span> <button type="button" id="rcRefresh" class="ghost">새로고침</button></h2>
|
||||
<p class="muted">사이트·사용자·진행 단계·실패 원인·사용자가 할 일을 보여 주고, 다시 실행할 수 있는 작업은 여기서 재시도합니다(이미 끝난 단계는 다시 하지 않습니다). 자동으로 해결할 수 없는 것은 "관리자 확인 필요" 로 표시됩니다.</p>
|
||||
<div class="table-wrap"><table class="adm-t" id="rcTable"><thead><tr><th>종류</th><th>사이트 / 사용자</th><th>상태</th><th>시각</th><th>내용</th><th>할 일</th><th>작업</th></tr></thead><tbody></tbody></table></div>
|
||||
<p class="muted" id="rcMsg"></p>
|
||||
</section>
|
||||
<!-- v1.6.31 (요구 21): 관리자 작업 기록 -->
|
||||
<section class="card tab hidden" id="tab-audit">
|
||||
<h2>작업 기록 <span class="muted">— 누가 · 언제 · 어떤 사이트/사용자에 · 무엇을 (비밀값은 기록하지 않음)</span></h2>
|
||||
<div class="row"><input id="auUser" placeholder="사용자 ID" style="width:140px;margin:0"><input id="auSite" placeholder="사이트 ID" style="width:200px;margin:0"><input id="auAction" placeholder="작업 (예: deploy., auth., setting)" style="width:180px;margin:0"><input id="auQ" placeholder="검색어" style="flex:1;margin:0"><button type="button" id="auSearch" class="primary">조회</button></div>
|
||||
<div class="table-wrap lg-wrap"><table class="adm-t" id="auTable"><thead><tr><th>시각</th><th>누가</th><th>작업</th><th>대상</th><th>사이트</th><th>결과</th><th>내용</th></tr></thead><tbody></tbody></table></div>
|
||||
</section>
|
||||
<section class="card tab hidden" id="tab-feedback">
|
||||
<h2>사용자 문의 <span class="muted" id="fbSummary"></span> <select id="fbTypeFilter" style="width:auto;margin:0 0 0 8px"><option value="">모든 종류</option><option value="inquiry">문의</option><option value="bug">오류</option><option value="suggestion">제안</option><option value="request">요구</option></select> <span class="spacer"></span><button type="button" class="ghost" id="fbBacklogBtn">다음 작업 반영 목록</button></h2>
|
||||
<div id="fbBacklog" class="hidden card" style="margin-bottom:10px"><div class="row"><b>다음 작업 반영 목록</b><span class="muted" id="fbBacklogInfo"></span><span class="spacer"></span><button type="button" class="ghost" id="fbBacklogCopy">마크다운 복사</button><button type="button" class="ghost" id="fbBacklogClose">닫기</button></div><pre class="log" id="fbBacklogMd" style="height:auto;max-height:360px;white-space:pre-wrap"></pre></div>
|
||||
<p class="muted">사용자가 올린 문의·제안·오류·요구 사항입니다. 글이 올라오면 고급 등급 모델이 종류에 맞게 1차 분석(문의: 질문 파악과 답변 초안, 오류: 추정 원인·재현 조건, 제안·요구: 필요성·작업량)을 해 댓글로 붙이며, 분석 비용은 사용자에게 청구하지 않고 여기 표시됩니다.</p>
|
||||
<div class="table-wrap"><table class="adm-t" id="fbTable"><thead><tr><th>접수</th><th>종류</th><th>제목</th><th>사용자</th><th>사이트</th><th>AI 분석</th><th>분석 비용</th><th>다음 작업</th><th>상태</th></tr></thead><tbody></tbody></table></div>
|
||||
<div id="fbDetail" class="hidden" style="margin-top:12px">
|
||||
<h3 id="fbdTitle"></h3>
|
||||
<div class="grid">
|
||||
<div><div class="field-label">내용</div><pre class="log" id="fbdBody" style="height:auto;max-height:260px;white-space:pre-wrap"></pre><div class="field-label">사용 환경 (신고 시 자동 수집)</div><pre class="log" id="fbdEnv" style="height:auto;max-height:180px;white-space:pre-wrap;font-size:11px"></pre><div class="field-label">댓글 (AI 분석 · 관리자 답변)</div><div id="fbdComments"></div></div>
|
||||
<div><div class="field-label">다음 작업 반영 <span class="muted">— AI 분석을 보고 결정</span></div>
|
||||
<label class="radio"><input type="checkbox" id="fbdPlanned"> 다음 수정(릴리스)에 반영</label>
|
||||
<textarea id="fbdPlanNote" rows="5" placeholder="반영할 내용을 구체적으로 적어 주세요. 예) 관리 탭 수정 지시 입력창 아래에 '색상 변경', '문구 수정', '섹션 추가' 예시 버튼 3개를 두고, 누르면 입력창에 예시 문장을 채운다."></textarea>
|
||||
<p class="muted" id="fbdPlanInfo"></p>
|
||||
<div class="field-label">처리</div><select id="fbdStatus"><option value="new">새 글 (접수)</option><option value="reviewing">검토 중 (AI 검토)</option><option value="done">처리 완료 (관리자 답변)</option></select><p class="muted" style="margin:4px 0">답변을 적어 저장하면 자동으로 "처리 완료"가 되고, 사용자 화면의 "내 제안 · 오류 신고" 목록에 답변이 표시됩니다. 제안은 반려하지 않습니다.</p><textarea id="fbdReply" rows="4" placeholder="관리자 답변 (사용자 화면의 내 제안 목록에 표시)"></textarea><div class="row"><button type="button" id="fbdSave" class="primary">저장</button><button type="button" id="fbdDraft" class="ghost hidden">AI 답변 초안 넣기</button><button type="button" id="fbdReanalyze" class="ghost">AI 다시 분석</button><span id="fbdMsg" class="muted"></span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card tab hidden" id="tab-i18n">
|
||||
<h2>언어 (다국어) <span class="muted" id="i18nSource"></span></h2>
|
||||
<p class="muted">사용자 화면 문구(기본 한국어)를 다른 언어로 보여 주는 기능입니다. ① "원본 파일 내려받기"로 한국어 문구 목록(JSON)을 받아 translation 을 채우고, lang(언어 코드)·name 을 적습니다. ② 아래에 끌어다 놓아 올립니다(JSON 여러 개 또는 ZIP 가능). ③ 켜기/끄기로 노출을 조절합니다. 서비스 버전이 올라가 문구가 바뀌면 그보다 낮은 버전에서 번역한 언어는 자동으로 숨겨지며, 새 원본 파일로 다시 번역해 올리면 됩니다.</p>
|
||||
<div class="row"><a class="btn ghost" id="i18nExport" href="/api/admin/i18n/export" download>원본(한국어) 파일 내려받기</a><span class="muted" id="i18nMsg"></span></div>
|
||||
<div class="dropzone" id="i18nDrop" style="margin:10px 0;padding:18px;text-align:center;border:2px dashed #475569;border-radius:10px">번역 파일(JSON)이나 ZIP 을 여기에 끌어다 놓거나 <label class="dz-pick" for="i18nFiles" style="text-decoration:underline;cursor:pointer">클릭해서 선택</label><input id="i18nFiles" type="file" multiple accept=".json,.zip" class="hidden"></div>
|
||||
<div id="i18nResults"></div>
|
||||
<div class="table-wrap"><table class="adm-t" id="i18nTable"><thead><tr><th>언어</th><th>표시 이름</th><th>번역 항목</th><th>번역 기준 버전</th><th>올린 날짜</th><th>상태</th><th>작업</th></tr></thead><tbody></tbody></table></div>
|
||||
<!-- v1.3.16: 서비스 화면 기본 언어 (관리자 강제 → 사용자는 접속 후 자기 선호 언어로 바꿀 수 있음) -->
|
||||
<h2 style="margin-top:16px">서비스 화면 기본 언어</h2>
|
||||
<p class="muted">빌더·관리자 콘솔·사이트 관리자·로그인 화면의 기본 언어입니다. "모든 사용자에게 지금 적용" 을 켜고 적용하면, 그 전에 사용자가 골라 둔 언어보다 이 언어가 우선합니다. 사용자는 접속한 뒤 화면 위 언어 선택으로 자기 선호 언어를 다시 고를 수 있고, 그 선택은 계정에 저장됩니다. 표시 중인(번역이 켜진) 언어만 고를 수 있습니다.</p>
|
||||
<div class="row"><select id="uiDefLang" style="width:auto;margin:0" data-dirty-watch></select><label class="radio" style="margin:0"><input type="checkbox" id="uiDefForce"> 모든 사용자에게 지금 적용(강제)</label><button type="button" id="uiDefSave" class="primary">적용</button><span id="uiDefMsg" class="muted"></span></div>
|
||||
<!-- v1.3.16: 생성 사이트 시스템 문구 (locales/site/ko.json) — 위젯·폼·오류·동의문·예약·문의·메일 알림·법적 고지 -->
|
||||
<h2 style="margin-top:16px">생성 사이트 시스템 문구 <span class="muted" id="smSummary"></span></h2>
|
||||
<p class="muted">사이트에 들어가는 모듈 위젯·폼·오류 메시지·동의문·쿠키·법적 고지·소유자 알림 메일의 글자입니다. 코드에는 문구 id 만 있고 원문은 <code>locales/site/ko.json</code> 에 있습니다. 다국어 사이트가 어떤 언어를 처음 쓰면 AI 가 그 언어 번역을 채우며(서비스 공용), 여기서 언어별로 고칠 수 있습니다(고친 번역이 우선).</p>
|
||||
<div class="row"><select id="smLang" style="width:auto;margin:0"></select><input id="smQ" placeholder="문구·id 찾기" style="width:200px;margin:0"><label class="radio" style="margin:0"><input type="checkbox" id="smMissing"> 번역 없는 것만</label><button type="button" id="smSave" class="primary">고친 번역 저장</button><span id="smMsg" class="muted"></span></div>
|
||||
<div class="table-wrap" style="max-height:420px;overflow:auto"><table class="adm-t" id="smTable"><thead><tr><th>id</th><th>묶음</th><th>원문(한국어)</th><th>번역</th><th>출처</th></tr></thead><tbody></tbody></table></div>
|
||||
</section>
|
||||
|
||||
<!-- v1.3.10: 사이트 템플릿 — 관리자가 만든 사이트를 템플릿으로, 사용자 사용 허용, 해시태그·디렉터리 -->
|
||||
<section class="card tab hidden" id="tab-templates">
|
||||
<h2>사이트 템플릿 <span class="muted" id="tpSummary"></span> <span class="spacer"></span><a class="btn primary" href="/builder" target="_blank" rel="noopener">빌더에서 사이트 만들기</a></h2>
|
||||
<p class="muted">관리자가 빌더로 만든 사이트를 템플릿으로 등록하고, "사용자 사용 허용" 을 켠 템플릿만 사용자 생성 탭의 "템플릿에서 시작" 에 보입니다. 디렉터리는 업종(대분류 › 세부 업종) 구조를 기본으로 쓰고, 필요하면 별도 디렉터리를 만듭니다. #해시태그로 검색할 수 있습니다.</p>
|
||||
<div class="grid">
|
||||
<div>
|
||||
<h3>디렉터리</h3>
|
||||
<div id="tpTree" class="tp-tree"></div>
|
||||
<div class="row" style="margin-top:6px"><input id="tpDirName" placeholder="새 디렉터리 이름" style="flex:1;margin:0"><select id="tpDirParent" style="width:auto;margin:0"></select><button type="button" id="tpDirAdd" class="ghost">만들기</button></div>
|
||||
<h3 style="margin-top:14px">템플릿 등록</h3>
|
||||
<label>관리자가 만든 사이트<select id="tpSite"></select></label>
|
||||
<label>템플릿 이름<input id="tpTitle"></label>
|
||||
<div class="row"><select id="tpDir" style="flex:1;margin:0"></select><input id="tpTags" placeholder="#카페 #미니멀" style="flex:1;margin:0"></div>
|
||||
<label>설명<textarea id="tpDesc" rows="2"></textarea></label>
|
||||
<div class="row"><button type="button" id="tpRegister" class="primary">템플릿으로 등록</button><span class="muted" id="tpMsg"></span></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="row"><input id="tpQ" placeholder="#해시태그 또는 낱말로 찾기" style="flex:1;margin:0"><span class="muted" id="tpDirNow"></span></div>
|
||||
<div class="table-wrap"><table class="adm-t" id="tpTable"><thead><tr><th>템플릿</th><th>디렉터리</th><th>해시태그</th><th>사용자 사용 허용</th><th>파일</th><th>담은 날</th><th>작업</th></tr></thead><tbody></tbody></table></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- v1.3.5: 검색사이트 등록 — 사람이 해야 하는 작업(확인 코드·DNS·검색사이트에서 확인·사이트맵 제출)을 관리자가 진행 -->
|
||||
<section class="card tab hidden" id="tab-search">
|
||||
<h2>검색 등록 <span class="muted" id="srSummary"></span></h2>
|
||||
<p class="muted">사용자가 관리 탭에서 구글·네이버 등록을 시작한 사이트입니다. 여기에는 서비스가 대신할 수 없어 <b>사람이 해야 하는 작업</b>만 보입니다(v1.3.10). 구글은 연동 키에 서비스 계정 키를 넣으면 확인 코드 받기부터 사이트맵 제출까지 모두 자동이고, 네이버는 공개 API 가 없어 "사이트 추가·확인 코드 받기" 와 "확인 누르기" 만 사람이 합니다(코드를 넣으면 태그 넣기·재배포·운영 확인·IndexNow 색인 요청은 자동). 배포가 끝난 사이트만 진행됩니다.</p>
|
||||
<label class="radio"><input type="checkbox" id="srPendingOnly" checked> 수동 작업이 남은 사이트만</label>
|
||||
<label class="radio"><input type="checkbox" id="srShowAuto"> 자동 단계도 보기</label>
|
||||
<div id="srList"></div>
|
||||
</section>
|
||||
|
||||
<!-- v1.3.2: 메일 시스템 — 사이트(호스팅 도메인) 단위로 켜고 설정한다 -->
|
||||
<section class="card tab hidden" id="tab-mail">
|
||||
<h2>메일 시스템 <span class="muted">— 사이트(호스팅 도메인)마다 켜고 끕니다</span></h2>
|
||||
<p class="muted">사이트를 골라 "메일 시스템 사용" 을 누르고 메일 도메인·메일 서버 주소 등을 입력하면, 그 도메인에 등록할 DNS 레코드가 표시됩니다. 도메인 소유자가 DNS 를 등록하고 전파가 확인되면 사용자 화면이 "사용 가능" 으로 바뀌고, 그 전에는 "메일시스템 설정중" 으로 보입니다. 메일 주소는 "아이디@메일도메인" 이며 사이트 관리자 계정과 같은 계정을 씁니다.</p>
|
||||
<p class="muted" id="msBackend"></p>
|
||||
<!-- v1.6.38 (요구 2): 메일 서버(Arimail) 등록부 — 웹메이커와 다른 서버에 설치된 Arimail 을 여러 대 등록하고 닉네임·IP 로 구분해 사이트마다 고른다 -->
|
||||
<h3>메일 서버 (Arimail) 연동 <span class="muted">— 받는 메일·웹메일·메일 계정을 맡는 서버. 다른 서버에 설치된 Arimail 도 여러 대 등록할 수 있습니다</span></h3>
|
||||
<p class="muted">Arimail 한 대는 메일 도메인 하나를 다룹니다(계정 = 아이디@메일도메인). 사이트의 메일 도메인과 같은 도메인을 다루는 서버가 자동으로 쓰이고, 아래 사이트 설정에서 서버를 직접 고를 수도 있습니다. 비밀번호는 암호화 보관되며 화면·기록에는 나오지 않습니다. "기본" 서버는 도메인이 맞는 서버가 없을 때 쓰입니다. 설정 파일(mail.*)에 등록된 서버는 "기본 서버(설정 파일)" 로 함께 보입니다.</p>
|
||||
<div class="table-wrap"><table class="adm-t" id="mvTable"><thead><tr><th>닉네임</th><th>서버 IP</th><th>주소 (웹메일 / API)</th><th>메일 도메인</th><th>관리자 ID</th><th>연결 확인</th><th>사용량 API</th><th>기본</th><th>작업</th></tr></thead><tbody></tbody></table></div>
|
||||
<div class="row"><button type="button" id="mvAdd" class="ghost">메일 서버 추가</button><span id="mvMsg" class="muted"></span></div>
|
||||
<!-- v1.7.3 (요구 2): 메일 사용량 — Arimail 1.0.9 사용량 API(모든 도메인 요약). 표의 [사용량] 또는 [키 발급] 뒤에 열린다 -->
|
||||
<div id="mvUsage" class="card hidden" style="margin-top:8px">
|
||||
<div class="row"><h3 id="mvUsageTitle" style="margin:0">메일 사용량</h3><button type="button" id="mvUsageRefresh" class="ghost small">새로 받기</button><button type="button" id="mvUsageClose" class="ghost small">닫기</button></div>
|
||||
<p class="muted" id="mvUsageMsg"></p>
|
||||
<div class="table-wrap"><table class="adm-t" id="mvUsageTable"><thead><tr><th>메일 도메인</th><th>계정</th><th>메일함 용량</th><th>메일 수</th><th>오늘 발송/수신</th><th>이번 주</th><th>이달</th><th>이달 발송/수신 바이트</th></tr></thead><tbody></tbody></table></div>
|
||||
<p class="muted small">Arimail 사용량 API(1.0.9 이상)가 준 값입니다 — 메일함 용량은 저장된 원본(.eml) 크기 합, 발송은 발송 완료 시각·수신은 받은 시각 기준, 시간대는 Arimail 통계 시간대(기본 Asia/Seoul). 5분 캐시. 같은 값이 사용자 화면(사이트 정보 › 디스크 사용량 "메일 데이터", 메일 › 한눈에 보기)에도 보입니다.</p>
|
||||
</div>
|
||||
<!-- v1.6.39 (요구 4): 저장된 비밀번호로 로그인이 안 될 때 — Arimail 의 다른 관리자 계정(초기 admin 등)으로 한 번 로그인해 연동 계정 비밀번호를 다시 만든다. 여기 넣은 비밀번호는 저장하지 않는다 -->
|
||||
<div id="mvReset" class="card hidden" style="margin-top:8px">
|
||||
<h3 id="mvRvTitle">다른 관리자 계정으로 재설정</h3>
|
||||
<p class="muted">저장된 비밀번호로는 Arimail 에 로그인되지 않습니다. Arimail 관리자 페이지(/admin)에 들어갈 수 있는 <b>다른 관리자 계정</b>(예: 초기 계정 admin)의 아이디·비밀번호를 넣으면, 그 계정으로 한 번 로그인해 연동 계정의 비밀번호를 새 무작위 값으로 바꾸고 보관합니다. 여기 넣은 비밀번호는 저장하지 않습니다. 연동 계정이 Arimail 에 없으면 관리자 역할로 새로 만듭니다.</p>
|
||||
<div class="row">
|
||||
<input id="mvRvLogin" placeholder="Arimail 관리자 아이디 (예: admin)" autocomplete="off" style="max-width:220px">
|
||||
<input id="mvRvPw" type="password" placeholder="그 계정의 비밀번호 (1회용)" autocomplete="off" style="max-width:220px">
|
||||
<button type="button" id="mvRvGo" class="primary">이 계정으로 재설정</button><button type="button" id="mvRvCancel" class="ghost">닫기</button>
|
||||
</div>
|
||||
<p class="msg err" id="mvRvMsg"></p>
|
||||
</div>
|
||||
<div id="mvForm" class="card hidden" style="margin-top:8px">
|
||||
<h3 id="mvFormTitle">메일 서버 추가</h3>
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label>닉네임 <span class="muted">(서버를 고를 때 IP 와 함께 표시)</span><input id="mvNick" placeholder="예: ibit 메일 서버" autocomplete="off"></label>
|
||||
<label>서버 IP <span class="muted">(표시·구분용, 선택)</span><input id="mvIp" placeholder="예: 115.68.194.192" autocomplete="off"></label>
|
||||
<label>Arimail 주소 (공개 — 웹메일 주소로 안내)<input id="mvUrl" placeholder="https://webmail.example.com" autocomplete="off"></label>
|
||||
<label>API 주소 <span class="muted">(선택 — 서버 간 호출용. 같은 서버면 http://127.0.0.1:8790, 비우면 공개 주소)</span><input id="mvApi" placeholder="비우면 공개 주소 사용" autocomplete="off"></label>
|
||||
</div>
|
||||
<div>
|
||||
<label>메일 도메인 <span class="muted">(Arimail 환경 MAILBOX 의 도메인과 같아야 함)</span><input id="mvDomain" placeholder="example.com" autocomplete="off"></label>
|
||||
<label>관리자 로그인 ID <span class="muted">(Arimail /admin 의 관리자 역할 계정)</span><input id="mvLogin" placeholder="admin" autocomplete="off"></label>
|
||||
<label>관리자 비밀번호 <span class="muted" id="mvPwNote">(새 서버는 필수)</span><input id="mvPw" type="password" placeholder="암호화 보관 — 수정 때 비우면 유지" autocomplete="new-password"></label>
|
||||
<!-- v1.7.3 (요구 2): 사용량 API 키 — 보통은 비워 두고 표의 [키 발급] 으로 받는다. Arimail 전체 설정 화면에서 직접 만든 키를 붙여 넣는 칸 -->
|
||||
<label>사용량 API 키 <span class="muted" id="mvUkNote">(선택)</span><input id="mvUsageKey" type="password" placeholder="au_… — Arimail 전체 설정 › 사용량 API 카드에서 만든 키 (비우면 유지)" autocomplete="off"></label>
|
||||
<p class="muted small">메일 사용량 연동(Arimail 1.0.9 이상): 키는 암호화 보관되며 메일함 용량·메일 수·발송량을 읽는 데만 씁니다(읽기 전용). 표의 <b>키 발급</b>을 누르면 저장된 관리자 계정으로 Arimail 에서 키를 받아 자동으로 보관하므로 보통 이 칸은 비워 둡니다. Arimail 쪽 "접속 허용 IP" 를 쓰면 이 웹메이커 서버의 공인 IP(같은 서버면 127.0.0.1 도)를 넣어야 합니다.</p>
|
||||
<p class="muted small">수정할 때 이 칸에 값을 넣으면 <b>저장된 비밀번호가 바뀝니다</b>(저장 전에 확인 창). 틀린 값으로 바꾸면 메일 계정 관리가 멈추므로, 비밀번호를 그대로 두려면 비워 두세요. 바꾼 뒤에는 표의 "이전 비밀번호로" 로 되돌릴 수 있습니다.</p>
|
||||
<div class="row"><button type="button" id="mvSave" class="primary">저장</button><button type="button" id="mvCancel" class="ghost">닫기</button></div>
|
||||
<p class="muted" id="mvFormMsg"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 style="margin-top:14px">사이트별 메일 시스템</h3>
|
||||
<div class="table-wrap"><table class="adm-t" id="msSites"><thead><tr><th>사이트</th><th>회사(회원)</th><th>연결된 도메인</th><th>메일 도메인</th><th>상태</th><th>메일 계정</th><th>작업</th></tr></thead><tbody></tbody></table></div>
|
||||
<div id="msPanel" class="card hidden" style="margin-top:12px">
|
||||
<h3 id="msPanelTitle"></h3>
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label>메일 도메인 <span class="muted">(메일 주소 = 아이디@이 도메인)</span><input id="msDomain" list="msDomainList" placeholder="example.com" autocomplete="off"><datalist id="msDomainList"></datalist></label>
|
||||
<!-- v1.6.38 (요구 2): 이 사이트의 받는 메일·웹메일을 맡을 메일 서버 (자동 = 메일 도메인이 같은 서버 → 기본 서버) -->
|
||||
<label>메일 서버 (Arimail) <span class="muted" id="msServerNote"></span><select id="msServer"></select></label>
|
||||
<label>메일 서버 주소 (MX 호스트) <span class="muted" id="msMxDefault"></span><input id="msMx" placeholder="비우면 기본값 사용 (예: mail.webmaker.ibit.kr)" autocomplete="off"></label>
|
||||
<div class="two"><label>SPF include (선택)<input id="msSpf" placeholder="비우면 기본값" autocomplete="off"></label><label>DMARC (선택)<input id="msDmarc" placeholder="비우면 기본값 v=DMARC1; p=none" autocomplete="off"></label></div>
|
||||
<div class="row"><button type="button" id="msApply" class="primary">메일 시스템 사용 · 설정 저장</button><button type="button" id="msCheck" class="ghost">DNS 다시 확인</button><button type="button" id="msOff" class="ghost danger">이 사이트 메일 끄기</button><button type="button" id="msClose" class="ghost">닫기</button></div>
|
||||
<p class="muted" id="msMsg"></p>
|
||||
</div>
|
||||
<div><div class="ms-status" id="msStatus"></div></div>
|
||||
</div>
|
||||
<div class="table-wrap"><table class="adm-t" id="msRecords"><thead><tr><th>종류</th><th>호스트(이름)</th><th>값</th><th>우선순위</th><th>용도</th><th>확인</th></tr></thead><tbody></tbody></table></div>
|
||||
</div>
|
||||
<h3 style="margin-top:16px">기본값 <span class="muted">— 사이트 설정에서 비워 둔 항목에 쓰입니다</span></h3>
|
||||
<div class="row"><input id="msDefMx" placeholder="기본 메일 서버 주소 (MX 호스트, 예: mail.webmaker.ibit.kr)" autocomplete="off" style="flex:1;margin:0"><input id="msDefSpf" placeholder="기본 SPF include (선택)" autocomplete="off" style="flex:1;margin:0"><input id="msDefDmarc" placeholder="기본 DMARC (선택)" autocomplete="off" style="flex:1;margin:0"><button type="button" id="msSave" class="primary">기본값 저장</button></div>
|
||||
<p class="muted" id="msDefMsg"></p>
|
||||
</section>
|
||||
|
||||
<!-- v1.3.2: 화면 설정 — 설정 탭에서 분리, 넓은 미리보기 -->
|
||||
<section class="card tab hidden" id="tab-theme">
|
||||
<h2>화면 설정 <span class="muted" id="thState"></span></h2>
|
||||
<p class="muted">사용자 화면(빌더)·로그인·사이트 관리자 페이지의 색상과 기본 구성을 바꿉니다. 바꾸는 즉시 오른쪽 미리보기에 반영되고, "저장" 을 눌러야 사용자에게 적용됩니다(사용자는 새로고침 시 반영). 만들어지는 고객 사이트의 디자인과는 관계없습니다.</p>
|
||||
<div class="th-layout">
|
||||
<div class="th-controls">
|
||||
<div class="row"><span class="muted">색 구성</span><select id="thPreset" style="width:auto;margin:0"></select></div>
|
||||
<div class="th-grid" id="thColors"></div>
|
||||
<div class="th-grid">
|
||||
<label>왼쪽 작업 패널 폭 <span class="muted" id="thLeftWidthV"></span><input id="thLeftWidth" type="range" min="360" max="720" step="10"></label>
|
||||
<label>글자 크기 <span class="muted" id="thFontSizeV"></span><input id="thFontSize" type="range" min="12" max="17" step="1"></label>
|
||||
<label>모서리 둥글기 <span class="muted" id="thRadiusV"></span><input id="thRadius" type="range" min="0" max="18" step="1"></label>
|
||||
<label>간격<select id="thDensity"><option value="compact">촘촘하게</option><option value="normal">보통</option><option value="comfortable">넉넉하게</option></select></label>
|
||||
<label>작업 패널 위치<select id="thPanelSide"><option value="left">왼쪽 (미리보기 오른쪽)</option><option value="right">오른쪽 (미리보기 왼쪽)</option></select></label>
|
||||
<label>위쪽 영역 스타일<select id="thHeaderStyle"><option value="light">밝게</option><option value="accent">강조색</option><option value="dark">어둡게</option></select></label>
|
||||
</div>
|
||||
<div class="row"><button type="button" id="thSave" class="primary">저장 (사용자에게 적용)</button><button type="button" id="thRevert" class="ghost">저장된 값으로 되돌리기</button><button type="button" id="thReset" class="ghost">기본값으로 되돌리기</button></div>
|
||||
<p class="muted" id="thMsg"></p>
|
||||
</div>
|
||||
<div class="th-stage">
|
||||
<div class="row th-views" id="thViews"><button type="button" data-view="builder" class="active">빌더 (PC)</button><button type="button" data-view="mobile">빌더 (모바일)</button><button type="button" data-view="login">로그인</button><button type="button" data-view="siteadmin">사이트 관리자</button><button type="button" data-view="all">한눈에 보기</button><span class="spacer"></span><a class="btn ghost" href="/" target="_blank" rel="noopener">실제 사용자 화면 열기</a></div>
|
||||
<div class="th-frames" id="thFrames"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- v1.5.16 (요구 3): AI 연결 탭 — LLM 초기 연결(Claude 인증·연결 테스트·사용할 Claude 모델, AI 제공자 키·모델 선택). 설정 탭의 "모델 연결"·"AI 제공자" 를 옮긴 것이며 입력칸 id 와 API 는 그대로다 -->
|
||||
<section class="card tab hidden" id="tab-ai">
|
||||
<h2>AI 연결 <span class="muted">— LLM 연결(서버 로그인 · OAuth 토큰 · API 키) · 사용할 모델 선택 · 연결 테스트 · AI 제공자</span></h2>
|
||||
<p class="muted">여기서 연결하고 "사용" 으로 켠 모델만 <b>설정</b> 탭의 모델 등급(기본·중급·고급)과 내부 호출 모델에 지정할 수 있습니다. 연결을 끊거나 모델을 끄면 그 모델을 쓰던 등급은 설정 탭에서 "현재 사용 불가" 로 표시되니 다른 모델로 바꿔 주세요.</p>
|
||||
<div class="grid">
|
||||
<div>
|
||||
<h2>Claude 모델 연결 <span class="muted">— Anthropic Claude Code CLI 인증</span></h2>
|
||||
<p class="muted">AI 작업(사이트 생성·수정)의 기본 엔진인 Claude Code 의 인증 방식입니다. 저장한 뒤 "연결 테스트" 로 실제 호출이 되는지 확인하세요. 초기 설정 페이지(시스템 설정 탭) 의 "AI 엔진 인증" 과 같은 값입니다.</p>
|
||||
<label class="radio"><input type="radio" name="authMode" value="server-login"> 서버 로그인 사용 <span class="muted" id="serverLoginInfo"></span></label>
|
||||
<label class="radio"><input type="radio" name="authMode" value="oauth-token"> OAuth 토큰 (Claude 구독) <span class="muted">— 서버 또는 PC 에서 <code>claude setup-token</code> 으로 발급</span></label>
|
||||
<input id="sOauth" placeholder="OAuth 토큰 붙여넣기" autocomplete="off">
|
||||
<label class="radio"><input type="radio" name="authMode" value="api-key"> API 키 (Anthropic Console, 종량제)</label>
|
||||
<input id="sApiKey" placeholder="sk-ant-api03-…" autocomplete="off">
|
||||
<div class="row"><button type="button" id="btnSaveAuth" class="primary">저장</button><button type="button" id="btnTestConn" class="ghost">연결 테스트</button><span id="connResult" class="muted"></span></div>
|
||||
<!-- v1.5.16 (요구 3): 사용할 Claude 모델 — 여기서 켠 모델만 설정 탭의 등급·내부 호출 모델 목록에 나온다 (settings.claudeModels, 비어 있으면 전체) -->
|
||||
<h2 style="margin-top:12px">사용할 Claude 모델 <span class="muted">— 체크한 모델만 등급·내부 호출에 지정할 수 있습니다</span></h2>
|
||||
<div id="claudeModelRows"></div>
|
||||
<div class="row"><button type="button" id="btnSaveClaudeModels" class="primary">사용할 모델 저장</button><span id="claudeModelsMsg" class="muted"></span></div>
|
||||
</div>
|
||||
<div>
|
||||
<h2>AI 제공자 (OpenAI · Gemini · Groq · OpenRouter 등)</h2>
|
||||
<p class="muted">제공자별 API 키(OpenAI 는 서버 로그인도 가능)를 저장하고 "모델 조회"로 사용 가능한 모델을 불러온 뒤, 이 서비스에서 쓸 모델을 체크해 저장합니다. 인증된(키가 있고 활성화된) 제공자의 체크한 모델만 설정 탭의 등급·내부 호출 모델 목록에 나옵니다. Claude 는 왼쪽 "Claude 모델 연결" 을 따릅니다.</p>
|
||||
<div class="table-wrap"><table class="adm-t" id="provTable"><thead><tr><th>제공자</th><th>상태</th><th>키</th><th>제공 모델</th><th>마지막 조회</th><th title="설정 탭 › 모델 등급 지정 목록에 이 제공자의 모델이 들어가는지 (v1.6.38)">등급 목록</th><th></th></tr></thead><tbody></tbody></table></div>
|
||||
<div id="provForm" class="hidden" style="margin-top:10px;border:1px solid var(--line);border-radius:10px;padding:10px">
|
||||
<h3 id="provTitle"></h3>
|
||||
<label class="radio"><input type="checkbox" id="pEnabled"> 활성화 (사용자에게 제공)</label>
|
||||
<div id="pAuthRow" class="hidden">
|
||||
<label class="radio"><input type="radio" name="pAuth" value="api-key"> API 키로 연결</label>
|
||||
<label class="radio"><input type="radio" name="pAuth" value="server-login"> 서버 로그인 (ChatGPT 계정, OpenAI Codex CLI) <span class="muted" id="pCodexInfo"></span></label>
|
||||
</div>
|
||||
<input id="pKey" placeholder="API 키" autocomplete="off">
|
||||
<input id="pBase" placeholder="기본 URL (OpenAI 호환 서버일 때만)" class="hidden">
|
||||
<div class="row"><button type="button" id="pFetch" class="ghost">모델 조회</button><span id="pMsg" class="muted"></span></div>
|
||||
<!-- v1.6.38 (요구 1): 이 제공자가 설정 탭의 등급 목록에 들어가는지와 빠진 이유 — 키 없이 저장해 놓고 "반영이 안 된다" 고 보이던 문제 -->
|
||||
<p id="pUsable" class="muted"></p>
|
||||
<div class="table-wrap"><table class="adm-t" id="pModels"><thead><tr><th>제공</th><th>모델</th><th>입력 $/1M</th><th>출력 $/1M</th></tr></thead><tbody></tbody></table></div>
|
||||
<div class="row"><button type="button" id="pSave" class="primary">저장</button><button type="button" id="pClose" class="ghost">닫기</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card tab hidden" id="tab-settings">
|
||||
<div class="grid">
|
||||
<div>
|
||||
<h2>모델 등급 지정 <span class="muted">— 사용자는 기본·중급·고급만 고르며, 실제 모델은 여기서만 보고 바꿉니다</span></h2>
|
||||
<!-- v1.5.16 (요구 3): 고를 수 있는 모델은 AI 연결 탭에서 연결하고 "사용" 으로 켠 것뿐이다 (서버도 저장 때 같은 조건으로 검사) -->
|
||||
<p class="muted">목록에는 <b>AI 연결</b> 탭에서 연결하고 "사용" 으로 켠 모델만 나옵니다. 모델이 없거나 더 필요하면 AI 연결 탭에서 Claude 인증·사용할 Claude 모델·AI 제공자를 먼저 설정하세요.</p>
|
||||
<div id="tierRows"></div>
|
||||
<div class="row"><span class="muted">새 사이트 기본 등급</span><select id="sDefaultTier" style="width:auto;margin:0"></select><button type="button" id="btnSaveTiers" class="primary">등급 저장</button><span id="tierMsg" class="muted"></span></div>
|
||||
<!-- v1.5.16 (요구 2): "Claude 기본 모델"(Claude 모델 id 고정) 을 없애고, 등급 지정과 같은 목록(AI 연결 탭에서 켠 모델)에서 고르는 "내부 호출 모델" 로 바꿨다 -->
|
||||
<h2 style="margin-top:12px">내부 호출 모델 <span class="muted">— 등급과 무관한 서비스 내부 호출(사용자 문의 AI 분석, 모델을 지정하지 않은 호출)에 사용</span></h2>
|
||||
<p class="muted">사이트 생성·수정·기획 단계는 사용자가 고른 등급의 모델을 쓰고, 등급에 묶이지 않은 내부 호출만 이 모델을 씁니다. 위 등급 지정과 같은 목록(AI 연결 탭에서 켠 모델)에서 고르며, 이 모델의 제공자를 끄면 그 사이에는 Claude 기본값으로 대신합니다.</p>
|
||||
<div class="row"><select id="sInternalRef" style="flex:1;margin:0" data-dirty-watch></select><button type="button" id="btnSaveInternal" class="primary">모델 변경</button><span id="internalMsg" class="muted"></span></div>
|
||||
<!-- v1.4.5 사용자 첫 화면 페이지: 빌더 오른쪽 미리보기에 사이트를 고르기 전 보이는 페이지 (관리자가 /builder 에서 만든 사이트 중 선택) -->
|
||||
<h2 style="margin-top:12px">사용자 첫 화면 페이지 <span class="muted">— 빌더 오른쪽 빈 창에 보이는 페이지</span></h2>
|
||||
<!-- v1.4.15: "첫 화면" 의 뜻을 분명히 — 서비스 홈페이지·로그인 화면이 아니라, 로그인한 사용자의 왼쪽 작업 영역이 비어 있을 때(아직 사이트를 고르지·만들지 않음) 오른쪽 빌더 창에 보이는 페이지 -->
|
||||
<p class="muted">서비스 홈페이지(로그인 화면)의 첫 화면이 <b>아닙니다</b>. 로그인한 사용자가 아직 사이트를 고르거나 만들지 않아 왼쪽 작업 영역이 비어 있을 때, 오른쪽 빌더 창(평소 "미리보기 없음" 으로 비어 있는 곳)에 보일 페이지입니다. 사용자가 사이트를 고르면 그 사이트 미리보기로 바뀝니다. 관리자 계정이 빌더(<a href="/builder" target="_blank" rel="noopener">/builder</a>)에서 만든 사이트 중에서 고르며, 배포했으면 운영 주소, 아니면 서비스의 사이트 주소로 보입니다. "기본 안내" 를 고르면 지금까지의 안내 문구가 보입니다.</p>
|
||||
<div class="row"><select id="sWelcome" data-dirty-watch style="flex:1;margin:0"></select><button type="button" id="btnSaveWelcome" class="primary">첫 화면 변경</button><a class="btn ghost" id="welcomeOpen" target="_blank" rel="noopener">열어 보기</a><span id="welcomeMsg" class="muted"></span></div>
|
||||
<!-- v1.4.1 캐시 표시 단위: 화면·안내에 보이는 캐시 단위 글자만 바꾼다 (1 단위 = 1원 기준, 청구·차감 계산은 그대로) -->
|
||||
<h2 style="margin-top:12px">캐시 표시 단위</h2>
|
||||
<p class="muted">사용자·관리자 화면에서 캐시 금액 뒤에 붙는 단위입니다. 원·환·동·골드·크레딧·유닛·D·ING 처럼 자유롭게 정할 수 있고, 금액 계산은 그대로(1 단위 = 1원)입니다. 환율·도메인 원가처럼 실제 화폐 금액은 계속 "원" 으로 보입니다.</p>
|
||||
<div class="row"><input id="sCashUnit" data-dirty-watch maxlength="12" placeholder="예: 원, 크레딧, D" style="width:160px;margin:0"><button type="button" id="btnSaveCashUnit" class="primary">단위 변경</button><span id="cashUnitMsg" class="muted"></span></div>
|
||||
<h2 style="margin-top:12px">신규 회원 기본 캐시</h2>
|
||||
<p class="muted">회원가입 직후 자동으로 지급되는 캐시(<span class="cash-unit">원</span>)입니다. 0 이면 지급하지 않으며, 가입 후에는 회원·청구 탭에서 개별 조정합니다.</p>
|
||||
<div class="row"><input id="sSignupCredit" data-dirty-watch type="number" min="0" step="100" placeholder="예: 5000" style="width:160px;margin:0"><span class="muted cash-unit">원</span><button type="button" id="btnSaveSignupCredit" class="primary">저장</button><span id="signupCreditMsg" class="muted"></span></div>
|
||||
<h2 style="margin-top:12px">AI에게 맡기기 수정 횟수 한도</h2>
|
||||
<p class="muted">생성 후 발행(배포) 전까지 사용자가 요청할 수 있는 수정 횟수입니다. 한도에 이르면 사이트는 "발행 대기" 상태가 되어 발행한 뒤에만 다시 수정할 수 있습니다. 0 이면 무제한.</p>
|
||||
<div class="row"><input id="sMaxEdits" data-dirty-watch type="number" min="0" step="1" placeholder="예: 10" style="width:160px;margin:0"><span class="muted">회</span><button type="button" id="btnSaveMaxEdits" class="primary">저장</button><span id="maxEditsMsg" class="muted"></span></div>
|
||||
<!-- v1.3.1: 외부 연동 접속 정보 (암호화 보관소 data/credentials.json) -->
|
||||
<h2 style="margin-top:12px">외부 연동 접속 정보 <span class="muted">— 암호화 보관</span></h2>
|
||||
<p class="muted">메일 발송(Resend), 메일 게이트웨이, Claude 인증처럼 다른 서비스에 접속할 때 쓰는 주소·설정·API 키/토큰입니다. 키는 서버의 <code>data/credentials.json</code>(권한 600)에 AES-256-GCM 으로 암호화되어 저장되고 화면에는 앞뒤 몇 글자만 보입니다. 키 파일(<code>.key</code> 등, KEY=VALUE 또는 JSON)의 서버 경로를 넣고 "키 파일에서 가져오기" 를 누르거나 키 파일을 직접 올리면 서버가 한 번 읽어 암호화 보관하므로, 이후에는 평문 키 파일을 지워도 됩니다.</p>
|
||||
<div class="row"><select id="vKind" style="width:auto;margin:0"></select><input id="vId" placeholder="식별자 (기타 서비스만, 예: crm-api)" style="width:200px;margin:0"><input id="vLabel" placeholder="표시 이름 (선택)" style="flex:1;margin:0"></div>
|
||||
<div class="row" style="margin-top:6px"><input id="vUrl" placeholder="주소 (https://…)" autocomplete="off" style="flex:1;margin:0"><input id="vSecret" type="password" placeholder="API 키 또는 토큰" autocomplete="new-password" style="flex:1;margin:0"></div>
|
||||
<textarea id="vSettings" rows="2" placeholder="설정 (선택, 한 줄에 이름=값)" style="margin-top:6px"></textarea>
|
||||
<div class="row" style="margin-top:6px"><button type="button" id="vSave" class="primary">암호화해 저장</button><input id="vFile" placeholder="또는 서버의 키 파일 경로 (예: /home/master/work/WebHoting_AI_Agent/.key)" style="flex:1;margin:0"><button type="button" id="vImport" class="ghost">키 파일에서 가져오기</button></div>
|
||||
<div class="row" style="margin-top:6px"><span class="muted">또는 키 파일을 직접 올리기 (.key, .env, .json — 서버에 파일로 저장하지 않고 필요한 값만 암호화 보관)</span><input id="vUpload" type="file" accept=".key,.env,.json,.txt,text/plain,application/json" style="width:auto;margin:0"><button type="button" id="vUploadGo" class="ghost">올려서 가져오기</button></div>
|
||||
<p class="muted" id="vMsg"></p>
|
||||
<div class="table-wrap"><table class="adm-t" id="vTable"><thead><tr><th>이름 · 종류</th><th>주소</th><th>키</th><th>출처</th><th>연결 확인</th><th>작업</th></tr></thead><tbody></tbody></table></div>
|
||||
<h2 style="margin-top:12px">작업 서버 수집 키 <span class="muted">— 다음 작업 반영 목록 읽기 전용</span></h2>
|
||||
<p class="muted">작업(개발) 서버가 <code>GET /api/service/backlog</code> (헤더 <code>X-Backlog-Key</code>) 로 "다음 작업 반영" 으로 결정한 제안·오류를 주기적으로 가져갑니다. 비워 두면 수집이 꺼집니다.</p>
|
||||
<div class="row"><input id="sBacklogKey" placeholder="수집 키 (비워 두면 비활성)" autocomplete="off" style="flex:1;margin:0"><button type="button" id="btnGenBacklogKey" class="ghost">생성</button><button type="button" id="btnSaveBacklogKey" class="primary">저장</button><span id="backlogKeyMsg" class="muted"></span></div>
|
||||
<h2 style="margin-top:12px">외부 서비스 캐시 연동 키</h2>
|
||||
<p class="muted">다른 서비스가 <code>POST /api/service/credits/<사용자ID></code> (헤더 <code>X-Service-Key</code>, 본문 <code>{type:set|add|adjust|refund, amount, memo, source}</code>) 로 캐시를 조정할 때 쓰는 키입니다. <code>GET</code> 은 잔액·원장 조회.</p>
|
||||
<div class="row"><input id="sServiceKey" placeholder="서비스 키 (비워 두면 외부 연동 비활성)" autocomplete="off" style="flex:1;margin:0"><button type="button" id="btnGenServiceKey" class="ghost">생성</button><button type="button" id="btnSaveServiceKey" class="primary">저장</button><span id="serviceKeyMsg" class="muted"></span></div>
|
||||
<!-- v1.5.16 (요구 3): "모델 연결"(Claude 인증·연결 테스트) 과 "AI 제공자" 는 AI 연결 탭으로 옮겼다 -->
|
||||
<p class="muted">Claude 인증(서버 로그인·OAuth 토큰·API 키)·연결 테스트·사용할 Claude 모델·AI 제공자(OpenAI 등)는 <b>AI 연결</b> 탭에 있습니다.</p>
|
||||
</div>
|
||||
<div>
|
||||
<!-- v1.3.16: 금액·비율 값마다 바로 옆에 실행 버튼 (예전에는 저장 버튼이 SSL 항목 아래에 하나뿐이라 마진을 고치고 새로고침하면 사라졌음) -->
|
||||
<h2>환율 · 마진</h2>
|
||||
<label class="radio"><input type="radio" name="fxMode" value="auto" data-dirty-watch> 자동 조회 (ECB/시장 중간환율 + 스프레드)</label>
|
||||
<div class="inline"><span>스프레드 %</span><input id="sSpread" type="number" step="0.1" min="0" style="width:90px" data-dirty-watch></div>
|
||||
<label class="radio"><input type="radio" name="fxMode" value="manual" data-dirty-watch> 직접 입력 (당일 USD 구입 가격)</label>
|
||||
<div class="inline"><span>1 USD =</span><input id="sManualRate" type="number" step="0.01" min="0" style="width:120px" data-dirty-watch><span>원</span></div>
|
||||
<div class="row"><button type="button" id="btnSaveFx" class="primary">환율 설정 저장</button><button type="button" id="btnRefreshFx" class="ghost">오늘 환율 갱신</button><span id="fxMsg" class="muted"></span></div>
|
||||
<p class="muted" id="fxNow"></p>
|
||||
<div class="inline"><span>사용자 청구 마진 %</span><input id="sMargin" type="number" step="1" min="0" max="1000" style="width:90px" data-dirty-watch><button type="button" id="btnSaveMargin" class="primary">마진 변경</button><span id="marginMsg" class="muted"></span></div>
|
||||
<p class="muted">청구 금액 = 비용(USD) × 명령 실행일의 USD 구입 가격(원) × (1 + 마진). 마진은 변경한 뒤 새로 끝나는 작업부터 적용됩니다.</p>
|
||||
<!-- v1.3.17: 비밀번호 규칙 (서비스 계정·사이트 관리자 계정의 새 비밀번호에 적용, 기본은 기존 규칙 6자 이상) -->
|
||||
<!-- v1.5.27 (요구 6): 회원가입 규칙 — 가입 기능 ON/OFF(설정 파일 service.allowSignup, 1.5.11) 와 가입 뒤 이용 방식(settings.signupApproval) -->
|
||||
<h2 style="margin-top:12px">회원가입 규칙</h2>
|
||||
<p class="muted">회원가입을 끄면 로그인 화면의 회원가입 탭이 사라지고 가입 API 는 "회원가입이 닫혀 있습니다" 로 거절합니다. "관리자 승인 후 이용" 을 고르면 가입은 신청으로 접수되고, 아래 회원 · 청구 탭에서 승인해야 로그인할 수 있습니다.</p>
|
||||
<label class="radio"><input type="checkbox" id="suEnabled" data-dirty-watch> 회원가입 허용 (끄면 가입 불가)</label>
|
||||
<div class="inline"><span>가입 후 이용</span><select id="suApproval" style="width:auto" data-dirty-watch><option value="immediate">가입 즉시 이용</option><option value="approval">관리자 승인 후 이용</option></select><button type="button" id="btnSaveSignup" class="primary">회원가입 규칙 저장</button><span id="suMsg" class="muted"></span></div>
|
||||
<h2 style="margin-top:12px">비밀번호 규칙</h2>
|
||||
<p class="muted">회원가입·비밀번호 변경·관리자 재설정·사이트 관리자 계정 만들기에서 새로 정하는 비밀번호에 적용됩니다. 이미 쓰고 있는 비밀번호로 로그인하는 것은 막지 않습니다. 기본값은 지금까지의 규칙(6자 이상)입니다.</p>
|
||||
<div class="inline"><span>최소 길이</span><input id="ppMin" type="number" min="4" max="64" step="1" style="width:80px" data-dirty-watch><span>최대 길이</span><input id="ppMax" type="number" min="4" max="256" step="1" style="width:80px" data-dirty-watch></div>
|
||||
<label class="radio"><input type="checkbox" id="ppLetter" data-dirty-watch> 영문자 포함</label>
|
||||
<label class="radio"><input type="checkbox" id="ppMixed" data-dirty-watch> 대문자·소문자 모두 포함</label>
|
||||
<label class="radio"><input type="checkbox" id="ppDigit" data-dirty-watch> 숫자 포함</label>
|
||||
<label class="radio"><input type="checkbox" id="ppSymbol" data-dirty-watch> 특수문자 포함</label>
|
||||
<label class="radio"><input type="checkbox" id="ppNoId" data-dirty-watch> 아이디를 포함하지 않음</label>
|
||||
<div class="row"><button type="button" id="btnSavePp" class="primary">비밀번호 규칙 저장</button><span id="ppMsg" class="muted"></span></div>
|
||||
<!-- v1.4.0: 도메인 서비스 (Openprovider) — 값마다 저장 버튼. 접속 정보(사용자 이름·비밀번호)는 왼쪽 "외부 연동 접속 정보" 의 Openprovider 항목 -->
|
||||
<h2 style="margin-top:12px">도메인 서비스</h2>
|
||||
<!-- v1.6.35 (요구 1-1): 관리자는 접속 정보·플랫폼 도메인·서브 도메인 허용 여부·가격만 정한다. 도메인 검색·구매(장바구니)·이미 가진 도메인 등록(네임서버 이전/직접 DNS)·영역·레코드 관리는 사용자 "도메인" 탭으로 옮겼다 -->
|
||||
<p class="muted">관리자는 여기서 <b>접속 정보</b>(왼쪽 "외부 연동 접속 정보" 에 Openprovider · Porkbun · Route 53 · Google Cloud DNS), <b>플랫폼 도메인</b>, <b>서브 도메인 등록 허용</b>, 가격 요율을 정합니다. 도메인 검색·구매(장바구니), 이미 가진 도메인 등록(네임서버 이전 또는 직접 DNS 관리), DNS 영역·레코드 관리는 v1.6.35 부터 사용자가 자기 "도메인" 탭에서 스스로 합니다. 접속 정보를 넣기 전에는 사용자 화면에 "도메인 서비스가 아직 준비되지 않았습니다" 가 보입니다.</p>
|
||||
<div class="inline"><span>도메인 요율 %</span><input id="dmMargin" type="number" step="1" min="100" max="1000" style="width:90px" data-dirty-watch><button type="button" id="btnDmMargin" class="primary">요율 변경</button><span id="dmMarginMsg" class="muted"></span></div>
|
||||
<p class="muted">사용자 가격 = 공급사(Openprovider) 가격 × 요율 ÷ 100 을 그날 환율로 원화로 바꿔 100원 단위로 올림. 기본 250 (2.5배). 구매·갱신 전에 사용자에게 먼저 보입니다.</p>
|
||||
<!-- v1.5.36 (요구 4·5): 등록 업체(Openprovider·Porkbun)와 DNS 공급자(수동·Openprovider·Porkbun·Route 53·Google Cloud DNS)를 고른다. 접속 정보는 왼쪽 "외부 연동 접속 정보" 의 같은 이름 종류 -->
|
||||
<div class="inline"><span>플랫폼 도메인</span><input id="dmPlatform" placeholder="ibit.kr" style="width:160px" data-dirty-watch><span>DNS 공급자</span><select id="dmDns" style="width:auto" data-dirty-watch></select><span>등록 업체</span><select id="dmRegistrar" style="width:auto" data-dirty-watch></select><button type="button" id="btnDmPlatform" class="primary">저장</button><span id="dmPlatformMsg" class="muted"></span></div>
|
||||
<p class="muted" id="dmProvNote"></p>
|
||||
<p class="muted">서브 도메인 계정은 이 도메인 아래 이름(mycompany.ibit.kr)을 씁니다. DNS 수동이면 운영자가 플랫폼 도메인 DNS 에 와일드카드 A 레코드(*.ibit.kr → 사이트 IP)를 한 번 넣어 두어야 하고, 자동이면 플랫폼 도메인이 Openprovider DNS 에 있어야 합니다.</p>
|
||||
<div class="inline"><span>사이트 IP</span><input id="dmIp" placeholder="비우면 서비스 주소의 IP" style="width:180px" data-dirty-watch><button type="button" id="btnDmIp" class="primary">저장</button><span id="dmIpMsg" class="muted"></span></div>
|
||||
<div class="inline"><span>판매 확장자</span><input id="dmTlds" placeholder="com, net, org, co, io, shop" style="flex:1;min-width:160px" data-dirty-watch><button type="button" id="btnDmTlds" class="primary">저장</button><span id="dmTldsMsg" class="muted"></span></div>
|
||||
<div class="inline"><span>등록 금지 이름</span><input id="dmReserved" placeholder="예: shop, support" style="flex:1;min-width:160px" data-dirty-watch><button type="button" id="btnDmReserved" class="primary">저장</button><span id="dmReservedMsg" class="muted"></span></div>
|
||||
<!-- v1.6.35 (요구 1-1): 서브 도메인 등록 자체를 막는 스위치. 끄면 서브 도메인 계정도 새 이름을 등록할 수 없고(이미 등록된 것은 유지), 사용자 화면에 이유가 보인다 -->
|
||||
<div class="inline"><label class="radio" style="margin:0"><input type="checkbox" id="dmAllowSub" data-dirty-watch> 서브 도메인 등록 허용 (끄면 플랫폼 도메인 아래 새 이름을 등록할 수 없습니다 — 자기 도메인만 사용)</label><button type="button" id="btnDmAllowSub" class="primary">저장</button><span id="dmAllowSubMsg" class="muted"></span></div>
|
||||
<div class="inline"><span>새 가입자 계정 종류</span><select id="dmDefType" style="width:auto" data-dirty-watch><option value="subdomain">서브 도메인 전용</option><option value="domain">자체 도메인</option></select><button type="button" id="btnDmDefType" class="primary">저장</button><span id="dmDefTypeMsg" class="muted"></span></div>
|
||||
<label class="radio"><input type="checkbox" id="dmUserMail" data-dirty-watch> 메일(MX) 자동 설정 때 그 사이트의 메일 시스템도 켜기</label>
|
||||
<div class="row"><button type="button" id="btnDmUserMail" class="primary">저장</button><span id="dmUserMailMsg" class="muted"></span></div>
|
||||
<p class="muted">회원별 계정 종류는 회원·청구 탭의 회원 목록에서 바꿉니다. 이미 있던(1.4.0 이전) 회원은 자체 도메인 계정으로 유지됩니다.</p>
|
||||
<!-- v1.5.36 (요구 5) DNS 영역 관리 → v1.6.35 (요구 1-1): 영역 만들기·이전된 영역 받기는 사용자 "도메인" 탭(이미 가진 도메인 등록 › 네임서버 이전)으로 옮겼다. 관리자는 현황 조회·레코드 확인·쓰지 않는 영역 정리만 한다. 회원 도메인의 영역은 force 로만 지운다. 모든 변경은 감사 기록 -->
|
||||
<h3 style="margin:12px 0 4px">DNS 영역 현황 (조회·정리) <button type="button" id="dzRefresh" class="ghost">목록</button></h3>
|
||||
<p class="muted">선택한 DNS 공급자에 있는 영역(hosted zone / managed zone)과 그 영역을 쓰는 회원을 봅니다. 영역 만들기·이전된 영역 받기는 v1.6.35 부터 회원이 자기 "도메인" 탭(이미 가진 도메인 등록 › 네임서버 이전)에서 직접 하므로 여기서는 하지 않습니다. 회원이 쓰지 않는 영역(회원 칸이 "-")만 정리하세요 — 회원 도메인의 영역은 확인을 거쳐야 지워집니다. 변경 내역은 아래 감사 기록에 남습니다.</p>
|
||||
<div class="row"><select id="dzProvider" style="width:auto;margin:0"></select><span id="dzMsg" class="muted"></span></div>
|
||||
<div class="table-wrap"><table class="adm-t" id="dzTable"><thead><tr><th>영역</th><th>네임서버</th><th>회원</th><th>레코드</th><th>작업</th></tr></thead><tbody></tbody></table></div>
|
||||
<div id="dzRecBox" class="hidden" style="margin-top:8px;border:1px solid var(--line);border-radius:10px;padding:10px">
|
||||
<h3 id="dzRecTitle" style="margin:0 0 6px"></h3>
|
||||
<div class="table-wrap"><table class="adm-t" id="dzRecTable"><thead><tr><th>이름</th><th>종류</th><th>값</th><th>TTL</th><th>우선순위</th><th></th></tr></thead><tbody></tbody></table></div>
|
||||
<div class="row" style="margin-top:6px"><input id="dzRecName" placeholder="이름 (비우면 도메인 자신)" style="width:150px;margin:0"><select id="dzRecType" style="width:auto;margin:0"><option>A</option><option>AAAA</option><option>CNAME</option><option>MX</option><option>TXT</option><option>CAA</option><option>SRV</option><option>NS</option></select><input id="dzRecValue" placeholder="값" style="flex:1;margin:0"><input id="dzRecPrio" placeholder="우선순위" type="number" style="width:90px;margin:0"><button type="button" id="dzRecAdd" class="primary">추가</button><button type="button" id="dzRecClose" class="ghost">닫기</button></div>
|
||||
</div>
|
||||
<details style="margin-top:6px"><summary class="muted">DNS 감사 기록 (최근 100건)</summary><div class="table-wrap"><table class="adm-t" id="dzAudit"><thead><tr><th>일시</th><th>누가</th><th>작업</th><th>공급자</th><th>영역</th><th>내용</th></tr></thead><tbody></tbody></table></div></details>
|
||||
<h2 style="margin-top:12px">내 비밀번호 변경</h2>
|
||||
<input id="aPwCur" type="password" placeholder="현재 비밀번호" autocomplete="current-password">
|
||||
<input id="aPwNew" type="password" placeholder="새 비밀번호 (6자 이상)" data-pw-rule="placeholder" data-pw-head="새 비밀번호" autocomplete="new-password">
|
||||
<div class="row"><button type="button" id="aPwSave" class="primary">변경</button><span id="aPwMsg" class="muted"></span></div>
|
||||
<h2 style="margin-top:12px">메일 발송 (문의 접수 위젯)</h2>
|
||||
<p class="muted">생성 사이트의 문의·예약 폼이 접수한 내용을 <code>webmaster@사이트도메인</code>(Resend 에 검증된 도메인일 때, 아니면 기본 발신 도메인)에서 사이트의 문의 수신 이메일로 보냅니다. 키는 메일 에이전트 키 파일(.key, <code>RESEND_API_KEY=…</code>)을 지정하거나 직접 입력합니다.</p>
|
||||
<p class="muted">v1.3.1: 키 파일 경로를 저장하면 서버가 그 파일을 한 번 읽어 "외부 연동 접속 정보" 보관소에 암호화해 넣고, 이후 발송은 보관소의 키를 씁니다(평문 키 파일은 지워도 됨). 직접 입력한 키도 보관소에 암호화 저장됩니다.</p>
|
||||
<input id="mKeyFile" placeholder="키 파일 경로 (예: /home/master/work/remember-voice/.key)">
|
||||
<input id="mApiKey" placeholder="또는 Resend API 키 직접 입력 (re_…)" autocomplete="off">
|
||||
<div class="two"><input id="mDomain" placeholder="기본 발신 도메인 (예: ibit.kr)"><input id="mFromName" placeholder="발신자 표시 이름 (기본 Webmaster)"></div>
|
||||
<input id="mFormBase" placeholder="폼 접수 서버 주소 (생성 사이트가 호출, 기본: 이 서비스 공개 URL)">
|
||||
<p class="muted" id="mStatus"></p>
|
||||
<div class="row"><button type="button" id="mSave" class="primary">저장</button><input id="mTestTo" placeholder="테스트 수신 주소" style="width:220px;margin:0"><button type="button" id="mTest" class="ghost">테스트 메일 발송</button><span id="mTestMsg" class="muted"></span></div>
|
||||
<h2 style="margin-top:12px">소스 저장소 (로컬 Gitea) <button type="button" id="gTest" class="ghost">연결 확인</button></h2>
|
||||
<!-- v1.4.14 (요구 8): 사이트 버전은 회사 소스 저장소가 아니라 서비스 서버 안의 전용 Gitea(webmaker/sites)에 보관한다 -->
|
||||
<p class="muted">사이트의 생성·수정 버전과 배포본을 보관하는 git 저장소입니다. 서비스 서버 안에 전용 Gitea(계정 webmaker, 저장소 webmaker/sites)를 두고 이 서버의 키로 푸시합니다. 아직 설치하지 않았으면 서버에서 <code>deploy/install-gitea.sh</code> 를 실행하세요(계정·저장소·키 등록·서비스 설정까지 자동).</p>
|
||||
<p id="gStatus" class="muted"></p>
|
||||
<textarea id="gPubKey" rows="2" readonly class="hidden" style="width:100%;font-size:11px;font-family:ui-monospace,monospace"></textarea>
|
||||
<h2 style="margin-top:12px">SSL (Let's Encrypt)</h2>
|
||||
<div class="row"><input id="sLeEmail" type="email" placeholder="인증서 만료 알림을 받을 이메일 (예: admin@example.com)" style="flex:1;margin:0" data-dirty-watch><button type="button" id="btnSaveLeEmail" class="primary">저장</button><span id="leMsg" class="muted"></span></div>
|
||||
<p class="muted">배포 시 certbot 으로 인증서를 발급할 때 사용합니다. 비워 두면 이메일 없이 발급합니다.</p>
|
||||
<!-- v1.4.14 (요구 9): 플랫폼 도메인 와일드카드 인증서 — 서브 도메인 사이트 전체의 HTTPS 를 인증서 하나로. 고객 도메인은 사이트별 인증서 그대로 -->
|
||||
<h3 style="margin:12px 0 4px">플랫폼 도메인 와일드카드 인증서 <button type="button" id="sslRefresh" class="ghost">상태 확인</button></h3>
|
||||
<p class="muted">서브 도메인 사이트(<span id="sslPlatformEx">이름.ibit.kr</span>)는 사이트마다 인증서를 받지 않고 <b>*.플랫폼 도메인</b> 와일드카드 인증서 하나로 HTTPS 를 적용합니다. 고객이 가진 도메인(example.com)은 지금처럼 배포 때 사이트별 인증서를 따로 발급합니다. 와일드카드는 DNS 인증(TXT 레코드)만 가능합니다 — 플랫폼 DNS 가 Openprovider(자동) 이면 서비스가 넣고, 수동이면 아래에 뜨는 TXT 를 DNS 관리 화면에 넣어 주세요(갱신 때도 같은 절차).</p>
|
||||
<p id="sslStatus" class="muted"></p>
|
||||
<div id="sslPending" class="hidden" style="border:1px solid var(--line);border-radius:8px;padding:8px 10px;margin:6px 0">
|
||||
<b>DNS 에 넣을 TXT 레코드</b> <span class="muted" id="sslPendingNote"></span>
|
||||
<div class="row" style="margin-top:6px"><span class="muted" style="min-width:48px">이름</span><input id="sslTxtName" readonly style="flex:1;margin:0;font-family:ui-monospace,monospace"><button type="button" class="ghost" data-copy="sslTxtName">복사</button></div>
|
||||
<div class="row" style="margin-top:4px"><span class="muted" style="min-width:48px">종류</span><input value="TXT" readonly style="width:80px;margin:0"></div>
|
||||
<div class="row" style="margin-top:4px"><span class="muted" style="min-width:48px">값</span><input id="sslTxtValue" readonly style="flex:1;margin:0;font-family:ui-monospace,monospace"><button type="button" class="ghost" data-copy="sslTxtValue">복사</button></div>
|
||||
</div>
|
||||
<div class="row"><button type="button" id="sslIssue" class="primary">와일드카드 인증서 발급·갱신</button><button type="button" id="sslCancel" class="ghost hidden">진행 취소</button><span id="sslMsg" class="muted"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- v1.5.7 (요구 2·7): 시스템 설정 — 설정 파일(data/config.json)·연동 서비스·AI 엔진 상태. 초기 설정 페이지(setup-server.js)와 같은 화면(public/setup.js)을 /api/admin/config 로 붙인다 -->
|
||||
<section class="card tab hidden" id="tab-system">
|
||||
<h2>시스템 설정 <span class="muted">— 설정 파일 · 연동 서비스(Gitea·Arimail) · AI 엔진</span></h2>
|
||||
<p class="muted">서비스가 동작하는 데 필요한 환경 정보는 코드·systemd 유닛이 아니라 설정 파일 <code>data/config.json</code> 에 있습니다(1.5.7). 설치 직후에는 별도 포트의 초기 설정 페이지(기본 <code>:8795</code>, 1.5.11)나 <code>/setup</code> 에서 같은 화면을 쓰고(설치 점검표가 끝나기 전에는 첫 화면이 그리로 넘어갑니다), 운영 중에는 여기서 바꿉니다. 비밀값(토큰·비밀번호)은 암호화 보관소에 저장되며 "재시작 후 적용" 표시가 있는 값은 저장한 뒤 서비스를 재시작해야 반영됩니다. 배포 때 정해지는 값(포트·경로)은 읽기 전용입니다.</p>
|
||||
<div id="sysConfig"></div>
|
||||
</section>
|
||||
</div>
|
||||
<script src="/numfmt.js"></script><script src="/i18n.js"></script><script src="/pwrule.js"></script><!-- v1.3.10: 관리자 콘솔 다국어 / v1.3.17 비밀번호 규칙 안내 -->
|
||||
<script src="/setup.js"></script><!-- v1.5.7 시스템 설정 탭 화면 -->
|
||||
<script src="/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,129 @@
|
||||
'use strict';
|
||||
/* ============================================================================
|
||||
* 캐시 · 충전 화면 (v1.6.40, 요구 1)
|
||||
*
|
||||
* 비용은 지금처럼 선불 적립 캐시(원 단위, lib/credits.js)에서 차감된다. 이 화면은
|
||||
* ① 내 캐시: 잔액(크게) · 누적 사용 · 누적 지급 · 캐시 충전을 기다리는 작업 · "상세 내역"(app.js showUsageDetail — 일/주/월 사용량·전체 원장)
|
||||
* ② 선불 충전: 관리자 콘솔에서 만든 충전 상품(금액 + 인센티브)을 고르면 결제 금액·인센티브·적립 예정 캐시·충전 뒤 예상 잔액을 보여 주고
|
||||
* "결제 진행" 으로 주문을 접수한다. **PG(결제 대행)가 아직 연동되지 않아 실제 결제는 일어나지 않는다** — 주문은 "결제 대기" 로 남고
|
||||
* 운영팀이 입금(결제)을 확인하면 적립된다(관리자 콘솔 › 회원·청구 › 선불 충전 주문). 이 사실을 화면에 그대로 적어 둔다.
|
||||
* ③ 내 충전 주문: 상태(결제 대기 · 적립 완료 · 취소)와 결제 대기 주문의 취소.
|
||||
* API: GET /api/credits/packages · GET/POST /api/credits/orders · POST /api/credits/orders/<id>/cancel · GET /api/me/credit
|
||||
* 전역 의존(app.js): $, api, T, TF, krwFmt, config, showUsageDetail, uiLocale, loadLedger
|
||||
* ========================================================================== */
|
||||
(() => {
|
||||
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined && text !== null) e.textContent = text; return e; };
|
||||
const btn = (text, cls, onclick) => { const b = el('button', cls || 'ghost small-btn', text); b.type = 'button'; b.onclick = onclick; return b; };
|
||||
const dtm = (s) => (s ? new Date(s).toLocaleString(uiLocale(), { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '-');
|
||||
const STATUS = { pending: '결제 대기', paid: '적립 완료', cancelled: '취소' };
|
||||
let packages = null; // GET /api/credits/packages 응답의 packages
|
||||
let orders = null; // GET /api/credits/orders 응답의 orders (최신순)
|
||||
let info = null; // GET /api/me/credit (잔액·누적·대기 작업)
|
||||
let sel = null; // 고른 상품 id
|
||||
let lastOrder = null; // 방금 접수한 주문 (접수 안내 상자)
|
||||
let busy = false;
|
||||
|
||||
const visible = () => { const p = $('pane-credits'); return p && !p.classList.contains('hidden'); };
|
||||
|
||||
async function load() {
|
||||
const box = $('creditsBody'); if (!box) return;
|
||||
if (!packages) { box.innerHTML = ''; box.appendChild(el('p', 'muted small', '불러오는 중…')); }
|
||||
try {
|
||||
const [pk, od, cr] = await Promise.all([api('/api/credits/packages'), api('/api/credits/orders'), api('/api/me/credit')]);
|
||||
packages = pk.packages || []; orders = od.orders || []; info = cr;
|
||||
if (sel && !packages.some((p) => p.id === sel)) sel = null;
|
||||
} catch (e) { box.innerHTML = ''; box.appendChild(el('p', 'muted small', '캐시 정보를 불러오지 못했습니다: ' + e.message)); return; }
|
||||
render();
|
||||
}
|
||||
/** app.js renderCredit 가 잔액을 바꿀 때마다 호출 — 화면이 보이면 잔액 숫자만 갱신 */
|
||||
function onCredit(i) { if (!i) return; info = { ...(info || {}), ...i }; const b = $('crBalance'); if (b) { b.textContent = krwFmt(info.balance); b.classList.toggle('neg', info.balance <= 0); } }
|
||||
|
||||
/** 인센티브 표시 문구: 정률 "10% 추가" · 정액 "+1,000원 추가" */
|
||||
const bonusText = (p) => (!p.bonus ? '' : (p.bonusType === 'percent' ? TF('{1}% 추가 적립', p.bonusValue) : TF('{1} 추가 적립', krwFmt(p.bonusValue))));
|
||||
|
||||
function render() {
|
||||
const box = $('creditsBody'); if (!box) return; box.innerHTML = '';
|
||||
const isAdmin = config && config.user && config.user.role === 'admin';
|
||||
// ① 내 캐시
|
||||
{
|
||||
const c = el('section', 'card'); c.appendChild(el('h2', null, '내 캐시'));
|
||||
const hero = el('div', 'cr-hero'); const left = el('div');
|
||||
const bal = el('div', 'cr-balance' + (info && info.balance <= 0 ? ' neg' : '')); bal.id = 'crBalance'; bal.textContent = info ? krwFmt(info.balance) : '-'; left.appendChild(bal);
|
||||
left.appendChild(el('div', 'cr-sub', info ? `${T('누적 사용')} ${krwFmt(info.used)} · ${T('누적 지급')} ${krwFmt(info.granted)}${isAdmin ? ' · ' + T('관리자 계정은 캐시 한도를 받지 않습니다') : ''}` : ''));
|
||||
hero.appendChild(left);
|
||||
const act = el('div', 'fs-act'); act.appendChild(btn('상세 내역', 'ghost small-btn', () => { if (window.showUsageDetail) showUsageDetail(); })); act.appendChild(btn('새로고침', 'ghost small-btn', () => load())); hero.appendChild(act);
|
||||
c.appendChild(hero);
|
||||
if (info && info.balance <= 0 && !isAdmin) c.appendChild(el('p', 'small cr-notice', '캐시가 없어 새 AI 작업을 시작할 수 없습니다. 아래에서 충전 상품을 골라 신청하세요.'));
|
||||
const waiting = (info && info.waiting) || [];
|
||||
if (waiting.length) { const w = el('div', 'small'); w.appendChild(el('div', 'field-label', TF('캐시 충전을 기다리는 작업 {1}건 — 충전되면 자동으로 이어서 진행됩니다', waiting.length))); const ul = el('ul', 'git-list'); for (const x of waiting) { const li = el('li'); li.appendChild(el('span', 'kind', '대기')); li.appendChild(el('span', 'bname', `${x.title} — ${(x.instruction || '').slice(0, 60)}`)); li.appendChild(el('span', 'st', dtm(x.at))); ul.appendChild(li); } w.appendChild(ul); c.appendChild(w); }
|
||||
box.appendChild(c);
|
||||
}
|
||||
// ② 선불 충전
|
||||
{
|
||||
const c = el('section', 'card'); c.appendChild(el('h2', null, '선불 충전'));
|
||||
if (lastOrder) {
|
||||
const d = el('div', 'cr-done');
|
||||
d.appendChild(el('div', null, `✓ ${TF('주문 #{1} 을 접수했습니다', lastOrder.id)} — ${lastOrder.name} · ${T('결제 금액')} ${krwFmt(lastOrder.amount)} → ${T('적립 예정')} ${krwFmt(lastOrder.credit)}`));
|
||||
d.appendChild(el('div', 'muted small', '결제 수단(PG)이 아직 연동되지 않아 실제 결제는 진행되지 않았습니다. 운영팀이 입금(결제)을 확인하면 적립 예정 캐시가 잔액에 더해집니다. 상태는 아래 "내 충전 주문" 에서 볼 수 있습니다.'));
|
||||
const r = el('div', 'row'); r.appendChild(btn('다른 상품 고르기', 'ghost small-btn', () => { lastOrder = null; render(); })); d.appendChild(r);
|
||||
c.appendChild(d); box.appendChild(c);
|
||||
} else {
|
||||
c.appendChild(el('p', 'muted small', '상품을 고르면 결제 금액과 적립될 캐시(인센티브 포함)를 확인할 수 있습니다. 적립 캐시 = 결제 금액 + 관리자가 정한 추가 인센티브.'));
|
||||
if (!packages.length) c.appendChild(el('p', 'small cr-notice', '아직 판매 중인 충전 상품이 없습니다. 운영팀(관리자)에게 캐시 충전을 요청하세요.'));
|
||||
else {
|
||||
const grid = el('div', 'pk-grid');
|
||||
for (const p of packages) {
|
||||
const b = el('button', 'pk' + (sel === p.id ? ' on' : '')); b.type = 'button';
|
||||
b.appendChild(el('span', 'pk-name', p.name)); b.appendChild(el('span', 'pk-amt', krwFmt(p.amount)));
|
||||
if (p.bonus) b.appendChild(el('span', 'pk-bonus', bonusText(p)));
|
||||
b.appendChild(el('span', 'pk-credit', TF('적립 {1}', krwFmt(p.credit))));
|
||||
if (p.note) b.appendChild(el('span', 'muted small', p.note));
|
||||
b.onclick = () => { sel = p.id; render(); };
|
||||
grid.appendChild(b);
|
||||
}
|
||||
c.appendChild(grid);
|
||||
const p = packages.find((x) => x.id === sel);
|
||||
if (p) {
|
||||
const sm = el('div', 'cr-summary');
|
||||
const row = (k, v, cls) => { const r = el('div', 'cs-row' + (cls ? ' ' + cls : '')); r.appendChild(el('span', null, T(k))); r.appendChild(el('span', null, v)); sm.appendChild(r); };
|
||||
row('결제 금액', krwFmt(p.amount)); row('추가 인센티브', p.bonus ? `+${krwFmt(p.bonus)}${p.bonusType === 'percent' ? ` (${p.bonusValue}%)` : ''}` : T('없음'));
|
||||
row('적립 예정 캐시', krwFmt(p.credit), 'total'); if (info) row('충전 뒤 예상 잔액', krwFmt((info.balance || 0) + p.credit));
|
||||
c.appendChild(sm);
|
||||
c.appendChild(el('p', 'cr-notice', '결제 수단(PG) 연동 전입니다 — "결제 진행" 을 누르면 주문만 접수되고 실제 결제는 일어나지 않습니다. 운영팀이 입금(결제)을 확인하면 적립 예정 캐시가 잔액에 더해집니다.'));
|
||||
const r = el('div', 'row'); const msg = el('span', 'muted small');
|
||||
const goBtn = btn(TF('결제 진행 — {1}', krwFmt(p.amount)), 'primary small-btn', async () => {
|
||||
if (busy) return; if (!confirm(TF('{1} ({2}) 충전 주문을 접수할까요?\n결제 수단 연동 전이라 지금은 결제가 진행되지 않고 주문만 남습니다.', p.name, krwFmt(p.amount)))) return;
|
||||
busy = true; goBtn.disabled = true; msg.textContent = T('접수 중…');
|
||||
try { const r2 = await api('/api/credits/orders', { method: 'POST', body: JSON.stringify({ packageId: p.id }) }); lastOrder = r2.order; orders = r2.orders || orders; sel = null; render(); }
|
||||
catch (e) { msg.textContent = e.message; msg.style.color = '#b91c1c'; goBtn.disabled = false; }
|
||||
busy = false;
|
||||
});
|
||||
r.appendChild(goBtn); r.appendChild(msg); c.appendChild(r);
|
||||
}
|
||||
}
|
||||
box.appendChild(c);
|
||||
}
|
||||
}
|
||||
// ③ 내 충전 주문
|
||||
{
|
||||
const c = el('section', 'card'); c.appendChild(el('h2', null, '내 충전 주문'));
|
||||
if (!orders || !orders.length) c.appendChild(el('p', 'muted small', '아직 충전 주문이 없습니다.'));
|
||||
else {
|
||||
const list = el('div', 'order-list');
|
||||
for (const o of orders) {
|
||||
const row = el('div', 'ol-row');
|
||||
const left = el('div'); const head = el('div'); head.appendChild(el('b', null, o.name)); head.appendChild(document.createTextNode(` · ${krwFmt(o.amount)} → ${T('적립')} ${krwFmt(o.credit)}`)); left.appendChild(head);
|
||||
left.appendChild(el('div', 'ol-meta', `${TF('주문 #{1}', o.id)} · ${dtm(o.at)}${o.status === 'paid' ? ` · ${T('적립')} ${dtm(o.paidAt)}` : ''}${o.status === 'cancelled' && o.cancelledAt ? ` · ${T('취소')} ${dtm(o.cancelledAt)}` : ''}`));
|
||||
row.appendChild(left);
|
||||
const act = el('div', 'ol-act'); act.appendChild(el('span', 'pill small ' + o.status, T(STATUS[o.status] || o.status)));
|
||||
if (o.status === 'pending') act.appendChild(btn('취소', 'ghost small-btn', async () => { if (!confirm(TF('주문 #{1} 을 취소할까요?', o.id))) return; try { const r = await api(`/api/credits/orders/${o.id}/cancel`, { method: 'POST', body: '{}' }); orders = r.orders || orders; if (lastOrder && lastOrder.id === o.id) lastOrder = null; render(); } catch (e) { alert(e.message); } }));
|
||||
row.appendChild(act); list.appendChild(row);
|
||||
}
|
||||
c.appendChild(list);
|
||||
}
|
||||
box.appendChild(c);
|
||||
}
|
||||
}
|
||||
|
||||
window.CreditsUI = { load, onCredit, visible };
|
||||
})();
|
||||
@@ -0,0 +1,742 @@
|
||||
'use strict';
|
||||
/* ============================================================================
|
||||
* 빌더 "도메인" 탭 (v1.4.0 → v1.6.35 프로세스 개편 → v1.6.40 세 화면 → v1.7.2 역할 분리: 도메인 = 구매·갱신·네임서버 이전, DNS = 사이트↔도메인 연결 + 레코드, 메일 = 계정·접속 주소·초기 관리자 + MX)
|
||||
*
|
||||
* 원칙(v1.6.35 요구 1): 자기 홈페이지 주소는 사용자가 스스로 마련하고 관리한다. 관리자는 접속 정보·플랫폼 도메인·서브 도메인 허용 여부만 정한다.
|
||||
*
|
||||
* 계정 종류(관리자가 정함)에 따라 보이는 기능이 다르다.
|
||||
* - 서브 도메인 계정: 플랫폼 도메인(예: ibit.kr) 아래 이름(mycompany.ibit.kr)을 검색·등록·해제하고 사이트에 연결한다. (관리자가 서브 도메인 등록을 금지하면 이유가 보인다)
|
||||
* - 자체 도메인 계정: "도메인 준비" 카드에서 세 가지 길 중 하나를 고른다.
|
||||
* ① 구매 : 검색(가격 포함) → 장바구니에 담기 → 장바구니에서 최종 선택해 구매(금액을 한 번 더 확인). 가격은 장바구니를 열 때마다 다시 받아 바뀐 값을 표시한다.
|
||||
* ② 네임서버 이전 : 다른 곳에서 산 도메인. 쓰고 있는 하위 이름을 적으면 지금 DNS 레코드를 읽어 이 서비스의 DNS 영역에 옮기고, 바꿀 네임서버를 보여 준다.
|
||||
* 사용자가 "네임서버 변경했습니다" 를 누르면 실제로 바뀌었는지 확인하고, 확인될 때까지 "DNS 변경 진행 중" 으로 표시해 레코드 수정을 잠근다(이중 수정 방지).
|
||||
* ③ 직접 DNS 관리 : 다른 곳에서 산 도메인을 자기 DNS 에서 관리. 배포·메일에 필요한 레코드를 보여 주고, "설정 완료 — 확인" 으로 실제 반영 여부를 확인한다.
|
||||
* 확인되지 않으면 "DNS 변경이 완료되지 않았거나 반영되지 않아 배포가 되지 않는다" 고 알린다.
|
||||
* ④ 자동 설정 : 사이트에 연결 → 발행 때 웹서버 설정·SSL 적용, 메일(MX) 자동 설정. "도메인 상태" 가 어느 단계에서 막혔는지(배포가 안 되는 이유·메일을 못 쓰는 이유)를 보인다.
|
||||
* - 두 종류 모두: "메일(MX) 자동 설정" 으로 받는 메일 MX·SPF·DMARC 와 Resend 보내기 레코드(DKIM 등)를 자동으로 만든다(직접 DNS 관리 도메인은 넣을 레코드를 안내).
|
||||
* 변경이 있는 모든 입력(연결·레코드·등록자 정보 등)은 그 자리마다 실행 버튼을 둔다.
|
||||
* 전역 의존: $, api, T, current, openSite, krwFmt (app.js)
|
||||
* ========================================================================== */
|
||||
(() => {
|
||||
let data = null; // GET /api/domains 응답
|
||||
let way = 'buy'; // 도메인 준비 카드에서 고른 길: buy | ns | manual
|
||||
/**
|
||||
* v1.6.40 (요구 1): 도메인 탭이 세 화면으로 나뉘었다 — view 에 따라 그리는 상자와 내용이 다르다.
|
||||
* - 'domains' (#domainsBody): 도메인 준비(서브 도메인 등록 / 검색·구매 / 이미 가진 도메인 등록) + 내 도메인(연결·확인·메일) — 1.6.39 까지의 도메인 탭
|
||||
* - 'dns' (#dnsBody) : 도메인별 DNS 레코드·전파·직접 관리 도메인의 필요한 설정 확인 — 배포·메일이 의존하는 중간 단계
|
||||
* - 'mail' (#mailBody) : 도메인별 메일(MX) 자동 설정·상태, 현재 사이트의 메일 시스템·웹메일
|
||||
*/
|
||||
let view = 'domains';
|
||||
const boxOf = () => $(view === 'dns' ? 'dnsBody' : view === 'mail' ? 'mailBody' : 'domainsBody');
|
||||
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined && text !== null) e.textContent = text; return e; };
|
||||
const btn = (text, cls, onclick) => { const b = el('button', cls || 'ghost small-btn', text); b.type = 'button'; b.onclick = onclick; return b; };
|
||||
const won = (n) => krwFmt(Number(n) || 0); // v1.4.1 캐시 표시 단위(관리자 설정)를 따른다 — app.js krwFmt
|
||||
const dt = (s) => (s ? new Date(s).toLocaleDateString() : '-');
|
||||
const dtm = (s) => (s ? new Date(s).toLocaleString() : '-');
|
||||
/** 결과·오류 한 줄 표시 (ok=false 면 빨간 글씨) */
|
||||
const say = (node, text, ok = true) => { node.textContent = text || ''; node.style.color = ok ? '' : '#b91c1c'; };
|
||||
/** 노란 안내 상자(전파 대기·진행 중) 표시/숨김 */
|
||||
const wait = (node, text) => { if (text) { node.textContent = text; node.classList.remove('hidden'); } else { node.textContent = ''; node.classList.add('hidden'); } };
|
||||
|
||||
/** 캐시 잔액 (응답의 credit.balance) */
|
||||
const balance = () => (data && data.credit ? Number(data.credit.balance) || 0 : 0);
|
||||
/** 도메인 종류 표시 이름 */
|
||||
const kindLabel = (d) => d.type === 'subdomain' ? '서브 도메인' : d.type === 'registered' ? '구입 도메인' : d.mode === 'ns' ? '네임서버 이전' : '직접 DNS 관리';
|
||||
|
||||
/**
|
||||
* 화면을 연다. @param {'domains'|'dns'|'mail'} [v] 그릴 화면 (기본 도메인)
|
||||
*/
|
||||
async function load(v) {
|
||||
view = ['domains', 'dns', 'mail'].includes(v) ? v : 'domains';
|
||||
const box = boxOf(); if (!box) return;
|
||||
box.innerHTML = ''; box.appendChild(el('p', 'muted small', '불러오는 중…'));
|
||||
try { data = await api('/api/domains'); } catch (e) { box.innerHTML = ''; box.appendChild(el('p', 'muted small', '도메인 정보를 불러오지 못했습니다: ' + e.message)); return; }
|
||||
render();
|
||||
}
|
||||
async function refreshList() { try { data = await api('/api/domains'); } catch (_) { return; } render(); if (window.FlowUI) FlowUI.invalidateDomains(); } // v1.6.40: 레일 배지·홈 지도도 새 자료로
|
||||
/** "내 도메인" 카드(마지막 카드)만 새로 그린다 — 위쪽 폼의 결과 안내(옮긴 레코드·네임서버)를 지우지 않기 위해 (DNS·메일 화면은 전체를 다시 그린다) */
|
||||
async function refreshMyDomains() { try { data = await api('/api/domains'); } catch (_) { return; } if (view !== 'domains') { render(); return; } const cards = boxOf().querySelectorAll('section.card'); const last = cards[cards.length - 1]; if (last) last.replaceWith(myDomainsCard()); if (window.FlowUI) FlowUI.invalidateDomains(); }
|
||||
|
||||
function render() {
|
||||
const box = boxOf(); if (!box) return; box.innerHTML = '';
|
||||
if (view === 'dns') return renderDns(box);
|
||||
if (view === 'mail') return renderMail(box);
|
||||
const sub = data.accountType === 'subdomain';
|
||||
// v1.7.2 (요구 1): 도메인 화면은 "구매·갱신·네임서버 이전" 만 다룬다. 사이트 연결·레코드·메일은 DNS·메일 화면으로 옮겼다
|
||||
const head = el('section', 'card');
|
||||
head.appendChild(el('h2', null, '도메인'));
|
||||
head.appendChild(el('p', 'muted small', sub
|
||||
? `서브 도메인 계정입니다. ${data.platform.domain || '플랫폼 도메인'} 아래 이름(예: mycompany.${data.platform.domain || 'ibit.kr'})을 무료로 등록합니다. 등록한 이름을 사이트에 연결하는 일은 "DNS" 화면에서 합니다. 직접 도메인을 사려면 관리자에게 계정 종류 변경을 요청하세요.`
|
||||
: '여기서는 도메인을 사고(구매·갱신) 다른 곳에서 산 도메인을 네임서버 이전으로 가져오는 일만 다룹니다. 등록 업체(기관) 이전은 지원하지 않습니다. 사이트를 어느 도메인에 어떤 이름으로 올릴지는 "DNS" 화면에서, 메일은 "메일" 화면에서 정합니다.'));
|
||||
head.appendChild(el('p', 'muted small', `캐시 잔액 ${won(balance())}`));
|
||||
box.appendChild(head);
|
||||
if (sub) box.appendChild(subdomainCard()); else box.appendChild(prepareCard());
|
||||
box.appendChild(myDomainsCard());
|
||||
}
|
||||
|
||||
// ---------- 서브 도메인 등록 ----------
|
||||
function subdomainCard() {
|
||||
const c = el('section', 'card'); c.appendChild(el('h2', null, '서브 도메인 등록'));
|
||||
if (!data.platform.domain) { c.appendChild(el('p', 'muted small', '플랫폼 도메인이 아직 설정되지 않았습니다. 관리자에게 문의하세요.')); return c; }
|
||||
// v1.6.35 (요구 1-1): 관리자가 서브 도메인 등록을 금지하면 이유를 보이고 입력을 막는다 (이미 등록된 것은 아래 "내 도메인" 에 그대로)
|
||||
if (data.platform.allowSubdomain === false) { c.appendChild(el('p', 'small dom-wait', '이 서비스는 서브 도메인 등록을 받지 않습니다 (관리자 설정). 이미 등록한 서브 도메인은 그대로 쓸 수 있습니다. 자기 도메인을 쓰려면 관리자에게 계정 종류 변경을 요청하세요.')); return c; }
|
||||
const row = el('div', 'row dom-sub-row'); const inp = el('input'); inp.placeholder = 'mycompany'; inp.maxLength = 32; inp.autocomplete = 'off';
|
||||
row.appendChild(inp); row.appendChild(el('span', 'dom-suffix', '.' + data.platform.domain));
|
||||
const msg = el('p', 'muted small');
|
||||
const bCheck = btn('중복 확인', 'ghost small-btn', async () => {
|
||||
say(msg, '확인 중…'); try { const r = await api('/api/domains/subdomain/check', { method: 'POST', body: JSON.stringify({ name: inp.value.trim() }) }); say(msg, r.available ? `${r.fqdn} — 등록할 수 있습니다` : `${r.fqdn} — ${r.reason}`, r.available); } catch (e) { say(msg, e.message, false); }
|
||||
});
|
||||
const bReg = btn('등록', 'primary small-btn', async () => {
|
||||
say(msg, '등록 중…'); try { const r = await api('/api/domains/subdomain', { method: 'POST', body: JSON.stringify({ name: inp.value.trim() }) }); say(msg, `${r.name} 를 등록했습니다. 아래 "내 도메인" 에서 사이트에 연결하세요`); inp.value = ''; await refreshList(); } catch (e) { say(msg, e.message, false); }
|
||||
});
|
||||
row.appendChild(bCheck); row.appendChild(bReg); c.appendChild(row); c.appendChild(msg);
|
||||
c.appendChild(el('p', 'muted small', '영문 소문자·숫자·하이픈 1~32자. 등록은 무료이며 계정당 10개까지입니다.'));
|
||||
return c;
|
||||
}
|
||||
|
||||
// ---------- ① 도메인 준비 (구매 / 네임서버 이전 / 직접 DNS 관리) ----------
|
||||
/** 세 가지 길을 탭처럼 고르는 카드 — 고른 길에 따라 아래 내용이 바뀐다 */
|
||||
function prepareCard() {
|
||||
const c = el('section', 'card'); c.appendChild(el('h2', null, '① 도메인 준비'));
|
||||
const tabs = el('div', 'row dom-ways');
|
||||
const WAYS = [['buy', '도메인 검색·구매', '새 도메인을 검색해 장바구니에 담고 구매합니다'], ['ns', '이미 가진 도메인 — 네임서버 이전', '다른 곳에서 산 도메인의 네임서버를 이 서비스로 바꿔 DNS 를 여기서 관리합니다'], ['manual', '이미 가진 도메인 — 직접 DNS 관리', '다른 곳에서 산 도메인의 DNS 를 그곳에서 직접 관리합니다 (필요한 레코드를 안내)']];
|
||||
const body = el('div');
|
||||
const draw = () => {
|
||||
tabs.innerHTML = ''; for (const [id, label, title] of WAYS) { const b = btn(label, (way === id ? 'primary' : 'ghost') + ' small-btn', () => { way = id; draw(); }); b.title = title; tabs.appendChild(b); }
|
||||
body.innerHTML = ''; body.appendChild(el('p', 'muted small', WAYS.find((w) => w[0] === way)[2]));
|
||||
if (way === 'buy') { body.appendChild(searchBlock()); body.appendChild(cartBlock()); if (data.providerReady) body.appendChild(contactCard()); } // 도메인 서비스 준비 전에는 등록자 정보를 저장할 곳(공급사)이 없어 숨김
|
||||
else body.appendChild(externalForm(way));
|
||||
};
|
||||
c.appendChild(tabs); c.appendChild(body); draw();
|
||||
return c;
|
||||
}
|
||||
|
||||
// ---------- ①-구매: 검색 → 장바구니 ----------
|
||||
function searchBlock() {
|
||||
const c = el('div', 'dom-block'); c.appendChild(el('h3', null, '도메인 검색'));
|
||||
if (!data.providerReady) { c.appendChild(el('p', 'muted small', '도메인 서비스가 아직 준비되지 않았습니다(등록 업체 접속 정보 없음). 관리자에게 문의하세요. 이미 가진 도메인은 "네임서버 이전" 또는 "직접 DNS 관리" 로 등록할 수 있습니다.')); return c; }
|
||||
const row = el('div', 'row dom-search'); const inp = el('input'); inp.placeholder = 'mycompany 또는 mycompany.com'; inp.autocomplete = 'off'; row.appendChild(inp);
|
||||
const tl = el('div', 'dom-tlds'); const checks = [];
|
||||
for (const t of data.tlds) { const lb = el('label', 'inline'); const cb = el('input'); cb.type = 'checkbox'; cb.value = t; cb.checked = ['com', 'net', 'co'].includes(t) || data.tlds.length <= 3; checks.push(cb); lb.appendChild(cb); lb.appendChild(document.createTextNode(' .' + t)); tl.appendChild(lb); }
|
||||
const msg = el('p', 'muted small'); const out = el('div', 'dom-results');
|
||||
const go = async () => {
|
||||
say(msg, '검색 중…'); out.innerHTML = '';
|
||||
try {
|
||||
const r = await api('/api/domains/search', { method: 'POST', body: JSON.stringify({ name: inp.value.trim(), tlds: checks.filter((x) => x.checked).map((x) => x.value) }) });
|
||||
say(msg, '가격은 1년 기준이며 구매 때 캐시에서 차감됩니다. 쓸 도메인을 장바구니에 담고, 장바구니에서 최종 선택해 구매하세요.');
|
||||
const tb = el('table', 'q-table dom-table'); const hr = el('tr'); for (const h of ['도메인', '상태', '가격 (1년)', '']) hr.appendChild(el('th', null, h)); tb.appendChild(hr);
|
||||
for (const x of r.results) {
|
||||
const tr = el('tr'); tr.appendChild(el('td', null, x.domain));
|
||||
tr.appendChild(el('td', null, x.available ? (x.premium ? '구매 가능 (프리미엄)' : '구매 가능') : '사용 중'));
|
||||
tr.appendChild(el('td', null, x.quote ? won(x.quote.costKrw) : '-'));
|
||||
const td = el('td');
|
||||
if (x.available && x.quote) {
|
||||
const inCart = (data.cart || []).some((i) => i.domain === x.domain);
|
||||
const b = btn(inCart ? '장바구니에 있음' : '장바구니에 담기', 'primary small-btn', async () => { b.disabled = true; try { const rr = await api('/api/domains/cart', { method: 'POST', body: JSON.stringify({ domain: x.domain }) }); data.cart = rr.items; b.textContent = '장바구니에 있음'; const cb = $('domCartBody'); if (cb) drawCart(cb, data.cart); } catch (e) { b.disabled = false; say(msg, e.message, false); } });
|
||||
if (inCart) b.disabled = true; td.appendChild(b);
|
||||
}
|
||||
tr.appendChild(td); tb.appendChild(tr);
|
||||
}
|
||||
out.appendChild(tb);
|
||||
} catch (e) { say(msg, e.message, false); }
|
||||
};
|
||||
inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') go(); });
|
||||
row.appendChild(btn('검색', 'primary small-btn', go));
|
||||
c.appendChild(row); c.appendChild(tl); c.appendChild(msg); c.appendChild(out);
|
||||
return c;
|
||||
}
|
||||
/** 장바구니(보관 목록): 열 때 가격·등록 가능 여부를 다시 받아 바뀐 값을 표시. 여기서 최종 선택해 구매한다 */
|
||||
function cartBlock() {
|
||||
const c = el('div', 'dom-block'); const h = el('h3', null, `장바구니 (${(data.cart || []).length})`); c.appendChild(h);
|
||||
if (!data.providerReady) return c;
|
||||
const body = el('div'); body.id = 'domCartBody'; c.appendChild(body);
|
||||
drawCart(body, data.cart || []);
|
||||
if ((data.cart || []).length) { body.classList.add('dom-refreshing'); api('/api/domains/cart').then((r) => { data.cart = r.items; drawCart(body, data.cart); h.textContent = `장바구니 (${data.cart.length})`; }).catch(() => {}).finally(() => body.classList.remove('dom-refreshing')); }
|
||||
return c;
|
||||
}
|
||||
function drawCart(body, items) {
|
||||
body.innerHTML = '';
|
||||
if (!items.length) { body.appendChild(el('p', 'muted small', '장바구니가 비어 있습니다. 위에서 검색해 쓸 도메인을 담으세요.')); return; }
|
||||
const tb = el('table', 'q-table dom-table'); const hr = el('tr'); for (const hh of ['도메인', '상태', '가격 (1년)', '']) hr.appendChild(el('th', null, hh)); tb.appendChild(hr);
|
||||
for (const i of items) {
|
||||
const tr = el('tr'); tr.appendChild(el('td', null, i.domain));
|
||||
tr.appendChild(el('td', null, i.refreshError ? '가격 확인 실패' : i.available ? (i.premium ? '구매 가능 (프리미엄)' : '구매 가능') : '사용 중 (다른 사람이 등록)'));
|
||||
const price = el('td', null, i.quote ? won(i.quote.costKrw) : '-'); if (i.priceChanged && i.prevKrw != null) { price.appendChild(el('span', 'muted small', ` (담을 때 ${won(i.prevKrw)})`)); price.style.fontWeight = '600'; } tr.appendChild(price);
|
||||
const td = el('td'); if (i.available && i.quote) td.appendChild(btn('구매', 'primary small-btn', () => buyDialog(i.domain, i.quote)));
|
||||
td.appendChild(btn('빼기', 'ghost small-btn', async () => { try { const r = await api('/api/domains/cart', { method: 'DELETE', body: JSON.stringify({ domain: i.domain }) }); data.cart = r.items; drawCart(body, data.cart); } catch (e) { alert(e.message); } }));
|
||||
tr.appendChild(td); tb.appendChild(tr);
|
||||
}
|
||||
const wrap = el('div', 'dom-table-wrap'); wrap.appendChild(tb); body.appendChild(wrap);
|
||||
body.appendChild(el('p', 'muted small', `가격은 장바구니를 열 때마다 다시 확인합니다 (마지막 확인 ${dtm(items[0].checkedAt)}). 구매 버튼을 누르면 금액을 한 번 더 확인합니다.`));
|
||||
}
|
||||
|
||||
/**
|
||||
* 구매 확인 창: 기간(년)을 바꾸면 견적을 다시 받는다. 확인을 누를 때 본 금액(expectedKrw)을 함께 보내
|
||||
* 그 사이 가격이 오르면 서버가 막는다(409 + 새 견적).
|
||||
*/
|
||||
function buyDialog(domain, firstQuote) {
|
||||
const dlg = $('domDialog'); const body = $('domDialogBody'); body.innerHTML = ''; $('domDialogTitle').textContent = '도메인 구매 확인';
|
||||
let q = firstQuote; let period = 1;
|
||||
const info = el('div'); const msg = el('p', 'muted small');
|
||||
const sel = el('select'); for (let y = 1; y <= 5; y++) { const o = el('option', null, `${y}년`); o.value = y; sel.appendChild(o); }
|
||||
const draw = () => {
|
||||
info.innerHTML = '';
|
||||
info.appendChild(el('p', null, domain));
|
||||
const after = balance() - q.costKrw;
|
||||
info.appendChild(el('p', 'dom-cost', `결제 금액 ${won(q.costKrw)} (${period}년)`));
|
||||
info.appendChild(el('p', 'muted small', `공급사 가격 ${q.base.amount} ${q.base.currency} × 요율 ${q.marginPct}% · 캐시 잔액 ${won(balance())} → 구매 후 ${won(after)}`));
|
||||
if (after < 0) info.appendChild(el('p', 'small', '캐시가 부족합니다. 캐시를 충전한 뒤 구매하세요.'));
|
||||
if (!data.contact) info.appendChild(el('p', 'small', '먼저 "등록자 정보" 를 저장해야 구매할 수 있습니다 (도메인 검색·구매 아래).'));
|
||||
};
|
||||
sel.onchange = async () => {
|
||||
period = Number(sel.value); say(msg, '가격 확인 중…');
|
||||
try { q = await api('/api/domains/quote', { method: 'POST', body: JSON.stringify({ domain, operation: 'create', period }) }); say(msg, ''); draw(); } catch (e) { say(msg, e.message, false); }
|
||||
};
|
||||
const lb = el('label', null, '등록 기간'); lb.appendChild(sel);
|
||||
body.appendChild(lb); body.appendChild(info); body.appendChild(msg);
|
||||
const row = el('div', 'row');
|
||||
row.appendChild(btn('구매', 'primary small-btn', async () => {
|
||||
say(msg, '등록 중… (최대 1분)');
|
||||
try {
|
||||
const r = await api('/api/domains/purchase', { method: 'POST', body: JSON.stringify({ domain, period, expectedKrw: q.costKrw }) });
|
||||
say(msg, `${r.name} 를 등록했습니다.`); dlg.close(); await load();
|
||||
} catch (e) {
|
||||
if (e.body && e.body.quote) { q = e.body.quote; draw(); } // 가격이 바뀜 — 새 금액을 보여 주고 다시 확인받는다
|
||||
say(msg, e.message, false);
|
||||
}
|
||||
}));
|
||||
row.appendChild(btn('취소', 'ghost small-btn', () => dlg.close()));
|
||||
body.appendChild(row); draw(); dlg.showModal();
|
||||
}
|
||||
|
||||
// ---------- ①-이미 가진 도메인: 네임서버 이전(ns) / 직접 DNS 관리(manual) ----------
|
||||
/**
|
||||
* 등록 폼. 공통: 도메인 이름 + "이미 구매(소유)했습니다" 확인(필수 — 서버도 owned 없이는 진행하지 않음).
|
||||
* ns: 쓰고 있는 하위 이름을 받아 레코드를 옮긴다 → 결과로 옮긴 레코드와 바꿀 네임서버, "네임서버 변경했습니다" 버튼.
|
||||
* manual: 결과로 필요한 DNS 설정 표와 "설정 완료 — 확인" 버튼.
|
||||
*/
|
||||
function externalForm(mode) {
|
||||
const c = el('div', 'dom-block');
|
||||
if (mode === 'ns') {
|
||||
c.appendChild(el('p', 'muted small', '도메인의 등록 업체(기관) 이전은 지원하지 않습니다. 대신 등록 업체에서 네임서버만 이 서비스로 바꾸면 DNS 레코드를 여기서 관리하고 사이트·메일을 자동으로 설정할 수 있습니다. 진행 순서: 지금 쓰는 DNS 레코드를 읽어 새 영역에 옮김 → 바꿀 네임서버 안내 → 등록 업체에서 네임서버 변경 → "네임서버 변경했습니다" 로 확인 → 전파 확인(최대 48시간) → 사용. 전파 확인 전까지는 "DNS 변경 진행 중" 으로 표시되고 레코드 수정이 잠깁니다(이중 수정 방지).'));
|
||||
if (!data.dnsReady) c.appendChild(el('p', 'small dom-wait', '이 서비스의 DNS 공급자가 아직 준비되지 않아 네임서버 이전은 지금 쓸 수 없습니다. "직접 DNS 관리" 로 등록하거나 관리자에게 문의하세요.'));
|
||||
} else c.appendChild(el('p', 'muted small', '도메인을 이미 구매했고 DNS 레코드를 그 업체(또는 다른 DNS 서비스)에서 직접 관리하는 경우입니다. 등록하면 배포와 메일 서비스에 필요한 DNS 레코드를 보여 드립니다. 그곳에 넣은 뒤 "설정 완료 — 확인" 을 누르면 실제로 반영됐는지 확인해 사용 가능 여부를 보여 줍니다.'));
|
||||
const grid = el('div', 'dom-grid');
|
||||
const lbD = el('label', null, '도메인'); const inp = el('input'); inp.placeholder = 'mycompany.com'; inp.autocomplete = 'off'; lbD.appendChild(inp); grid.appendChild(lbD);
|
||||
let subs = null;
|
||||
if (mode === 'ns') { const lbS = el('label', null, '쓰고 있는 하위 이름 (쉼표로 구분, 선택)'); subs = el('input'); subs.placeholder = 'www, blog, shop'; lbS.appendChild(subs); grid.appendChild(lbS); }
|
||||
c.appendChild(grid);
|
||||
const own = el('label', 'inline'); const cb = el('input'); cb.type = 'checkbox'; own.appendChild(cb); own.appendChild(document.createTextNode(' 이 도메인을 이미 구매(소유)했습니다 — 아직 없으면 "도메인 검색·구매" 로 구매하세요')); c.appendChild(own);
|
||||
const msg = el('p', 'muted small'); const out = el('div');
|
||||
const row = el('div', 'row');
|
||||
const b = btn(mode === 'ns' ? '레코드 옮기고 네임서버 받기' : '등록하고 필요한 DNS 설정 보기', 'primary small-btn', async () => {
|
||||
if (!inp.value.trim()) return say(msg, '도메인을 입력하세요', false);
|
||||
if (!cb.checked) return say(msg, '도메인을 이미 구매(소유)했는지 확인해 주세요', false);
|
||||
say(msg, mode === 'ns' ? '공용 DNS 에서 지금 레코드를 읽고 영역을 만드는 중… (최대 1분)' : '도메인을 확인하는 중…'); out.innerHTML = ''; b.disabled = true;
|
||||
try {
|
||||
const r = await api('/api/domains/external', { method: 'POST', body: JSON.stringify({ domain: inp.value.trim(), mode, subdomains: subs ? subs.value : '', owned: true }) });
|
||||
say(msg, r.message);
|
||||
if (mode === 'ns') {
|
||||
if (r.imported && r.imported.length) { out.appendChild(el('p', 'small', `옮긴 레코드 ${r.imported.length}개:`)); out.appendChild(recordTable(r.imported.map((x) => ({ type: x.type, name: x.name, value: x.value, prio: x.prio })), null)); }
|
||||
else out.appendChild(el('p', 'small', '공용 DNS 에서 읽은 레코드가 없습니다(zone 파일 구성을 찾을 수 없음) — 빈 영역을 만들었습니다. 사이트에 연결하면 사이트 레코드가 만들어지고, 필요한 레코드는 "DNS 레코드" 에서 직접 넣을 수 있습니다.'));
|
||||
if (r.next === 'ns-change') out.appendChild(nsChangeBox(r.domain, r.nameServers, r.currentNs));
|
||||
} else out.appendChild(el('p', 'small', '아래 "내 도메인" 에서 "필요한 DNS 설정" 을 열어 레코드를 넣고 확인하세요.'));
|
||||
inp.value = ''; cb.checked = false; b.disabled = false;
|
||||
await refreshMyDomains(); // 결과 안내는 그대로 두고 아래 "내 도메인" 만 새로 그린다
|
||||
} catch (e) { say(msg, e.message, false); b.disabled = false; }
|
||||
});
|
||||
row.appendChild(b); c.appendChild(row); c.appendChild(msg); c.appendChild(out);
|
||||
return c;
|
||||
}
|
||||
/**
|
||||
* 바꿀 네임서버 안내 상자 — v1.6.38 (요구 3): 네임서버 목록은 "네임서버 정보" 팝업(복사 가능)으로 보이고, 본문에는 요약과 "네임서버 변경했습니다" 버튼만 둔다.
|
||||
* 네임서버가 여러 개라 화면이 복잡해지는 것을 막고, 팝업 안에서는 값마다 Ctrl-C·복사 버튼으로 가져갈 수 있다.
|
||||
*/
|
||||
function nsChangeBox(d, nameServers, currentNs) {
|
||||
const box = el('div', 'dom-ns');
|
||||
box.appendChild(el('p', 'small', `등록 업체(${d.name} 을 구매한 곳)의 도메인 설정에서 네임서버를 이 서비스의 네임서버 ${nameServers.length}개로 바꿔야 합니다${currentNs && currentNs.length ? ` (지금: ${currentNs.join(', ')})` : ''}. 어디서 어떻게 바꾸는지와 복사할 값은 "네임서버 정보" 에 있습니다.`));
|
||||
const row = el('div', 'row'); const msg = el('p', 'muted small');
|
||||
row.appendChild(btn('네임서버 정보', 'ghost small-btn', () => nsInfoDialog(d, nameServers, currentNs)));
|
||||
row.appendChild(btn('네임서버 변경했습니다', 'primary small-btn', async () => {
|
||||
if (!confirm(`등록 업체에서 ${d.name} 의 네임서버를 "네임서버 정보" 의 목록으로 바꾸어 저장하셨나요?\n확인을 누르면 공용 DNS 에서 실제로 바뀌었는지 확인합니다.`)) return;
|
||||
say(msg, '확인 중…'); try { const r = await api(`/api/domains/${d.id}/transfer-check`, { method: 'POST', body: JSON.stringify({ confirmed: true }) }); say(msg, r.message, r.done || r.step === 'propagating'); if (r.done) await refreshList(); } catch (e) { say(msg, e.message, false); }
|
||||
}));
|
||||
box.appendChild(row); box.appendChild(msg);
|
||||
return box;
|
||||
}
|
||||
|
||||
// ---------- v1.6.38 (요구 3): 네임서버 정보 · DNS 레코드 정보 팝업 (복사 가능) ----------
|
||||
/** 클립보드 복사 — https 가 아니거나 막히면 false (그때는 값을 직접 선택해 Ctrl-C) */
|
||||
async function copyText(text) { try { await navigator.clipboard.writeText(text); return true; } catch (_) { return false; } }
|
||||
/** 복사 버튼: 누르면 복사하고 잠시 "복사됨" 으로 바뀐다 */
|
||||
function copyBtn(text, label) {
|
||||
const b = btn(label || '복사', 'ghost small-btn dom-copy-btn', async () => { const ok = await copyText(text); const old = b.textContent; b.textContent = ok ? '복사됨' : '직접 선택해 Ctrl-C'; setTimeout(() => { b.textContent = old; }, 1500); });
|
||||
b.title = '클립보드에 복사'; return b;
|
||||
}
|
||||
/** 값 칸: 클릭하면 값 전체가 선택되어 Ctrl-C 로 복사되고(user-select: all), 옆의 작은 버튼으로도 복사된다 */
|
||||
function copyCell(value) {
|
||||
const td = el('td', 'dom-val'); const v = String(value == null ? '' : value);
|
||||
const code = el('code', 'dom-copy', v); code.title = '클릭하면 전체 선택 — Ctrl-C 로 복사'; code.onclick = () => { try { const r = document.createRange(); r.selectNodeContents(code); const sl = window.getSelection(); sl.removeAllRanges(); sl.addRange(r); } catch (_) {} };
|
||||
td.appendChild(code); if (v) td.appendChild(copyBtn(v)); return td;
|
||||
}
|
||||
/**
|
||||
* 안내 팝업 열기 (index.html 의 #domDialog 재사용). build(body) 가 내용을 채운다.
|
||||
* 팝업 안의 글은 모두 선택·복사할 수 있다.
|
||||
*/
|
||||
function infoDialog(title, build) {
|
||||
const dlg = $('domDialog'); const body = $('domDialogBody'); if (!dlg || !body) return;
|
||||
body.innerHTML = ''; $('domDialogTitle').textContent = title; dlg.classList.add('wide');
|
||||
const inner = el('div', 'dom-dialog-body'); build(inner); body.appendChild(inner);
|
||||
const row = el('div', 'row dom-dialog-foot'); row.appendChild(btn('닫기', 'ghost', () => { dlg.classList.remove('wide'); dlg.close(); })); body.appendChild(row);
|
||||
dlg.showModal();
|
||||
}
|
||||
/** 단계 안내 목록 (ol) */
|
||||
function steps(items) { const ol = el('ol', 'dom-guide'); for (const t of items) ol.appendChild(el('li', null, t)); return ol; }
|
||||
/**
|
||||
* "네임서버 정보" 팝업 — 어디를 눌러 네임서버를 바꾸는지 + 바꿀 네임서버 목록(값마다 복사, 전체 복사).
|
||||
* 네임서버 이전 도메인(mode ns)이 아니면(직접 DNS 관리) 네임서버 이전으로 바꾸는 방법만 안내한다 — 이 서비스의 네임서버는 영역을 만들 때 정해지기 때문.
|
||||
*/
|
||||
function nsInfoDialog(d, nameServers, currentNs) {
|
||||
infoDialog(`네임서버 정보 — ${d.name}`, (box) => {
|
||||
const prov = data.dnsProviderLabel ? `이 서비스의 DNS 공급자: ${data.dnsProviderLabel}` : '';
|
||||
if (!nameServers || !nameServers.length) {
|
||||
box.appendChild(el('p', 'small', `${d.name} 은 "직접 DNS 관리" 로 등록되어 있어 레코드를 도메인 업체(또는 쓰고 있는 DNS 서비스)에서 직접 관리합니다. 이 서비스가 DNS 를 맡게 하려면(네임서버 이전) 아래 순서로 바꾸세요. 레코드만 넣으려면 "DNS 레코드 정보" 를 누르세요.`));
|
||||
box.appendChild(steps(['"내 도메인" 에서 이 도메인을 "해제" 합니다 (사이트 연결은 다시 하면 됩니다).', '"도메인 준비" 카드에서 "네임서버 이전" 을 고르고 도메인과 쓰고 있는 하위 이름을 적어 "레코드 옮기고 네임서버 받기" 를 누릅니다.', '그때 만들어지는 네임서버 목록을 등록 업체의 네임서버(DNS 서버) 설정에 넣고 "네임서버 변경했습니다" 를 누릅니다.']));
|
||||
if (!data.dnsReady) box.appendChild(el('p', 'small dom-wait', '지금은 이 서비스의 DNS 공급자가 준비되지 않아 네임서버 이전을 쓸 수 없습니다. 관리자에게 문의하세요.'));
|
||||
if (prov) box.appendChild(el('p', 'muted small', prov));
|
||||
return;
|
||||
}
|
||||
box.appendChild(el('p', 'small', `도메인을 구매한 등록 업체(가비아·후이즈·GoDaddy·Namecheap 등)에서 ${d.name} 의 네임서버를 아래 ${nameServers.length}개로 바꾸면 이 서비스가 DNS 를 맡아 사이트·메일 레코드를 자동으로 넣습니다.${currentNs && currentNs.length ? ` 지금 네임서버: ${currentNs.join(', ')}` : ''}`));
|
||||
box.appendChild(el('p', 'small', '어디를 누르나요?'));
|
||||
box.appendChild(steps(['등록 업체에 로그인 → "도메인 관리"(My 도메인·Domain List) → ' + d.name + ' 선택', '"네임서버 설정"(네임서버 변경 · DNS 서버 · Nameservers) 메뉴를 엽니다 — "DNS 레코드 관리" 가 아닙니다', '"사용자 네임서버(Custom nameservers)" 를 고르고 기존 값을 지운 뒤 아래 목록을 1차·2차·3차·4차 순서로 붙여 넣습니다 (값 옆 "복사" 또는 값을 클릭해 Ctrl-C)', '저장한 뒤 이 화면으로 돌아와 "네임서버 변경했습니다" 를 누릅니다. 전파에는 몇 분~최대 48시간이 걸리고, 그동안 "DNS 변경 진행 중" 으로 표시됩니다.']));
|
||||
const tb = el('table', 'q-table dom-table'); const hr = el('tr'); for (const h of ['순서', '네임서버']) hr.appendChild(el('th', null, h)); tb.appendChild(hr);
|
||||
nameServers.forEach((ns, i) => { const tr = el('tr'); tr.appendChild(el('td', null, `${i + 1}차`)); tr.appendChild(copyCell(ns)); tb.appendChild(tr); });
|
||||
const tw = el('div', 'dom-table-wrap'); tw.appendChild(tb); box.appendChild(tw);
|
||||
const row = el('div', 'row'); row.appendChild(copyBtn(nameServers.join('\n'), '네임서버 전체 복사 (줄바꿈)')); row.appendChild(copyBtn(nameServers.join(', '), '전체 복사 (쉼표)')); box.appendChild(row);
|
||||
if (prov) box.appendChild(el('p', 'muted small', prov));
|
||||
});
|
||||
}
|
||||
/** 레코드 목록 → 텍스트 (복사용). "이름 종류 [우선순위] 값" 한 줄씩 */
|
||||
const recordsText = (recs) => recs.map((r) => [r.host || r.name, r.type, r.priority != null ? String(r.priority) : '', r.value].filter((x) => x !== '').join('\t')).join('\n');
|
||||
/**
|
||||
* "DNS 레코드 정보" 팝업 — 도메인 업체(또는 쓰는 DNS 서비스)의 DNS 레코드 관리에서 넣을 값. 사이트용(A)·메일용(MX·SPF·DMARC)·보내기 인증(DKIM 등)으로 나누고
|
||||
* 값마다 복사·Ctrl-C 가 되게 한다. MX 는 우선순위까지 따로 강조한다.
|
||||
*/
|
||||
function dnsRecordsDialog(d, siteId) {
|
||||
infoDialog(`DNS 레코드 정보 — ${d.name}`, async (box) => {
|
||||
box.appendChild(el('p', 'muted small', '필요한 레코드를 계산하는 중…'));
|
||||
let r; try { r = await api(`/api/domains/${d.id}/required?${siteId ? 'siteId=' + encodeURIComponent(siteId) + '&' : ''}mail=1`); } catch (e) { box.innerHTML = ''; box.appendChild(el('p', 'small', e.message)); return; }
|
||||
box.innerHTML = '';
|
||||
const recs = r.records || []; const siteRecs = recs.filter((x) => x.group === 'site'); const mailRecs = recs.filter((x) => x.group !== 'site' && x.purpose !== 'send'); const sendRecs = recs.filter((x) => x.purpose === 'send');
|
||||
box.appendChild(el('p', 'small', `${d.name} 의 DNS 레코드를 직접 관리하는 경우(도메인 업체 또는 쓰고 있는 DNS 서비스) 아래 값을 그곳에 넣습니다. 사이트 IP: ${r.ip}`));
|
||||
box.appendChild(el('p', 'small', '어디를 누르나요?'));
|
||||
box.appendChild(steps(['도메인 업체(또는 DNS 서비스)에 로그인 → "도메인 관리" → ' + d.name + ' → "DNS 레코드 관리"(DNS 설정 · DNS 관리 · Manage DNS) 를 엽니다 — "네임서버 설정" 이 아닙니다', '레코드마다 "추가" 를 눌러 종류(A·MX·TXT…)·이름(호스트: @ 는 도메인 자체, www 등은 하위 이름)·값·우선순위(MX 만)를 아래 표대로 넣습니다. 값은 클릭하면 전체 선택되고 "복사" 버튼으로도 복사됩니다', 'TXT 값(SPF·DMARC·DKIM)은 따옴표 없이 그대로 붙여 넣습니다. 이미 v=spf1 레코드가 있으면 새로 만들지 말고 기존 값에 mx 를 더합니다', '저장 뒤 이 화면의 "필요한 DNS 설정" → "설정 완료 — 확인" 을 누르면 실제 반영을 확인합니다 (반영까지 몇 분~최대 48시간)']));
|
||||
const table = (list, title, note) => {
|
||||
if (!list.length) return;
|
||||
box.appendChild(el('h3', 'dom-dialog-h', title)); if (note) box.appendChild(el('p', 'muted small', note));
|
||||
const tb = el('table', 'q-table dom-table'); const hr = el('tr'); for (const h of ['종류', '이름(호스트)', '값', '우선순위']) hr.appendChild(el('th', null, h)); tb.appendChild(hr);
|
||||
for (const x of list) { const tr = el('tr'); const t = el('td', null, x.type); t.title = x.why || ''; tr.appendChild(t); tr.appendChild(copyCell(x.host || x.name)); tr.appendChild(copyCell(x.value)); tr.appendChild(x.priority != null ? copyCell(String(x.priority)) : el('td', null, '')); tb.appendChild(tr); }
|
||||
const tw = el('div', 'dom-table-wrap'); tw.appendChild(tb); box.appendChild(tw);
|
||||
const row = el('div', 'row'); row.appendChild(copyBtn(recordsText(list), `${title} 전체 복사`)); box.appendChild(row);
|
||||
};
|
||||
table(siteRecs, '사이트용 (A 레코드)', '홈페이지가 이 서비스의 서버로 열리게 하는 레코드. @ 와 www 둘 다 넣습니다.');
|
||||
const mx = mailRecs.filter((x) => x.type === 'MX'); const txt = mailRecs.filter((x) => x.type !== 'MX');
|
||||
table(mx, '메일용 — MX (받는 메일)', '받는 메일을 이 서비스의 메일 서버로 보내는 레코드. "우선순위(priority)" 칸이 따로 있는 업체가 많고, 없으면 값 앞에 "10 " 을 붙여 넣습니다. 값 끝의 점(.)을 요구하는 업체도 있습니다.');
|
||||
table(txt, '메일용 — TXT (SPF · DMARC)', 'SPF 는 이름 @ 에, DMARC 는 이름 _dmarc 에 TXT 로 넣습니다.');
|
||||
table(sendRecs, '보내기 인증 (DKIM 등)', r.mailNote || '보내는 메일이 스팸으로 분류되지 않게 하는 레코드. 메일 발송 서비스가 확인합니다.');
|
||||
if (!mailRecs.length && !sendRecs.length) box.appendChild(el('p', 'muted small', r.mailNote || '메일용 레코드는 메일 서버가 준비되면 표시됩니다.'));
|
||||
const all = el('div', 'row'); all.appendChild(copyBtn(recordsText(recs), '모든 레코드 복사 (이름·종류·우선순위·값, 탭 구분)')); box.appendChild(all);
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 직접 구매한(또는 다른 곳에서 관리하는) 도메인의 사전 안내 줄 — 어떤 경우에 어느 버튼을 누르는지. v1.6.38 (요구 3)
|
||||
* @param {object} d 도메인
|
||||
* @param {() => string} siteIdOf 선택된 사이트 id (레코드 계산용)
|
||||
*/
|
||||
function externalGuide(d, siteIdOf) {
|
||||
const g = el('div', 'dom-guide-box');
|
||||
const ns = d.mode === 'ns';
|
||||
g.appendChild(el('p', 'small', ns
|
||||
? '이 도메인은 다른 곳에서 구매해 네임서버를 이 서비스로 옮기는 중이거나 옮긴 도메인입니다. 등록 업체에서 네임서버를 바꿔야 하면 "네임서버 정보" 를, 네임서버를 옮기지 않고 그 업체의 DNS 에 레코드를 직접 넣으려면 "DNS 레코드 정보" 를 누르세요. 표시되는 값은 모두 클릭해 Ctrl-C 로 복사할 수 있습니다.'
|
||||
: '이 도메인은 직접 구매해 DNS 를 다른 곳에서 관리하는 도메인입니다. 그 업체의 DNS 레코드 관리에 넣을 값(사이트 A · 메일 MX/SPF/DMARC · 보내기 인증)은 "DNS 레코드 정보" 에, 네임서버를 이 서비스로 옮기는 방법은 "네임서버 정보" 에 있습니다. 표시되는 값은 모두 클릭해 Ctrl-C 로 복사할 수 있습니다.'));
|
||||
const row = el('div', 'row');
|
||||
row.appendChild(btn('네임서버 정보', 'ghost small-btn', () => nsInfoDialog(d, ns ? (d.nameServers || []) : [], d.currentNs)));
|
||||
row.appendChild(btn('DNS 레코드 정보', 'ghost small-btn', () => dnsRecordsDialog(d, siteIdOf())));
|
||||
g.appendChild(row);
|
||||
return g;
|
||||
}
|
||||
|
||||
// ---------- 등록자 정보 ----------
|
||||
const CONTACT = [['firstName', '이름 (영문)', 'Gildong'], ['lastName', '성 (영문)', 'Hong'], ['company', '회사명 (선택)', ''], ['email', '이메일', 'you@example.com'], ['phoneCountry', '국가 번호', '+82'], ['phoneArea', '지역 번호 (선택)', '2'], ['phoneNumber', '전화번호', '12345678'], ['street', '도로명 (영문)', 'Teheran-ro'], ['number', '건물 번호', '123'], ['zipcode', '우편번호', '06123'], ['city', '도시 (영문)', 'Seoul'], ['state', '시·도 (선택)', ''], ['country', '국가 코드', 'KR']];
|
||||
function contactCard() {
|
||||
const c = el('details', 'dom-block dom-contact'); const sm = el('summary', null, data.contact ? '등록자 정보 (저장됨)' : '등록자 정보 (도메인 구매 전에 필요)'); c.appendChild(sm);
|
||||
if (!data.contact) c.open = true;
|
||||
c.appendChild(el('p', 'muted small', '도메인 소유자로 등록되는 정보입니다. 국제 등록 기관 규칙에 따라 영문으로 입력하세요. 공개 WHOIS 에는 가려집니다.'));
|
||||
const grid = el('div', 'dom-grid'); const ins = {};
|
||||
for (const [k, label, ph] of CONTACT) { const lb = el('label', null, label); const i = el('input'); i.placeholder = ph; i.value = (data.contact && data.contact[k]) || (k === 'phoneCountry' ? '+82' : k === 'country' ? 'KR' : ''); ins[k] = i; lb.appendChild(i); grid.appendChild(lb); }
|
||||
const msg = el('span', 'muted small'); const row = el('div', 'row');
|
||||
row.appendChild(btn('등록자 정보 저장', 'primary small-btn', async () => {
|
||||
say(msg, '저장 중…'); const b = {}; for (const k of Object.keys(ins)) b[k] = ins[k].value.trim();
|
||||
try { data.contact = await api('/api/domains/contact', { method: 'PUT', body: JSON.stringify(b) }); say(msg, '저장했습니다'); sm.textContent = '등록자 정보 (저장됨)'; } catch (e) { say(msg, e.message, false); }
|
||||
}));
|
||||
row.appendChild(msg); c.appendChild(grid); c.appendChild(row);
|
||||
return c;
|
||||
}
|
||||
|
||||
// ---------- ②~④ 내 도메인 ----------
|
||||
// ---------- ② 내 도메인 — 구매·갱신·네임서버 (v1.7.2) ----------
|
||||
/** 도메인 종류별 짧은 상태 배지 */
|
||||
function domainStatePill(d) {
|
||||
if (d.type === 'registered') { if (d.status === 'PENDING') return el('span', 'pill small waiting', '등록 처리 중'); if (d.status === 'FAILED') return el('span', 'pill small error', '등록 실패'); if (d.pending) return el('span', 'pill small waiting', 'DNS 전파 대기'); return el('span', 'pill small done', '사용 중'); }
|
||||
if (d.type === 'external' && d.mode === 'ns') { const st = d.transfer ? d.transfer.step : 'ns-change'; return el('span', 'pill small ' + (st === 'done' ? 'done' : 'waiting'), st === 'done' ? '네임서버 이전 완료' : st === 'propagating' ? '네임서버 전파 중' : '네임서버 변경 필요'); }
|
||||
if (d.type === 'external') { const m = d.manual || {}; return el('span', 'pill small ' + (m.siteOk ? 'done' : 'idle'), m.siteOk ? 'DNS 확인됨' : 'DNS 직접 관리'); }
|
||||
return el('span', 'pill small done', '등록됨');
|
||||
}
|
||||
function myDomainsCard() {
|
||||
const c = el('section', 'card'); c.appendChild(el('h2', null, data.accountType === 'subdomain' ? '내 서브 도메인' : '② 내 도메인 — 구매 · 갱신 · 네임서버'));
|
||||
if (!data.domains.length) { c.appendChild(el('p', 'muted small', '아직 등록한 도메인이 없습니다.')); return c; }
|
||||
c.appendChild(el('p', 'muted small', '사이트 연결·DNS 레코드는 "DNS" 화면, 메일(MX)은 "메일" 화면에서 다룹니다.'));
|
||||
for (const d of data.domains) c.appendChild(domainRow(d));
|
||||
return c;
|
||||
}
|
||||
/**
|
||||
* 도메인 한 줄: 이름 · 종류 · 상태 + 종류별 관리 항목 — 구입 도메인은 만료·갱신, 네임서버 이전 도메인은 단계 표시(nsStepper), 직접 관리 도메인은 DNS 화면 안내.
|
||||
* 사이트 연결 정보는 요약 한 줄로만 보이고 바꾸는 일은 DNS 화면에서 한다.
|
||||
*/
|
||||
function domainRow(d) {
|
||||
const sub = d.type === 'subdomain'; const ext = d.type === 'external';
|
||||
const it = el('div', 'dom-item');
|
||||
const top = el('div', 'dom-top'); top.appendChild(el('b', null, d.name)); top.appendChild(el('span', 'pill idle small', kindLabel(d))); top.appendChild(domainStatePill(d));
|
||||
if (d.type === 'registered' && d.status !== 'PENDING') top.appendChild(el('span', 'muted small', `만료 ${dt(d.expiresAt)}`));
|
||||
it.appendChild(top);
|
||||
const site = data.sites.find((s0) => s0.id === d.siteId);
|
||||
const link = el('p', 'muted small'); link.appendChild(document.createTextNode(site ? `연결된 사이트: ${site.title} → ${(d.siteNames || []).join(', ') || d.name} · ` : '아직 사이트에 연결되지 않았습니다 · '));
|
||||
const go = el('a', null, 'DNS 화면에서 연결·레코드 관리'); go.href = '#'; go.onclick = (e) => { e.preventDefault(); window.showTab('dns'); }; link.appendChild(go); it.appendChild(link);
|
||||
const waitBox = el('p', 'small dom-wait hidden'); it.appendChild(waitBox); if (d.pending && !(ext && d.mode === 'ns')) checkPropagation(d, waitBox);
|
||||
const msg = el('p', 'muted small');
|
||||
if (ext && d.mode === 'ns') it.appendChild(nsStepper(d, msg));
|
||||
if (ext && d.mode === 'manual') it.appendChild(el('p', 'small', 'DNS 를 직접 관리하는 도메인입니다. 사이트·메일에 필요한 레코드와 "설정 완료 — 확인" 은 DNS 화면의 이 도메인 항목에 있습니다.'));
|
||||
if (d.type === 'registered' && d.status === 'PENDING') it.appendChild(el('p', 'small dom-wait', '등록 업체가 아직 등록을 처리하고 있습니다. 끝나면 DNS 영역이 자동으로 만들어집니다 (이 화면을 다시 열면 확인합니다).'));
|
||||
const row = el('div', 'row dom-row');
|
||||
if (d.type === 'registered') row.appendChild(btn('갱신 (1년)', 'primary small-btn', () => renewFlow(d, msg)));
|
||||
if (sub || ext) row.appendChild(btn('해제', 'ghost small-btn danger', async () => {
|
||||
if (!confirm(`${d.name} 를 이 서비스에서 해제할까요? 연결된 사이트는 이 주소로 열리지 않게 됩니다.${ext && d.mode === 'ns' ? '\n(DNS 영역은 공급자에 남습니다 — 네임서버를 다른 곳으로 옮긴 뒤 관리자에게 정리를 요청하세요)' : ''}`)) return;
|
||||
try { const r = await api(`/api/domains/${d.id}`, { method: 'DELETE' }); if (r.note) alert(r.note); await refreshList(); } catch (e) { say(msg, e.message, false); }
|
||||
}));
|
||||
if (row.children.length) it.appendChild(row); it.appendChild(msg);
|
||||
return it;
|
||||
}
|
||||
/**
|
||||
* v1.7.2 (요구 1): 네임서버 이전 진행 단계 — ① 영역 준비·레코드 옮김 → ② 등록 업체에서 네임서버 변경 → ③ 전파 확인 → ④ 완료.
|
||||
* 지금 단계는 transfer.step(ns-change | propagating | done | error)과 userConfirmedAt 으로 정한다. ③ 에서는 기대 네임서버와 지금 공용 DNS 에 보이는 네임서버를 나란히 ✓/✗ 로 보인다.
|
||||
*/
|
||||
function nsStepper(d, msg) {
|
||||
const t = d.transfer || { step: 'ns-change' }; const step = t.step === 'done' ? 4 : (t.step === 'propagating' || t.userConfirmedAt ? 3 : 2);
|
||||
const wrap = el('div', 'ns-stepper');
|
||||
const STEPS = [['영역 준비', `레코드 ${d.imported || 0}개를 옮겨 둠`], ['네임서버 변경', '등록 업체에서 바꾸기'], ['전파 확인', '최대 48시간'], ['완료', 'DNS 를 이 서비스가 관리']];
|
||||
const line = el('div', 'ns-steps');
|
||||
STEPS.forEach(([title, sub], i) => { const n = i + 1; const s0 = el('div', 'ns-step ' + (n < step ? 'done' : n === step ? (step === 4 ? 'done' : 'now') : 'todo')); s0.appendChild(el('span', 'ns-dot', n < step || step === 4 ? '✓' : String(n))); const tx = el('div'); tx.appendChild(el('div', 'ns-t', title)); tx.appendChild(el('div', 'ns-s', sub)); s0.appendChild(tx); line.appendChild(s0); });
|
||||
wrap.appendChild(line);
|
||||
const detail = el('div', 'ns-detail');
|
||||
if (step === 2) {
|
||||
detail.appendChild(el('p', 'small', `등록 업체(${d.name} 을 구매한 곳)의 네임서버 설정을 이 서비스의 네임서버 ${(d.nameServers || []).length}개로 바꾼 뒤 "네임서버 변경했습니다" 를 누르세요.${d.currentNs && d.currentNs.length ? ` 지금 네임서버: ${d.currentNs.join(', ')}` : ''}`));
|
||||
const row = el('div', 'row'); row.appendChild(btn('네임서버 정보 (복사)', 'ghost small-btn', () => nsInfoDialog(d, d.nameServers || [], d.currentNs)));
|
||||
row.appendChild(btn('네임서버 변경했습니다', 'primary small-btn', async () => { if (!confirm(`등록 업체에서 ${d.name} 의 네임서버를 "네임서버 정보" 의 목록으로 바꾸어 저장하셨나요?\n확인을 누르면 공용 DNS 에서 실제로 바뀌었는지 확인합니다.`)) return; say(msg, '확인 중…'); try { const r = await api(`/api/domains/${d.id}/transfer-check`, { method: 'POST', body: JSON.stringify({ confirmed: true }) }); say(msg, r.message, r.done || r.step === 'propagating'); await refreshList(); } catch (e) { say(msg, e.message, false); } }));
|
||||
detail.appendChild(row);
|
||||
} else if (step === 3) {
|
||||
detail.appendChild(el('p', 'small', `네임서버 변경을 ${dtm(t.userConfirmedAt)} 에 확인했습니다 — 공용 DNS 에 새 네임서버가 보일 때까지 기다리는 중입니다 (보통 수 분~수 시간, 최대 48시간). 그동안 이 도메인의 레코드 수정은 잠깁니다.`));
|
||||
const expect = d.nameServers || []; const got = (d.currentNs || []).map((x) => String(x).toLowerCase());
|
||||
const tb = el('table', 'q-table dom-table'); const hr = el('tr'); for (const h of ['바꿀 네임서버', '공용 DNS 에 보이는 값']) hr.appendChild(el('th', null, h)); tb.appendChild(hr);
|
||||
for (const ns of expect) { const tr = el('tr'); tr.appendChild(copyCell(ns)); const okCell = el('td', null, got.includes(String(ns).toLowerCase()) ? '✓ 보임' : '✕ 아직'); okCell.style.color = got.includes(String(ns).toLowerCase()) ? '#15803d' : '#b91c1c'; tr.appendChild(okCell); tb.appendChild(tr); }
|
||||
for (const ns of got.filter((x) => !expect.map((e) => String(e).toLowerCase()).includes(x))) { const tr = el('tr'); tr.appendChild(el('td', 'muted small', '(옛 네임서버)')); tr.appendChild(el('td', 'muted small', ns)); tb.appendChild(tr); }
|
||||
const tw = el('div', 'dom-table-wrap'); tw.appendChild(tb); detail.appendChild(tw);
|
||||
const row = el('div', 'row'); row.appendChild(btn('지금 다시 확인', 'primary small-btn', async () => { say(msg, '확인 중…'); try { const r = await api(`/api/domains/${d.id}/transfer-check`, { method: 'POST', body: JSON.stringify({}) }); say(msg, r.message, r.done || r.step === 'propagating'); await refreshList(); } catch (e) { say(msg, e.message, false); } }));
|
||||
row.appendChild(btn('네임서버 정보', 'ghost small-btn', () => nsInfoDialog(d, expect, d.currentNs))); detail.appendChild(row);
|
||||
} else detail.appendChild(el('p', 'small', `${t.completedAt ? dtm(t.completedAt) + ' 에 ' : ''}네임서버 이전이 확인되었습니다. 이 도메인의 DNS 레코드를 이 서비스에서 관리합니다 — 사이트 연결·레코드는 DNS 화면에서.`));
|
||||
wrap.appendChild(detail);
|
||||
return wrap;
|
||||
}
|
||||
/** 전파 상태 확인 → 진행 중이면 "약 N분 후에 다시 이용"·"DNS 변경 진행 중" 안내 */
|
||||
async function checkPropagation(d, node) {
|
||||
try { const p = await api(`/api/domains/${d.id}/propagation`); wait(node, p.pending ? p.message : ''); } catch (_) {}
|
||||
}
|
||||
/** 갱신: 견적을 먼저 받아 금액을 보여 주고 확인받는다 */
|
||||
async function renewFlow(d, msg) {
|
||||
say(msg, '갱신 가격 확인 중…');
|
||||
try {
|
||||
let q = await api('/api/domains/quote', { method: 'POST', body: JSON.stringify({ domain: d.name, operation: 'renew', period: 1 }) }); say(msg, '');
|
||||
for (let i = 0; i < 2; i++) {
|
||||
if (!confirm(`${d.name} 를 1년 갱신합니다.\n결제 금액 ${won(q.costKrw)} (캐시 잔액 ${won(balance())} → ${won(balance() - q.costKrw)})\n진행할까요?`)) return;
|
||||
try { await api(`/api/domains/${d.id}/renew`, { method: 'POST', body: JSON.stringify({ period: 1, expectedKrw: q.costKrw }) }); say(msg, '갱신했습니다'); await refreshList(); return; }
|
||||
catch (e) { if (e.body && e.body.quote) { q = e.body.quote; say(msg, e.message, false); continue; } throw e; }
|
||||
}
|
||||
} catch (e) { say(msg, e.message, false); }
|
||||
}
|
||||
|
||||
// ---------- ③ 직접 DNS 관리: 필요한 DNS 설정 · 설정 완료 확인 ----------
|
||||
/** 필요한 레코드 표(사이트용 + 선택적으로 메일용)와 확인 줄 */
|
||||
async function loadRequired(d, sel, box, withMail) {
|
||||
box.innerHTML = ''; box.appendChild(el('p', 'muted small', '필요한 레코드를 계산하는 중…'));
|
||||
try {
|
||||
const r = await api(`/api/domains/${d.id}/required?${sel.value ? 'siteId=' + encodeURIComponent(sel.value) + '&' : ''}${withMail ? 'mail=1' : ''}`);
|
||||
box.innerHTML = '';
|
||||
box.appendChild(el('p', 'small', `${d.name} 의 DNS 서비스(도메인을 구매한 곳 또는 쓰고 있는 DNS)에 아래 레코드를 넣으세요. 사이트 IP: ${r.ip}`));
|
||||
box.appendChild(requiredTable(r.records));
|
||||
if (r.mailNote) box.appendChild(el('p', 'muted small', r.mailNote));
|
||||
if (r.verified) box.appendChild(el('p', r.verified.siteOk ? 'muted small' : 'small dom-wait', r.verified.siteOk ? `사이트용 레코드 확인됨 (${dtm(r.verified.at)})${r.verified.mailOk === true ? ' · 메일용 레코드 확인됨' : r.verified.mailOk === false ? ' · 메일용 레코드는 아직 확인되지 않음' : ''}` : r.verified.lastCheck ? `마지막 확인 ${dtm(r.verified.lastCheck)}: DNS 변경이 완료되지 않았거나 아직 반영되지 않았습니다 — 이 상태로는 배포가 정상적으로 되지 않습니다` : '아직 확인하지 않았습니다 — 레코드를 넣은 뒤 "설정 완료 — 확인" 을 누르세요'));
|
||||
box.appendChild(verifyRow(d, sel, box, !!r.wantMail));
|
||||
} catch (e) { box.innerHTML = ''; box.appendChild(el('p', 'small', e.message)); }
|
||||
}
|
||||
/** "설정 완료 — 확인" 줄: 사용자 확인(confirmed) → 공용 DNS 조회 → 레코드별 결과 표와 사용 가능 여부 */
|
||||
function verifyRow(d, sel, box, withMail) {
|
||||
const row = el('div', 'row dom-row'); const msg = el('p', 'small');
|
||||
const mailCb = el('input'); mailCb.type = 'checkbox'; mailCb.checked = !!withMail; const lb = el('label', 'inline'); lb.appendChild(mailCb); lb.appendChild(document.createTextNode(' 메일용 레코드 포함'));
|
||||
row.appendChild(lb);
|
||||
row.appendChild(btn('설정 완료 — 확인', 'primary small-btn', async () => {
|
||||
if (!confirm(`${d.name} 의 DNS 서비스에 위 레코드를 모두 넣고 저장하셨나요?\n확인을 누르면 공용 DNS 에서 실제로 반영됐는지 확인합니다 (반영까지 몇 분~최대 48시간).`)) return;
|
||||
say(msg, '공용 DNS 에서 확인 중…');
|
||||
try {
|
||||
const r = await api(`/api/domains/${d.id}/verify`, { method: 'POST', body: JSON.stringify({ siteId: sel.value || undefined, confirmed: true, mail: mailCb.checked }) });
|
||||
say(msg, r.message, r.ok); const old = box.querySelector('.dom-verify-result'); if (old) old.remove();
|
||||
const res = el('div', 'dom-verify-result'); res.appendChild(requiredTable(r.records, true)); box.appendChild(res);
|
||||
if (r.ok) await refreshList();
|
||||
} catch (e) { say(msg, e.message, false); }
|
||||
}));
|
||||
row.appendChild(btn('필요한 설정 다시 보기', 'ghost small-btn', () => loadRequired(d, sel, box, mailCb.checked)));
|
||||
const wrap = el('div'); wrap.appendChild(row); wrap.appendChild(msg); return wrap;
|
||||
}
|
||||
/** 필요한 레코드 표. withResult 면 확인 결과(지금 값·확인) 열을 더한다 */
|
||||
function requiredTable(recs, withResult) {
|
||||
const tb = el('table', 'q-table dom-table'); const hr = el('tr'); for (const h of ['용도', '종류', '이름', '값', '우선순위', ...(withResult ? ['지금 값', '확인'] : [])]) hr.appendChild(el('th', null, h)); tb.appendChild(hr);
|
||||
for (const r of recs) {
|
||||
const tr = el('tr'); const g = el('td', null, r.group === 'site' ? '사이트' : (r.purpose === 'send' ? '메일(보내기)' : '메일')); g.title = r.why || ''; tr.appendChild(g);
|
||||
tr.appendChild(el('td', null, r.type)); tr.appendChild(el('td', null, r.host || r.name)); tr.appendChild(copyCell(r.value)); tr.appendChild(el('td', null, r.priority != null ? String(r.priority) : '')); // v1.6.38: 값은 클릭 선택·복사 버튼
|
||||
if (withResult) { tr.appendChild(el('td', 'dom-val', r.providerChecked ? '(공급자가 확인)' : (r.found || []).join(', ') || '없음')); const ok = el('td', null, r.providerChecked ? '-' : r.ok ? '✓ 확인됨' : (r.required ? '✕ 아직' : '– 선택')); ok.style.color = r.providerChecked ? '' : r.ok ? '#15803d' : (r.required ? '#b91c1c' : ''); tr.appendChild(ok); }
|
||||
tb.appendChild(tr);
|
||||
}
|
||||
const wrap = el('div', 'dom-table-wrap'); wrap.appendChild(tb); return wrap;
|
||||
}
|
||||
/** ④ 도메인 상태 표 — 단계별 ✓/✕/⏳ 와 설명, 요약 */
|
||||
function statusTable(s) {
|
||||
const wrap = el('div', 'dom-status');
|
||||
wrap.appendChild(el('p', s.ok ? 'small' : 'small dom-wait', s.summary));
|
||||
const ICON = { ok: '✓', fail: '✕', wait: '⏳', warn: '△', na: '–' }; const COLOR = { ok: '#15803d', fail: '#b91c1c', wait: '#a16207', warn: '#a16207', na: '' };
|
||||
const tb = el('table', 'q-table dom-table');
|
||||
for (const st of s.steps) { const tr = el('tr'); const ic = el('td', 'dom-st-icon', ICON[st.state] || ''); ic.style.color = COLOR[st.state] || ''; tr.appendChild(ic); tr.appendChild(el('td', 'dom-st-label', st.label)); tr.appendChild(el('td', 'dom-st-detail', st.detail)); tb.appendChild(tr); }
|
||||
const tw = el('div', 'dom-table-wrap'); tw.appendChild(tb); wrap.appendChild(tw);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* "메일(MX) 자동 설정" 버튼 (v1.6.40: 도메인 화면의 도메인 항목과 메일 화면이 같이 쓴다).
|
||||
* 받는 메일 MX·SPF·DMARC 와 Resend 보내기 레코드(DKIM 등)를 자동으로 만든다. 직접 DNS 관리 도메인은 넣을 레코드를 안내한다.
|
||||
* @param {object} d 도메인 @param {HTMLSelectElement|{value:string}} sel 메일을 쓸 사이트 선택 @param msg 결과 줄 @param detail 레코드 표가 열리는 상자 @param waitBox 전파 안내 상자 @param {boolean} manual 직접 DNS 관리 도메인인지
|
||||
*/
|
||||
function mailSetupBtn(d, sel, msg, detail, waitBox, manual, cls) {
|
||||
return btn('메일(MX) 자동 설정', cls || 'primary small-btn', async () => {
|
||||
if (!sel.value) return say(msg, '메일을 쓸 사이트를 고르세요', false);
|
||||
say(msg, '메일 레코드를 만드는 중…');
|
||||
try {
|
||||
const r = await api(`/api/domains/${d.id}/mail`, { method: 'POST', body: JSON.stringify({ siteId: sel.value }) });
|
||||
if (r.mode === 'manual') { say(msg, manual ? '이 도메인의 DNS 는 직접 관리하므로 아래 레코드를 도메인의 DNS 서비스에 넣은 뒤 "설정 완료 — 확인" 을 누르세요' : '이 도메인은 DNS 를 자동으로 바꿀 수 없어 아래 레코드를 직접 넣어야 합니다'); detail.innerHTML = ''; detail.appendChild(recordTable(r.records.map((x) => ({ name: x.host || x.name, type: x.type, value: x.value, prio: x.priority })), null)); detail.appendChild(btn('DNS 레코드 정보 (복사 가능)', 'ghost small-btn', () => dnsRecordsDialog(d, sel.value))); if (manual) detail.appendChild(verifyRow(d, sel, detail, true)); }
|
||||
else say(msg, `메일 레코드 ${r.records.length}개를 만들었습니다${r.mailNote ? ' · ' + r.mailNote : ''}`);
|
||||
if (r.propagation && r.propagation.pending) wait(waitBox, r.propagation.message);
|
||||
} catch (e) { say(msg, e.message, false); }
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- DNS 레코드 (구입 도메인 · 네임서버 이전 도메인) ----------
|
||||
/** DNS 레코드 표 (readonly 면 지우기·추가 없음 — 서브 도메인, 또는 네임서버 이전 전파 중 잠금) */
|
||||
async function loadRecords(d, box, readonly) {
|
||||
box.innerHTML = ''; box.appendChild(el('p', 'muted small', '레코드 불러오는 중…'));
|
||||
let recs; try { recs = (await api(`/api/domains/${d.id}/records`)).records; } catch (e) { box.innerHTML = ''; box.appendChild(el('p', 'muted small', e.message)); return; }
|
||||
box.innerHTML = '';
|
||||
if (d.dnsLocked) box.appendChild(el('p', 'small dom-wait', 'DNS 변경 진행 중 — 네임서버 이전이 확인될 때까지 레코드를 수정할 수 없습니다 (이중 수정 방지). 아래는 새 영역에 옮겨 둔 레코드입니다.'));
|
||||
box.appendChild(recordTable(recs, readonly ? null : d, box));
|
||||
if (!readonly) box.appendChild(addRecordRow(d, box));
|
||||
}
|
||||
function recordTable(recs, d, box) {
|
||||
const tb = el('table', 'q-table dom-table'); const hr = el('tr'); for (const h of ['종류', '이름', '값', '우선순위', '']) hr.appendChild(el('th', null, h)); tb.appendChild(hr);
|
||||
if (!recs.length) { const tr = el('tr'); const td = el('td', 'muted small', '레코드가 없습니다'); td.colSpan = 5; tr.appendChild(td); tb.appendChild(tr); }
|
||||
for (const r of recs) {
|
||||
const tr = el('tr'); tr.appendChild(el('td', null, r.type)); tr.appendChild(el('td', null, r.name || '@')); const v = el('td', 'dom-val', r.value); tr.appendChild(v); tr.appendChild(el('td', null, r.prio !== undefined && r.prio !== null ? String(r.prio) : ''));
|
||||
const td = el('td');
|
||||
if (d && !['NS', 'SOA'].includes(r.type)) td.appendChild(btn('삭제', 'ghost small-btn danger', async () => {
|
||||
if (!confirm(`${r.type} ${r.name || '@'} → ${r.value} 레코드를 지울까요?`)) return;
|
||||
try { await api(`/api/domains/${d.id}/records`, { method: 'DELETE', body: JSON.stringify(r) }); await loadRecords(d, box); } catch (e) { alert(e.message); }
|
||||
}));
|
||||
tr.appendChild(td); tb.appendChild(tr);
|
||||
}
|
||||
const wrap = el('div', 'dom-table-wrap'); wrap.appendChild(tb); return wrap;
|
||||
}
|
||||
function addRecordRow(d, box) {
|
||||
const row = el('div', 'row dom-add'); const type = el('select'); for (const t of ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'CAA', 'SRV']) { const o = el('option', null, t); o.value = t; type.appendChild(o); }
|
||||
const name = el('input'); name.placeholder = '이름 (비우면 @)'; const value = el('input'); value.placeholder = '값'; const prio = el('input'); prio.placeholder = '우선순위'; prio.type = 'number'; prio.className = 'dom-prio';
|
||||
const msg = el('span', 'muted small');
|
||||
row.appendChild(type); row.appendChild(name); row.appendChild(value); row.appendChild(prio);
|
||||
row.appendChild(btn('레코드 추가', 'primary small-btn', async () => {
|
||||
say(msg, '추가 중…');
|
||||
try { await api(`/api/domains/${d.id}/records`, { method: 'POST', body: JSON.stringify({ type: type.value, name: name.value.trim(), value: value.value.trim(), prio: prio.value }) }); await loadRecords(d, box); } catch (e) { say(msg, e.message, false); }
|
||||
}));
|
||||
row.appendChild(msg); return row;
|
||||
}
|
||||
|
||||
// ---------- v1.7.2 DNS 화면: 사이트 ↔ 도메인 연결이 먼저, 레코드 관리는 그 아래 ----------
|
||||
/** 도메인의 짧은 준비 상태(연결 표용) */
|
||||
function readyPill(d) {
|
||||
if (d.dnsLocked || (d.transfer && d.transfer.step && d.transfer.step !== 'done')) return el('span', 'pill small waiting', '네임서버 전파 대기');
|
||||
if (d.pending) return el('span', 'pill small waiting', 'DNS 전파 대기');
|
||||
if (d.type === 'external' && d.mode === 'manual') return el('span', 'pill small ' + (d.manual && d.manual.siteOk ? 'done' : 'error'), d.manual && d.manual.siteOk ? 'DNS 확인됨' : 'DNS 확인 필요');
|
||||
if (d.type === 'registered' && d.status === 'PENDING') return el('span', 'pill small waiting', '등록 처리 중');
|
||||
return el('span', 'pill small done', '준비됨');
|
||||
}
|
||||
function renderDns(box) {
|
||||
const head = el('section', 'card'); head.appendChild(el('h2', null, 'DNS — 사이트를 어느 주소로 열까요?'));
|
||||
head.appendChild(el('p', 'muted small', '먼저 사이트와 도메인의 연결 관계를 정합니다: 어떤 사이트를 어느 도메인의 어떤 이름(기본 @·www, 또는 shop 같은 하위 이름)으로 열지. 연결하면 그 이름의 A 레코드가 자동으로 만들어지고(직접 관리 도메인은 안내), 다음 발행 때 웹서버 설정·SSL 이 적용됩니다. 도메인별 DNS 레코드 관리는 아래 "도메인별 DNS 레코드" 에서 그대로 할 수 있습니다.'));
|
||||
if (data.dnsProviderLabel) head.appendChild(el('p', 'muted small', `DNS 공급자: ${data.dnsProviderLabel}${data.targetIp ? ` · 사이트 IP: ${data.targetIp}` : ''}`));
|
||||
box.appendChild(head);
|
||||
if (!data.domains.length) { const c = el('section', 'card'); c.appendChild(el('p', 'muted small', '아직 등록한 도메인이 없습니다. 먼저 "도메인" 화면에서 도메인을 준비하세요.')); const r = el('div', 'row'); r.appendChild(btn('도메인 화면으로', 'primary small-btn', () => window.showTab('domains'))); c.appendChild(r); box.appendChild(c); return; }
|
||||
box.appendChild(connectionsCard());
|
||||
const h = el('section', 'card'); h.appendChild(el('h2', null, '도메인별 DNS 레코드')); h.appendChild(el('p', 'muted small', '구매한 도메인과 네임서버를 이전한 도메인은 레코드를 직접 추가·삭제할 수 있고, 서브 도메인은 보기만, 직접 DNS 관리 도메인은 필요한 레코드를 안내하고 반영 여부를 확인합니다.')); box.appendChild(h);
|
||||
for (const d of data.domains) box.appendChild(dnsItem(d));
|
||||
}
|
||||
/**
|
||||
* 사이트 ↔ 도메인 연결 표 (v1.7.2 요구 1의 핵심). 사이트마다 한 줄: 열리는 주소(연결된 도메인의 이름들) · 준비 상태 · 연결 바꾸기(도메인 선택 + 하위 이름) · 연결 끊기.
|
||||
* 도메인 하나에는 사이트 하나만 연결된다(다른 사이트에 연결하면 이전 연결은 풀린다). 연결되지 않은 도메인은 표 아래에 따로 보인다.
|
||||
*/
|
||||
function connectionsCard() {
|
||||
const c = el('section', 'card'); c.appendChild(el('h2', null, '사이트 ↔ 도메인 연결'));
|
||||
if (!data.sites.length) { c.appendChild(el('p', 'muted small', '아직 사이트가 없습니다. "만들기" 에서 사이트를 만든 뒤 여기서 도메인에 연결하세요.')); return c; }
|
||||
const msg = el('p', 'muted small');
|
||||
const cur = typeof current !== 'undefined' ? current : null;
|
||||
// 사이트마다 한 블록(좁은 작업 칸에서도 읽히게 표 대신 세로 블록): 제목 → 열리는 주소 + 상태 → 연결 바꾸기 → 연결 끊기
|
||||
for (const s0 of data.sites) {
|
||||
const mine = data.domains.filter((d) => d.siteId === s0.id);
|
||||
const blk = el('div', 'conn-site' + (cur && cur.id === s0.id ? ' on' : ''));
|
||||
const head = el('div', 'conn-head'); head.appendChild(el('b', null, s0.title)); if (cur && cur.id === s0.id) head.appendChild(el('span', 'pill small running', '지금 보는 사이트')); blk.appendChild(head);
|
||||
const addr = el('div', 'conn-addr');
|
||||
if (!mine.length) { addr.appendChild(el('span', 'pill small idle', '미연결')); addr.appendChild(el('span', 'muted small', '연결된 도메인 없음 — 서버 주소로만 열립니다')); }
|
||||
else for (const d of mine) { const line = el('div', 'conn-line'); line.appendChild(el('span', 'conn-arrow', '→')); (d.siteNames || [d.name]).forEach((n, i) => { if (i) line.appendChild(document.createTextNode(', ')); const a = el('a', null, n); a.href = 'https://' + n + '/'; a.target = '_blank'; a.rel = 'noopener'; line.appendChild(a); }); line.appendChild(readyPill(d)); line.appendChild(btn('연결 끊기', 'ghost small-btn danger', async () => { if (!confirm(`${s0.title} 에서 ${d.name} 연결을 끊을까요? DNS 레코드는 남고, 다음 발행 때 웹서버 설정에서 빠집니다.`)) return; try { await api(`/api/domains/${d.id}/disconnect`, { method: 'POST', body: JSON.stringify({ siteId: s0.id }) }); say(msg, `${d.name} 연결을 끊었습니다`); if (cur && cur.id === s0.id) openSite(s0.id, { tab: 'dns' }).catch(() => {}); await refreshList(); } catch (e) { say(msg, e.message, false); } })); addr.appendChild(line); }
|
||||
blk.appendChild(addr);
|
||||
const sel = el('select'); const o0 = el('option', null, mine.length ? '다른 도메인으로 바꾸기…' : '연결할 도메인 선택'); o0.value = ''; sel.appendChild(o0);
|
||||
for (const d of data.domains) { if (d.siteId === s0.id) continue; const o = el('option', null, d.name + (d.siteId ? ` (다른 사이트: ${(data.sites.find((x) => x.id === d.siteId) || {}).title || ''})` : '')); o.value = d.id; sel.appendChild(o); }
|
||||
const host = el('input', 'dom-host hidden'); host.placeholder = '하위 이름 (비우면 @ · www)'; host.maxLength = 32; host.autocomplete = 'off';
|
||||
const bConn = btn('연결', 'primary small-btn', async () => {
|
||||
if (!sel.value) return say(msg, `${s0.title}: 연결할 도메인을 고르세요`, false);
|
||||
const d = data.domains.find((x) => x.id === sel.value); const h = host.classList.contains('hidden') ? '' : host.value.trim();
|
||||
if (d.siteId && d.siteId !== s0.id && !confirm(`${d.name} 은 이미 다른 사이트에 연결되어 있습니다. ${s0.title} 로 바꿀까요? (이전 사이트의 주소 목록에서 빠집니다)`)) return;
|
||||
say(msg, '연결 중…'); bConn.disabled = true;
|
||||
try { const r = await api(`/api/domains/${d.id}/connect`, { method: 'POST', body: JSON.stringify({ siteId: s0.id, host: h }) }); say(msg, `연결했습니다: ${s0.title} → ${r.domains.join(', ')} — 다음 발행·재배포 때 웹서버 설정과 SSL 이 적용됩니다${r.guide ? ' · ' + r.guide : ''}`); if (cur && cur.id === s0.id) openSite(s0.id, { tab: 'dns' }).catch(() => {}); await refreshList(); } catch (e) { say(msg, e.message, false); bConn.disabled = false; }
|
||||
});
|
||||
bConn.disabled = true;
|
||||
sel.onchange = () => { const d = data.domains.find((x) => x.id === sel.value); host.classList.toggle('hidden', !d || d.type === 'subdomain'); bConn.disabled = !sel.value; };
|
||||
const row = el('div', 'row dom-conn-row'); row.appendChild(sel); row.appendChild(host); row.appendChild(bConn); blk.appendChild(row);
|
||||
c.appendChild(blk);
|
||||
}
|
||||
c.appendChild(msg);
|
||||
const free = data.domains.filter((d) => !d.siteId);
|
||||
c.appendChild(el('p', 'muted small', free.length ? `연결되지 않은 도메인: ${free.map((d) => d.name).join(', ')} — 위에서 사이트를 골라 연결하세요.` : '모든 도메인이 사이트에 연결되어 있습니다.'));
|
||||
c.appendChild(el('p', 'muted small', '하위 이름을 적으면 그 이름 하나(예: shop.example.com)로, 비우면 도메인 자신과 www 로 엽니다. 도메인 하나에는 사이트 하나만 연결됩니다.'));
|
||||
return c;
|
||||
}
|
||||
function dnsItem(d) {
|
||||
const sub = d.type === 'subdomain'; const ext = d.type === 'external'; const manual = ext && d.mode === 'manual';
|
||||
const c = el('section', 'card');
|
||||
const top = el('div', 'dom-head-row'); top.appendChild(el('h2', null, d.name)); top.appendChild(el('span', 'pill idle small', kindLabel(d))); top.appendChild(readyPill(d));
|
||||
const site = data.sites.find((s0) => s0.id === d.siteId); top.appendChild(el('span', 'muted small', site ? `→ ${site.title} (${(d.siteNames || []).join(', ') || d.name})` : '사이트에 연결되지 않음'));
|
||||
c.appendChild(top);
|
||||
const waitBox = el('p', 'small dom-wait hidden'); c.appendChild(waitBox); if (d.pending) checkPropagation(d, waitBox);
|
||||
const msg = el('p', 'muted small'); const detail = el('div');
|
||||
if (ext && d.mode === 'ns' && d.transfer && d.transfer.step !== 'done') c.appendChild(el('p', 'small dom-wait', '네임서버 이전이 아직 끝나지 않았습니다 — 진행 단계와 "네임서버 변경했습니다" 는 도메인 화면에 있습니다. 전파가 확인될 때까지 레코드 수정은 잠깁니다.'));
|
||||
if (ext) c.appendChild(externalGuide(d, () => d.siteId || ''));
|
||||
const row = el('div', 'row dom-row');
|
||||
row.appendChild(btn('도메인 상태', 'ghost small-btn', async () => { detail.innerHTML = ''; detail.appendChild(el('p', 'muted small', '확인 중…')); try { const s0 = await api(`/api/domains/${d.id}/status${d.siteId ? '?siteId=' + encodeURIComponent(d.siteId) : ''}`); detail.innerHTML = ''; detail.appendChild(statusTable(s0)); } catch (e) { detail.innerHTML = ''; detail.appendChild(el('p', 'small', e.message)); } }));
|
||||
const recBox = el('div', 'dom-recs');
|
||||
if (manual) { const selStub = { value: d.siteId || '' }; row.appendChild(btn('필요한 DNS 설정 다시 보기', 'ghost small-btn', () => loadRequired(d, selStub, recBox))); loadRequired(d, selStub, recBox); }
|
||||
else { const open = el('details', 'dom-rec-details'); const sm = el('summary', null, 'DNS 레코드 보기 · 관리'); open.appendChild(sm); open.appendChild(recBox); open.ontoggle = () => { if (open.open && !recBox.dataset.loaded) { recBox.dataset.loaded = '1'; loadRecords(d, recBox, sub || d.dnsLocked); } }; c.appendChild(row); c.appendChild(msg); c.appendChild(detail); c.appendChild(open); return c; }
|
||||
c.appendChild(row); c.appendChild(msg); c.appendChild(detail); c.appendChild(recBox);
|
||||
return c;
|
||||
}
|
||||
|
||||
// ---------- v1.7.2 메일 화면: 계정 형식 · 접속 주소 · 초기 관리자를 한눈에 ----------
|
||||
function renderMail(box) {
|
||||
const head = el('section', 'card'); head.appendChild(el('h2', null, '메일'));
|
||||
head.appendChild(el('p', 'muted small', '순서: ① 도메인의 DNS 준비 → ② 아래에서 도메인에 "메일(MX) 자동 설정" → ③ 운영팀이 사이트 메일 시스템을 켬 → ④ 사이트 관리자 페이지 › 회원에서 "메일 사용 켜기" → ⑤ 웹메일 또는 메일 프로그램으로 접속. 메일은 사이트 제작·배포와 무관하게 준비할 수 있지만 DNS 가 준비된 도메인에서만 동작합니다.'));
|
||||
box.appendChild(head);
|
||||
const cur = typeof current !== 'undefined' ? current : null;
|
||||
if (cur) box.appendChild(mailGlanceCard(cur));
|
||||
else box.appendChild(el('p', 'muted small', '왼쪽 레일에서 사이트를 고르면 그 사이트의 메일 계정 형식·접속 주소·초기 관리자 계정이 여기에 보입니다.'));
|
||||
if (!data.domains.length) { const c = el('section', 'card'); c.appendChild(el('p', 'muted small', '아직 등록한 도메인이 없습니다. 메일을 쓰려면 먼저 "도메인" 화면에서 도메인을 준비하세요.')); const r = el('div', 'row'); r.appendChild(btn('도메인 화면으로', 'primary small-btn', () => window.showTab('domains'))); c.appendChild(r); box.appendChild(c); return; }
|
||||
const h = el('section', 'card'); h.appendChild(el('h2', null, '도메인별 메일(MX) 설정')); h.appendChild(el('p', 'muted small', `받는 메일 MX·SPF·DMARC 와 보내는 메일 DKIM 레코드를 자동으로 만듭니다.${data.mailHost ? ` 받는 메일 서버(MX): ${data.mailHost}` : ''}`)); box.appendChild(h);
|
||||
for (const d of data.domains) box.appendChild(mailItem(d));
|
||||
}
|
||||
/**
|
||||
* 한눈에 보기: 이 사이트의 메일 — 상태, 계정 형식(아이디@도메인), 접속 주소(웹메일 · 메일 서버 웹메일 · IMAP/POP/SMTP), 초기 관리자(사이트 관리자 계정과 메일 상태).
|
||||
* 값은 app.js current.mail (publicView 의 mailAtAGlance) 에서 온다. 비밀번호는 보이지 않는다 — 메일 비밀번호는 사이트 관리자 비밀번호와 같다(1.6.39 동기화).
|
||||
*/
|
||||
function mailGlanceCard(site) {
|
||||
const m = site.mail || {}; const c = el('section', 'card'); c.appendChild(el('h2', null, `이 사이트의 메일 — ${site.title}`));
|
||||
const line = el('div', 'dom-mailstat');
|
||||
if (!m || m.level === 'off') { line.appendChild(el('span', 'pill small idle', '꺼짐')); line.appendChild(el('span', 'muted small', '이 사이트의 메일 시스템은 아직 켜지지 않았습니다. 아래에서 도메인에 메일(MX)을 설정한 뒤 운영팀에 메일 시스템 사용을 요청하세요.')); c.appendChild(line); return c; }
|
||||
line.appendChild(el('span', 'pill small ' + (m.level === 'setting-up' ? 'waiting' : 'done'), m.label)); line.appendChild(el('span', 'muted small', m.message || '')); c.appendChild(line);
|
||||
const kv = el('div', 'mail-glance');
|
||||
const row = (k, node) => { const r = el('div', 'mg-row'); r.appendChild(el('div', 'mg-k', k)); const v = el('div', 'mg-v'); if (typeof node === 'string') v.textContent = node; else v.appendChild(node); r.appendChild(v); kv.appendChild(r); };
|
||||
row('메일 계정 형식', m.accountFormat ? m.accountFormat : `아이디@${m.domain || '(도메인 없음)'}`);
|
||||
const acc = el('div'); acc.appendChild(el('div', null, '사이트 관리자 페이지 › 회원 에서 "메일 사용 켜기" 를 누른 회원의 로그인 ID 가 메일 아이디가 됩니다. 비밀번호는 그 회원의 사이트 관리자 비밀번호와 같습니다.')); row('계정 만들기', acc);
|
||||
const links = el('div');
|
||||
if (m.webmailUrl) { const a = el('a', 'btn ghost small-btn', '웹메일 열기 (이 서비스)'); a.href = m.webmailUrl; a.target = '_blank'; a.rel = 'noopener'; links.appendChild(a); }
|
||||
if (m.server && m.server.webmail) { const a = el('a', 'btn ghost small-btn', `메일 서버 웹메일 (${m.server.host || ''})`); a.href = m.server.webmail; a.target = '_blank'; a.rel = 'noopener'; links.appendChild(a); }
|
||||
if (!links.children.length) links.appendChild(el('span', 'muted small', '메일 서버가 연결되면 접속 주소가 여기에 보입니다'));
|
||||
row('접속 주소', links);
|
||||
if (m.server && m.server.client) { const cl = m.server.client; const t = el('div', 'small'); t.appendChild(el('div', null, `받는 서버(IMAP) ${cl.imap.host} — 포트 ${cl.imap.ports.join(' / ')} (${cl.imap.note})`)); t.appendChild(el('div', null, `받는 서버(POP3) ${cl.pop.host} — 포트 ${cl.pop.ports.join(' / ')}`)); t.appendChild(el('div', null, `보내는 서버(SMTP) ${cl.smtp.host} — 포트 ${cl.smtp.ports.join(' / ')} (${cl.smtp.note})`)); t.appendChild(el('div', 'muted', `사용자 이름: ${cl.username}`)); row('메일 프로그램 설정', t); }
|
||||
const adm = el('div');
|
||||
if (!(m.admins || []).length) adm.appendChild(el('span', 'muted small', '사이트 관리자 계정이 없습니다 — 사이트 관리자 페이지에서 먼저 만드세요'));
|
||||
else for (const a of m.admins) { const d = el('div', 'mg-admin'); d.appendChild(el('b', null, a.loginId)); d.appendChild(document.createTextNode(a.name && a.name !== a.loginId ? ` (${a.name})` : '')); d.appendChild(el('span', 'pill small ' + (a.mail ? (a.mail.state === 'active' ? 'done' : 'waiting') : 'idle'), a.mail ? `${a.mail.address}${a.mail.state && a.mail.state !== 'active' ? ' · ' + a.mail.state : ''}` : '메일 미사용')); adm.appendChild(d); }
|
||||
adm.appendChild(el('div', 'muted small', '처음 접속: 사이트 관리자 계정으로 사이트 관리자 페이지에 로그인 → 회원 › 자기 계정의 "메일 사용 켜기"(비밀번호는 사이트 관리자 비밀번호 그대로) → 위 웹메일 주소에서 "아이디@도메인" 과 같은 비밀번호로 로그인.'));
|
||||
row('초기 관리자', adm);
|
||||
// v1.7.3 (요구 2): 메일 사용량 — 메일 서버(Arimail) 사용량 API 에서 이 사이트 메일 도메인의 메일함 용량·메일 수·이달 발송/수신과 이 사이트 계정별 메일함 크기. 비동기로 채운다
|
||||
const us = el('div', 'small mg-usage'); us.appendChild(el('span', 'muted', '불러오는 중…')); row('메일 사용량', us); loadMailUsage(site, us, false);
|
||||
const bt = el('div', 'row'); const a2 = el('a', 'btn ghost small-btn', '사이트 관리자 페이지 열기'); a2.href = `/site-admin/${site.id}/`; a2.target = '_blank'; a2.rel = 'noopener'; bt.appendChild(a2); bt.appendChild(btn('운영 화면에서 관리', 'ghost small-btn', () => window.showTab('manage')));
|
||||
row('관리', bt);
|
||||
c.appendChild(kv);
|
||||
return c;
|
||||
}
|
||||
/**
|
||||
* v1.7.3 (요구 2): GET /api/sites/<ID>/mail-usage → 메일함 용량(도메인 전체) · 메일 수 · 계정 수(이 사이트 것 따로) · 이달/오늘 발송·수신 · 이 사이트 계정별 메일함.
|
||||
* 운영자가 사용량 API 키를 아직 연결하지 않았거나 Arimail 이 낮은 버전이면 그 이유를 한 줄로 보여 준다(비밀값은 오지 않는다).
|
||||
*/
|
||||
async function loadMailUsage(site, box, refresh) {
|
||||
const fb = (n) => (typeof fmtBytes === 'function' ? fmtBytes(Number(n) || 0) : `${Number(n) || 0} B`); const fn = (n) => (typeof fmt === 'function' ? fmt(Number(n) || 0) : String(Number(n) || 0));
|
||||
let u; try { u = await api(`/api/sites/${site.id}/mailusage${refresh ? '?refresh=1' : ''}`); } catch (e) { box.innerHTML = ''; box.appendChild(el('span', 'muted', e.message)); return; }
|
||||
box.innerHTML = '';
|
||||
if (!u.ok) { const soft = ['no_usage_key', 'usage_unsupported', 'usage_api_disabled', 'no_server'].includes(u.code); box.appendChild(el('span', 'muted', soft ? `메일 서버 사용량은 운영팀이 메일 서버의 사용량 API 를 연결하면 보입니다 (${u.message})` : u.message)); return; }
|
||||
const t = u.traffic || {}; const mo = t.month || {}; const dy = t.day || {};
|
||||
box.appendChild(el('div', null, `메일함 용량 ${fb(u.storage.bytes)}${u.storage.limit ? ` / ${fb(u.storage.limit)}` : ''} · 메일 ${fn(u.messages.total)}통 · 계정 ${fn(u.accounts.total)}개${u.accounts.mine ? ` (이 사이트 ${u.accounts.mine}개, ${fb(u.myBytes || 0)})` : ''}`));
|
||||
box.appendChild(el('div', null, `이달 발송 ${fn(mo.sent)}통 · 수신 ${fn(mo.received)}통 (${fb(mo.sentBytes)} / ${fb(mo.receivedBytes)}) · 오늘 ${fn(dy.sent)} / ${fn(dy.received)}`));
|
||||
if ((u.accountList || []).length) { const ul = el('ul', 'git-list'); for (const a of u.accountList) { const li = el('li'); li.appendChild(el('span', 'bname', a.mailbox || a.login)); li.appendChild(el('span', 'st', `${fb(a.bytes)}${a.limit ? ` / ${fb(a.limit)}` : ''} · ${fn(a.messages)}통 · 발송 ${fn(a.sent)} / 수신 ${fn(a.received)}`)); ul.appendChild(li); } box.appendChild(ul); }
|
||||
const meta = el('div', 'muted'); meta.appendChild(document.createTextNode(`메일 서버 ${u.server ? (u.server.nickname || u.server.host) : ''} · ${new Date(u.at).toLocaleString(typeof uiLocale === 'function' ? uiLocale() : undefined)}${u.cached ? ' (캐시)' : ''} `));
|
||||
meta.appendChild(btn('새로 받기', 'ghost small-btn', () => { box.innerHTML = ''; box.appendChild(el('span', 'muted', '불러오는 중…')); loadMailUsage(site, box, true); })); box.appendChild(meta);
|
||||
}
|
||||
function mailItem(d) {
|
||||
const sub = d.type === 'subdomain'; const ext = d.type === 'external'; const manual = ext && d.mode === 'manual';
|
||||
const c = el('section', 'card');
|
||||
const top = el('div', 'dom-head-row'); top.appendChild(el('h2', null, d.name)); top.appendChild(el('span', 'pill idle small', kindLabel(d))); top.appendChild(readyPill(d));
|
||||
const site = data.sites.find((s0) => s0.id === d.siteId); top.appendChild(el('span', 'muted small', site ? `연결된 사이트: ${site.title}` : '사이트에 연결되지 않음'));
|
||||
c.appendChild(top);
|
||||
if (sub) c.appendChild(el('p', 'muted small', `메일 주소 예: admin@${d.name}`));
|
||||
const waitBox = el('p', 'small dom-wait hidden'); c.appendChild(waitBox); if (d.pending) checkPropagation(d, waitBox);
|
||||
const msg = el('p', 'muted small'); const detail = el('div');
|
||||
const row = el('div', 'row dom-row'); const sel = el('select');
|
||||
const o0 = el('option', null, '메일을 쓸 사이트 선택'); o0.value = ''; sel.appendChild(o0);
|
||||
for (const s0 of data.sites) { const o = el('option', null, s0.title); o.value = s0.id; if (s0.id === d.siteId || (!d.siteId && typeof current !== 'undefined' && current && current.id === s0.id)) o.selected = true; sel.appendChild(o); }
|
||||
row.appendChild(sel);
|
||||
row.appendChild(mailSetupBtn(d, sel, msg, detail, waitBox, manual, 'primary small-btn'));
|
||||
row.appendChild(btn('도메인 상태', 'ghost small-btn', async () => { detail.innerHTML = ''; detail.appendChild(el('p', 'muted small', '확인 중…')); try { const s0 = await api(`/api/domains/${d.id}/status${sel.value ? '?siteId=' + encodeURIComponent(sel.value) : ''}`); detail.innerHTML = ''; detail.appendChild(statusTable(s0)); } catch (e) { detail.innerHTML = ''; detail.appendChild(el('p', 'small', e.message)); } }));
|
||||
if (manual) row.appendChild(btn('필요한 DNS 설정 (메일 포함)', 'ghost small-btn', async () => { detail.innerHTML = ''; await loadRequired(d, sel, detail, true); }));
|
||||
c.appendChild(row); c.appendChild(msg); c.appendChild(detail);
|
||||
return c;
|
||||
}
|
||||
|
||||
window.DomainsUI = { load };
|
||||
})();
|
||||
@@ -0,0 +1,239 @@
|
||||
'use strict';
|
||||
/* ============================================================================
|
||||
* 작업 흐름 지도 · 레일 상태 배지 · 배포 점검표 (v1.6.40, 요구 1)
|
||||
*
|
||||
* 웹메이커의 일은 서로 이어져 있다 — 그러나 모두 한 줄로 늘어선 것은 아니다:
|
||||
* 만들기 ──▶ 수정 ──▶ 배포 ──▶ 운영 (사이트 축: 배포는 사이트 내용이 있어야 한다)
|
||||
* 도메인 ──▶ DNS ──▶ (배포의 도메인 연결) · (메일) (주소 축: 도메인은 다른 일과 무관하게 먼저 준비할 수 있고, DNS 는 배포·메일이 의존한다)
|
||||
* 캐시 (모든 AI 작업·도메인 구매의 비용)
|
||||
* 이 모듈은 현재 사이트(app.js 의 current)와 도메인 목록(/api/domains), 캐시 잔액을 읽어 각 단계의 상태를 계산하고
|
||||
* - 레일(#mainTabs) 버튼마다 상태 점(data-state)과 짧은 배지(.nb)를 붙이고,
|
||||
* - 홈 화면(#homeBody)에 단계별 상태·다음 할 일·의존 관계를 그리고,
|
||||
* - 배포 화면 맨 위(#deployChecklist)에 "배포 준비 점검표" 를 그린다.
|
||||
* 상태 값: done(완료) · active(진행 중) · wait(기다리는 중: 전파·배포 단계) · warn(주의: 실패·확인 필요) · todo(아직) · na(이 사이트에 해당 없음)
|
||||
* 전역 의존(app.js): $, api, T, TF, krwFmt, current, credit, config, showTab, inProgress, uiLocale
|
||||
* ========================================================================== */
|
||||
(() => {
|
||||
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined && text !== null) e.textContent = text; return e; };
|
||||
const btn = (text, cls, onclick) => { const b = el('button', cls || 'ghost small-btn', text); b.type = 'button'; b.onclick = onclick; return b; };
|
||||
const go = (tab) => () => window.showTab(tab);
|
||||
/** 상태 → 홈 단계 머리의 짧은 배지 문구 (배지가 따로 없을 때) */
|
||||
const STATE_LABEL = { done: '완료', active: '진행 중', wait: '대기', warn: '확인 필요', todo: '아직', na: '해당 없음' };
|
||||
/** 배포 단계 이름 (wizard.js 의 PHASE 와 같은 뜻, 짧게) */
|
||||
const PHASE = { 'vhost-wait': '웹서버 설정 대기', 'dns-wait': 'DNS 설정 대기', 'cert-wait': '인증서 준비 중', 'check-failed': '접속 확인 실패', 'vhost-failed': '도메인 연결 실패', failed: '배포 실패' };
|
||||
const MODE_LABEL = { ai: 'AI 에게 맡기기', manual: '내가 고르기', template: '템플릿', copy: '사이트 복사', import: '가져옴', quick: '빠른 생성' };
|
||||
|
||||
// ---------- 도메인 목록 (60초 캐시 — 레일 배지·홈·점검표가 같이 쓴다) ----------
|
||||
let domData = null, domAt = 0, domP = null;
|
||||
async function domains(force) {
|
||||
if (!force && domData && Date.now() - domAt < 60000) return domData;
|
||||
if (!domP) domP = api('/api/domains').then((d) => { domData = d; domAt = Date.now(); return d; }).catch(() => domData).finally(() => { domP = null; });
|
||||
return domP;
|
||||
}
|
||||
/** 도메인 화면에서 연결·등록이 바뀌면 호출 — 다음 그리기 때 새로 받는다 */
|
||||
function invalidateDomains() { domAt = 0; }
|
||||
|
||||
// ---------- 단계 계산 ----------
|
||||
/**
|
||||
* 현재 사이트와 도메인 자료로 7 단계의 상태를 만든다.
|
||||
* @param {object|null} site app.js current (publicView 응답)
|
||||
* @param {object|null} dom /api/domains 응답 (아직 못 받았으면 null)
|
||||
* @returns {Array<{id, title, state, badge, detail, dep, optional, actions}>}
|
||||
*/
|
||||
function stages(site, dom) {
|
||||
const out = [];
|
||||
const siteDone = !!site && !inProgress(site) && site.status !== 'importing';
|
||||
// 1. 만들기
|
||||
{
|
||||
let st = 'todo', badge = '', detail = '', actions = [];
|
||||
if (!site) { detail = '아직 고른 사이트가 없습니다. AI 에게 맡기기 · 내가 고르기 · 템플릿 · 기존 사이트 복사 중 하나로 시작하세요.'; actions = [btn('사이트 만들기', 'primary small-btn', go('create'))]; }
|
||||
// 빠른 생성(plan 없음)도 생성 명령이 도는 동안은 "생성 중"
|
||||
else if (site.status === 'importing' || (site.running && (!siteDone || (site.commands || []).some((c) => c.kind === 'create' && c.status === 'running')))) { st = 'active'; badge = site.mode === 'copy' ? '복사 중' : '생성 중'; detail = '작업이 진행 중입니다. 위 진행 상태에서 확인하세요.'; actions = [btn('만들기 화면', 'ghost small-btn', go('create'))]; }
|
||||
else if (inProgress(site)) { st = 'active'; badge = '생성 중'; const p = site.plan || {}; detail = `기획 단계 "${p.stage || ''}" 에서 멈춰 있습니다. 만들기 화면에서 이어서 진행하세요.`; actions = [btn('이어서 만들기', 'primary small-btn', go('create'))]; }
|
||||
else if (site.importError && !site.copyInfo) { st = 'warn'; badge = '실패'; detail = site.importError; actions = [btn('만들기 화면', 'ghost small-btn', go('create'))]; }
|
||||
else { st = 'done'; badge = '완성'; detail = `${T(MODE_LABEL[site.mode] || site.mode || '')} · ${new Date(site.createdAt).toLocaleDateString(uiLocale())} 에 만든 사이트입니다.`; actions = [btn('새 사이트 만들기', 'ghost small-btn', () => { if (window.resetCreate) resetCreate(); window.showTab('create'); })]; }
|
||||
out.push({ id: 'create', title: '만들기', state: st, badge, detail, dep: '다른 작업과 무관하게 바로 시작할 수 있습니다.', actions });
|
||||
}
|
||||
// 2. 수정
|
||||
{
|
||||
let st = 'na', badge = '', detail = '사이트가 완성되면 수정 지시를 보낼 수 있습니다.', actions = [];
|
||||
if (siteDone) {
|
||||
const ups = (site.commands || []).filter((c) => c.kind === 'update'); const last = ups[ups.length - 1]; const lim = site.editLimit; const v = site.plan && site.plan.verification;
|
||||
const running = !!site.running && (site.commands || []).some((c) => c.kind === 'update' && c.status === 'running'); // 수정 명령이 도는 중일 때만 "수정 중" (생성 중에는 아님)
|
||||
st = running ? 'active' : (lim && lim.locked ? 'warn' : (ups.length ? 'done' : 'todo'));
|
||||
badge = running ? '수정 중' : (lim && lim.locked ? '발행 대기' : (lim && lim.max ? `수정 ${lim.used}/${lim.max}` : (ups.length ? `수정 ${ups.length}회` : '')));
|
||||
detail = lim && lim.locked ? `수정 횟수 한도(${lim.max}회)에 도달했습니다. 배포(발행)한 뒤 다시 수정할 수 있습니다.` : (last ? `마지막 수정 ${new Date(last.finishedAt || last.startedAt).toLocaleString(uiLocale(), { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}${v && v.stale ? ' · 수정 뒤 재검증 필요' : ''}` : '아직 수정 지시가 없습니다. 미리보기를 보고 바꿀 점을 문장으로 적어 보내세요.');
|
||||
actions = [btn(lim && lim.locked ? '배포로 이동' : '수정하기', 'primary small-btn', go(lim && lim.locked ? 'deploy' : 'edit'))];
|
||||
}
|
||||
out.push({ id: 'edit', title: '수정', state: st, badge, detail, dep: '배포와 무관하게 계속할 수 있습니다. 배포 뒤의 수정은 "재배포" 로 올립니다.', actions });
|
||||
}
|
||||
// 3. 도메인 (선택)
|
||||
{
|
||||
let st = 'todo', badge = '', detail = '', actions = [btn('도메인 준비', 'ghost small-btn', go('domains'))];
|
||||
const mine = dom ? dom.domains || [] : null; const connected = site ? (site.domains || []) : [];
|
||||
if (!dom) detail = '도메인 정보를 불러오는 중…';
|
||||
else if (connected.length) { st = 'done'; badge = TF('{1}개 연결', connected.length); detail = `연결된 도메인: ${connected.join(', ')}`; actions = [btn('도메인 관리', 'ghost small-btn', go('domains'))]; }
|
||||
else if (mine.length) { badge = TF('{1}개 등록', mine.length); detail = site ? `등록된 도메인(${mine.map((d) => d.name).join(', ')})이 있습니다. 이 사이트에 연결하면 그 주소로 배포됩니다.` : `등록된 도메인: ${mine.map((d) => d.name).join(', ')}`; actions = [btn(site ? '사이트에 연결' : '도메인 관리', 'primary small-btn', go('domains'))]; }
|
||||
else detail = dom.accountType === 'subdomain' ? `플랫폼 도메인(${dom.platform && dom.platform.domain ? dom.platform.domain : ''}) 아래 서브 도메인을 무료로 등록할 수 있습니다.` : '도메인을 사거나(장바구니) 이미 가진 도메인을 등록하세요. 도메인 없이도 서버 주소로 배포할 수 있습니다.';
|
||||
out.push({ id: 'domains', title: '도메인', state: st, badge, detail, dep: '사이트 제작과 무관하게 먼저 준비해 둘 수 있습니다. 도메인만 사 두고 사이트는 나중에 연결해도 됩니다.', optional: true, actions });
|
||||
}
|
||||
// 4. DNS
|
||||
{
|
||||
let st = 'na', badge = '', detail = '사이트에 도메인을 연결하면 그 도메인의 DNS 준비 상태가 여기에 보입니다. 서브 도메인·여기서 구매한 도메인은 자동으로 준비됩니다.', actions = [btn('DNS 보기', 'ghost small-btn', go('dns'))];
|
||||
const connected = site ? (site.domains || []) : []; const mine = dom ? dom.domains || [] : [];
|
||||
if (connected.length && dom) {
|
||||
// v1.7.2: 하위 이름(shop.example.com)으로 연결한 도메인도 찾는다 — siteNames 또는 '.도메인' 으로 끝나는 이름
|
||||
const findDom = (n) => mine.find((d) => d.name === n || (d.siteNames || []).includes(n) || n.endsWith('.' + d.name));
|
||||
const recs = [...new Set(connected.map(findDom).filter(Boolean))];
|
||||
const waiting = recs.filter((d) => d.dnsLocked || d.pending || (d.transfer && d.transfer.step && d.transfer.step !== 'done'));
|
||||
const manualUnverified = recs.filter((d) => d.type === 'external' && d.mode === 'manual' && !(d.verified && d.verified.siteOk));
|
||||
const unknown = connected.filter((n) => !findDom(n));
|
||||
if (waiting.length) { st = 'wait'; badge = '전파 대기'; detail = `${waiting.map((d) => d.name).join(', ')} — 네임서버 변경·DNS 전파를 확인하는 중입니다 (최대 48시간).`; }
|
||||
else if (manualUnverified.length) { st = 'warn'; badge = '확인 필요'; detail = `${manualUnverified.map((d) => d.name).join(', ')} — 직접 관리하는 DNS 에 필요한 레코드를 넣고 "설정 완료 — 확인" 을 눌러야 배포가 됩니다.`; actions = [btn('필요한 설정 확인', 'primary small-btn', go('dns'))]; }
|
||||
else if (unknown.length && !recs.length) { st = 'todo'; badge = '직접 입력'; detail = `${unknown.join(', ')} — 배포 카드에 직접 적은 도메인입니다. 그 도메인의 A 레코드가 배포 서버를 가리켜야 합니다.`; }
|
||||
else { st = 'done'; badge = '준비됨'; detail = `${recs.map((d) => d.name).join(', ')} 의 DNS 가 준비되어 배포·메일에 쓸 수 있습니다.`; }
|
||||
} else if (!site && mine.length) { st = mine.some((d) => d.dnsLocked || d.pending) ? 'wait' : 'done'; badge = st === 'wait' ? '전파 대기' : TF('{1}개', mine.length); detail = '등록된 도메인의 DNS 레코드·전파 상태를 봅니다.'; }
|
||||
out.push({ id: 'dns', title: 'DNS', state: st, badge, detail, dep: '웹 제작과는 무관하지만 배포(웹서버 연결)와 메일(MX)이 이 설정에 달려 있습니다.', actions });
|
||||
}
|
||||
// 5. 메일 (선택)
|
||||
{
|
||||
let st = 'todo', badge = '', detail = '', actions = [btn('메일 설정', 'ghost small-btn', go('mail'))];
|
||||
const m = site && site.mail;
|
||||
if (!site) { st = 'na'; detail = '사이트를 고르면 그 사이트의 메일 시스템 상태가 보입니다. 도메인별 메일(MX) 자동 설정은 메일 화면에서 합니다.'; }
|
||||
else if (!m || m.level === 'off') { badge = '꺼짐'; detail = '도메인에 메일(MX·SPF·DMARC)을 자동 설정하면 그 도메인으로 메일을 받을 준비가 되고, 운영팀이 사이트 메일 시스템을 켜면 사이트 관리자 페이지에서 메일 계정을 쓸 수 있습니다.'; }
|
||||
else if (m.level === 'setting-up') { st = 'wait'; badge = m.label || '설정 중'; detail = `${m.domain} — 도메인 연결(DNS) 확인을 기다리는 중입니다.`; }
|
||||
else { st = 'done'; badge = m.label || '사용 가능'; detail = `${m.domain} — 아이디@${m.domain} 로 메일을 쓸 수 있습니다.`; if (m.webmailUrl) { const a = el('a', 'btn ghost small-btn', '웹메일 열기'); a.href = m.webmailUrl; a.target = '_blank'; a.rel = 'noopener'; actions = [a, btn('메일 화면', 'ghost small-btn', go('mail'))]; } }
|
||||
out.push({ id: 'mail', title: '메일', state: st, badge, detail, dep: 'DNS 가 준비된 도메인에서 동작합니다. 사이트 제작·배포와는 무관합니다.', optional: true, actions });
|
||||
}
|
||||
// 6. 배포
|
||||
{
|
||||
let st = 'na', badge = '', detail = '사이트가 완성되면 운영 주소로 올릴 수 있습니다.', actions = [];
|
||||
if (site) {
|
||||
const ds = site.deployState || {}; const stg = site.plan && site.plan.stage;
|
||||
const changed = !!(ds.lastChangeAt && ds.lastDeployAt && ds.lastChangeAt > ds.lastDeployAt);
|
||||
if (!siteDone) { st = 'na'; detail = '만들기가 끝나야 배포할 수 있습니다.'; }
|
||||
else if (ds.state === 'empty') { st = 'todo'; badge = '내용 없음'; detail = '배포할 페이지가 아직 없습니다.'; }
|
||||
else if (ds.state === 'working') { st = 'active'; badge = '작업 중'; detail = 'AI 작업이 끝나면 배포할 수 있습니다.'; }
|
||||
else if (ds.state === 'deploying') { st = 'active'; badge = '배포 중'; detail = '운영 서버로 전송하고 있습니다.'; }
|
||||
else if (site.unpublishedAt) { st = 'warn'; badge = '발행 취소됨'; detail = `운영 주소에는 "사이트 수정 중" 안내만 보입니다. 수정을 마친 뒤 다시 발행하세요.`; }
|
||||
else if (ds.phase && PHASE[ds.phase] && ds.phase !== 'failed') { st = /failed/.test(ds.phase) ? 'warn' : 'wait'; badge = PHASE[ds.phase]; detail = ds.phaseDetail || PHASE[ds.phase]; }
|
||||
else if (ds.phase === 'failed' || ds.state === 'failed') { st = 'warn'; badge = '배포 실패'; detail = ds.phaseDetail || '배포 서버 전송에 실패했습니다. 메시지창의 원인을 확인하세요.'; }
|
||||
else if (stg === 'published' && ds.lastDeployAt && !(ds.state === 'pending' && changed)) { st = 'done'; badge = '발행됨'; detail = site.liveUrl ? `운영 주소 ${site.liveUrl}` : '배포되었습니다.'; }
|
||||
else if (ds.lastDeployAt) { st = 'wait'; badge = '변경 있음'; detail = '마지막 배포 뒤 바뀐 내용이 있습니다. 재배포하면 운영 주소에 반영됩니다.'; }
|
||||
else { st = 'todo'; badge = '발행 전'; detail = (site.domains || []).length ? `연결 도메인 ${site.domains.join(', ')} 으로 발행합니다.` : '도메인을 연결하지 않으면 서버 주소로 발행됩니다.'; }
|
||||
actions = siteDone ? [btn(st === 'done' ? '배포 보기' : (st === 'wait' && badge === '변경 있음' ? '재배포' : '배포하기'), st === 'done' ? 'ghost small-btn' : 'primary small-btn', go('deploy'))] : [];
|
||||
}
|
||||
out.push({ id: 'deploy', title: '배포', state: st, badge, detail, dep: '사이트 내용이 있어야 하고, 도메인을 연결했다면 그 DNS 가 준비되어야 합니다. 한 번 배포된 뒤의 수정·운영은 도메인·DNS 와 직접 묶이지 않습니다.', actions });
|
||||
}
|
||||
// 7. 운영
|
||||
{
|
||||
let st = 'na', badge = '', detail = '배포한 사이트의 기능 모듈·사이트 관리자 페이지·문의·접속 통계를 여기서 다룹니다.', actions = [];
|
||||
if (siteDone) { const live = !!site.liveUrl && site.plan && site.plan.stage === 'published'; st = live ? 'active' : 'todo'; badge = live ? '운영 중' : ''; detail = live ? '문의 접수·접속 통계·기능 모듈·사이트 관리자 페이지를 봅니다.' : '배포 전에도 기능 모듈을 켜고 사이트 관리자 페이지를 준비할 수 있습니다.'; actions = [btn('운영 보기', 'ghost small-btn', go('manage'))]; }
|
||||
out.push({ id: 'manage', title: '운영', state: st, badge, detail, dep: '완성된 사이트의 기능·상태 관리는 "수정" 과 이어집니다.', actions });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------- 레일 배지 ----------
|
||||
/** 레일 버튼에 상태 점·배지를 붙인다. 캐시 버튼은 잔액(부족하면 빨강) */
|
||||
function navBadges(list) {
|
||||
const byId = Object.fromEntries(list.map((s) => [s.id, s]));
|
||||
document.querySelectorAll('#mainTabs button').forEach((b) => {
|
||||
const s = byId[b.dataset.tab]; const nb = b.querySelector('.nb'); if (!nb) return;
|
||||
if (s) { if (s.state === 'na') { b.removeAttribute('data-state'); nb.textContent = ''; } else { b.dataset.state = s.state; nb.textContent = T(s.badge || ''); } }
|
||||
else if (b.dataset.tab === 'credits') { const c = typeof credit !== 'undefined' && credit; const isAdmin = config && config.user && config.user.role === 'admin'; if (c && c.balance != null) { const low = c.balance <= 0 && !isAdmin; b.dataset.state = low ? 'warn' : 'done'; nb.textContent = low ? T('부족') : krwFmt(c.balance); } }
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 홈 ----------
|
||||
/** 단계 한 줄 (아이콘 번호 · 제목 · 배지 · 설명 · 의존 · 실행 버튼) */
|
||||
function stepRow(s, no) {
|
||||
const row = el('div', 'flow-step' + (s.optional ? ' optional' : '')); row.dataset.state = s.state;
|
||||
row.appendChild(el('div', 'fs-icon', s.state === 'done' ? '✓' : String(no)));
|
||||
const mid = el('div');
|
||||
const t = el('div', 'fs-title'); t.appendChild(el('span', null, T(s.title)));
|
||||
const pill = el('span', 'pill small ' + ({ done: 'done', active: 'running', wait: 'waiting', warn: 'error', todo: 'idle', na: 'idle' }[s.state] || 'idle'), T(s.badge || STATE_LABEL[s.state]));
|
||||
t.appendChild(pill); if (s.optional) t.appendChild(el('span', 'muted small', '(선택)')); mid.appendChild(t);
|
||||
if (s.detail) mid.appendChild(el('div', 'fs-detail', s.detail));
|
||||
if (s.dep) mid.appendChild(el('div', 'fs-dep', s.dep));
|
||||
row.appendChild(mid);
|
||||
const act = el('div', 'fs-act'); for (const a of s.actions || []) act.appendChild(a); row.appendChild(act);
|
||||
return row;
|
||||
}
|
||||
/** 의존 관계 지도 (글자로): 사이트 축 · 주소 축 · 캐시 */
|
||||
function flowMap() {
|
||||
const c = el('div', 'flow-map');
|
||||
const line = (nodes, note) => { const r = el('div', 'fm-row'); nodes.forEach((n, i) => { if (i) r.appendChild(el('span', 'fm-arrow', '→')); r.appendChild(el('span', 'fm-node' + (n.dim ? ' dim' : ''), n.t)); }); c.appendChild(r); if (note) c.appendChild(el('div', 'fm-note', note)); };
|
||||
line([{ t: '만들기' }, { t: '수정' }, { t: '배포' }, { t: '운영' }], '사이트 축 — 배포는 사이트 내용이 있어야 하는 마지막 작업이고, 배포 뒤의 수정·운영은 도메인·DNS 와 직접 묶이지 않습니다.');
|
||||
line([{ t: '도메인', dim: true }, { t: 'DNS' }, { t: '배포의 도메인 연결 · 메일', dim: true }], '주소 축 — 도메인 구매·등록은 다른 일과 무관하게 먼저 할 수 있고, DNS 는 배포(웹서버 연결)와 메일(MX)이 의존합니다.');
|
||||
line([{ t: '캐시' }, { t: 'AI 작업 · 도메인 구매 비용', dim: true }], '비용 — AI 생성·수정·검증과 도메인 구매·갱신은 선불 캐시에서 차감됩니다.');
|
||||
return c;
|
||||
}
|
||||
async function renderHome() {
|
||||
const box = $('homeBody'); if (!box) return;
|
||||
const site = typeof current !== 'undefined' ? current : null;
|
||||
const dom = await domains();
|
||||
if (!$('homeBody') || $('pane-home').classList.contains('hidden')) { navBadges(stages(site, dom)); return; } // 그 사이 다른 화면으로 옮겼으면 배지만
|
||||
const list = stages(site, dom); navBadges(list);
|
||||
box.innerHTML = ''; const wrap = el('div', 'flow');
|
||||
const hero = el('div', 'flow-hero');
|
||||
if (!site) {
|
||||
hero.appendChild(el('h3', null, '무엇부터 할까요?'));
|
||||
hero.appendChild(el('p', null, '사이트 만들기, 도메인 준비, 캐시 충전은 서로 무관하게 시작할 수 있습니다. 만들어 둔 사이트가 있으면 왼쪽 레일에서 고르세요 — 그 사이트의 진행 상태가 여기에 보입니다.'));
|
||||
wrap.appendChild(hero);
|
||||
const entry = el('div', 'flow-entry');
|
||||
const ent = (t, d, tab, primary) => { const b = el('button', primary ? 'primary' : ''); b.type = 'button'; b.appendChild(el('span', 'fe-t', t)); b.appendChild(el('span', 'fe-d', d)); b.onclick = go(tab); entry.appendChild(b); };
|
||||
ent('사이트 만들기', 'AI 에게 맡기기 · 내가 고르기 · 템플릿 · 기존 사이트 복사', 'create');
|
||||
ent('도메인 준비', dom && dom.accountType === 'subdomain' ? '서브 도메인 무료 등록' : '검색·구매(장바구니) 또는 이미 가진 도메인 등록', 'domains');
|
||||
ent('캐시 충전', '선불 충전 상품을 골라 충전 신청', 'credits');
|
||||
wrap.appendChild(entry);
|
||||
} else {
|
||||
const pill = $('siteStatePill'); hero.appendChild(el('h3', null, site.title));
|
||||
const next = list.find((s) => !s.optional && ['todo', 'active', 'wait', 'warn'].includes(s.state));
|
||||
hero.appendChild(el('p', null, (pill && pill.textContent ? pill.textContent + ' · ' : '') + (site.liveUrl ? TF('운영 주소: {1}', site.liveUrl) : T('아직 배포되지 않았습니다 (미리보기 주소만 사용 가능)'))));
|
||||
if (next) { const r = el('div', 'row'); r.appendChild(el('span', 'small', TF('다음 할 일: {1}', T(next.title) + (next.badge ? ` (${T(next.badge)})` : '')))); if (next.actions && next.actions[0]) { const a = next.actions[0].cloneNode(true); a.onclick = next.actions[0].onclick; r.appendChild(a); } hero.appendChild(r); }
|
||||
wrap.appendChild(hero);
|
||||
}
|
||||
const steps = el('div', 'flow-steps'); list.forEach((s, i) => steps.appendChild(stepRow(s, i + 1))); wrap.appendChild(steps);
|
||||
wrap.appendChild(flowMap());
|
||||
box.appendChild(wrap);
|
||||
}
|
||||
|
||||
// ---------- 배포 점검표 ----------
|
||||
async function renderDeployChecklist() {
|
||||
const box = $('deployChecklist'); if (!box) return;
|
||||
const site = typeof current !== 'undefined' ? current : null; const dom = await domains();
|
||||
if (!$('deployChecklist') || $('pane-deploy').classList.contains('hidden')) return;
|
||||
box.innerHTML = '';
|
||||
const card = el('section', 'card'); card.appendChild(el('h2', null, '배포 준비 점검'));
|
||||
if (!site) { card.appendChild(el('p', 'muted small', '왼쪽 레일에서 사이트를 고르면 배포에 필요한 항목을 점검하고 발행할 수 있습니다. 배포는 사이트 내용이 있어야 하고, 도메인을 연결했다면 그 DNS 가 준비되어야 합니다.')); box.appendChild(card); return; }
|
||||
const list = stages(site, dom); const by = Object.fromEntries(list.map((s) => [s.id, s]));
|
||||
const ul = el('ul', 'chk-list');
|
||||
const item = (state, label, detail, action) => { const li = el('li'); li.dataset.state = state; li.appendChild(el('span', 'ci', state === 'done' ? '✓' : state === 'warn' ? '!' : state === 'wait' ? '…' : '·')); const m = el('div'); m.appendChild(el('span', null, T(label))); if (detail) m.appendChild(el('span', 'cd', detail)); li.appendChild(m); li.appendChild(action || el('span')); ul.appendChild(li); };
|
||||
const c = by.create; item(c.state === 'done' ? 'done' : (c.state === 'active' ? 'wait' : 'warn'), '사이트 내용', c.state === 'done' ? '완성된 사이트입니다' : c.detail, c.state === 'done' ? null : btn('만들기', 'ghost small-btn', go('create')));
|
||||
const v = site.plan && site.plan.verification; const s = v && v.rules && v.rules.summary; const issues = s ? (s.pagesMissing + s.extraneous + s.regulated) : 0;
|
||||
item(!v ? 'todo' : (v.stale ? 'wait' : (issues ? 'warn' : 'done')), '검증', !v ? '아직 검증하지 않았습니다 (발행 때 자동 점검)' : v.stale ? '수정 뒤 재검증이 필요합니다' : issues ? TF('점검 항목 {1}건 — 그대로 발행할 수도 있습니다', issues) : '이상 없음', null);
|
||||
const d = by.domains; item(d.state === 'done' ? 'done' : 'todo', '도메인 연결 (선택)', d.state === 'done' ? d.detail : '연결하지 않으면 서버 주소로 발행됩니다', d.state === 'done' ? null : btn('도메인', 'ghost small-btn', go('domains')));
|
||||
const n = by.dns; if (n.state !== 'na') item(n.state, 'DNS 준비', n.detail, n.state === 'done' ? null : btn('DNS', 'ghost small-btn', go('dns')));
|
||||
const isAdmin = config && config.user && config.user.role === 'admin'; const bal = site.credit ? site.credit.balance : (typeof credit !== 'undefined' && credit ? credit.balance : null);
|
||||
if (bal != null) item(bal > 0 || isAdmin ? 'done' : 'warn', '캐시', bal > 0 || isAdmin ? TF('잔액 {1}', krwFmt(bal)) : '캐시가 없습니다 — 발행은 가능하지만 AI 수정은 할 수 없습니다', bal > 0 || isAdmin ? null : btn('충전', 'ghost small-btn', go('credits')));
|
||||
card.appendChild(ul);
|
||||
const dep = by.deploy; card.appendChild(el('p', 'muted small', dep.badge ? `${T('배포 상태')}: ${T(dep.badge)} — ${dep.detail}` : dep.detail));
|
||||
box.appendChild(card);
|
||||
}
|
||||
|
||||
// ---------- app.js 와의 연결점 ----------
|
||||
let timer = null;
|
||||
/** 사이트가 열리거나 바뀌면(작업 완료 포함) 배지와 보이는 화면을 다시 그린다. 연달아 오는 이벤트는 150ms 로 모은다 */
|
||||
function onSite() { clearTimeout(timer); timer = setTimeout(refresh, 150); }
|
||||
async function refresh() {
|
||||
const site = typeof current !== 'undefined' ? current : null; const dom = await domains();
|
||||
navBadges(stages(site, dom));
|
||||
if (!$('pane-home').classList.contains('hidden')) renderHome();
|
||||
if (!$('pane-deploy').classList.contains('hidden')) renderDeployChecklist();
|
||||
}
|
||||
function onTab(name) { if (name === 'home') renderHome(); else if (name === 'deploy') renderDeployChecklist(); else navBadges(stages(typeof current !== 'undefined' ? current : null, domData)); }
|
||||
function onCredit() { navBadges(stages(typeof current !== 'undefined' ? current : null, domData)); }
|
||||
|
||||
window.FlowUI = { renderHome, renderDeployChecklist, onSite, onTab, onCredit, invalidateDomains: () => { invalidateDomains(); onSite(); }, stages };
|
||||
})();
|
||||
@@ -0,0 +1,147 @@
|
||||
/* ============================================================================
|
||||
* 다국어 런타임 (v1.1.2)
|
||||
* 서버가 준 "원문(한국어) → 번역" 사전으로 화면 텍스트를 치환한다. 코드에 번역 키가 없으므로
|
||||
* 텍스트 노드와 placeholder/title/data-tip 속성의 값이 사전에 있으면 그대로 바꾼다.
|
||||
* 동적으로 추가·변경되는 내용은 MutationObserver 로 따라가며 치환한다.
|
||||
* 언어 선택은 localStorage('wm_lang') 에 저장. 'ko' 는 원문이므로 치환하지 않는다.
|
||||
* v1.3.2: 변수가 들어가는 문장(코드의 템플릿 문자열)은 카탈로그에 "현재 사이트: {1} · 화면: {2}" 같은 패턴으로 들어 있다.
|
||||
* 사전에 정확히 같은 문장이 없으면 패턴과 맞춰 보고, 맞으면 번역문의 {1}·{2} 자리에 화면의 실제 값을 넣는다
|
||||
* (값 자체가 사전에 있으면 그것도 번역 — 예: "관리 탭" → "Manage tab").
|
||||
* v1.3.10 속도: ① 서버가 정적 HTML 을 미리 번역해 보낸다(쿠키 wm_lang) ② 사전을 localStorage 에 언어별로 캐시하고
|
||||
* 서버의 stamp(번역 기준 버전|올린 시각)가 같으면 다시 받지 않는다 ③ 패턴은 첫 글자별로 나눠 둔다.
|
||||
* 언어를 바꾸면 쿠키를 바꾸고 페이지를 다시 연다(서버 번역본과 맞추기 위해). 오른쪽→왼쪽 언어는 dir=rtl.
|
||||
* 전역: window.I18N = { lang, t(text), setLang(lang), languages }
|
||||
* ========================================================================== */
|
||||
(function () {
|
||||
var ATTRS = ['placeholder', 'title', 'data-tip', 'aria-label', 'alt'];
|
||||
var dict = null; var lang = 'ko'; var languages = [{ lang: 'ko', name: '한국어' }];
|
||||
try { lang = localStorage.getItem('wm_lang') || (navigator.language || 'ko').slice(0, 2); } catch (e) {}
|
||||
// v1.3.16: 서버가 정한 화면 언어(관리자 강제 기본값 · 사용자 선택 · 사이트 관리 언어)를 먼저 따른다 — <meta name="wm-ui-lang">
|
||||
var srv = document.querySelector('meta[name="wm-ui-lang"]'); if (srv && srv.content) { lang = srv.content; try { localStorage.setItem('wm_lang', lang); } catch (e) {} }
|
||||
var qs = new URLSearchParams(location.search); if (qs.get('lang')) { lang = qs.get('lang'); try { localStorage.setItem('wm_lang', lang); } catch (e) {} }
|
||||
/** 서버 미리 번역용 쿠키 (1년). 화면 언어와 다르면 맞춘다 */
|
||||
/** 화면 코드가 사전 준비 후 다시 그리도록 알림 (조각 번역 T() 를 쓴 목록 등) */
|
||||
function ready() { try { window.dispatchEvent(new CustomEvent('wm:i18n')); } catch (e) {} }
|
||||
function setCookie(l) { try { document.cookie = 'wm_lang=' + encodeURIComponent(l) + '; Path=/; Max-Age=31536000; SameSite=Lax'; } catch (e) {} }
|
||||
if (!new RegExp('(?:^|; )wm_lang=' + lang.replace(/[^\w-]/g, '') + '(?:;|$)').test(document.cookie || '')) setCookie(lang);
|
||||
var orig = new WeakMap(); // 노드 → 원문 (언어를 바꿀 때 원문으로 되돌리기 위해)
|
||||
var patterns = []; // [{ prefix, suffix, re, tr }] — 사전 키 중 {숫자} 자리표시가 있는 것
|
||||
var buckets = {}; // v1.3.10: 고정 앞글자의 첫 글자 → 패턴 목록 (앞글자가 없는 패턴은 buckets[''])
|
||||
var HANGUL = /[\uac00-\ud7a3]/;
|
||||
/** 사전에서 패턴 항목만 골라 정규식으로 바꾼다. 앞뒤 고정 글자(prefix/suffix)로 먼저 걸러 비용을 줄인다 */
|
||||
function buildPatterns() {
|
||||
patterns = []; if (!dict) return;
|
||||
Object.keys(dict).forEach(function (k) {
|
||||
if (!/\{\d+\}/.test(k)) return;
|
||||
var pieces = k.split(/\{(\d+)\}/); var src = '^'; var order = [];
|
||||
for (var i = 0; i < pieces.length; i++) { if (i % 2 === 0) src += pieces[i].replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); else { src += '([\\s\\S]+?)'; order.push(pieces[i]); } }
|
||||
var fixedText = k.replace(/\{\d+\}/g, ''); var hc = (fixedText.match(/[\uac00-\ud7a3]/g) || []).length;
|
||||
// weak: 고정 한글이 2자 이하인 패턴("{1}개" 등)은 아무 문장에나 맞을 수 있어, 값에 한글이 없을 때만 쓴다 (v1.3.10)
|
||||
patterns.push({ prefix: pieces[0], suffix: pieces[pieces.length - 1], re: new RegExp(src + '$'), order: order, tr: dict[k], fixed: fixedText.length, weak: hc < 3 });
|
||||
});
|
||||
patterns.sort(function (a, b) { return b.fixed - a.fixed; }); // 고정 글자가 많은(더 구체적인) 패턴 먼저
|
||||
buckets = {}; patterns.forEach(function (p) { var c = p.prefix.charAt(0); (buckets[c] = buckets[c] || []).push(p); });
|
||||
}
|
||||
/** 문장 하나 번역: 정확히 같은 문장 → 패턴 순서로 찾고, 없으면 null */
|
||||
function lookup(k, depth) {
|
||||
depth = depth || 0;
|
||||
var v = dict && dict[k]; if (v) return v;
|
||||
if (!patterns.length || !HANGUL.test(k)) return null;
|
||||
var cand = (buckets[k.charAt(0)] || []).concat(buckets[''] || []);
|
||||
for (var i = 0; i < cand.length; i++) {
|
||||
var p = cand[i]; if (k.length < p.prefix.length + p.suffix.length || k.indexOf(p.prefix) !== 0 || k.slice(k.length - p.suffix.length) !== p.suffix) continue;
|
||||
var m = p.re.exec(k); if (!m) continue;
|
||||
if (p.weak) { var bad = false; for (var w = 1; w < m.length; w++) if (HANGUL.test(m[w])) bad = true; if (bad) continue; }
|
||||
var vals = {}; for (var j = 0; j < p.order.length; j++) { var raw = m[j + 1]; var tv = depth < 2 && HANGUL.test(raw) ? lookup(raw.trim(), depth + 1) : null; vals[p.order[j]] = tv ? raw.replace(raw.trim(), tv) : raw; } // 값 안의 문장도 번역(중첩 템플릿) — 2단계까지
|
||||
return p.tr.replace(/\{(\d+)\}/g, function (all, n) { return vals[n] !== undefined ? vals[n] : all; });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function t(text) { if (!dict || !text) return text; var k = String(text).trim(); var v = lookup(k); if (!v) return text; return text.replace(k, v); }
|
||||
function translateNode(n) {
|
||||
if (n.nodeType === 3) { var p = n.parentNode; if (!p || p.nodeName === 'SCRIPT' || p.nodeName === 'STYLE' || p.nodeName === 'TEXTAREA') return; var src = orig.has(n) ? orig.get(n) : n.nodeValue; var k = src.trim(); if (!k) return; if (!orig.has(n)) orig.set(n, src); var v = dict && lookup(k); var next = v ? src.replace(k, v) : src; if (n.nodeValue !== next) n.nodeValue = next; return; }
|
||||
if (n.nodeType !== 1) return;
|
||||
for (var i = 0; i < ATTRS.length; i++) { var a = ATTRS[i]; if (!n.hasAttribute(a)) continue; var key = 'wm-orig-' + a; var o = n.getAttribute('data-' + key) || n.getAttribute(a); if (!n.hasAttribute('data-' + key)) n.setAttribute('data-' + key, o); var tv = dict && lookup(o.trim()); var val = tv ? o.replace(o.trim(), tv) : o; if (n.getAttribute(a) !== val) n.setAttribute(a, val); }
|
||||
if (n.nodeName === 'OPTION' && n.firstChild && n.firstChild.nodeType === 3) translateNode(n.firstChild);
|
||||
}
|
||||
function translateTree(root) { if (!root) return; if (root.nodeType === 3) return translateNode(root); if (root.nodeType !== 1 && root.nodeType !== 9) return; if (root.nodeType === 1) translateNode(root); var walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT); var n; while ((n = walker.nextNode())) translateNode(n); if (document.title) document.title = t(document.title); }
|
||||
var observer = null; var pending = false;
|
||||
/**
|
||||
* 화면 변화 따라가기. v1.3.10 수정: 1.3.9 까지는 한 프레임 안에 처음 들어온 변화 묶음만 처리하고 그 사이에 들어온
|
||||
* 다른 묶음을 버려(pending 동안 return), 동적으로 그린 문구(카드·표·상태 배지 등)가 한국어로 남았다. 또 처리 중 오류가 나면
|
||||
* 관찰이 다시 연결되지 않았다. 이제 프레임까지 들어온 변화를 모두 모아 처리하고, 오류가 나도 항상 다시 연결한다.
|
||||
*/
|
||||
var queue = [];
|
||||
function flush() {
|
||||
pending = false; var muts = queue; queue = []; observer.disconnect();
|
||||
try {
|
||||
for (var i = 0; i < muts.length; i++) {
|
||||
var m = muts[i];
|
||||
try {
|
||||
if (m.type === 'characterData') { orig.delete(m.target); translateNode(m.target); }
|
||||
else if (m.type === 'attributes') { if (m.target.removeAttribute) m.target.removeAttribute('data-wm-orig-' + m.attributeName); translateNode(m.target); }
|
||||
else { for (var j = 0; j < m.addedNodes.length; j++) translateTree(m.addedNodes[j]); }
|
||||
} catch (e) { /* 한 노드의 오류가 나머지를 막지 않게 */ }
|
||||
}
|
||||
} finally { observe(); }
|
||||
}
|
||||
function startObserver() {
|
||||
if (observer) return;
|
||||
observer = new MutationObserver(function (muts) {
|
||||
for (var i = 0; i < muts.length; i++) queue.push(muts[i]);
|
||||
if (pending) return; pending = true;
|
||||
// 화면이 숨겨진 탭에서는 requestAnimationFrame 이 멈추므로 setTimeout 으로도 한 번 보장
|
||||
var done = false; var go = function () { if (done) return; done = true; flush(); };
|
||||
requestAnimationFrame(go); setTimeout(go, 50);
|
||||
});
|
||||
observe();
|
||||
}
|
||||
function observe() { observer.observe(document.documentElement, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ATTRS }); }
|
||||
function apply(d) {
|
||||
// 사전을 바꿀 때는 원문으로 되돌린 뒤 다시 치환 (orig 에 저장된 원문 사용)
|
||||
if (observer) observer.disconnect();
|
||||
dict = d; buildPatterns(); translateTree(document.documentElement); document.documentElement.lang = lang;
|
||||
var info = languages.filter(function (l) { return l.lang === lang; })[0]; document.documentElement.dir = info && info.dir === 'rtl' ? 'rtl' : 'ltr';
|
||||
if (observer) observe(); else if (dict) startObserver();
|
||||
}
|
||||
/** 언어별 사전 캐시 (localStorage). stamp 가 서버와 같으면 그대로 쓴다 */
|
||||
function cached(l, stamp) { try { var c = JSON.parse(localStorage.getItem('wm_dict_' + l) || 'null'); return c && c.stamp === stamp ? c.dict : null; } catch (e) { return null; } }
|
||||
function store(l, stamp, d) { try { localStorage.setItem('wm_dict_' + l, JSON.stringify({ stamp: stamp, dict: d })); } catch (e) {} }
|
||||
function load(cb) {
|
||||
fetch('/api/i18n/languages').then(function (r) { return r.json(); }).then(function (j) {
|
||||
languages = j.languages || languages; var info = languages.filter(function (l) { return l.lang === lang; })[0];
|
||||
if (!info) { lang = 'ko'; setCookie('ko'); }
|
||||
if (lang === 'ko') { apply(null); return cb && cb(); }
|
||||
var hit = cached(lang, info.stamp); if (hit) { if (hit !== dict) apply(hit); ready(); return cb && cb(); }
|
||||
return fetch('/api/i18n/' + encodeURIComponent(lang) + '.json').then(function (r) { return r.ok ? r.json() : null; }).then(function (dd) { if (dd && dd.dict) { store(lang, info.stamp, dd.dict); apply(dd.dict); ready(); } else { lang = 'ko'; apply(null); } cb && cb(); });
|
||||
}).catch(function () { apply(null); cb && cb(); });
|
||||
}
|
||||
/** 언어 바꾸기: 저장 → 쿠키 → 페이지 다시 열기 (서버가 그 언어로 미리 번역한 화면을 받는다) */
|
||||
function setLang(l) {
|
||||
lang = l; try { localStorage.setItem('wm_lang', l); } catch (e) {} setCookie(l);
|
||||
// v1.3.16: 로그인 사용자는 선택을 계정에 저장한다(관리자가 기본 언어를 강제한 뒤에도 사용자가 다시 고르면 그 선택이 우선). 비로그인이면 실패해도 쿠키로 충분
|
||||
var done = function () { location.reload(); };
|
||||
try { fetch('/api/me/ui-lang', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ lang: l }), credentials: 'same-origin' }).then(done, done); } catch (e) { done(); }
|
||||
}
|
||||
/** 헤더에 언어 선택 상자(#langSelect 또는 .lang-switch 컨테이너)가 있으면 채운다. 언어가 하나뿐이면 숨긴다 */
|
||||
function renderSwitcher() {
|
||||
var sel = document.getElementById('langSelect'); if (!sel) return;
|
||||
sel.innerHTML = ''; languages.forEach(function (l) { var o = document.createElement('option'); o.value = l.lang; o.textContent = l.name; sel.appendChild(o); }); sel.value = lang;
|
||||
var wrap = sel.closest('.lang-switch') || sel; wrap.style.display = languages.length > 1 ? '' : 'none';
|
||||
sel.onchange = function () { setLang(sel.value); };
|
||||
}
|
||||
/**
|
||||
* 화면 코드용 도우미 (v1.3.10)
|
||||
* T(문구) : 사용자 데이터(사이트 이름 등)와 섞어 문장을 만들 때, 고정 문구 조각을 먼저 번역한다.
|
||||
* uiLocale() : 날짜·숫자 표기용 로케일 (ko→ko-KR, ja→ja-JP …). 1.3.9 까지는 'ko-KR' 이 하드코딩되어 다른 언어에서도 한국식 날짜였다.
|
||||
*/
|
||||
var LOCALE_OF = { ko: 'ko-KR', en: 'en-US', ja: 'ja-JP', zh: 'zh-CN', 'zh-CN': 'zh-CN', 'zh-TW': 'zh-TW', mn: 'mn-MN', fr: 'fr-FR', de: 'de-DE', es: 'es-ES', vi: 'vi-VN', th: 'th-TH', id: 'id-ID', ru: 'ru-RU', ar: 'ar', tr: 'tr-TR' };
|
||||
window.T = function (s) { return t(s); };
|
||||
/** TF('{1}원', '1,200') → 번역 사전의 '{1}원' 번역(예: '₩{1}')에 값을 넣는다. 사전에 없으면 원문 틀에 넣는다 */
|
||||
window.TF = function (key) { var args = arguments; var tpl = (dict && dict[key]) || key; return tpl.replace(/\{(\d+)\}/g, function (a, n) { var v = args[Number(n)]; return v === undefined ? a : String(v); }); };
|
||||
window.uiLocale = function () { return LOCALE_OF[lang] || lang || 'ko-KR'; };
|
||||
window.I18N = { get lang() { return lang; }, t: t, setLang: setLang, get languages() { return languages; }, refresh: function () { translateTree(document.documentElement); } };
|
||||
// v1.3.10: 캐시된 사전이 있으면 즉시 쓴다(화면 코드가 처음 그릴 때부터 T() 가 번역). 서버와 다른지는 load() 가 확인해 바꾼다
|
||||
if (lang !== 'ko') { try { var c0 = JSON.parse(localStorage.getItem('wm_dict_' + lang) || 'null'); if (c0 && c0.dict) { dict = c0.dict; buildPatterns(); } } catch (e) {} }
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { if (dict) apply(dict); load(renderSwitcher); }); else { if (dict) apply(dict); load(renderSwitcher); }
|
||||
})();
|
||||
@@ -0,0 +1,74 @@
|
||||
'use strict';
|
||||
// 기존 사이트로 시작하기
|
||||
// - 사이트 복사하기(v1.2.0~): 웹 주소만 입력 → 서버가 웹으로 접속해 화면(HTML·CSS·JS·이미지)만 복사해 새 사이트로 만든다.
|
||||
// 기능(로그인·게시판·신청·결제 등 서버 프로그램)은 복사하지 않는다. 기존 사이트의 모양을 최대한 살려 새 사이트를 만들 때 쓴다.
|
||||
// - 사이트 관리하기(서버 접속으로 가져오기)는 v1.3.4 에서 새로 시작하는 기능을 없앴다(사용자 요청).
|
||||
// 이미 가져온 사이트는 관리 탭의 "원본 서버 반영" 카드(onSiteOpened)로 계속 수정·반영할 수 있다.
|
||||
(() => {
|
||||
// v1.3.4: "사이트 관리하기(서버 접속으로 가져오기)" 새로 시작하기는 없앴다. 이전에 가져온 사이트의 반영 카드(아래 onSiteOpened)는 그대로 동작한다.
|
||||
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; };
|
||||
// ---------- 사이트 복사하기 (v1.2.0): 웹 주소만으로 화면 코드(HTML·CSS·JS·이미지)를 복사 ----------
|
||||
function showCopyStart() { $('copyStartCard').classList.remove('hidden'); $('cpStart').disabled = false; setTimeout(() => $('cpUrl').focus(), 0); }
|
||||
$('cpStart').onclick = async () => {
|
||||
let url = $('cpUrl').value.trim(); if (!url) return alert('복사할 사이트 주소를 입력하세요.');
|
||||
if (!/^https?:\/\//i.test(url)) url = 'https://' + url; // "example.com" 처럼 입력해도 https 로 시도
|
||||
$('cpStart').disabled = true;
|
||||
try {
|
||||
const site = await api('/api/sites/copy', { method: 'POST', body: JSON.stringify({ url, title: $('cpTitle').value.trim(), contactEmail: $('cpEmail').value.trim(), tier: $('tierSelect').value || '', optimize: seoOptsOf('copyStartCard') }) });
|
||||
$('cpUrl').value = ''; $('cpTitle').value = ''; $('cpEmail').value = '';
|
||||
await openSite(site.id); await refreshSiteList();
|
||||
} catch (e) { alert('복사 시작 실패: ' + e.message); $('cpStart').disabled = false; }
|
||||
};
|
||||
|
||||
// ---------- 가져온 사이트: 반영(배포) 카드 ----------
|
||||
function onSiteOpened(site) {
|
||||
const card = $('importCard');
|
||||
if (!site || site.mode !== 'import' || !site.source) { card.classList.add('hidden'); return; }
|
||||
card.classList.remove('hidden'); $('imCredsBox').classList.add('hidden');
|
||||
const s = site.source; const last = site.deploy && site.deploy.last;
|
||||
const importing = site.status === 'importing' || (site.running && !s.importedAt);
|
||||
const pill = $('imState');
|
||||
if (importing) { pill.textContent = '가져오는 중'; pill.className = 'pill running'; }
|
||||
else if (!s.importedAt) { pill.textContent = site.importError ? '가져오기 실패' : '가져오기 전'; pill.className = 'pill ' + (site.importError ? 'error' : 'idle'); }
|
||||
else if (last && last.ok && s.lastPushAt && (!site.versions || site.versions.length <= 1)) { pill.textContent = '반영 완료'; pill.className = 'pill done'; }
|
||||
else { pill.textContent = '반영 대기'; pill.className = 'pill idle'; }
|
||||
const info = $('imInfo'); info.innerHTML = '';
|
||||
const lines = [
|
||||
`원본: ${s.url ? s.url + ' · ' : ''}${s.type === 'ssh' ? 'SSH 키' : (s.protocol === 'ftp' ? 'FTP' : 'SSH/SFTP 비밀번호')} · ${s.user}@${s.host}:${s.port} · 경로 ${s.docroot}${s.shell === false ? ' · 파일 전송 방식' : ''}`,
|
||||
s.importedAt ? `가져옴: ${new Date(s.importedAt).toLocaleString(uiLocale())} · 파일 ${s.fileCount}개${site.importInfo && site.importInfo.dynamic ? ' · 서버 스크립트 포함(PHP 등)' : ''}` : (site.importError ? `가져오기 실패: ${site.importError}` : '가져오기 진행 중…'),
|
||||
s.lastPushAt ? `마지막 반영: ${new Date(s.lastPushAt).toLocaleString(uiLocale())}${s.backupNote ? ' · ' + s.backupNote : ''}` : '아직 원본 서버에 반영한 적이 없습니다.',
|
||||
];
|
||||
for (const l of lines) info.appendChild(el('div', '', l));
|
||||
if (site.importError && !s.importedAt) { const b = el('button', 'ghost small-btn', '다시 가져오기'); b.onclick = () => reimport(false); info.appendChild(b); }
|
||||
$('imBtnDeploy').disabled = importing || !s.importedAt || !!site.running;
|
||||
const msg = $('imDeployMsg'); msg.className = 'small'; msg.textContent = '';
|
||||
if (last) { msg.className = 'small ' + (last.ok ? 'ok' : 'fail'); msg.textContent = `${new Date(last.at).toLocaleString(uiLocale())} ${last.ok ? '반영 완료' : '반영 실패'} — ${last.detail || ''}`; const acc = (last.access && last.access.items) || []; for (const it of acc) { const d = el('div', ''); const a = el('a'); a.href = it.url; a.target = '_blank'; a.rel = 'noopener'; a.textContent = it.url; d.appendChild(a); d.appendChild(document.createTextNode(` — ${it.ok ? it.note : '접속 불가: ' + it.note}`)); msg.appendChild(d); } }
|
||||
$('imcUser').value = s.user; $('imcPort').value = s.port; $('imcDocroot').value = s.docroot; $('imcUrl').value = s.url || ''; $('imcPw').value = ''; $('imcPw').parentElement.classList.toggle('hidden', s.type !== 'password'); $('imcMsg').textContent = '';
|
||||
}
|
||||
async function reimport(force) {
|
||||
try { await api(`/api/sites/${current.id}/reimport`, { method: 'POST', body: JSON.stringify({ force }) }); setStatus('running', '기존 사이트 가져오기'); }
|
||||
catch (e) { alert(e.message); }
|
||||
}
|
||||
$('imBtnDeploy').onclick = async () => {
|
||||
if (!current || !current.source) return;
|
||||
const first = !((current.deploy && current.deploy.history) || []).some((d) => d.ok);
|
||||
const s = current.source;
|
||||
if (!confirm(`${first ? '처음으로 ' : ''}수정 내용을 원본 서버에 반영합니다.\n\n서버: ${s.user}@${s.host}:${s.port}\n경로: ${s.docroot}\n${s.url ? '주소: ' + s.url + '\n' : ''}\n서버의 같은 이름 파일이 덮어써집니다${s.type !== 'password' || s.protocol !== 'ftp' ? ' (반영 전 서버에 백업을 만듭니다)' : ''}. 계속할까요?`)) return;
|
||||
$('imBtnDeploy').disabled = true; $('imDeployMsg').className = 'small'; $('imDeployMsg').textContent = '반영 중… 아래 "진행 상태" 상자에서 단계와 결과를 확인할 수 있습니다.';
|
||||
try { const r = await api(`/api/sites/${current.id}/deploy`, { method: 'POST', body: '{}' }); if (r.site) { current = r.site; renderGit(current); } onSiteOpened(current); }
|
||||
catch (e) { $('imDeployMsg').className = 'small fail'; $('imDeployMsg').textContent = '반영 실패: ' + e.message; $('imBtnDeploy').disabled = false; try { const s2 = await api(`/api/sites/${current.id}`); current = s2; onSiteOpened(current); } catch (_) {} }
|
||||
};
|
||||
$('imBtnCreds').onclick = () => $('imCredsBox').classList.toggle('hidden');
|
||||
$('imcSave').onclick = async () => {
|
||||
if (!current) return; $('imcSave').disabled = true; $('imcMsg').textContent = '연결 확인 중…';
|
||||
try {
|
||||
const b = { user: $('imcUser').value.trim(), port: $('imcPort').value.trim(), docroot: $('imcDocroot').value.trim(), url: $('imcUrl').value.trim() };
|
||||
if ($('imcPw').value) b.password = $('imcPw').value;
|
||||
const r = await api(`/api/sites/${current.id}/source`, { method: 'PUT', body: JSON.stringify(b) });
|
||||
current.source = r.source; onSiteOpened(current); $('imCredsBox').classList.remove('hidden'); $('imcMsg').textContent = '저장되었습니다 — ' + r.detail;
|
||||
} catch (e) { $('imcMsg').textContent = e.message; }
|
||||
$('imcSave').disabled = false;
|
||||
};
|
||||
|
||||
window.Importer = { showCopyStart, onSiteOpened };
|
||||
})();
|
||||
@@ -0,0 +1,504 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>AI Webmaker</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- =====================================================================================================
|
||||
v1.6.40 (요구 1) 사용자 화면 전면 재구성 — "작업 흐름" 중심의 세 칸 구조
|
||||
① 왼쪽 레일(.rail) : 제품명 · 작업 중인 사이트 선택 · 작업 흐름 내비게이션(홈 / 사이트: 만들기·수정·배포·운영 / 주소·메일: 도메인·DNS·메일 / 계정: 캐시·충전·설정)
|
||||
각 항목 오른쪽에 그 작업의 상태 점·짧은 배지가 붙어, 어디까지 되었고 다음이 무엇인지 한눈에 보인다 (public/flow-ui.js 가 채움).
|
||||
② 가운데 작업 칸(.left): 위(.top-pane) = 화면 이름·설명 + 진행 상태·메시지창(모든 화면 공통), 아래(.bottom-pane) = 고른 화면의 작업 카드.
|
||||
③ 오른쪽 캔버스(.right): 미리보기(+소스 편집기) 또는 운영 화면에서는 사이트 관리 창.
|
||||
1.4.14~1.6.39 의 "생성/수정/관리/도메인/설정" 다섯 탭은 아래처럼 나뉘고 늘어났다 (카드의 요소 ID 는 그대로라 wizard.js·modules-ui.js 등 기존 화면 코드는 바뀌지 않는다):
|
||||
생성 → 만들기(create) · 수정 → 수정(edit) + 배포(deploy: 발행·원본 서버 반영·검색 등록) · 관리 → 운영(manage)
|
||||
도메인 → 도메인(domains: 구매·등록·연결) + DNS(dns: 레코드·전파·확인) + 메일(mail: MX 자동 설정·웹메일) · 설정 → 캐시·충전(credits) + 설정(settings) · 새로 홈(home: 작업 흐름 지도)
|
||||
===================================================================================================== -->
|
||||
<div class="app">
|
||||
<div class="rail" id="rail">
|
||||
<div class="rail-brand"><span class="rail-logo" aria-hidden="true"></span><h1>AI Webmaker <span class="ver" id="verBadge"></span></h1></div>
|
||||
<!-- 작업 중인 사이트: 여기서 고르면 가운데 작업 칸과 오른쪽 캔버스가 그 사이트로 바뀐다 (v1.2.0 사이트 선택 상자, 1.6.40 에서 레일로 이동) -->
|
||||
<div class="rail-site">
|
||||
<div class="rail-label">작업 중인 사이트 <button type="button" class="help right" data-tip="작업할 사이트를 여기서 고릅니다. 생성 중인 사이트는 만들기 화면, 완성된 사이트는 보고 있던 화면(홈·수정·배포·운영)에서 이어집니다. '새 사이트 만들기'를 고르면 만들기 화면에서 새로 시작합니다.">?</button></div>
|
||||
<div class="site-picker"><select id="siteSelect" title="작업할 사이트"><option value="">+ 새 사이트 만들기</option></select></div>
|
||||
<!-- v1.4.14 (요구 4): 상태·운영 주소 줄은 항상 같은 높이 (사이트를 고르기 전에는 안내 한 줄) -->
|
||||
<div class="site-meta-top" id="siteMetaBox"><b id="siteTitle"></b><span class="pill idle small" id="siteStatePill"></span><span class="muted small" id="siteLiveLine">사이트를 고르면 상태와 운영 주소가 여기에 표시됩니다</span></div>
|
||||
</div>
|
||||
<!-- 작업 흐름 내비게이션. data-tab 값은 app.js TABS 와 같다. .nb(배지)·data-state(점 색) 는 flow-ui.js 가 채운다 -->
|
||||
<nav class="rail-nav" id="mainTabs" aria-label="작업 흐름">
|
||||
<button type="button" data-tab="home"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M3 9.5 10 3l7 6.5V17H12v-5H8v5H3z"/></svg><span>홈</span><em class="nb"></em></button>
|
||||
<div class="rail-group">사이트</div>
|
||||
<button type="button" data-tab="create"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 3v14M3 10h14"/></svg><span>만들기</span><em class="nb"></em></button>
|
||||
<button type="button" data-tab="edit"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 14.5 13.5 5l1.5 1.5L5.5 16H4zM12 6.5l1.5 1.5"/></svg><span>수정</span><em class="nb"></em></button>
|
||||
<button type="button" data-tab="deploy"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 15V5M5.5 9.5 10 5l4.5 4.5M4 17h12"/></svg><span>배포</span><em class="nb"></em></button>
|
||||
<button type="button" data-tab="manage"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 16V9M10 16V4M16 16v-6"/></svg><span>운영</span><em class="nb"></em></button>
|
||||
<div class="rail-group">주소 · 메일</div>
|
||||
<button type="button" data-tab="domains"><svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="7"/><path d="M3 10h14M10 3c2.5 2.5 2.5 11.5 0 14M10 3c-2.5 2.5-2.5 11.5 0 14"/></svg><span>도메인</span><em class="nb"></em></button>
|
||||
<button type="button" data-tab="dns"><svg viewBox="0 0 20 20" aria-hidden="true"><rect x="3" y="4" width="14" height="4.5" rx="1.2"/><rect x="3" y="11.5" width="14" height="4.5" rx="1.2"/><path d="M6 6.2h.01M6 13.7h.01"/></svg><span>DNS</span><em class="nb"></em></button>
|
||||
<button type="button" data-tab="mail"><svg viewBox="0 0 20 20" aria-hidden="true"><rect x="3" y="5" width="14" height="10" rx="1.5"/><path d="m3.5 6 6.5 5 6.5-5"/></svg><span>메일</span><em class="nb"></em></button>
|
||||
<div class="rail-group">계정</div>
|
||||
<button type="button" data-tab="credits"><svg viewBox="0 0 20 20" aria-hidden="true"><rect x="3" y="5.5" width="14" height="9" rx="1.5"/><path d="M3 9h14"/></svg><span>캐시 · 충전</span><em class="nb"></em></button>
|
||||
<button type="button" data-tab="settings"><svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="2.6"/><path d="M10 3v2M10 15v2M3 10h2M15 10h2M5.2 5.2l1.4 1.4M13.4 13.4l1.4 1.4M5.2 14.8l1.4-1.4M13.4 6.6l1.4-1.4"/></svg><span>설정</span><em class="nb"></em></button>
|
||||
</nav>
|
||||
<div class="rail-foot">
|
||||
<!-- 잔여 캐시: 누르면 캐시·충전 화면. 작업 중에는 실시간으로 바뀐다 (app.js renderRunTokens) -->
|
||||
<button type="button" class="credit-pill" id="creditPill" title="잔여 캐시 — 누르면 캐시·충전 화면이 열립니다"><span>캐시</span><b id="creditBadge">-</b></button>
|
||||
<span class="muted small" id="creditNote"></span>
|
||||
<div class="rail-user"><span class="user"><b id="userName"></b> <span class="muted" id="userId"></span></span></div>
|
||||
<div class="rail-actions"><a id="adminLink" class="btn ghost small-btn hidden" href="/admin">관리자</a><button id="btnFeedback" class="ghost small-btn" title="사용 문의, 개선 제안, 오류를 운영팀에 보냅니다">문의 · 제안 · 오류</button><span class="lang-switch" style="display:none"><select id="langSelect" title="화면 언어 / Language"></select></span><button id="btnLogout" class="ghost small-btn">로그아웃</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="left">
|
||||
<!-- ===== 위쪽: 화면 이름·설명(레일에서 고른 화면) + 진행 상태·메시지창 (모든 화면 공통) ===== -->
|
||||
<div class="top-pane">
|
||||
<header class="hdr v12 pane-head">
|
||||
<div class="hdr-line"><h1 class="pane-title" id="paneTitle">홈</h1><span class="spacer"></span><span id="paneSite" class="muted small pane-site"></span></div>
|
||||
<p class="muted small pane-desc" id="paneDesc"></p>
|
||||
</header>
|
||||
<!-- 현재 상태: 어느 탭에서 지시를 내려도 여기서 즉시 바뀐다 (생성·수정·배포·검증 공통) -->
|
||||
<section class="status-strip" id="logCard">
|
||||
<div class="ss-head"><span class="muted small">진행 상태</span><span id="ssSite" class="ss-site muted small"></span><span id="statusPill" class="pill idle">대기</span></div>
|
||||
<ol class="steps-strip hidden" id="progressSteps"><li>지시 전달</li><li>AI 작업</li><li>버전 저장</li><li>완료</li></ol>
|
||||
<!-- v1.5.31 (요구 1·3): 진행 상자는 한 줄 — 아이콘 · 상태(progressText) · 오른쪽 정렬 보조 문구(progressSub). 진행 중 메시지(progressMsg)만 아래 작은 줄.
|
||||
배포 결과 상자(progressDeploy)는 없애고 결과는 메시지창에 시각과 함께 쌓인다. -->
|
||||
<div id="progress" class="progress idle">
|
||||
<div class="pi"><span class="spinner"></span><span class="check">✓</span><span class="bang">!</span><span class="dot"></span></div>
|
||||
<div class="pt">
|
||||
<div class="pt-line"><span id="progressText">대기 중</span><span id="progressSub" class="muted small"></span></div>
|
||||
<div id="progressMsg" class="pmsg"></div>
|
||||
</div>
|
||||
<button id="btnCancel" class="ghost small-btn cancel hidden" title="실행 중인 AI 작업을 중단합니다">작업 중단</button>
|
||||
</div>
|
||||
<!-- v1.5.27 (요구 1·2): 메시지창 — 아래 작업 영역에서 한 일의 결과(배포·발행 취소·도메인·문의 메일 등)가 여기에 쌓인다. 크기 고정, 최신이 맨 위, 지난 메시지는 아래로 스크롤.
|
||||
메시지마다 × 로 지우고 "모두 지우기" 로 비운다. 1.5.19 까지의 경고 한 줄 상자(progressWarn)는 지워지지 않고 계속 남아 있었다. -->
|
||||
<div id="msgBox" class="msg-box hidden">
|
||||
<div class="mb-head"><span class="muted small">메시지</span><span class="muted small" id="msgCount"></span><span class="spacer"></span><button type="button" class="ghost small-btn" id="msgClear" title="메시지를 모두 지웁니다">모두 지우기</button></div>
|
||||
<ul id="msgList" class="mb-list"></ul>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ===== 아래쪽: 작업 영역 (스크롤) ===== -->
|
||||
<div class="bottom-pane">
|
||||
<!-- ===== 홈 (v1.6.40): 작업 흐름 지도 — 만들기 → 수정 → 도메인 → DNS·메일 → 배포 → 운영 의 상태와 다음 할 일. public/flow-ui.js 가 그린다 ===== -->
|
||||
<div class="tabpane" id="pane-home"><div id="homeBody"></div></div>
|
||||
|
||||
<!-- ===== 만들기(create): 새 사이트 생성 ~ 생성 완료 (수정·운영 화면의 사이트 선택과 무관) ===== -->
|
||||
<div class="tabpane" id="pane-create">
|
||||
<div class="model-row">
|
||||
<span class="muted small">모델 등급 <button type="button" class="help" data-tip="기본: HTML5, CSS3, 자바스크립트 구조의 정적 웹페이지 생성. 중급: 웹페이지의 시각적 완성도(UI/UX)와 심미성 우수. 고급: 복잡한 UI/UX의 프런트엔드 아키텍쳐 구현. 등급별 예상 비용은 아래 표에 있습니다.">?</button></span>
|
||||
<select id="tierSelect" title="이 사이트 생성에 사용할 AI 모델 등급"></select>
|
||||
</div>
|
||||
<div class="tier-guide" id="tierGuideBox"></div>
|
||||
|
||||
<section class="card" id="modeCard">
|
||||
<h2>1. 어떻게 만들까요? <button type="button" class="help" data-tip="AI에게 맡기기: 설명만 적으면 AI 가 분석 후 필요한 정보만 묻고 끝까지 자동 생성합니다. 내가 고르기: 업종·목적·페이지·디자인·문구를 단계별로 직접 고릅니다. 기존 사이트의 모양을 살려 새로 만들려면 아래 '2. 기존 사이트 모양으로 시작'을 고르세요.">?</button></h2>
|
||||
<div class="mode-grid">
|
||||
<button type="button" class="mode-opt" data-mode="ai"><span class="mode-title">AI에게 맡기기</span><span class="mode-desc">설명 + 추가 정보 입력 → 자동 생성</span></button>
|
||||
<button type="button" class="mode-opt" data-mode="manual"><span class="mode-title">내가 고르기</span><span class="mode-desc">구성·디자인·문구를 직접 선택</span></button>
|
||||
<button type="button" class="mode-opt" data-mode="template"><span class="mode-title">템플릿에서 시작</span><span class="mode-desc">업종별·해시태그로 고른 완성 사이트를 바탕으로</span></button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- v1.3.4: 기존 사이트로 시작하기 — "사이트 복사하기" 만 남김 ("사이트 관리하기(서버에서 가져오기)" 는 삭제) -->
|
||||
<section class="card" id="existingCard">
|
||||
<h2>2. 기존 사이트 모양으로 시작 <button type="button" class="help" data-tip="이미 있는 사이트의 모양(디자인·구성·문구)을 최대한 살려 새 사이트를 만들 때 씁니다. 웹 주소만 입력하면 화면을 이루는 HTML·CSS·JS·이미지를 복사해 오며, 로그인·게시판·신청·결제 같은 기능(서버 프로그램)은 복사되지 않습니다. 복사한 뒤에는 새로 만든 사이트처럼 수정·발행합니다.">?</button></h2>
|
||||
<div class="mode-grid">
|
||||
<button type="button" class="mode-opt" data-mode="copy"><span class="mode-title">사이트 복사하기</span><span class="mode-desc">웹 주소만 입력 → 화면(HTML 페이지)만 복사해 새 사이트로</span></button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card hidden" id="copyStartCard">
|
||||
<h2>사이트 복사하기 <button type="button" class="ghost small-btn" id="btnBackMode4">← 방식 변경</button></h2>
|
||||
<label>사이트 주소 <span class="req">*</span> <button type="button" class="help" data-tip="복사할 사이트의 첫 페이지 주소입니다. 같은 도메인 안의 페이지와 그 페이지가 쓰는 CSS·JS·이미지·글꼴을 따라가며 내려받고, 링크를 이 빌더 안의 경로로 바꿉니다. 다른 도메인(CDN 등)의 자원은 원래 주소를 그대로 씁니다. 사이트 크기가 서버 용량의 10% 를 넘으면 중지됩니다.">?</button><input id="cpUrl" placeholder="https://www.example.com" autocomplete="off"></label>
|
||||
<label>사이트 이름 <span class="muted small">(비우면 페이지 제목)</span><input id="cpTitle" autocomplete="off"></label>
|
||||
<label>문의 수신 이메일 <span class="muted small">(선택)</span><input id="cpEmail" type="email" autocomplete="off" placeholder="info@example.com"></label>
|
||||
<!-- v1.3.4: 무엇이 복사되고 무엇이 안 되는지, 왜 있는 기능인지 상세 설명 -->
|
||||
<div class="copy-explain">
|
||||
<p><b>이 기능은 무엇인가요?</b> 기존 사이트의 <b>모양은 최대한 유지하면서 새로운 사이트를 만들 때</b> 쓰는 기능입니다. 원래 사이트를 옮기거나 그대로 운영하는 기능이 아닙니다.</p>
|
||||
<ul>
|
||||
<li><b>복사되는 것</b> — 웹 주소로 열리는 기본 HTML 페이지와, 그 페이지가 쓰는 CSS·JS·이미지·글꼴 (같은 도메인 안의 페이지를 따라가며 최대 300쪽)</li>
|
||||
<li><b>복사되지 않는 것</b> — 로그인·회원, 게시판 글 저장, 문의·신청·예약 접수, 결제, 검색처럼 서버에서 동작하는 기능과 그 데이터(DB), 로그인해야 보이는 화면</li>
|
||||
<li><b>복사한 뒤</b> — 새로 만든 사이트로 취급되어 수정 지시·소스 편집·발행을 그대로 쓸 수 있습니다. 필요한 기능은 수정 지시로 빌더의 공통 기능 모듈(회원·게시판·문의·예약 등)로 새로 붙입니다.</li>
|
||||
</ul>
|
||||
<p class="muted small">본인 또는 권한이 있는 사이트만 복사하세요.</p>
|
||||
</div>
|
||||
<!-- v1.3.5: 검색·AI 최적화 생성 옵션 — 생성이 끝나면 AI 초안 → 수정 탭에서 검토 후 반영 -->
|
||||
<div class="seo-opts" data-seo-opts>
|
||||
<div class="field-label">검색·AI 최적화 <button type="button" class="help" data-tip="SEO: 검색 결과에 보이는 페이지별 제목·설명·키워드, 공유 미리보기, 사이트맵. AEO: 질문형 검색(AI·음성 답변)에 맞춘 자주 묻는 질문과 짧은 답, 구조화 데이터. GEO: 생성형 AI 검색이 정확히 인용하도록 사업체 정보 구조화 데이터·핵심 사실·llms.txt. 생성이 끝나면 AI 가 초안을 만들고(기본 등급, 캐시 차감) 수정 탭에서 확인·수정한 뒤 반영합니다. 바로 사이트에 들어가지 않습니다.">?</button></div>
|
||||
<label class="chk"><input type="checkbox" data-opt="seo"> SEO AI 생성 <span class="muted small">— 페이지별 검색 제목·설명·키워드, 공유 미리보기, 사이트맵</span></label>
|
||||
<label class="chk"><input type="checkbox" data-opt="aeo"> AEO 생성 <span class="muted small">— 질문형 FAQ·핵심 답변 (AI·음성 답변용)</span></label>
|
||||
<label class="chk"><input type="checkbox" data-opt="geo"> GEO 생성 <span class="muted small">— 사업체 정보·인용용 사실·llms.txt (생성형 AI 검색용)</span></label>
|
||||
</div>
|
||||
<button id="cpStart" class="primary">사이트 복사 시작</button>
|
||||
</section>
|
||||
|
||||
<!-- v1.3.10: 템플릿에서 시작 — 관리자가 사용을 허용한 템플릿 (public/templates-ui.js) -->
|
||||
<section class="card hidden" id="templateStartCard">
|
||||
<h2>템플릿에서 시작 <button type="button" class="ghost small-btn" id="btnBackMode5">← 방식 변경</button> <button type="button" class="help" data-tip="운영팀이 만든 완성 사이트(템플릿)를 바탕으로 새 사이트를 만듭니다. 업종 디렉터리나 #해시태그로 찾고 미리보기로 확인한 뒤 고르세요. 시작하면 템플릿의 페이지·디자인이 그대로 복사되며, 그 뒤에는 수정 지시로 내용을 바꿉니다.">?</button></h2>
|
||||
<input id="tplQ" placeholder="#카페 #미니멀 처럼 해시태그나 낱말로 찾기" autocomplete="off">
|
||||
<div class="tpl-tags" id="tplTags"></div>
|
||||
<div class="tpl-layout"><div class="tpl-tree" id="tplTree"></div><div class="tpl-list" id="tplList"></div></div>
|
||||
<p class="small" id="tplChosen"></p>
|
||||
<label>사이트 이름 <span class="muted small">(비우면 템플릿 이름)</span><input id="tplTitle" autocomplete="off"></label>
|
||||
<label>문의 수신 이메일 <span class="muted small">(선택)</span><input id="tplEmail" type="email" autocomplete="off" placeholder="info@example.com"></label>
|
||||
<div class="seo-opts" data-seo-opts>
|
||||
<div class="field-label">검색·AI 최적화</div>
|
||||
<label class="chk"><input type="checkbox" data-opt="seo" checked> SEO AI 생성</label>
|
||||
<label class="chk"><input type="checkbox" data-opt="aeo"> AEO 생성</label>
|
||||
<label class="chk"><input type="checkbox" data-opt="geo"> GEO 생성</label>
|
||||
</div>
|
||||
<button id="tplStart" class="primary" disabled>이 템플릿으로 시작</button>
|
||||
</section>
|
||||
|
||||
<section class="card hidden" id="aiStartCard">
|
||||
<h2>AI에게 맡기기 <button type="button" class="ghost small-btn" id="btnBackMode">← 방식 변경</button></h2>
|
||||
<label class="ai-main-label">만들고 싶은 사이트 <span class="req">*</span> <button type="button" class="help" data-tip="업종, 위치, 주요 고객, 꼭 들어가야 할 내용(예: 예약, 가격표, 강사 소개)을 자유롭게 적습니다. 많이 적을수록 AI 가 정확히 이해합니다. 결과: AI 가 업종·목표·페이지 구성을 정리해 추가로 필요한 정보만 묻습니다.">?</button>
|
||||
<textarea id="aiText" class="ai-main-input" rows="10" placeholder="예: 강남역 근처 필라테스 스튜디오. 1:1 수업 중심, 30대 직장인 여성 대상. 예약을 많이 받고 싶고, 강사 소개와 가격표가 들어갔으면 함."></textarea>
|
||||
</label>
|
||||
<div class="field-label">디자인 컨셉 (선택) <button type="button" class="help" data-tip="주 색상은 버튼·강조색에 쓰이고, 톤은 전체 분위기(여백·글꼴·이미지 스타일)를 정합니다. 방향에는 '사진 크게', '둥근 버튼' 같은 요구를 적습니다. 비우면 업종에 맞게 AI 가 추천합니다.">?</button></div>
|
||||
<div class="two">
|
||||
<label>주 색상<div class="color-row"><input id="aiColor" type="color" value="#2f6fed"><input id="aiColorText" placeholder="#2f6fed 또는 '네이비'" autocomplete="off"></div></label>
|
||||
<label>분위기(톤)<select id="aiTone"><option value="">AI 추천</option></select></label>
|
||||
</div>
|
||||
<label>디자인 방향<input id="aiDesignDir" placeholder="예: 여백 많은 고급스러운 느낌, 사진 크게" autocomplete="off"></label>
|
||||
<label>페이지 수 <button type="button" class="help" data-tip="홈을 포함한 페이지 개수입니다. '자동'은 AI 가 업종과 목표에 맞게 3~6개를 정합니다. 1개를 고르면 모든 내용을 한 페이지에 섹션으로 배치하는 원페이지 사이트가 됩니다. 페이지가 많을수록 생성 비용과 시간이 늘어납니다.">?</button><select id="aiPageCount"><option value="0">자동 (AI 가 결정, 보통 3~6개)</option><option value="1">1개 (원페이지)</option><option value="2">2개</option><option value="3">3개</option><option value="4">4개</option><option value="5">5개</option><option value="6">6개</option><option value="8">8개</option><option value="10">10개</option></select></label>
|
||||
<label>기존 사이트 URL <button type="button" class="help" data-tip="이미 사이트가 있으면 주소를 적습니다. AI 가 본문을 읽어 사업 정보와 전화·주소 같은 사실정보를 출처와 함께 가져옵니다.">?</button><input id="aiExistingUrl" placeholder="https://"></label>
|
||||
<div class="field-label">첨부 <button type="button" class="help" data-tip="소개서(PDF·문서)는 내용에, 로고·사진은 페이지 이미지에 쓰입니다. 첨부가 없는 자리는 AI 가 그라데이션·도형으로 채웁니다.">?</button></div>
|
||||
<div class="dropzone" id="dzCreate" data-target="create">
|
||||
<div class="dz-text">파일을 끌어다 놓거나 <label class="dz-pick" for="fFiles">클릭해서 선택</label></div>
|
||||
<input id="fFiles" type="file" multiple class="visually-hidden">
|
||||
<ul class="pending" id="pendingCreate"></ul>
|
||||
</div>
|
||||
<div class="field-label">참고 사이트 (최대 10개) <button type="button" class="help" data-tip="참고할 사이트 주소와 '어떤 점을 반영할지'(메뉴 구성, 색감, 가격표 형식 등)를 함께 적습니다. 디자인·구조 제안에 반영됩니다.">?</button></div>
|
||||
<div id="refsAi" class="refs"></div>
|
||||
<!-- v1.3.5: 검색·AI 최적화 생성 옵션 — 생성이 끝나면 AI 초안 → 수정 탭에서 검토 후 반영 -->
|
||||
<div class="seo-opts" data-seo-opts>
|
||||
<div class="field-label">검색·AI 최적화 <button type="button" class="help" data-tip="SEO: 검색 결과에 보이는 페이지별 제목·설명·키워드, 공유 미리보기, 사이트맵. AEO: 질문형 검색(AI·음성 답변)에 맞춘 자주 묻는 질문과 짧은 답, 구조화 데이터. GEO: 생성형 AI 검색이 정확히 인용하도록 사업체 정보 구조화 데이터·핵심 사실·llms.txt. 생성이 끝나면 AI 가 초안을 만들고(기본 등급, 캐시 차감) 수정 탭에서 확인·수정한 뒤 반영합니다. 바로 사이트에 들어가지 않습니다.">?</button></div>
|
||||
<label class="chk"><input type="checkbox" data-opt="seo" checked> SEO AI 생성 <span class="muted small">— 페이지별 검색 제목·설명·키워드, 공유 미리보기, 사이트맵</span></label>
|
||||
<label class="chk"><input type="checkbox" data-opt="aeo" checked> AEO 생성 <span class="muted small">— 질문형 FAQ·핵심 답변 (AI·음성 답변용)</span></label>
|
||||
<label class="chk"><input type="checkbox" data-opt="geo" checked> GEO 생성 <span class="muted small">— 사업체 정보·인용용 사실·llms.txt (생성형 AI 검색용)</span></label>
|
||||
</div>
|
||||
<!-- v1.3.10: 다국어 생성 옵션 — 언어마다 /ko/ /en/ 폴더, 기본 언어 페이지와 같은 구조로 번역 (app.js fillLangOpts) -->
|
||||
<div class="lang-opts" data-lang-opts>
|
||||
<div class="field-label">사이트 언어 <button type="button" class="help" data-tip="추가 언어를 고르면 다국어 사이트로 만듭니다. 주소는 언어마다 /ko/, /en/ 처럼 나뉘고, 다른 언어 페이지는 기본 언어 페이지와 같은 구조(페이지·섹션·순서)로 번역됩니다. 날짜·숫자는 언어별 형식으로, 아랍어 등은 오른쪽→왼쪽으로 표시되며, 언어마다 검색·AI 최적화 값이 따로 만들어집니다. 번역은 기본 등급 모델로 캐시가 차감됩니다. 나중에 수정 탭에서 언어를 더할 수도 있습니다.">?</button></div>
|
||||
<label class="lang-default">기본 언어<select data-lang-default></select></label>
|
||||
<div class="lang-chips" data-lang-extra></div>
|
||||
<!-- v1.3.16: x-default — 어떤 언어에도 맞지 않는 방문자에게 보일 언어 (추가 언어를 골랐을 때만 보임) -->
|
||||
<label class="lang-default lang-xdef hidden">x-default (다른 언어 방문자에게 보일 언어) <button type="button" class="help" data-tip="검색엔진의 hreflang x-default 와 사이트 맨 위 주소(언어 선택 페이지)의 자동 이동에 쓰입니다. 브라우저 언어가 사이트 언어 중 어느 것과도 맞지 않는 방문자는 이 언어로 갑니다. '언어 선택 페이지' 를 고르면 자동 이동 없이 선택 페이지를 보여 줍니다.">?</button><select data-lang-xdefault></select></label>
|
||||
</div>
|
||||
<button id="btnAiStart" class="primary">AI 분석 시작</button>
|
||||
</section>
|
||||
|
||||
<section class="card hidden" id="manualStartCard">
|
||||
<h2>내가 고르기 <button type="button" class="ghost small-btn" id="btnBackMode2">← 방식 변경</button></h2>
|
||||
<label>상호명 <span class="req">*</span><input id="mName" placeholder="예: 한강 카페 로스터스"></label>
|
||||
<div class="two">
|
||||
<label>업종 대분류 <button type="button" class="help" data-tip="업종을 고르면 목적·기능·페이지·디자인 기본값이 미리 선택됩니다. 각 단계에서 바꿀 수 있습니다.">?</button><select id="mMajor"></select></label>
|
||||
<label>소분류<select id="mMinor"></select></label>
|
||||
</div>
|
||||
<div class="two">
|
||||
<label>지역 시·도<select id="mSido"></select></label>
|
||||
<label>시·군·구<select id="mSigungu"></select></label>
|
||||
</div>
|
||||
<div class="field-label">참고 사이트 (최대 10개) <button type="button" class="help" data-tip="참고할 사이트 주소와 반영할 점을 적습니다. 디자인·구조에 반영됩니다.">?</button></div>
|
||||
<div id="refsManual" class="refs"></div>
|
||||
<!-- v1.3.5: 검색·AI 최적화 생성 옵션 — 생성이 끝나면 AI 초안 → 수정 탭에서 검토 후 반영 -->
|
||||
<div class="seo-opts" data-seo-opts>
|
||||
<div class="field-label">검색·AI 최적화 <button type="button" class="help" data-tip="SEO: 검색 결과에 보이는 페이지별 제목·설명·키워드, 공유 미리보기, 사이트맵. AEO: 질문형 검색(AI·음성 답변)에 맞춘 자주 묻는 질문과 짧은 답, 구조화 데이터. GEO: 생성형 AI 검색이 정확히 인용하도록 사업체 정보 구조화 데이터·핵심 사실·llms.txt. 생성이 끝나면 AI 가 초안을 만들고(기본 등급, 캐시 차감) 수정 탭에서 확인·수정한 뒤 반영합니다. 바로 사이트에 들어가지 않습니다.">?</button></div>
|
||||
<label class="chk"><input type="checkbox" data-opt="seo" checked> SEO AI 생성 <span class="muted small">— 페이지별 검색 제목·설명·키워드, 공유 미리보기, 사이트맵</span></label>
|
||||
<label class="chk"><input type="checkbox" data-opt="aeo" checked> AEO 생성 <span class="muted small">— 질문형 FAQ·핵심 답변 (AI·음성 답변용)</span></label>
|
||||
<label class="chk"><input type="checkbox" data-opt="geo" checked> GEO 생성 <span class="muted small">— 사업체 정보·인용용 사실·llms.txt (생성형 AI 검색용)</span></label>
|
||||
</div>
|
||||
<!-- v1.3.10: 다국어 생성 옵션 — 언어마다 /ko/ /en/ 폴더, 기본 언어 페이지와 같은 구조로 번역 (app.js fillLangOpts) -->
|
||||
<div class="lang-opts" data-lang-opts>
|
||||
<div class="field-label">사이트 언어 <button type="button" class="help" data-tip="추가 언어를 고르면 다국어 사이트로 만듭니다. 주소는 언어마다 /ko/, /en/ 처럼 나뉘고, 다른 언어 페이지는 기본 언어 페이지와 같은 구조(페이지·섹션·순서)로 번역됩니다. 날짜·숫자는 언어별 형식으로, 아랍어 등은 오른쪽→왼쪽으로 표시되며, 언어마다 검색·AI 최적화 값이 따로 만들어집니다. 번역은 기본 등급 모델로 캐시가 차감됩니다. 나중에 수정 탭에서 언어를 더할 수도 있습니다.">?</button></div>
|
||||
<label class="lang-default">기본 언어<select data-lang-default></select></label>
|
||||
<div class="lang-chips" data-lang-extra></div>
|
||||
<!-- v1.3.16: x-default — 어떤 언어에도 맞지 않는 방문자에게 보일 언어 (추가 언어를 골랐을 때만 보임) -->
|
||||
<label class="lang-default lang-xdef hidden">x-default (다른 언어 방문자에게 보일 언어) <button type="button" class="help" data-tip="검색엔진의 hreflang x-default 와 사이트 맨 위 주소(언어 선택 페이지)의 자동 이동에 쓰입니다. 브라우저 언어가 사이트 언어 중 어느 것과도 맞지 않는 방문자는 이 언어로 갑니다. '언어 선택 페이지' 를 고르면 자동 이동 없이 선택 페이지를 보여 줍니다.">?</button><select data-lang-xdefault></select></label>
|
||||
</div>
|
||||
<button id="btnManualStart" class="primary">기본값으로 시작하기</button>
|
||||
</section>
|
||||
|
||||
<!-- v1.3.4: "사이트 관리하기(서버 접속으로 가져오기)" 시작 카드 삭제 -->
|
||||
|
||||
<!-- 기획 진행 카드 (AI / 내가 고르기) -->
|
||||
<section class="card hidden" id="planCard">
|
||||
<h2><span id="planTitle">기획</span> <span id="planStage" class="pill idle"></span> <span class="spacer"></span><button type="button" class="ghost small-btn" id="btnAbortCreate" title="진행 중인 생성 작업을 멈추고 새 사이트 만들기로 돌아갑니다">중단하고 새로 만들기</button></h2>
|
||||
<div id="planBody"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ===== 수정(edit, v1.4.14 → 1.6.40): 수정 지시·기본 사항·다국어·검색 최적화·버전·첨부. 발행·배포는 "배포" 화면으로 ===== -->
|
||||
<div class="tabpane hidden" id="pane-edit">
|
||||
<p class="muted small" id="editHint"></p>
|
||||
<!-- v1.6.40: 발행·배포 / 원본 서버 반영 / 검색사이트 등록 카드는 "배포" 화면(#pane-deploy)으로 옮겼다. 수정 화면은 사이트를 고치는 일에만 집중한다. -->
|
||||
<!-- 수정 완료 안내 (v1.1.2): 마지막 수정 작업의 결과 요약 + 상세 보기 -->
|
||||
<section class="card done-banner hidden" id="doneBanner">
|
||||
<div class="db-head"><span class="db-icon">✓</span><div><b id="dbTitle">수정 완료</b><div class="muted small" id="dbMeta"></div></div><span class="spacer"></span><button type="button" class="ghost small-btn" id="dbDetail">상세 내역</button><button type="button" class="ghost small-btn" id="dbClose" title="닫기">×</button></div>
|
||||
<div class="small hidden" id="dbSummary"></div>
|
||||
</section>
|
||||
<!-- 수정 지시: 수정 탭의 핵심 -->
|
||||
<section class="card update-card hidden" id="updateCard">
|
||||
<h2>수정 지시 <span class="muted small" id="editLimitNote"></span> <button type="button" class="help" data-tip="바꾸고 싶은 내용을 문장으로 적으면 AI 가 해당 부분만 고칩니다. 예: '첫 화면 배너 문구를 더 짧게', '문의 폼에 회사명 항목 추가', '게시판 페이지 추가'. 기본 사항·이전 지시·설정은 그대로 유지됩니다. 수정이 끝나면 미리보기에 바로 반영되고 버전이 남습니다.">?</button></h2>
|
||||
<div id="editLockBox" class="progress-warn hidden"></div>
|
||||
<textarea id="fInstruction" rows="7" placeholder="무엇을 어떻게 바꿀지 적어 주세요. 예) 첫 화면 배너 문구를 '당신의 하루를 바꾸는 한 시간'으로 바꾸고, 가격표에 10회권 35만원을 추가해 주세요."></textarea>
|
||||
<div class="update-tools">
|
||||
<div class="model-row inline-row"><span class="muted small">이번 수정 모델</span><select id="editTierSelect" title="이번 수정 지시에 사용할 모델 등급"></select><button type="button" class="help" data-tip="수정마다 등급을 다르게 고를 수 있습니다. 모델이 바뀌어도 사이트 기본 사항과 이전 지시 내용은 그대로 유지되어 이어서 작업합니다.">?</button></div>
|
||||
<div class="muted small" id="editCostLine"></div>
|
||||
</div>
|
||||
<div class="dropzone slim" id="dzUpdate" data-target="update">
|
||||
<div class="dz-text">함께 보낼 파일을 끌어다 놓거나 <label class="dz-pick" for="fUpdFiles">선택</label> <button type="button" class="help" data-tip="이 지시와 함께 쓸 사진·문서입니다. 이미지는 페이지에, 문서는 내용에 반영됩니다.">?</button></div>
|
||||
<input id="fUpdFiles" type="file" multiple class="visually-hidden">
|
||||
<ul class="pending" id="pendingUpdate"></ul>
|
||||
</div>
|
||||
<button id="btnUpdate" class="primary big">수정 요청</button>
|
||||
<details id="basicsBox" class="basics">
|
||||
<summary>홈페이지 기본 사항 보기 / 수정 <span id="basicsState" class="basics-state"></span> <button type="button" class="help" data-tip="제목·목적·내용·디자인 방향·기타 요구사항·문의 수신 이메일. 저장하면 다음 수정 지시 때 AI 에게 함께 전달되어 페이지 전반에 반영됩니다.">?</button></summary>
|
||||
<label>페이지 제목 <span class="req">*</span><input id="bTitle"></label>
|
||||
<label>목적<textarea id="bPurpose" rows="2"></textarea></label>
|
||||
<label>주요 컨텐츠<textarea id="bContent" rows="3"></textarea></label>
|
||||
<label>디자인 방향<textarea id="bDesign" rows="2"></textarea></label>
|
||||
<label>기타 요구사항<textarea id="bExtra" rows="3"></textarea></label>
|
||||
<label>문의 수신 이메일<input id="bEmail" type="email" placeholder="예: info@example.com"></label>
|
||||
<div class="row">
|
||||
<button id="btnSaveBasics" class="primary small-btn" disabled>기본 사항 저장</button>
|
||||
<button id="btnRevertBasics" class="ghost small-btn" disabled>되돌리기</button>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
<!-- v1.3.10: 다국어 — 언어 추가·빼기, 단일 언어 사이트를 다국어 구조로 옮기기 (public/optimize.js LangUI) -->
|
||||
<section class="card hidden" id="langCard">
|
||||
<h2>다국어 <span class="pill small" id="langState"></span> <button type="button" class="help" data-tip="사이트를 여러 언어로 제공합니다. 주소는 언어마다 /ko/, /en/ 처럼 나뉘고, 사이트 맨 위 주소는 방문자 브라우저 언어로 안내하는 언어 선택 페이지가 됩니다. 기본 언어 페이지를 수정하면 다른 언어 페이지도 같은 구조로 다시 번역됩니다(바뀐 문장만 번역, 기본 등급 모델로 캐시 차감). 한 언어 사이트를 다국어로 바꿀 때는 AI 가 페이지를 기본 언어 폴더로 옮기는 수정 작업을 먼저 합니다.">?</button></h2>
|
||||
<div id="langBody"></div>
|
||||
</section>
|
||||
<!-- v1.3.5: 검색·AI 최적화 (SEO·AEO·GEO) — AI 초안을 검토·수정한 뒤 반영 (public/optimize.js) -->
|
||||
<section class="card hidden" id="optimizeCard">
|
||||
<h2>검색·AI 최적화 <span class="pill small" id="optState"></span> <button type="button" class="help" data-tip="SEO(검색 결과 제목·설명·키워드·공유 미리보기·사이트맵), AEO(질문형 FAQ·핵심 답변, AI·음성 답변용), GEO(사업체 정보 구조화 데이터·인용용 사실·llms.txt, 생성형 AI 검색용) 값을 AI 가 초안으로 만들면 여기서 확인·수정해 반영합니다. 반영하면 버전이 저장되고, 다음 배포 때 운영 주소 기준(canonical·사이트맵)으로 함께 올라갑니다.">?</button></h2>
|
||||
<div id="optBody"></div>
|
||||
</section>
|
||||
<section class="card hidden" id="versionsCard">
|
||||
<h2>이전 버전으로 되돌리기 <span class="muted small" id="versionsCount"></span> <button type="button" class="help" data-tip="생성·수정·저장마다 버전이 남습니다. 되돌리면 그 시점 파일로 복원되고 복원도 새 버전으로 기록됩니다. 배포가 완료되면 이전 버전은 정리되고 배포본 하나만 남습니다.">?</button></h2>
|
||||
<ul id="versionsList" class="git-list"></ul>
|
||||
</section>
|
||||
<section class="card hidden" id="attachCard">
|
||||
<h2>첨부 파일 <span class="muted small" id="attachCount"></span> <button type="button" class="help" data-tip="파일당 최대 25MB. 이미지는 uploads/파일명 경로로 페이지에 삽입되고, 문서는 AI 가 읽어 내용에 반영합니다. 허용 형식은 아래 드롭존에 표시됩니다.">?</button></h2>
|
||||
<ul id="attachList" class="attach-list"></ul>
|
||||
<div class="dropzone slim" id="dzNow" data-target="now">
|
||||
<div class="dz-text">파일을 놓으면 바로 업로드 · <label class="dz-pick" for="fAttachNow">선택</label> <span class="muted small">(<span id="attachExts"></span>)</span></div>
|
||||
<input id="fAttachNow" type="file" multiple class="visually-hidden">
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ===== 배포(deploy, v1.6.40 신설): 이전 단계(사이트 완성·도메인·DNS)가 갖춰졌는지 점검표(#deployChecklist, flow-ui.js) + 발행·배포 · 원본 서버 반영 · 검색사이트 등록 =====
|
||||
배포는 다른 작업이 모두 끝나야 하는 마지막 작업이지만, 한 번 배포된 뒤의 수정·관리는 도메인·DNS 와 직접 묶이지 않는다 — 그래서 수정 화면과 분리했다. -->
|
||||
<div class="tabpane hidden" id="pane-deploy">
|
||||
<div id="deployChecklist"></div>
|
||||
<section class="card hidden" id="publishCard">
|
||||
<h2>발행 · 배포 <span id="publishState" class="pill idle"></span> <button type="button" class="help" data-tip="발행은 점검(입력 필요 항목·누락 페이지·규제 표현) 후 운영자가 지정한 배포 서버로 사이트를 전송합니다. 배포가 끝나면 상단 주소가 운영 주소로 바뀝니다. 발행 취소를 하면 운영 주소에는 '사이트 수정 중' 안내만 보이고, 다시 발행하면 정상 내용이 올라갑니다.">?</button></h2>
|
||||
<div id="publishBody"></div>
|
||||
</section>
|
||||
<section class="card hidden" id="importCard">
|
||||
<h2>원본 서버에 반영 <span id="imState" class="pill idle"></span> <button type="button" class="help" data-tip="여기서 수정한 파일을 원본 서버의 같은 경로에 덮어씁니다. 가져온 뒤 삭제한 파일은 서버에서도 지워지고, 서버에만 있는 파일(업로드·캐시)은 건드리지 않습니다. SSH 방식은 반영 전에 서버에 백업을 만듭니다.">?</button></h2>
|
||||
<div id="imInfo" class="small"></div>
|
||||
<div class="row">
|
||||
<button id="imBtnDeploy" class="primary small-btn">수정 내용을 원본 서버에 반영 (배포)</button>
|
||||
<button id="imBtnCreds" class="ghost small-btn">접속 정보 변경</button>
|
||||
</div>
|
||||
<div id="imDeployMsg" class="small"></div>
|
||||
<div id="imCredsBox" class="hidden">
|
||||
<div class="two">
|
||||
<label>접속 계정(ID)<input id="imcUser" autocomplete="off"></label>
|
||||
<label>포트<input id="imcPort" inputmode="numeric" autocomplete="off"></label>
|
||||
</div>
|
||||
<label>새 비밀번호 <span class="muted small">(바꿀 때만)</span><input id="imcPw" type="password" autocomplete="new-password"></label>
|
||||
<label>사이트 파일 경로<input id="imcDocroot" autocomplete="off"></label>
|
||||
<label>사이트 주소<input id="imcUrl" autocomplete="off"></label>
|
||||
<div class="row"><button type="button" id="imcSave" class="primary small-btn">확인 후 저장</button><span id="imcMsg" class="muted small"></span></div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- v1.3.5: 검색사이트 등록 (구글·네이버, IndexNow) — 배포가 끝난 사이트만 -->
|
||||
<section class="card hidden" id="searchCard">
|
||||
<h2>검색사이트 등록 <button type="button" class="help" data-tip="구글 Search Console 과 네이버 서치어드바이저에 사이트를 등록합니다. 검색사이트가 주는 확인 코드를 넣으면 첫 페이지에 확인 태그를 넣고 다시 배포하며, 검색사이트에서 '확인'·사이트맵 제출 같은 사람이 해야 하는 단계는 체크리스트로 보여 줍니다(관리자 콘솔에서도 진행 가능). IndexNow 로 빙·네이버 등에 새 페이지 색인을 바로 요청할 수 있습니다. 배포가 끝난 사이트만 쓸 수 있고, 생성·수정·배포 중에는 쓸 수 없습니다.">?</button></h2>
|
||||
<div id="searchBody"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ===== 운영(manage, v1.4.14 "관리" → 1.6.40 "운영"): 기능 모듈·사이트 관리자 페이지·메일, 문의 접수, 사이트 현황, 현황 분석, 캐시 사용, 사이트 설정 =====
|
||||
이 화면을 고르면 오른쪽 캔버스가 "사이트 관리 창"(사이트 관리자 페이지 + 현황 요약)으로 바뀐다 (public/manage-ui.js). -->
|
||||
<div class="tabpane hidden" id="pane-manage">
|
||||
<p class="muted small" id="manageHint"></p>
|
||||
<section class="card hidden" id="moduleCard">
|
||||
<h2>사이트 기능 모듈 <button type="button" class="ghost small-btn" id="btnModuleReload">새로고침</button> <button type="button" class="help" data-tip="게시판·문의폼·설문·예약·갤러리·지도·후기·FAQ·회원 기능입니다. 켜면 관리자 페이지가 생기고, 수정 지시에 '게시판을 추가해 주세요'처럼 적으면 표준 코드로 페이지에 들어갑니다. 글·예약·응답 같은 데이터는 사이트 관리자 페이지에서 관리합니다.">?</button></h2>
|
||||
<div id="moduleList" class="module-list"></div>
|
||||
<div class="row"><a id="btnSiteAdmin" class="btn ghost small-btn" target="_blank" rel="noopener">사이트 관리자 페이지 열기</a><span class="muted small">— 오른쪽 사이트 관리 창에서도 바로 관리할 수 있습니다</span></div>
|
||||
<!-- v1.3.2: 이 사이트의 메일 시스템 상태 (운영자가 켜면 "메일시스템 설정중" → DNS 확인 후 "사용 가능") -->
|
||||
<div class="mail-line hidden" id="mailLine"><span class="muted small">메일</span> <span class="pill small" id="mailLabel"></span> <span class="muted small" id="mailMsg"></span> <a id="mailOpen" class="btn ghost small-btn hidden" target="_blank" rel="noopener">웹메일 열기</a></div>
|
||||
</section>
|
||||
<section class="card hidden" id="inquiryCard">
|
||||
<h2>문의 접수 내역 <span class="muted small" id="inquiryCount"></span> <button type="button" class="ghost small-btn" id="btnInquiryReload">새로고침</button> <button type="button" class="help" data-tip="사이트 문의 폼으로 들어온 내용입니다. 문의 수신 이메일이 설정되어 있으면 메일로도 발송되며, 미발송 건은 '재발송'으로 다시 보낼 수 있습니다.">?</button></h2>
|
||||
<p class="muted small" id="inquiryNote"></p>
|
||||
<ul id="inquiryList" class="git-list"></ul>
|
||||
</section>
|
||||
<!-- v1.7.2 (요구 2): 사이트 정보는 오른쪽 캔버스의 "사이트 정보" 페이지(public/site-info.js)로 옮겼다. 여기에는 요약 세 줄과 "자세히 보기" 만 남는다 (app.js renderStats) -->
|
||||
<section class="card hidden" id="siteInfoCard">
|
||||
<h2>사이트 정보 <span class="muted small" id="statsUpdated"></span> <button type="button" class="ghost small-btn" id="btnStatsReload">새로고침</button> <button type="button" class="help" data-tip="접속 통계·네트워크 사용량(송신/수신)·디스크 사용량(웹·DB·메일)을 그래프로 보는 사이트 정보 페이지는 오른쪽 창에 열립니다. 방문자·페이지뷰·네트워크는 사이트가 올라간 서버의 접속 로그(5분 주기, 봇 제외)로 집계하고, 로그를 읽을 수 없는 서버는 통계 스크립트의 보고(추정)를 씁니다.">?</button></h2>
|
||||
<div class="totals stat-grid" id="statVisits"></div>
|
||||
<div class="row"><button type="button" class="primary small-btn" id="btnSiteInfoOpen">오른쪽 창에서 자세히 보기 (그래프)</button></div>
|
||||
</section>
|
||||
<!-- 사이트 관리하기·복사하기: 현황 분석 (v1.2.0) -->
|
||||
<section class="card hidden" id="analysisCard">
|
||||
<h2>사이트 현황 분석 <span class="muted small" id="anAt"></span> <button type="button" class="ghost small-btn" id="btnReanalyze">다시 분석</button> <button type="button" class="help" data-tip="가져오거나 복사한 사이트의 파일 통계(종류별 개수·크기, 페이지, 사용 기술)와 웹서버 정보, AI 요약(구성·기술·수정 난이도·문제점·개선 제안)입니다. AI 요약은 '기본' 등급으로 실행되며 캐시가 차감됩니다.">?</button></h2>
|
||||
<div id="anBody" class="small"></div>
|
||||
</section>
|
||||
<section class="card hidden" id="usageCard">
|
||||
<h2>캐시 사용 내역 <span class="muted small" id="usageScope"></span> <button type="button" class="ghost small-btn" id="btnUsageDetail">상세 보기</button> <button type="button" class="help" data-tip="작업 단위(생성·수정·검증 등)별 사용 캐시입니다. 작업이 끝날 때 잔액에서 차감되며 중단된 회차(*)는 추정값입니다. 최근 5건이 보이고 표 안에서 스크롤하면 나머지가 보입니다. 상세 보기에서 전체 이용 내역과 일간·주간·월간 사용량을 볼 수 있습니다.">?</button></h2>
|
||||
<div class="totals">
|
||||
<div class="tot"><span class="k">이 사이트 사용 캐시</span><span class="v" id="tKrw">0원</span></div>
|
||||
<div class="tot"><span class="k">잔여 캐시</span><span class="v" id="tBalance">-</span></div>
|
||||
</div>
|
||||
<div class="table-wrap usage-scroll">
|
||||
<table id="usageTable"><thead><tr><th>#</th><th>종류</th><th>지시</th><th>캐시</th><th>시간</th></tr></thead><tbody></tbody></table>
|
||||
</div>
|
||||
<ul id="creditLedger" class="hidden"></ul>
|
||||
</section>
|
||||
<!-- v1.3.10: 관리자 빌더 전용 — 이 사이트를 템플릿으로 등록 -->
|
||||
<section class="card hidden" id="tplRegisterCard">
|
||||
<h2>템플릿으로 등록 <span class="pill small">관리자</span> <button type="button" class="help" data-tip="지금 사이트의 파일을 템플릿으로 담습니다(이후 사이트를 고쳐도 템플릿은 그대로이며, 관리자 콘솔 › 템플릿 에서 '다시 담기' 로 바꿉니다). 사용자에게 보이게 하려면 관리자 콘솔에서 '사용자 사용 허용' 을 켭니다.">?</button></h2>
|
||||
<label>템플릿 이름<input id="tplRegTitle" autocomplete="off"></label>
|
||||
<div class="two"><label>디렉터리<select id="tplRegDir"></select></label><label>해시태그<input id="tplRegTags" placeholder="#카페 #미니멀" autocomplete="off"></label></div>
|
||||
<label>설명<textarea id="tplRegDesc" rows="2"></textarea></label>
|
||||
<div class="row"><button type="button" id="tplRegGo" class="primary small-btn">템플릿으로 등록</button><span class="muted small" id="tplRegMsg"></span></div>
|
||||
</section>
|
||||
<section class="card hidden" id="siteDangerCard">
|
||||
<h2>사이트 설정</h2>
|
||||
<div class="small muted">사이트 ID <code id="siteId"></code></div>
|
||||
<div class="row" style="margin-top:6px"><button type="button" class="ghost small-btn danger hidden" id="btnDeleteSite">사이트 폐기</button><button type="button" class="help" data-tip="사이트 폐기는 배포된 파일·도메인 설정·버전·통계·문의 데이터를 모두 지우며 되돌릴 수 없습니다. 가져온 사이트의 원본 서버 파일은 지우지 않습니다.">?</button></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ===== 도메인(domains, v1.4.0 → 1.6.40 분리): 서브 도메인 등록 / 도메인 검색·구매(장바구니) / 이미 가진 도메인 등록 / 사이트에 연결 — public/domains-ui.js ===== -->
|
||||
<div class="tabpane hidden" id="pane-domains"><div id="domainsBody"></div></div>
|
||||
<!-- ===== DNS(dns, v1.6.40 신설): 도메인별 DNS 레코드·전파·직접 관리 도메인의 필요한 설정 확인 — 배포·메일과 연결되는 중간 단계. public/domains-ui.js (view=dns) ===== -->
|
||||
<div class="tabpane hidden" id="pane-dns"><div id="dnsBody"></div></div>
|
||||
<!-- ===== 메일(mail, v1.6.40 신설): 도메인별 메일(MX) 자동 설정·상태, 사이트 메일 시스템·웹메일 — public/domains-ui.js (view=mail) ===== -->
|
||||
<div class="tabpane hidden" id="pane-mail"><div id="mailBody"></div></div>
|
||||
<!-- ===== 캐시·충전(credits, v1.6.40 신설): 잔액·사용 요약, 선불 충전 상품 선택 → 결제 진행(PG 연동 전: 주문 접수만), 내 주문 — public/credits-ui.js ===== -->
|
||||
<div class="tabpane hidden" id="pane-credits"><div id="creditsBody"></div></div>
|
||||
|
||||
<!-- ===== 설정(settings): 계정 ===== -->
|
||||
<div class="tabpane hidden" id="pane-settings">
|
||||
<section class="card" id="profileCard">
|
||||
<h2>계정 정보</h2>
|
||||
<label>아이디<input id="pfId" readonly></label>
|
||||
<label>이름 (회사명)<input id="pfName" autocomplete="organization"></label>
|
||||
<div class="row"><button type="button" id="pfSave" class="primary small-btn">저장</button><span id="pfMsg" class="muted small"></span></div>
|
||||
<p class="muted small" id="pfCreditLine"></p>
|
||||
</section>
|
||||
<section class="card" id="myFeedbackCard">
|
||||
<h2>내 문의 내역 <button type="button" class="ghost small-btn" id="btnFeedback2">새 글</button> <button type="button" class="help" data-tip="사용 방법 문의, 불편한 점이나 오류, 바라는 기능을 운영팀에 보냅니다. 올리면 AI 가 내용을 분석해 운영팀에 전달하고, 운영팀 답변이 오면 여기에 표시됩니다.">?</button></h2>
|
||||
<ul id="myFeedbackList" class="git-list stat-list"></ul>
|
||||
</section>
|
||||
<section class="card" id="pwCard">
|
||||
<h2>비밀번호 변경</h2>
|
||||
<label>현재 비밀번호<input id="pwCur" type="password" autocomplete="current-password"></label>
|
||||
<label data-pw-rule="label">새 비밀번호 (6자 이상)<input id="pwNew" type="password" autocomplete="new-password"></label>
|
||||
<label>새 비밀번호 확인<input id="pwNew2" type="password" autocomplete="new-password"></label>
|
||||
<div class="row"><button type="button" id="pwSave" class="primary small-btn">변경</button><span id="pwMsg" class="muted small"></span></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="right">
|
||||
<div class="toolbar">
|
||||
<span class="url" id="siteUrl">아직 생성된 페이지가 없습니다</span>
|
||||
<select id="fileSelect" class="hidden" title="페이지 선택"></select>
|
||||
<button id="btnMobile" class="ghost" disabled title="모바일 화면 크기로 미리보기">모바일 미리보기</button>
|
||||
<button id="btnReload" class="ghost" disabled>새로고침</button>
|
||||
<a id="btnOpen" class="btn ghost disabled" target="_blank" rel="noopener">새 탭에서 열기</a>
|
||||
<button id="btnCopy" class="ghost" disabled>URL 복사</button>
|
||||
<button id="btnToggleSource" class="ghost" disabled title="우측 화면을 위(미리보기)/아래(소스) 로 분할">소스 보기</button>
|
||||
</div>
|
||||
<div class="preview" id="previewArea">
|
||||
<div id="emptyState" class="empty">
|
||||
<div class="empty-inner">
|
||||
<h3>미리보기</h3>
|
||||
<p>왼쪽 <b>만들기</b>에서 사이트를 만들면 이 영역에 결과가 표시됩니다. 만들어 둔 사이트는 왼쪽 레일의 사이트 선택 상자에서 고르고 <b>수정</b>에서 고칩니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.4.15 사용자 첫 화면 페이지: 사이트를 고르기 전 오른쪽 창에만 보이는 관리자 지정 페이지 (app.js showWelcome) -->
|
||||
<div id="welcomeBox" class="welcome-box hidden"><iframe id="welcomeFrame" class="welcome-frame" title="첫 화면" sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"></iframe></div>
|
||||
<div class="frame-wrap" id="frameWrap"><iframe id="frame" class="hidden" title="생성된 페이지 미리보기" sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-modals allow-downloads"></iframe><!-- v1.3.17 (S02): 미리보기는 별도 출처(sandbox, allow-same-origin 없음) — 사이트 스크립트가 빌더 세션·저장소를 쓰지 못한다 --></div>
|
||||
<div id="splitter" class="splitter hidden" title="드래그하여 크기 조절"></div>
|
||||
<div id="editorPane" class="editor hidden">
|
||||
<div class="editor-bar">
|
||||
<select id="editFile" title="편집할 파일"></select>
|
||||
<span id="editState" class="edit-state">저장됨</span>
|
||||
<span class="spacer"></span>
|
||||
<label class="inline"><input type="checkbox" id="liveApply" checked> 편집 즉시 미리보기 반영</label>
|
||||
<button id="btnRevert" class="ghost small-btn">되돌리기</button>
|
||||
<button id="btnSave" class="primary small-btn">파일에 저장 (Ctrl+S)</button>
|
||||
</div>
|
||||
<div class="editor-body">
|
||||
<pre id="lineNos" class="line-nos" aria-hidden="true"></pre>
|
||||
<textarea id="editor" spellcheck="false" wrap="off"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ===== v1.4.14 (요구 2) 관리 탭의 오른쪽 창: 미리보기 대신 "사이트 관리 창" =====
|
||||
위: 사이트 관리자 페이지(회원·게시판·문의·예약 등 모듈 데이터 관리, /site-admin/<ID>/?embed=1) 와 현황 보기 전환
|
||||
아래: 관리자 페이지 iframe 또는 현황 대시보드(접속·저장 공간·문의·모듈·메일·배포 상태). 그리기는 public/manage-ui.js -->
|
||||
<div class="manage-view hidden" id="manageView">
|
||||
<div class="toolbar mv-bar">
|
||||
<span class="url" id="mvTitle">사이트 관리</span>
|
||||
<button type="button" id="mvTabAdmin" class="ghost active" title="회원·게시판·문의·예약 등 모듈 데이터를 관리하는 사이트 관리자 페이지">사이트 관리자</button>
|
||||
<button type="button" id="mvTabStatus" class="ghost" title="사이트 상태·접속 통계 그래프·네트워크 송수신 그래프·디스크 사용량 (v1.7.2)">사이트 정보</button>
|
||||
<button type="button" id="mvReload" class="ghost" disabled>새로고침</button>
|
||||
<a id="mvOpen" class="btn ghost disabled" target="_blank" rel="noopener">새 탭에서 열기</a>
|
||||
</div>
|
||||
<div class="mv-body">
|
||||
<div id="mvEmpty" class="empty"><div class="empty-inner"><h3>사이트 관리</h3><p>왼쪽 레일의 사이트 선택 상자에서 사이트를 고르면 여기에 <b>사이트 관리자 페이지</b>와 <b>사이트 정보</b>(통계·네트워크·디스크 그래프)가 표시됩니다.</p></div></div>
|
||||
<iframe id="mvAdminFrame" class="hidden" title="사이트 관리자 페이지"></iframe>
|
||||
<div id="mvStatus" class="mv-status hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<div id="dropOverlay" class="drop-overlay hidden"><div class="drop-overlay-box" id="dropOverlayText">여기에 파일을 놓으세요</div></div>
|
||||
<dialog id="fbDialog" class="wm-dialog"><form method="dialog"><h2>문의 · 제안 · 오류</h2>
|
||||
<label>종류<select id="fbType"><option value="inquiry">사용 문의</option><option value="suggestion">개선 제안</option><option value="bug">오류 보고</option><option value="request">요구 사항</option></select></label>
|
||||
<label>제목<input id="fbTitle" maxlength="120" placeholder="한 줄 요약"></label>
|
||||
<label>내용 <span class="muted small">(무엇을 하려 했고, 무엇이 기대와 달랐는지)</span><textarea id="fbBody" rows="6" maxlength="5000"></textarea></label>
|
||||
<p class="muted small" id="fbContext"></p>
|
||||
<div class="fb-env-note small">보낼 때 문제 해결을 위해 <b>사용 환경 정보</b>가 함께 전송됩니다: 브라우저·운영체제, 화면 해상도와 창 크기, 화면 배율, 언어, 시간대, 서비스 버전, 현재 탭과 사이트 상태. 비밀번호나 입력 내용은 보내지 않습니다. <button type="button" class="ghost small-btn" id="fbEnvToggle">보낼 정보 보기</button><pre class="hidden" id="fbEnvPreview"></pre></div>
|
||||
<div class="row"><button type="button" class="primary small-btn" id="fbSend">보내기</button><button type="button" class="ghost small-btn" id="fbCancel">취소</button><span class="muted small" id="fbMsg"></span></div>
|
||||
</form></dialog>
|
||||
<dialog id="usageDialog" class="wm-dialog wide"><form method="dialog"><h2>캐시 상세 내역</h2><div id="usageDialogBody"></div><div class="row"><button type="button" class="ghost small-btn" id="usageDialogClose">닫기</button></div></form></dialog>
|
||||
<dialog id="domDialog" class="wm-dialog"><form method="dialog"><h2 id="domDialogTitle">도메인</h2><div id="domDialogBody"></div></form></dialog>
|
||||
<div id="helpTip" class="help-tip hidden" role="tooltip"></div>
|
||||
<dialog id="detailDialog" class="wm-dialog wide"><form method="dialog"><h2 id="ddTitle2">작업 상세 내역</h2><div id="ddBody2"></div><div class="row"><button type="button" class="ghost small-btn" id="ddClose2">닫기</button></div></form></dialog>
|
||||
<script src="/numfmt.js"></script><!-- v1.4.5 숫자 칸 천 단위 쉼표 -->
|
||||
<script src="/i18n.js"></script>
|
||||
<script src="/pwrule.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
<script src="/wizard.js"></script>
|
||||
<script src="/import.js"></script>
|
||||
<script src="/modules-ui.js"></script>
|
||||
<script src="/optimize.js"></script>
|
||||
<script src="/templates-ui.js"></script>
|
||||
<script src="/domains-ui.js"></script>
|
||||
<script src="/manage-ui.js"></script><!-- v1.4.14 관리 탭 오른쪽 사이트 관리 창 -->
|
||||
<script src="/flow-ui.js"></script><!-- v1.6.40 홈(작업 흐름 지도)·레일 상태 배지·배포 점검표 -->
|
||||
<script src="/credits-ui.js"></script><!-- v1.6.40 캐시·충전 화면(선불 충전 상품·주문) -->
|
||||
<script src="/site-info.js"></script><!-- v1.7.2 오른쪽 캔버스 사이트 정보 페이지(그래프) -->
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,110 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>로그인 — AI Webmaker</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<style>
|
||||
body { display: grid; place-items: center; min-height: 100vh; }
|
||||
.auth { width: 380px; max-width: calc(100vw - 32px); }
|
||||
.auth h1 { text-align: center; margin-bottom: 4px; }
|
||||
.auth .sub { text-align: center; margin-bottom: 16px; }
|
||||
.tabs { display: flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; margin-bottom: 14px; }
|
||||
.tabs button { flex: 1; border: 0; border-radius: 0; padding: 9px; background: #fff; color: var(--muted); font-weight: 600; }
|
||||
.tabs button.active { background: var(--accent); color: #fff; }
|
||||
.msg { min-height: 18px; font-size: 12px; color: var(--warn); margin: 6px 0 10px; }
|
||||
.notice { font-size: 13px; background: #fff7ed; border: 1px solid #fed7aa; color: #9a3412; border-radius: 8px; padding: 8px 10px; margin-bottom: 12px; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="/theme.css"><!-- v1.2.0: 관리자 콘솔에서 정한 화면 색상·외형 -->
|
||||
</head>
|
||||
<body>
|
||||
<div class="card auth">
|
||||
<div class="lang-switch small" style="display:none;text-align:right"><select id="langSelect" title="Language"></select></div>
|
||||
<h1>AI Webmaker</h1>
|
||||
<div class="sub">로그인하면 내 사이트만 보이고, 생성·수정 이력이 Gitea 에 보관됩니다.</div>
|
||||
<!-- v1.5.11 (요구 1): 기본 비밀번호(admin/admin)로 로그인한 관리자는 다른 화면으로 가기 전에 여기서 비밀번호를 바꾼다 (server.js 가 /login?change=1 로 보낸다) -->
|
||||
<div class="notice hidden" id="changeNotice">기본 비밀번호로 로그인했습니다. 계속하려면 먼저 새 비밀번호를 정하세요.</div>
|
||||
<div class="tabs" id="tabs">
|
||||
<button id="tabLogin" class="active" type="button">로그인</button>
|
||||
<button id="tabSignup" type="button">회원가입</button>
|
||||
</div>
|
||||
<form id="form" autocomplete="on">
|
||||
<label>아이디
|
||||
<input id="uid" name="username" autocomplete="username" placeholder="영문 소문자·숫자 3~32자" required>
|
||||
</label>
|
||||
<label>비밀번호
|
||||
<input id="upw" name="password" type="password" autocomplete="current-password" placeholder="6자 이상" data-pw-rule="placeholder" required>
|
||||
</label>
|
||||
<label id="nameRow" class="hidden">이름 (회사명)
|
||||
<input id="uname" name="name" autocomplete="organization" placeholder="예: 오즈 그룹">
|
||||
</label>
|
||||
<div class="msg" id="msg"></div>
|
||||
<button id="submit" class="primary" type="submit">로그인</button>
|
||||
</form>
|
||||
<!-- 비밀번호 변경 폼 (mustChangePassword) -->
|
||||
<form id="changeForm" class="hidden" autocomplete="on">
|
||||
<label>현재 비밀번호
|
||||
<input id="cpw" type="password" autocomplete="current-password" required>
|
||||
</label>
|
||||
<label>새 비밀번호
|
||||
<input id="npw" type="password" autocomplete="new-password" placeholder="6자 이상" data-pw-rule="placeholder" required>
|
||||
</label>
|
||||
<label>새 비밀번호 확인
|
||||
<input id="npw2" type="password" autocomplete="new-password" required>
|
||||
</label>
|
||||
<div class="msg" id="changeMsg"></div>
|
||||
<button id="changeSubmit" class="primary" type="submit">비밀번호 바꾸고 시작</button>
|
||||
<p class="muted small" style="text-align:center;margin-top:10px"><a href="#" id="changeLogout">다른 계정으로 로그인</a></p>
|
||||
</form>
|
||||
<p class="muted small" id="signupHint" style="text-align:center;margin-top:12px">계정이 없으면 <a href="#" id="toSignup">회원가입</a>으로 바로 만들 수 있습니다.</p>
|
||||
<p class="muted small hidden" id="signupClosed" style="text-align:center;margin-top:12px">회원가입은 닫혀 있습니다. 관리자에게 계정을 요청하세요.</p>
|
||||
</div>
|
||||
<script src="/i18n.js"></script>
|
||||
<script src="/pwrule.js"></script>
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
let mode = 'login';
|
||||
function setMode(m) {
|
||||
mode = m;
|
||||
$('tabLogin').classList.toggle('active', m === 'login'); $('tabSignup').classList.toggle('active', m === 'signup');
|
||||
$('nameRow').classList.toggle('hidden', m !== 'signup'); $('uname').required = m === 'signup';
|
||||
$('upw').autocomplete = m === 'signup' ? 'new-password' : 'current-password';
|
||||
$('submit').textContent = m === 'signup' ? '회원가입 후 시작' : '로그인'; $('msg').textContent = '';
|
||||
}
|
||||
$('tabLogin').onclick = () => setMode('login'); $('tabSignup').onclick = () => setMode('signup');
|
||||
$('toSignup').onclick = (e) => { e.preventDefault(); setMode('signup'); $('uid').focus(); };
|
||||
/** 로그인 뒤 갈 곳: 관리자는 콘솔, 그 밖은 빌더. 비밀번호를 바꿔야 하는 계정은 변경 폼 */
|
||||
function go(user) { if (user && user.mustChangePassword) return showChange(); location.href = user && user.role === 'admin' ? '/admin' : '/'; }
|
||||
function showChange() { $('changeNotice').classList.remove('hidden'); $('tabs').classList.add('hidden'); $('form').classList.add('hidden'); $('signupHint').classList.add('hidden'); $('signupClosed').classList.add('hidden'); $('changeForm').classList.remove('hidden'); $('cpw').focus(); }
|
||||
$('form').onsubmit = async (e) => {
|
||||
e.preventDefault(); $('submit').disabled = true; $('msg').textContent = '';
|
||||
try {
|
||||
const r = await fetch(mode === 'signup' ? '/api/auth/signup' : '/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: $('uid').value, password: $('upw').value, name: $('uname').value }) });
|
||||
const b = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(b.error || (r.status === 403 && mode === 'signup' ? '회원가입이 닫혀 있습니다 (HTTP 403)' : `HTTP ${r.status}`));
|
||||
// v1.5.27 (요구 6): 관리자 승인 방식이면 가입은 "신청 접수" 로 끝난다 — 세션 없음, 안내만
|
||||
if (r.status === 202 && b.pendingApproval) { $('msg').style.color = 'var(--ok)'; $('msg').textContent = b.message || '가입 신청이 접수되었습니다. 관리자가 승인하면 로그인할 수 있습니다'; setMode('login'); $('msg').textContent = b.message || '가입 신청이 접수되었습니다. 관리자가 승인하면 로그인할 수 있습니다'; $('submit').disabled = false; return; }
|
||||
go(b.user);
|
||||
} catch (err) { $('msg').textContent = err.message; $('submit').disabled = false; }
|
||||
};
|
||||
$('changeForm').onsubmit = async (e) => {
|
||||
e.preventDefault(); $('changeMsg').textContent = '';
|
||||
if ($('npw').value !== $('npw2').value) { $('changeMsg').textContent = '새 비밀번호 확인이 다릅니다'; return; }
|
||||
$('changeSubmit').disabled = true;
|
||||
try {
|
||||
const r = await fetch('/api/auth/password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current: $('cpw').value, next: $('npw').value }) });
|
||||
const b = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(b.error || `HTTP ${r.status}`);
|
||||
location.href = b.user && b.user.role === 'admin' ? '/admin' : '/';
|
||||
} catch (err) { $('changeMsg').textContent = err.message; $('changeSubmit').disabled = false; }
|
||||
};
|
||||
$('changeLogout').onclick = async (e) => { e.preventDefault(); await fetch('/api/auth/logout', { method: 'POST' }); location.href = '/login?direct=1'; };
|
||||
// v1.5.11: ?change=1 (서버가 보낸 경우) 이면 로그인 상태를 확인해 변경 폼을 바로 연다. 회원가입이 닫혀 있으면 탭·안내를 숨긴다
|
||||
(async () => {
|
||||
try { const r = await fetch('/api/auth/info'); const b = await r.json(); if (b && b.signupOpen === false) { $('tabSignup').classList.add('hidden'); $('signupHint').classList.add('hidden'); $('signupClosed').classList.remove('hidden'); } } catch (_) {}
|
||||
if (new URL(location.href).searchParams.get('change') === '1') { try { const r = await fetch('/api/auth/me'); const b = await r.json(); if (r.ok && b.user && b.user.mustChangePassword) showChange(); } catch (_) {} }
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,72 @@
|
||||
'use strict';
|
||||
/* ============================================================================
|
||||
* 관리 탭 오른쪽 "사이트 관리 창" (v1.4.14, 요구 2)
|
||||
*
|
||||
* 관리 탭을 고르면 오른쪽 미리보기(iframe·소스 편집기) 대신 이 창이 보인다. 두 가지 보기:
|
||||
* - 사이트 관리자 : 사이트 관리자 페이지(/site-admin/<ID>/?embed=1)를 iframe 으로 — 회원·게시판·문의·예약·갤러리 등
|
||||
* 모듈 데이터와 사이트 관리자 계정을 여기서 바로 다룬다. 빌더 소유자 세션으로 자동 로그인된다(embed 모드).
|
||||
* - 사이트 현황 : 접속 통계(오늘·7일·누적)·네트워크·저장 공간, 문의 접수 건수, 켜진 모듈, 메일, 배포·발행 상태,
|
||||
* 수정 횟수, 버전 수, 사용 캐시를 한 화면에 요약한다. 자료는 app.js 가 이미 받아 둔 것(statsData·current)과
|
||||
* 문의 목록 응답을 그대로 쓰고, 모듈 상태만 /api/sites/<ID>/modules 로 받는다.
|
||||
*
|
||||
* app.js 와의 연결점(전역): current(현재 사이트), statsData(접속 통계), krwFmt/fmt/fmtBytes(표시), T/TF(조각 번역), api().
|
||||
* - ManageUI.show(site) : 관리 탭이 보일 때 — iframe 을 (아직 안 열었으면) 열고 현황을 그린다
|
||||
* - ManageUI.onSiteOpened(site): 사이트가 바뀌면 iframe 주소를 바꾸고(관리 탭이 보일 때만 바로 로드) 현황을 다시 그린다
|
||||
* - ManageUI.onStats(st) / onInquiries(r): 통계·문의 자료가 갱신되면 현황 숫자만 다시 그린다
|
||||
* ========================================================================== */
|
||||
(() => {
|
||||
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; };
|
||||
let view = 'admin'; // 'admin' | 'info' — 마지막으로 고른 보기 (localStorage 에 기억). v1.7.2: 'status'(현황) 는 '사이트 정보'(info) 페이지로 합쳐졌다
|
||||
let frameSiteId = null; // iframe 에 지금 열려 있는 사이트 ID (사이트가 바뀌었을 때만 다시 로드)
|
||||
let inquiries = null; // 마지막 문의 목록 응답 (renderInquiries 가 넘겨 준다)
|
||||
let modules = null; // { enabled, counts, catalog } — 모듈 상태 (사이트마다 한 번 받는다)
|
||||
let modulesFor = null; // modules 를 받은 사이트 ID
|
||||
try { view = ['status', 'info'].includes(localStorage.getItem('manageView')) ? 'info' : 'admin'; } catch (_) {}
|
||||
|
||||
/** 지금 관리 탭이 화면에 보이는지 (보이지 않을 때는 iframe 을 미리 로드하지 않아 불필요한 요청을 막는다) */
|
||||
const visible = () => !$('manageView').classList.contains('hidden');
|
||||
|
||||
/** 보기 전환 버튼 상태와 본문(iframe / 현황 / 빈 안내) 표시 */
|
||||
function layout() {
|
||||
const site = current;
|
||||
$('mvTabAdmin').classList.toggle('active', view === 'admin'); $('mvTabStatus').classList.toggle('active', view === 'info');
|
||||
$('mvEmpty').classList.toggle('hidden', !!site);
|
||||
$('mvAdminFrame').classList.toggle('hidden', !site || view !== 'admin');
|
||||
$('mvStatus').classList.toggle('hidden', !site || view !== 'info');
|
||||
$('mvReload').disabled = !site; $('mvOpen').classList.toggle('disabled', !site);
|
||||
$('mvTitle').textContent = site ? `${T('사이트 관리')} · ${site.title}` : T('사이트 관리');
|
||||
if (site) $('mvOpen').href = view === 'admin' ? `/site-admin/${site.id}/` : (site.liveUrl || site.url);
|
||||
}
|
||||
/** 사이트 관리자 페이지 iframe 로드 (사이트가 바뀌었거나 강제 새로고침일 때만) */
|
||||
function loadFrame(force) {
|
||||
const site = current; const f = $('mvAdminFrame'); if (!site) { f.removeAttribute('src'); frameSiteId = null; return; }
|
||||
if (!force && frameSiteId === site.id && f.getAttribute('src')) return;
|
||||
frameSiteId = site.id; f.src = `/site-admin/${site.id}/?embed=1`;
|
||||
}
|
||||
/** 모듈 상태를 받아 현황에 쓴다 (사이트마다 한 번, 새로고침 때 다시) */
|
||||
async function loadModules(force) {
|
||||
const site = current; if (!site) { modules = null; modulesFor = null; return; }
|
||||
if (!force && modulesFor === site.id && modules) return;
|
||||
try { modules = await api(`/api/sites/${site.id}/modules`); modulesFor = site.id; } catch (_) { modules = null; modulesFor = site.id; }
|
||||
}
|
||||
|
||||
/**
|
||||
* 사이트 정보 페이지 (v1.7.2 요구 2): 그리기는 public/site-info.js 가 맡는다 — 상태 타일·접속 통계 그래프·네트워크 선/막대 그래프·디스크 사용량·페이지/유입.
|
||||
* 1.6.40 까지의 "사이트 현황" 요약은 이 페이지의 맨 위 타일로 들어갔다.
|
||||
*/
|
||||
function renderStatus() { const root = $('mvStatus'); if (!current) { root.innerHTML = ''; return; } if (window.SiteInfoUI) SiteInfoUI.render(root, { site: current, stats: typeof statsData !== 'undefined' ? statsData : null, inquiries, modules }); }
|
||||
|
||||
function show(site) { layout(); if (!site) return; if (view === 'admin') loadFrame(false); else { loadModules(false).then(renderStatus); renderStatus(); } }
|
||||
function setView(v) { view = v; try { localStorage.setItem('manageView', v); } catch (_) {} show(current); }
|
||||
$('mvTabAdmin').onclick = () => setView('admin'); $('mvTabStatus').onclick = () => setView('info');
|
||||
$('mvReload').onclick = () => { if (!current) return; if (view === 'admin') loadFrame(true); else { loadModules(true).then(renderStatus); if (typeof loadStats === 'function') loadStats(); if (typeof renderInquiries === 'function') renderInquiries(); } };
|
||||
|
||||
window.ManageUI = {
|
||||
show,
|
||||
onSiteOpened(site) { if (!site || site.id !== frameSiteId) { frameSiteId = null; $('mvAdminFrame').removeAttribute('src'); } if (!site || site.id !== modulesFor) { modules = null; modulesFor = null; inquiries = null; } if (visible()) show(site); else layout(); },
|
||||
onStats() { if (visible() && view === 'info') renderStatus(); },
|
||||
onInquiries(r) { inquiries = r; if (visible() && view === 'info') renderStatus(); },
|
||||
/** v1.7.2: 가운데 칸의 "사이트 정보" 요약 카드에서 '자세히 보기' → 운영 화면 + 사이트 정보 페이지 */
|
||||
openInfo() { setView('info'); },
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,22 @@
|
||||
'use strict';
|
||||
/* 빌더 관리 탭 "사이트 기능 모듈" 카드 (v1.0.3)
|
||||
* 모듈 켜기/끄기와 사이트 관리자 페이지 열기(새 창). 세부 설정·데이터 관리는 사이트 관리자 페이지에서 한다.
|
||||
* v1.4.14: 카드 안에 내장하던 iframe("여기서 관리")은 없앴다 — 관리 탭의 오른쪽 창(public/manage-ui.js)이 관리자 페이지를 항상 보여 준다. */
|
||||
(() => {
|
||||
let data = null;
|
||||
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; };
|
||||
async function load() {
|
||||
if (!current) return; $('moduleCard').classList.remove('hidden');
|
||||
try { data = await api(`/api/sites/${current.id}/modules`); } catch (e) { $('moduleList').textContent = '모듈 정보를 불러오지 못했습니다: ' + e.message; return; }
|
||||
const list = $('moduleList'); list.innerHTML = '';
|
||||
for (const c of data.catalog) {
|
||||
const row = el('div', 'module-row'); const sw = el('button', 'switch' + (data.enabled[c.id] ? ' on' : '')); sw.type = 'button'; sw.title = data.enabled[c.id] ? '끄기' : '켜기';
|
||||
sw.onclick = async () => { try { const r = await api(`/api/sites/${current.id}/modules/enabled`, { method: 'PUT', body: JSON.stringify({ module: c.id, on: !data.enabled[c.id] }) }); data.enabled = r.enabled; sw.classList.toggle('on', !!data.enabled[c.id]); if (data.enabled[c.id]) logLine({ level: 'warn', text: `${c.label} 모듈을 켰습니다. 수정 지시에 "${c.label}을(를) 추가해 주세요"라고 요청하면 페이지에 들어갑니다. 세부 설정은 사이트 관리자 페이지에서 합니다.` }); } catch (e) { alert(e.message); } };
|
||||
const t = el('div'); t.appendChild(el('div', 'mt', T(c.label) + (data.counts && c.collections && Object.keys(c.collections).length ? ` · ${Object.keys(c.collections).map((k) => `${T(c.collections[k].label)} ${data.counts[k] || 0}`).join(', ')}` : ''))); t.appendChild(el('div', 'md', c.desc));
|
||||
row.appendChild(sw); row.appendChild(t); const open = el('a', 'ghost small-btn', '관리'); open.href = `/site-admin/${current.id}/`; open.target = '_blank'; open.rel = 'noopener'; open.style.visibility = data.enabled[c.id] ? '' : 'hidden'; row.appendChild(open); list.appendChild(row);
|
||||
}
|
||||
$('btnSiteAdmin').href = `/site-admin/${current.id}/`;
|
||||
}
|
||||
$('btnModuleReload').onclick = load;
|
||||
window.ModulesUI = { onSiteOpened: (site) => { if (site && (site.mode !== 'import' || site.source)) load(); } };
|
||||
})();
|
||||
@@ -0,0 +1,54 @@
|
||||
'use strict';
|
||||
/* ============================================================================
|
||||
* 숫자 입력 칸 천 단위 쉼표 (v1.4.5)
|
||||
*
|
||||
* 화면의 모든 숫자 입력 칸(input type=number)을 "12,000" 처럼 3자리마다 쉼표가 보이는 칸으로 바꾼다.
|
||||
* - 브라우저의 number 칸은 쉼표를 넣을 수 없어, 글자 칸(type=text, inputmode=numeric/decimal)으로 바꾸고 입력할 때마다 쉼표를 다시 넣는다.
|
||||
* - 기존 화면 코드(admin.js 등)는 input.value 로 값을 읽어 서버에 보낸다. 그 코드를 모두 고치지 않도록 이 칸들의 value 를
|
||||
* "쉼표를 뺀 숫자 문자열" 로 돌려주고, value 에 넣는 값은 쉼표를 붙여 보이게 한다(요소별 value 속성 재정의).
|
||||
* → 서버로 가는 값·검증(최소·최대)·저장하지 않은 변경 표시(data-dirty-watch)는 이전과 같다.
|
||||
* - 나중에 만들어지는 칸(사이트 관리자 입력 창, 도메인 레코드 등)도 MutationObserver 로 따라가 바꾼다.
|
||||
* - 쉼표가 어울리지 않는 칸(포트 번호 등)은 data-plain 속성을 붙이면 그대로 둔다.
|
||||
* 전역: window.WMNum = { format(값), clean(문자열), enhance(입력칸) }
|
||||
* ========================================================================== */
|
||||
(function () {
|
||||
var desc = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value');
|
||||
/** 화면에 보일 모양: 정수부 3자리마다 쉼표, 소수부·음수 부호는 그대로 */
|
||||
function format(raw) {
|
||||
raw = clean(raw); if (raw === '' || raw === '-' || raw === '.') return raw;
|
||||
var neg = raw.charAt(0) === '-'; var s = neg ? raw.slice(1) : raw; var dot = s.indexOf('.');
|
||||
var int = dot < 0 ? s : s.slice(0, dot); var frac = dot < 0 ? '' : s.slice(dot);
|
||||
int = int.replace(/^0+(?=\d)/, '').replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
return (neg ? '-' : '') + int + frac;
|
||||
}
|
||||
/** 쉼표·공백·숫자 아닌 글자를 뺀 값 (서버로 보낼 값). 부호는 맨 앞, 소수점은 하나만 */
|
||||
function clean(v) {
|
||||
var s = String(v == null ? '' : v).replace(/[^\d.\-]/g, ''); var neg = s.charAt(0) === '-'; s = s.replace(/-/g, '');
|
||||
var i = s.indexOf('.'); if (i >= 0) s = s.slice(0, i + 1) + s.slice(i + 1).replace(/\./g, '');
|
||||
return (neg ? '-' : '') + s;
|
||||
}
|
||||
/** 입력 중 쉼표를 다시 넣어도 커서가 같은 숫자 뒤에 머물도록 위치를 옮긴다 */
|
||||
function reformat(inp) {
|
||||
var old = desc.get.call(inp); var pos = inp.selectionStart == null ? old.length : inp.selectionStart;
|
||||
var keep = old.slice(0, pos).replace(/[^\d.\-]/g, '').length; // 커서 앞의 의미 있는 글자 수
|
||||
var next = format(old); if (next === old) return; desc.set.call(inp, next);
|
||||
var p = 0, n = 0; while (p < next.length && n < keep) { if (/[\d.\-]/.test(next.charAt(p))) n++; p++; }
|
||||
try { inp.setSelectionRange(p, p); } catch (_) {}
|
||||
}
|
||||
function enhance(inp) {
|
||||
if (!inp || inp.__wmNum || inp.tagName !== 'INPUT' || inp.type !== 'number' || inp.hasAttribute('data-plain')) return;
|
||||
inp.__wmNum = true; var cur = desc.get.call(inp); var step = inp.getAttribute('step');
|
||||
inp.type = 'text'; inp.setAttribute('inputmode', step && step !== 'any' && Number(step) % 1 === 0 && !(Number(inp.getAttribute('min')) < 0) ? 'numeric' : 'decimal');
|
||||
inp.setAttribute('autocomplete', 'off'); inp.classList.add('num-input');
|
||||
Object.defineProperty(inp, 'value', { configurable: true, get: function () { return clean(desc.get.call(this)); }, set: function (v) { desc.set.call(this, format(v)); } });
|
||||
inp.value = cur; inp.addEventListener('input', function () { reformat(inp); });
|
||||
}
|
||||
function scan(root) { if (!root || !root.querySelectorAll) return; if (root.tagName === 'INPUT') enhance(root); var l = root.querySelectorAll('input[type="number"]'); for (var i = 0; i < l.length; i++) enhance(l[i]); }
|
||||
function start() {
|
||||
scan(document.body);
|
||||
new MutationObserver(function (ms) { for (var i = 0; i < ms.length; i++) { var m = ms[i]; if (m.type === 'attributes') enhance(m.target); else for (var j = 0; j < m.addedNodes.length; j++) scan(m.addedNodes[j]); } })
|
||||
.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['type'] });
|
||||
}
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start); else start();
|
||||
window.WMNum = { format: format, clean: clean, enhance: enhance };
|
||||
})();
|
||||
@@ -0,0 +1,273 @@
|
||||
'use strict';
|
||||
/* ============================================================================
|
||||
* 관리 탭: 검색·AI 최적화(SEO·AEO·GEO) 검토·반영, 검색사이트 등록 (v1.3.5)
|
||||
*
|
||||
* 검색·AI 최적화 카드 (#optimizeCard)
|
||||
* - 생성 옵션으로 고른 항목은 생성이 끝나면 AI 가 초안을 만든다(GET/POST /api/sites/<id>/optimize[/draft]).
|
||||
* - 사용자가 초안의 값을 고치고 항목별 "반영" 체크를 정한 뒤 "검토한 내용 반영" 을 누르면(POST …/optimize/apply)
|
||||
* 서버가 HTML <head>·FAQ 영역·sitemap.xml·robots.txt·llms.txt 에 넣고 버전을 저장한다.
|
||||
* - 각 입력에 작성 기준(글자 수 등)과 현재 글자 수를 보여 줘 검토를 돕는다.
|
||||
* 검색사이트 등록 카드 (#searchCard)
|
||||
* - 배포가 끝난 사이트만. 구글·네이버: 확인 코드 입력 → 태그 넣고 재배포 → 검색사이트에서 확인 → 사이트맵 제출.
|
||||
* 사람이 해야 하는 단계는 체크리스트로 보이며 완료 표시를 할 수 있다(관리자 콘솔 "검색 등록" 탭에서도 진행 가능).
|
||||
* - IndexNow: 키 파일을 올리고 빙·네이버 등에 색인 요청.
|
||||
* 전역: window.OptimizeUI.onSiteOpened(site) — app.js 가 사이트를 열거나 작업이 끝날 때 부른다.
|
||||
* ========================================================================== */
|
||||
(() => {
|
||||
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; };
|
||||
const btn = (label, cls, fn) => { const b = el('button', cls || 'ghost small-btn', label); b.type = 'button'; b.onclick = fn; return b; };
|
||||
let siteId = null; let ov = null; let ed = null; // ov: 서버 상태, ed: 편집 중인 값 (draft 또는 final 의 복사본)
|
||||
// v1.3.10 다국어: edAll[언어] = 언어별 편집 값, curLang = 지금 보는 언어 탭 (단일 언어 사이트는 edAll 없음)
|
||||
let edAll = null; let curLang = null;
|
||||
const LABEL = { none: '사용 안 함', waiting: '생성 후 초안 예정', drafting: '초안 만드는 중', draft: '검토 대기', applied: '반영됨', error: '초안 실패' };
|
||||
|
||||
// ---------- 검색·AI 최적화 ----------
|
||||
async function loadOptimize() {
|
||||
try { ov = await api(`/api/sites/${siteId}/optimize`); } catch (_) { return; }
|
||||
const st = ov.status; $('optState').textContent = LABEL[st] || st; $('optState').className = 'pill small ' + (st === 'applied' ? 'done' : st === 'draft' ? 'waiting' : st === 'drafting' || st === 'waiting' ? 'running' : st === 'error' ? 'error' : 'idle');
|
||||
const box = $('optBody'); box.innerHTML = '';
|
||||
if (st === 'drafting' || st === 'waiting') { box.appendChild(el('p', 'muted small', st === 'waiting' ? '사이트 생성·검증이 끝나면 AI 가 초안을 만듭니다.' : 'AI 가 사이트 페이지를 읽고 초안을 만들고 있습니다. 끝나면 여기서 검토할 수 있습니다.')); return; }
|
||||
if (st === 'none' || st === 'error' || (!ov.draft && !ov.final)) { renderStarter(box, st === 'error' ? ov.error : null); return; }
|
||||
ed = JSON.parse(JSON.stringify(ov.applied && ov.final ? ov.final : ov.draft));
|
||||
edAll = null;
|
||||
if (ov.langs) {
|
||||
// 언어별 편집 값: 기본 언어는 위의 ed, 다른 언어는 반영 값 → 없으면 그 언어 초안
|
||||
edAll = {}; for (const l of ov.langs) { const src = l.isDefault ? ed : ((ov.applied && ov.langFinals[l.code]) || ov.langDrafts[l.code] || null); edAll[l.code] = l.isDefault ? ed : (src ? JSON.parse(JSON.stringify(src)) : null); }
|
||||
if (!curLang || !edAll.hasOwnProperty(curLang)) curLang = ov.langs[0].code;
|
||||
}
|
||||
renderLangTabs(box);
|
||||
}
|
||||
/** v1.3.10 언어 탭 (다국어 사이트): 탭마다 그 언어 폴더(/en/ 등)에 들어갈 값을 따로 검토한다 */
|
||||
function renderLangTabs(box) {
|
||||
box.innerHTML = '';
|
||||
if (!edAll) { renderEditor(box); return; }
|
||||
const tabs = el('div', 'opt-langs');
|
||||
for (const l of ov.langs) { const b = btn(`${l.name}${l.isDefault ? ' · ' + T('기본') : ''}`, 'ghost small-btn' + (curLang === l.code ? ' on' : ''), () => { curLang = l.code; renderLangTabs(box); }); b.lang = l.code; tabs.appendChild(b); }
|
||||
box.appendChild(tabs);
|
||||
const cur = ov.langs.find((l) => l.code === curLang);
|
||||
box.appendChild(el('p', 'muted small', TF('{1} 페이지(/{2}/)에 들어갈 값입니다. 언어마다 따로 검토하고, 아래 "반영" 은 모든 언어를 함께 반영합니다.', `${cur.name} (${T(cur.label)})`, cur.code)));
|
||||
if (!edAll[curLang]) { box.appendChild(el('p', 'msg err small', ov.langErrors && ov.langErrors[curLang] ? TF('이 언어 초안을 만들지 못했습니다: {1}', ov.langErrors[curLang]) : T('이 언어 초안이 아직 없습니다. "AI 로 다시 만들기" 로 만드세요.'))); renderEditor(box, true); return; }
|
||||
ed = edAll[curLang]; renderEditor(box);
|
||||
// 값 입력칸만 그 언어의 글 방향·언어로 (아랍어 등은 오른쪽→왼쪽). 안내 문구·버튼은 화면 언어 그대로
|
||||
box.querySelectorAll('.opt-field input, .opt-field textarea').forEach((i) => { i.dir = cur.dir; i.lang = cur.code; });
|
||||
}
|
||||
/** 초안이 없을 때: 항목 고르고 AI 초안 만들기 */
|
||||
function renderStarter(box, err) {
|
||||
if (err) box.appendChild(el('p', 'msg err small', '초안을 만들지 못했습니다: ' + err));
|
||||
box.appendChild(el('p', 'muted small', '검색엔진(SEO)·AI 답변 엔진(AEO)·생성형 AI 검색(GEO)에 맞는 값을 AI 가 사이트 내용으로 초안을 만들고, 검토 후 반영합니다. (기본 등급, 캐시 차감)'));
|
||||
const row = el('div', 'row'); const ck = {};
|
||||
for (const [k, l] of [['seo', 'SEO'], ['aeo', 'AEO'], ['geo', 'GEO']]) { const lab = el('label', 'chk'); const c = el('input'); c.type = 'checkbox'; c.checked = ov.wanted[k] !== false; ck[k] = c; lab.appendChild(c); lab.appendChild(document.createTextNode(' ' + l)); row.appendChild(lab); }
|
||||
row.appendChild(btn('AI 로 초안 만들기', 'primary small-btn', async () => { try { await api(`/api/sites/${siteId}/optimize/draft`, { method: 'POST', body: JSON.stringify({ wanted: { seo: ck.seo.checked, aeo: ck.aeo.checked, geo: ck.geo.checked } }) }); setTimeout(loadOptimize, 400); } catch (e) { alert(e.message); } }));
|
||||
box.appendChild(row);
|
||||
}
|
||||
/** 글자 수 안내가 붙은 입력. rule: [권장 최소, 권장 최대, 최대] */
|
||||
function field(label, obj, key, opts = {}) {
|
||||
const wrap = el('label', 'opt-field'); const head = el('span', '', label); wrap.appendChild(head);
|
||||
const inp = opts.area ? el('textarea') : el('input'); if (opts.area) inp.rows = opts.rows || 3; inp.value = Array.isArray(obj[key]) ? obj[key].join(opts.sep || ', ') : (obj[key] || ''); if (opts.placeholder) inp.placeholder = opts.placeholder;
|
||||
const cnt = opts.rule ? el('span', 'opt-count') : null; if (cnt) head.appendChild(cnt);
|
||||
const upd = () => { const v = inp.value; obj[key] = opts.list ? v.split(opts.sep === '\n' ? /\n/ : /,/).map((x) => x.trim()).filter(Boolean) : v; if (cnt) { const n = v.trim().length; const [lo, hi, max] = opts.rule; cnt.textContent = ' ' + (max ? TF('{1}자 (권장 {2}~{3}, 최대 {4})', n, lo, hi, max) : TF('{1}자 (권장 {2}~{3})', n, lo, hi)); /* v1.3.10: 조각 번역(TF) — 값이 인접한 문장은 화면에서 나눠 맞출 수 없음 */ cnt.className = 'opt-count ' + (n > (max || hi) ? 'bad' : n < lo || n > hi ? 'warn' : 'ok'); } };
|
||||
inp.oninput = upd; upd(); wrap.appendChild(inp); return wrap;
|
||||
}
|
||||
const useBox = (obj, key, label) => { const lab = el('label', 'chk'); const c = el('input'); c.type = 'checkbox'; c.checked = obj[key] !== false; c.onchange = () => { obj[key] = c.checked; }; lab.appendChild(c); lab.appendChild(document.createTextNode(' ' + (label || '반영'))); return lab; };
|
||||
function section(box, title, help) { const d = el('details', 'opt-sec'); d.open = true; const s = el('summary', '', title); d.appendChild(s); if (help) d.appendChild(el('p', 'muted small', help)); box.appendChild(d); return d; }
|
||||
/** 초안(또는 반영값) 편집 화면 */
|
||||
function renderEditor(box, buttonsOnly) {
|
||||
if (!buttonsOnly) box.appendChild(el('p', 'muted small', ov.applied ? TF('반영됨 ({1}). 값을 고친 뒤 다시 반영할 수 있습니다.', new Date(ov.appliedAt).toLocaleString(uiLocale())) + (ov.liveBase ? '' : ' ' + T('운영 주소가 생기면(배포) canonical·사이트맵 주소가 자동으로 채워집니다.')) : TF('AI 초안입니다 ({1}).', ov.draftAt ? new Date(ov.draftAt).toLocaleString(uiLocale()) : '') + ' ' + T(' 내용을 확인·수정하고 반영할 항목만 체크한 뒤 "검토한 내용 반영" 을 누르세요. 반영 전에는 사이트에 들어가지 않습니다.')));
|
||||
if (!buttonsOnly && ed.seo) {
|
||||
const d = section(box, 'SEO — 검색 결과 제목·설명·키워드', '제목은 핵심어를 앞쪽에, 페이지마다 다르게. 설명은 페이지 내용을 요약하고 행동을 유도합니다. 과장·키워드 나열은 피하세요.');
|
||||
const two = el('div', 'two'); two.appendChild(field('사이트 이름', ed.seo, 'siteName')); two.appendChild(field('사이트 핵심 검색어 (쉼표)', ed.seo, 'keywords', { list: true })); d.appendChild(two);
|
||||
for (const pg of ed.seo.pages) {
|
||||
const card = el('div', 'opt-page'); const h = el('div', 'opt-page-head'); h.appendChild(el('b', '', pg.file)); h.appendChild(useBox(pg, 'use')); card.appendChild(h);
|
||||
card.appendChild(field('검색 제목 (title)', pg, 'title', { rule: [25, 35, 60] }));
|
||||
card.appendChild(field('검색 설명 (meta description)', pg, 'description', { area: true, rows: 2, rule: [70, 110, 155] }));
|
||||
card.appendChild(field('페이지 검색어 (쉼표)', pg, 'keywords', { list: true }));
|
||||
const more = el('details', 'opt-more'); more.appendChild(el('summary', '', '공유 미리보기 (SNS)·h1'));
|
||||
more.appendChild(field('공유 제목 (og:title)', pg, 'ogTitle', { rule: [10, 40, 90] })); more.appendChild(field('공유 설명 (og:description)', pg, 'ogDescription', { area: true, rows: 2, rule: [40, 100, 200] }));
|
||||
more.appendChild(field('h1 제안 (참고용 — 본문은 바꾸지 않음)', pg, 'h1')); card.appendChild(more); d.appendChild(card);
|
||||
}
|
||||
}
|
||||
if (!buttonsOnly && ed.aeo) {
|
||||
const d = section(box, 'AEO — 질문형 FAQ·핵심 답변', '질문은 실제 고객이 검색할 문장으로, 답은 첫 문장에 바로 답하고 2~4문장 이내로. "사이트에 있음" 이 아닌 질문은 반영 시 해당 페이지에 "자주 묻는 질문" 영역으로 추가됩니다.');
|
||||
d.appendChild(field('핵심 답변 요약 (AI·음성 답변용)', ed.aeo, 'summary', { area: true, rows: 2, rule: [60, 200, 400] }));
|
||||
const list = el('div'); d.appendChild(list);
|
||||
const drawFaqs = () => {
|
||||
list.innerHTML = '';
|
||||
ed.aeo.faqs.forEach((f, i) => {
|
||||
const card = el('div', 'opt-page'); const h = el('div', 'opt-page-head'); h.appendChild(el('b', '', `질문 ${i + 1}`)); h.appendChild(useBox(f, 'use'));
|
||||
const onp = el('label', 'chk'); const oc = el('input'); oc.type = 'checkbox'; oc.checked = !!f.onPage; oc.onchange = () => { f.onPage = oc.checked; }; onp.appendChild(oc); onp.appendChild(document.createTextNode(' 사이트에 이미 있음')); h.appendChild(onp);
|
||||
const sel = el('select'); sel.style.width = 'auto'; sel.style.margin = '0'; for (const fl of ov.files) sel.appendChild(new Option(fl, fl)); sel.value = f.page; sel.onchange = () => { f.page = sel.value; }; h.appendChild(sel);
|
||||
h.appendChild(btn('삭제', 'ghost small-btn', () => { ed.aeo.faqs.splice(i, 1); drawFaqs(); })); card.appendChild(h);
|
||||
card.appendChild(field('질문', f, 'q', { rule: [8, 40, 200] })); card.appendChild(field('답변', f, 'a', { area: true, rows: 3, rule: [40, 200, 800] })); list.appendChild(card);
|
||||
});
|
||||
list.appendChild(btn('+ 질문 추가', 'ghost small-btn', () => { ed.aeo.faqs.push({ q: '', a: '', page: ov.files[0] || 'index.html', onPage: false, use: true }); drawFaqs(); }));
|
||||
};
|
||||
drawFaqs();
|
||||
}
|
||||
if (!buttonsOnly && ed.geo) {
|
||||
const d = section(box, 'GEO — 사업체 정보·인용용 사실·llms.txt', '생성형 AI 검색이 사업체를 정확히 알아보고 인용하도록 합니다. 사실과 다른 값은 지우세요(모르는 값은 비워 둡니다).');
|
||||
const e = ed.geo.entity; const h = el('div', 'opt-page-head'); h.appendChild(el('b', '', '사업체 정보 (구조화 데이터)')); h.appendChild(useBox(ed.geo, 'useEntity')); d.appendChild(h);
|
||||
const g1 = el('div', 'two'); const tsel = el('label', 'opt-field'); tsel.appendChild(el('span', '', '유형')); const ts = el('select'); for (const t of ['Organization', 'LocalBusiness', 'Restaurant', 'Store', 'ProfessionalService', 'MedicalClinic', 'EducationalOrganization']) ts.appendChild(new Option(t, t)); ts.value = e.type; ts.onchange = () => { e.type = ts.value; }; tsel.appendChild(ts); g1.appendChild(tsel); g1.appendChild(field('이름', e, 'name')); d.appendChild(g1);
|
||||
d.appendChild(field('소개', e, 'description', { area: true, rows: 2 }));
|
||||
const g2 = el('div', 'two'); g2.appendChild(field('전화', e, 'telephone')); g2.appendChild(field('이메일', e, 'email')); d.appendChild(g2);
|
||||
const g3 = el('div', 'two'); g3.appendChild(field('주소 (도로명)', e.address, 'streetAddress')); g3.appendChild(field('시·군·구', e.address, 'addressLocality')); g3.appendChild(field('시·도', e.address, 'addressRegion')); g3.appendChild(field('우편번호', e.address, 'postalCode')); d.appendChild(g3);
|
||||
const g4 = el('div', 'two'); g4.appendChild(field('영업시간 (쉼표, 예: Mo-Fr 09:00-18:00)', e, 'openingHours', { list: true })); g4.appendChild(field('가격대', e, 'priceRange')); d.appendChild(g4);
|
||||
d.appendChild(field('공식 SNS·지도 주소 (한 줄에 하나)', e, 'sameAs', { area: true, rows: 2, list: true, sep: '\n' }));
|
||||
d.appendChild(field('인용용 핵심 사실 (한 줄에 하나)', ed.geo, 'facts', { area: true, rows: 4, list: true, sep: '\n' }));
|
||||
const h2 = el('div', 'opt-page-head'); h2.appendChild(el('b', '', 'llms.txt (AI 크롤러용 사이트 요약)')); h2.appendChild(useBox(ed.geo, 'useLlms')); d.appendChild(h2);
|
||||
const ll = field('', ed.geo, 'llmsTxt', { area: true, rows: 8 }); ll.querySelector('textarea').classList.add('mono'); d.appendChild(ll);
|
||||
}
|
||||
const row = el('div', 'row'); const msg = el('span', 'muted small');
|
||||
row.appendChild(btn(ov.applied ? '고친 내용 다시 반영' : '검토한 내용 반영', 'primary small-btn', async () => {
|
||||
msg.textContent = T('반영 중…');
|
||||
// 다국어: 기본 언어 값은 final, 다른 언어는 langs[언어] 로 함께 보낸다
|
||||
const body = edAll ? { final: edAll[ov.langs[0].code], langs: Object.fromEntries(ov.langs.slice(1).filter((l) => edAll[l.code]).map((l) => [l.code, edAll[l.code]])) } : { final: ed };
|
||||
try { const r = await api(`/api/sites/${siteId}/optimize/apply`, { method: 'POST', body: JSON.stringify(body) }); msg.textContent = TF('반영했습니다 — 바뀐 파일 {1}개.', r.changed.length) + (r.changed.length ? ` (${r.changed.slice(0, 5).join(', ')}${r.changed.length > 5 ? ' …' : ''})` : '') + ' ' + T('운영 사이트에는 다음 배포 때 올라갑니다.'); if (typeof current !== 'undefined' && current && r.site) Object.assign(current, r.site); /* app.js 의 현재 사이트(전역 let) */ if (typeof loadFrame === 'function') loadFrame(); setTimeout(loadOptimize, 1500); }
|
||||
catch (e) { msg.textContent = e.message; }
|
||||
}));
|
||||
row.appendChild(btn('AI 로 다시 만들기', 'ghost small-btn', async () => { if (!confirm('AI 초안을 다시 만듭니다(캐시 차감). 지금 고친 값은 반영하지 않으면 사라집니다. 계속할까요?')) return; try { await api(`/api/sites/${siteId}/optimize/draft`, { method: 'POST', body: JSON.stringify({ wanted: ov.wanted && (ov.wanted.seo || ov.wanted.aeo || ov.wanted.geo) ? ov.wanted : { seo: !!ed.seo, aeo: !!ed.aeo, geo: !!ed.geo } }) }); setTimeout(loadOptimize, 400); } catch (e) { alert(e.message); } }));
|
||||
if (ov.applied) row.appendChild(btn('반영한 값 빼기', 'ghost small-btn danger', async () => { if (!confirm('사이트에 넣은 검색·AI 최적화 값(관리 구역·FAQ 영역·자동 생성 파일)을 뺍니다. 초안은 남습니다. 계속할까요?')) return; try { const r = await api(`/api/sites/${siteId}/optimize/remove`, { method: 'POST', body: '{}' }); msg.textContent = TF('뺐습니다 — 바뀐 파일 {1}개', r.changed.length); if (typeof loadFrame === 'function') loadFrame(); loadOptimize(); } catch (e) { alert(e.message); } }));
|
||||
row.appendChild(msg); box.appendChild(row);
|
||||
}
|
||||
|
||||
// ---------- 검색사이트 등록 ----------
|
||||
async function loadSearch() {
|
||||
let v; try { v = await api(`/api/sites/${siteId}/search`); } catch (_) { return; }
|
||||
const box = $('searchBody'); box.innerHTML = '';
|
||||
if (!v.availability.ok) { box.appendChild(el('p', 'muted small', v.availability.reason)); return; }
|
||||
box.appendChild(el('p', 'muted small', TF('운영 주소 {1} 기준으로 등록합니다.', v.liveUrl) + (v.seoApplied ? '' : ' ' + T('검색·AI 최적화 값을 먼저 반영하면 검색 결과 제목·설명이 더 좋아집니다.'))));
|
||||
for (const e of v.engines) box.appendChild(engineBlock(e));
|
||||
// IndexNow
|
||||
const ix = el('div', 'reg-engine'); ix.appendChild(el('b', '', 'IndexNow (빙·네이버 등 색인 요청)'));
|
||||
ix.appendChild(el('p', 'muted small', v.indexnow && v.indexnow.last ? TF('마지막 요청 {1}: {2}', new Date(v.indexnow.lastPingAt).toLocaleString(uiLocale()), T(v.indexnow.last.detail)) : '사이트에 키 파일을 올리고(재배포) 모든 페이지의 색인을 요청합니다. 페이지를 고친 뒤에도 다시 보낼 수 있습니다.'));
|
||||
ix.appendChild(btn('색인 요청 보내기', 'ghost small-btn', () => act('indexnow', {})));
|
||||
box.appendChild(ix);
|
||||
}
|
||||
/** 검색엔진 한 개의 등록 단계 표시 */
|
||||
function engineBlock(e) {
|
||||
const d = el('div', 'reg-engine'); const head = el('div', 'opt-page-head'); head.appendChild(el('b', '', e.label)); head.appendChild(el('span', 'pill small ' + (e.state === '등록 완료' ? 'done' : e.started ? 'waiting' : 'idle'), e.state)); d.appendChild(head);
|
||||
if (!e.started) {
|
||||
const row = el('div', 'row');
|
||||
if (e.engine === 'google' && e.autoAvailable) { d.appendChild(el('p', 'muted small', T('구글 API 로 확인 코드 받기 → 태그 넣고 재배포 → 소유 확인 → Search Console 등록·사이트맵 제출까지 모두 자동으로 진행합니다.'))); row.appendChild(btn('자동 등록 시작', 'primary small-btn', () => act('start', { engine: e.engine, method: 'meta' }))); } /* v1.3.10 */
|
||||
else if (e.engine === 'google') { const sel = el('select'); sel.style.width = 'auto'; sel.style.margin = '0'; sel.appendChild(new Option('HTML 태그로 확인 (권장)', 'meta')); sel.appendChild(new Option('도메인 DNS(TXT)로 확인', 'dns')); row.appendChild(sel); row.appendChild(btn('등록 시작', 'primary small-btn', () => act('start', { engine: e.engine, method: sel.value }))); }
|
||||
else row.appendChild(btn('등록 시작', 'primary small-btn', () => act('start', { engine: e.engine })));
|
||||
d.appendChild(row); return d;
|
||||
}
|
||||
// v1.3.10 자동 진행 상태 (구글 서비스 계정)
|
||||
if (e.auto) { const p = el('p', 'small ' + (e.auto.state === 'error' ? 'msg err' : 'muted'), `${e.auto.state === 'running' ? '⏳ ' : e.auto.state === 'done' ? '✓ ' : '✗ '}${T(e.auto.detail)}`); d.appendChild(p); if (e.auto.state === 'running') setTimeout(loadSearch, 4000); }
|
||||
if (e.autoAvailable && e.method !== 'dns' && (!e.auto || e.auto.state === 'error') && e.state !== '등록 완료') { const r0 = el('div', 'row'); r0.appendChild(btn(e.auto ? '자동 진행 다시' : '자동 진행으로 전환', 'primary small-btn', () => act('auto', { engine: e.engine }))); d.appendChild(r0); }
|
||||
const ol = el('ol', 'reg-steps');
|
||||
for (const s of e.steps) {
|
||||
const li = el('li', s.done ? 'done' : ''); li.appendChild(el('span', 'reg-actor ' + s.actor, s.actor === 'auto' ? '자동' : '직접')); li.appendChild(el('b', '', ' ' + s.title)); li.appendChild(el('div', 'muted small', s.detail));
|
||||
const row = el('div', 'row');
|
||||
if (s.id === 'code' && s.actor === 'manual') { const inp = el('input'); inp.placeholder = '확인 코드 또는 meta 태그 전체'; inp.value = e.code || ''; inp.style.flex = '1'; row.appendChild(inp); row.appendChild(btn(e.method === 'dns' ? '저장' : '저장하고 태그 넣기·재배포', 'ghost small-btn', () => act('code', { engine: e.engine, code: inp.value }))); }
|
||||
if (s.actor === 'auto' && e.autoAvailable && e.method !== 'dns') { /* 구글 자동 진행 단계 — 버튼 없음 */ }
|
||||
else if (s.id === 'apply' && e.code) row.appendChild(btn(s.done ? '다시 넣고 재배포' : '태그 넣고 재배포', 'primary small-btn', () => act('apply', { engine: e.engine })));
|
||||
if (s.id === 'dns' && e.code) row.appendChild(btn('DNS 확인', 'ghost small-btn', () => act('check', { engine: e.engine })));
|
||||
if (s.id === 'verify' && e.method !== 'dns' && e.code) row.appendChild(btn('운영 페이지에 태그 있는지 확인', 'ghost small-btn', () => act('check', { engine: e.engine })));
|
||||
if (['verify', 'sitemap', 'dns'].includes(s.id) && s.actor === 'manual') row.appendChild(btn(s.done ? '완료 취소' : '완료 표시', 'ghost small-btn', () => act('step', { engine: e.engine, step: s.id, done: !s.done })));
|
||||
if (row.children.length) li.appendChild(row); ol.appendChild(li);
|
||||
}
|
||||
d.appendChild(ol);
|
||||
const foot = el('div', 'row'); const a = el('a', 'btn ghost small-btn', TF('{1} 열기', T(e.label))); a.href = e.console; a.target = '_blank'; a.rel = 'noopener'; foot.appendChild(a);
|
||||
foot.appendChild(btn('등록 기록 지우기', 'ghost small-btn', () => { if (confirm('이 검색사이트의 등록 진행 기록을 지웁니다(사이트의 확인 태그는 다음 배포 때 빠집니다). 계속할까요?')) act('stop', { engine: e.engine }); })); d.appendChild(foot);
|
||||
return d;
|
||||
}
|
||||
/** 등록 동작 호출 → 결과 표시 후 다시 그림 */
|
||||
async function act(action, body) {
|
||||
try { const r = await api(`/api/sites/${siteId}/search/${action}`, { method: 'POST', body: JSON.stringify(body) }); if (r.check) alert(`${r.check.ok ? '✓' : '✗'} ${r.check.detail}`); if (action === 'indexnow' && r.indexnow && r.indexnow.last) alert(r.indexnow.last.detail); }
|
||||
catch (e) { alert(e.message); }
|
||||
loadSearch();
|
||||
}
|
||||
|
||||
// ---------- v1.3.10 다국어 카드 ----------
|
||||
/** 지금 언어 목록과 추가·빼기 체크 (GET/PUT /api/sites/<id>/langs) */
|
||||
async function loadLangs(site) {
|
||||
const box = $('langBody'); let d; try { d = await api(`/api/sites/${site.id}/langs`); } catch (_) { return; }
|
||||
box.innerHTML = ''; const L = d.langs; const multi = !!(L && L.list && L.list.length > 1);
|
||||
$('langState').textContent = multi ? TF('{1}개 언어', L.list.length) : T('한 언어'); $('langState').className = 'pill small ' + (multi ? 'done' : 'idle');
|
||||
if (site.source) { box.appendChild(el('p', 'muted small', T('원본 서버에서 가져온 사이트는 구조를 바꿀 수 없어 다국어로 만들 수 없습니다. "사이트 복사하기" 로 새 사이트를 만든 뒤 사용하세요.'))); return; }
|
||||
if (multi) {
|
||||
const now = el('div', 'lang-now'); for (const c of L.list) { const lc = d.locales.find((x) => x.code === c); const a = el('a', '', `/${c}/ ${lc ? lc.name : c}${c === L.default ? ' · ' + T('기본') : ''}`); a.href = `${site.url.replace(/\/?$/, '/')}${c}/`; a.target = '_blank'; a.rel = 'noopener'; now.appendChild(a); } box.appendChild(now);
|
||||
if (L.syncedAt) box.appendChild(el('p', 'muted small', TF('마지막 번역 맞춤: {1}', new Date(L.syncedAt).toLocaleString(uiLocale()))));
|
||||
} else box.appendChild(el('p', 'muted small', T('지금은 한 언어 사이트입니다. 추가 언어를 고르면 AI 가 페이지를 기본 언어 폴더로 옮긴 뒤(수정 작업 1회) 다른 언어 페이지를 만듭니다.')));
|
||||
let defSel = null;
|
||||
if (!multi) { const lab = el('label', 'lang-default', T('기본 언어 (지금 사이트의 언어)')); defSel = el('select'); for (const l of d.locales) defSel.appendChild(new Option(`${l.name} — ${T(l.label)}`, l.code)); defSel.value = site.uiLang && d.locales.some((l) => l.code === site.uiLang) ? site.uiLang : 'ko'; lab.appendChild(defSel); box.appendChild(lab); }
|
||||
const chips = el('div', 'lang-chips'); const def = () => (multi ? L.default : defSel.value);
|
||||
for (const l of d.locales) { const lab = el('label', 'chk lang-chip'); const c = el('input'); c.type = 'checkbox'; c.value = l.code; c.checked = multi && L.list.includes(l.code); lab.appendChild(c); lab.appendChild(document.createTextNode(' ' + l.name)); lab.title = T(l.label); chips.appendChild(lab); }
|
||||
const syncDef = () => { for (const i of chips.querySelectorAll('input')) { const isDef = i.value === def(); i.disabled = isDef; if (isDef) i.checked = true; i.parentElement.classList.toggle('off', isDef); } };
|
||||
if (defSel) defSel.onchange = syncDef; syncDef(); box.appendChild(chips);
|
||||
const row = el('div', 'row'); const msg = el('span', 'muted small');
|
||||
row.appendChild(btn(multi ? T('언어 변경 적용') : T('다국어 사이트로 바꾸기'), 'primary small-btn', async () => {
|
||||
const list = [...chips.querySelectorAll('input:checked')].map((i) => i.value); if (list.length < 2) { msg.textContent = T('추가 언어를 하나 이상 고르세요'); return; }
|
||||
if (!confirm(multi ? T('언어 목록을 바꿉니다. 새 언어는 번역(캐시 차감)하고, 뺀 언어 폴더는 지웁니다. 계속할까요?') : T('AI 가 페이지를 기본 언어 폴더로 옮기고(수정 작업, 캐시 차감) 다른 언어 페이지를 번역해 만듭니다. 계속할까요?'))) return;
|
||||
msg.textContent = T('진행 중…'); try { await api(`/api/sites/${site.id}/langs`, { method: 'PUT', body: JSON.stringify({ default: def(), list, xDefault: multi ? (L.xDefault || L.default) : def() }) }); msg.textContent = T('시작했습니다. 진행 상황은 작업 로그에 보입니다.'); } catch (e) { msg.textContent = e.message; }
|
||||
}));
|
||||
row.appendChild(msg); box.appendChild(row);
|
||||
langSettings(site, d, box, multi);
|
||||
if (multi) loadQuality(site, box);
|
||||
}
|
||||
/**
|
||||
* v1.3.16 다국어·현지 설정: URL 원칙 안내, x-default, 사업장 시간대·통화·국가(현지 형식의 기준), 사이트 관리 언어(사이트 관리자 화면·알림 메일).
|
||||
* 저장하면 AI 없이 언어 선택 페이지·현지 형식·검색 값만 다시 쓴다(PUT /api/sites/<id>/langs/settings).
|
||||
*/
|
||||
function langSettings(site, d, box, multi) {
|
||||
const sec = el('details', 'opt-sec'); sec.appendChild(el('summary', '', T('주소 원칙 · x-default · 현지 설정'))); box.appendChild(sec);
|
||||
sec.appendChild(el('p', 'muted small', T(d.principle || '')));
|
||||
const two = el('div', 'two'); let xs = null;
|
||||
if (multi) { const lab = el('label', 'opt-field'); lab.appendChild(el('span', '', T('x-default (다른 언어 방문자에게 보일 언어)'))); xs = el('select'); for (const c of d.langs.list) { const l = d.locales.find((x) => x.code === c); xs.appendChild(new Option(`${l ? l.name : c} (${c})`, c)); } xs.appendChild(new Option(T('언어 선택 페이지 (자동 이동 없음)'), 'root')); xs.value = d.langs.xDefault || d.langs.default; lab.appendChild(xs); two.appendChild(lab); }
|
||||
const al = el('label', 'opt-field'); al.appendChild(el('span', '', T('사이트 관리 언어 (사이트 관리자 화면·알림 메일)'))); const as = el('select'); for (const l of d.locales) as.appendChild(new Option(`${l.name} (${l.code})`, l.code)); as.value = d.adminLang || 'ko'; al.appendChild(as); two.appendChild(al); sec.appendChild(two);
|
||||
const three = el('div', 'two'); const inp = (label, val, ph) => { const lab = el('label', 'opt-field'); lab.appendChild(el('span', '', label)); const i = el('input'); i.value = val || ''; i.placeholder = ph; lab.appendChild(i); three.appendChild(lab); return i; };
|
||||
const tz = inp(T('사업장 시간대'), d.locale.timeZone, 'Asia/Seoul'); const cur = inp(T('가격 통화 (ISO 4217)'), d.locale.currency, 'KRW'); const co = inp(T('사업장 국가 (ISO 3166)'), d.locale.country, 'KR'); sec.appendChild(three);
|
||||
sec.appendChild(el('p', 'muted small', T('날짜·시각은 사업장 시간대로, 가격은 이 통화로(언어마다 기호·자릿수 형식만 바뀜), 전화번호는 페이지 지역이 사업장 국가와 같으면 국내식·다르면 국제식(+82)으로, 주소·이름은 언어마다 순서가 바뀌어 표시됩니다. 페이지에서는 data-wm-date·datetime·price·tel·address·name·legal 속성으로 쓰입니다.')));
|
||||
const r2 = el('div', 'row'); const m2 = el('span', 'muted small');
|
||||
r2.appendChild(btn(T('설정 저장'), 'primary small-btn', async () => { m2.textContent = T('저장 중…'); try { const body = { adminLang: as.value, timeZone: tz.value.trim(), currency: cur.value.trim(), country: co.value.trim() }; if (xs) body.xDefault = xs.value; const r = await api(`/api/sites/${site.id}/langs/settings`, { method: 'PUT', body: JSON.stringify(body) }); m2.textContent = TF('저장했습니다 — 바뀐 파일 {1}개', r.changed); if (typeof loadFrame === 'function') loadFrame(); } catch (e) { m2.textContent = e.message; } }));
|
||||
r2.appendChild(m2); sec.appendChild(r2);
|
||||
}
|
||||
const pct = (x) => `${(x * 100).toFixed(1)}%`;
|
||||
const REVIEW = { approved: '검수 완료', rejected: '수정 필요', pending: '검수 대기' };
|
||||
/** v1.3.16 번역 품질 게이트(누락률·잔존율·링크 대응·검수)와 로케일별 메타·구조화 데이터 현황 */
|
||||
async function loadQuality(site, box) {
|
||||
let d; try { d = await api(`/api/sites/${site.id}/langs/quality`); } catch (_) { return; }
|
||||
const q = d.quality; if (!q) return;
|
||||
const sec = el('details', 'opt-sec'); sec.open = !q.pass; const sm = el('summary', '', `${T('번역 품질 게이트')} — ${q.pass ? T('통과') : T('미통과')}`); sec.appendChild(sm); box.appendChild(sec);
|
||||
sec.appendChild(el('p', 'muted small', TF('기준: 번역 누락률 ≤ {1} (시스템 문구 누락 0), 원문 잔존율 ≤ {2}, 사이트 안 링크가 기본 언어와 같은 짝, 언어별 검수 완료. 페이지가 바뀌면 그 언어는 다시 "검수 대기" 가 됩니다.', pct(q.thresholds.maxMissing), pct(q.thresholds.maxResidue))));
|
||||
const wrap = el('div', 'table-wrap'); const t = el('table', 'q-table'); wrap.appendChild(t); sec.appendChild(wrap);
|
||||
const hr = el('tr'); for (const h of ['언어', '누락률', '잔존율', '링크', '검수', '']) hr.appendChild(el('th', '', T(h))); t.appendChild(hr);
|
||||
for (const [c, r] of Object.entries(q.languages)) {
|
||||
const tr = el('tr'); tr.appendChild(el('td', '', c + (r.source ? ' · ' + T('기본') : '')));
|
||||
if (r.source) { const td0 = el('td', 'muted', T('원문')); td0.colSpan = 3; tr.appendChild(td0); tr.appendChild(el('td', 'ok', T(REVIEW.approved))); tr.appendChild(el('td')); t.appendChild(tr); continue; }
|
||||
const cell = (ok, text, title) => { const x = el('td', ok ? 'ok' : 'bad', text); if (title) x.title = title; return x; };
|
||||
tr.appendChild(cell(r.checks.missing, pct(r.missing.rate) + (r.runtimeMissing ? ` · ${TF('시스템 문구 {1}', r.runtimeMissing)}` : ''), r.missing.samples.join('\n')));
|
||||
tr.appendChild(cell(r.checks.residue, pct(r.residue.rate), r.residue.samples.join('\n')));
|
||||
tr.appendChild(cell(r.checks.links, r.links.ok ? T('일치') : T('불일치'), [...r.links.issues, ...r.links.missingPages].join('\n')));
|
||||
tr.appendChild(cell(r.checks.review, T(REVIEW[r.review] || r.review), r.reviewNote || ''));
|
||||
const act = el('td'); const go = (state) => async () => { const note = state === 'rejected' ? prompt(T('고칠 점을 적어 주세요 (선택)')) || '' : ''; try { await api(`/api/sites/${site.id}/langs/review`, { method: 'POST', body: JSON.stringify({ lang: c, state, note }) }); loadLangs(site); } catch (e) { alert(e.message); } };
|
||||
const a = el('a', 'btn ghost small-btn', T('보기')); a.href = `${site.url.replace(/\/?$/, '/')}${c}/`; a.target = '_blank'; a.rel = 'noopener'; act.appendChild(a);
|
||||
if (r.review !== 'approved') act.appendChild(btn(T('검수 완료'), 'ghost small-btn', go('approved'))); if (r.review !== 'rejected') act.appendChild(btn(T('수정 필요'), 'ghost small-btn', go('rejected')));
|
||||
tr.appendChild(act); t.appendChild(tr);
|
||||
}
|
||||
// 로케일별 메타·구조화 데이터
|
||||
const mx = d.matrix; if (!mx) return;
|
||||
const ms = el('details', 'opt-sec'); ms.appendChild(el('summary', '', T('로케일별 메타·구조화 데이터'))); box.appendChild(ms);
|
||||
ms.appendChild(el('p', 'muted small', mx.baseUrl ? TF('운영 주소 {1} 기준 · x-default: {2}', mx.baseUrl, mx.xDefault === 'root' ? T('언어 선택 페이지') : mx.xDefault) : T('배포 전이라 canonical·hreflang·사이트맵의 절대 주소는 배포할 때 채워집니다.')));
|
||||
const w2 = el('div', 'table-wrap'); const t2 = el('table', 'q-table'); w2.appendChild(t2); ms.appendChild(w2);
|
||||
const h2 = el('tr'); for (const h of ['언어', '페이지', 'title', 'description', 'canonical', 'og:locale', 'hreflang', 'sitemap']) h2.appendChild(el('th', '', T(h))); t2.appendChild(h2);
|
||||
const yn = (v) => (v === null ? '—' : v ? '✓' : '✗');
|
||||
for (const l of mx.languages) for (const pg of l.pages) {
|
||||
const tr = el('tr'); tr.appendChild(el('td', '', `${l.lang}${pg.file === 'index.html' ? ` · ${l.schema || T('사업체 정보 없음')}${l.geo === 'default' ? ' (' + T('기본 언어 값') + ')' : ''}` : ''}`)); tr.appendChild(el('td', '', pg.file));
|
||||
if (pg.missing) { const x = el('td', 'bad', T('페이지 없음')); x.colSpan = 6; tr.appendChild(x); t2.appendChild(tr); continue; }
|
||||
const tdt = el('td', pg.title ? '' : 'bad', pg.title || '✗'); tdt.lang = l.hreflang; tr.appendChild(tdt); const tdd = el('td', pg.description ? '' : 'bad', pg.description ? pg.description.slice(0, 60) : '✗'); tdd.lang = l.hreflang; tr.appendChild(tdd);
|
||||
tr.appendChild(el('td', pg.canonicalOk === false ? 'bad' : '', yn(pg.canonicalOk))); tr.appendChild(el('td', pg.localeOk ? 'ok' : 'bad', pg.ogLocale || '✗')); tr.appendChild(el('td', '', String(pg.hreflang))); tr.appendChild(el('td', pg.inSitemap === false ? 'bad' : '', yn(pg.inSitemap)));
|
||||
t2.appendChild(tr);
|
||||
}
|
||||
}
|
||||
|
||||
/** 사이트를 열거나 작업이 끝났을 때: 생성이 끝난 사이트에만 카드를 보인다 */
|
||||
function onSiteOpened(site) {
|
||||
const ready = !!(site && (!site.plan || ['generated', 'published'].includes(site.plan.stage)) && (site.files || []).some((f) => /\.html?$/i.test(f)));
|
||||
$('optimizeCard').classList.toggle('hidden', !ready); $('searchCard').classList.toggle('hidden', !ready); $('langCard').classList.toggle('hidden', !ready);
|
||||
if (!ready) { siteId = null; return; }
|
||||
siteId = site.id; loadOptimize(); loadSearch(); loadLangs(site);
|
||||
}
|
||||
window.OptimizeUI = { onSiteOpened };
|
||||
})();
|
||||
@@ -0,0 +1,23 @@
|
||||
'use strict';
|
||||
/* ============================================================================
|
||||
* 비밀번호 규칙 안내 (v1.3.17)
|
||||
* 화면의 "비밀번호(6자 이상)" 같은 안내 문구를 관리자 콘솔에서 정한 지금 규칙으로 바꿔 보인다.
|
||||
* 대상: data-pw-rule 속성이 있는 입력칸(placeholder 의 괄호 안 규칙)과 라벨. 규칙 검사는 서버가 한다.
|
||||
* data-pw-rule="placeholder" — placeholder 를 "<원래 앞말> (<규칙>)" 으로
|
||||
* data-pw-rule="label" — 라벨 글자의 "(…)" 부분을 규칙으로
|
||||
* 전역: window.pwRuleText (규칙 문장, 불러오기 전에는 null), window.applyPwRule(root)
|
||||
* ========================================================================== */
|
||||
(function () {
|
||||
window.pwRuleText = null;
|
||||
var tr = function (s) { return typeof window.T === 'function' ? window.T(s) : s; };
|
||||
function apply(root) {
|
||||
if (!window.pwRuleText) return;
|
||||
(root || document).querySelectorAll('[data-pw-rule]').forEach(function (el) {
|
||||
var mode = el.getAttribute('data-pw-rule');
|
||||
if (mode === 'placeholder') { var head = (el.getAttribute('data-pw-head') || ''); el.placeholder = head ? head + ' (' + window.pwRuleText + ')' : window.pwRuleText; }
|
||||
else if (mode === 'label') { var n = el.firstChild; if (n && n.nodeType === 3) n.nodeValue = n.nodeValue.replace(/\([^)]*\)/, '(' + window.pwRuleText + ')'); }
|
||||
});
|
||||
}
|
||||
window.applyPwRule = apply;
|
||||
fetch('/api/auth/password-policy', { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : null; }).then(function (d) { if (d && d.text) { window.pwRuleText = d.text; apply(); } }).catch(function () {});
|
||||
})();
|
||||
@@ -0,0 +1,48 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>초기 설정 — AI Webmaker</title>
|
||||
<!-- v1.5.7 (요구 2): 설정 파일(data/config.json)을 채우는 전용 웹페이지.
|
||||
두 가지 경로로 열린다 — (1) 별도 포트의 setup-server.js (설치 직후, 토큰 또는 관리자 로그인; 이 때 <head> 에 wm-setup-standalone 메타가 들어간다)
|
||||
(2) 서비스 안의 /setup — v1.5.11: 로그인 없이 열리고(wm-setup-inline 메타) 화면 안에서 토큰 또는 관리자 계정으로 들어온다. 설치 점검표가 끝나기 전에는 첫 화면이 여기로 넘어온다.
|
||||
화면 코드는 public/setup.js 하나이며 관리자 콘솔 › 시스템 설정 탭도 같은 코드를 쓴다. -->
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
<style>
|
||||
body { padding: 18px 22px; max-width: 1240px; margin: 0 auto; }
|
||||
.setup-top { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.setup-top h1 { margin: 0; font-size: 20px; }
|
||||
.setup-top .spacer { flex: 1; }
|
||||
.setup-login { max-width: 460px; }
|
||||
.setup-login h2 { margin: 0 0 6px; font-size: 16px; }
|
||||
.setup-login .or { text-align: center; color: var(--muted); font-size: 12px; margin: 8px 0; }
|
||||
.msg { min-height: 18px; font-size: 12px; color: var(--warn); margin: 6px 0 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="setup-top">
|
||||
<h1>AI Webmaker 초기 설정</h1><span class="muted" id="setupVer"></span>
|
||||
<span class="spacer"></span>
|
||||
<a class="btn ghost" href="/admin" id="toAdmin" title="관리자 콘솔 (서비스 포트)">관리자 콘솔</a>
|
||||
<button type="button" class="ghost" id="setupLogout">로그아웃</button>
|
||||
</header>
|
||||
<div class="card setup-login hidden" id="setupLogin">
|
||||
<h2>접속 확인</h2>
|
||||
<p class="muted small">설치 스크립트가 알려 준 접속 토큰(서버의 <code>data/setup.token</code> 파일 내용)을 넣거나, 웹메이커 관리자 계정으로 로그인하세요. 처음 설치한 서버에는 관리자 계정이 없으므로 접속 토큰으로 들어와 "관리자 계정" 카드에서 최초 관리자를 만드세요(v1.6.31). 설치 점검이 끝나면 토큰은 더 쓸 수 없습니다.</p>
|
||||
<input id="setupToken" placeholder="접속 토큰" autocomplete="off">
|
||||
<div class="row"><button type="button" class="primary" id="setupTokenBtn">토큰으로 들어가기</button></div>
|
||||
<div class="or">— 또는 —</div>
|
||||
<input id="setupId" placeholder="관리자 아이디" autocomplete="username">
|
||||
<input id="setupPw" type="password" placeholder="비밀번호" autocomplete="current-password">
|
||||
<div class="row"><button type="button" class="ghost" id="setupAdminBtn">관리자 로그인</button></div>
|
||||
<div class="msg" id="setupLoginMsg"></div>
|
||||
</div>
|
||||
<div id="setupRoot"></div>
|
||||
<script src="/numfmt.js"></script>
|
||||
<script src="/setup.js"></script>
|
||||
<script>WMSetup.boot();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,297 @@
|
||||
'use strict';
|
||||
/**
|
||||
* AI Webmaker 초기 설정 화면 (v1.5.7, 요구 2·7)
|
||||
*
|
||||
* 설정 파일(lib/config.js)의 항목표를 받아 그룹별 카드로 그리고, 바뀐 값만 저장한다. 두 곳에서 같은 코드를 쓴다.
|
||||
* - 전용 설정 페이지 (setup-server.js, 포트 3081): 설치 직후 토큰 또는 관리자 로그인으로 들어온다. API /api/setup/*
|
||||
* - 관리자 콘솔 › 시스템 설정 탭 (admin.html): 로그인한 관리자. API /api/admin/config/*
|
||||
* 어느 쪽이든 WMSetup.mount(컨테이너, { apiBase }) 로 붙인다. 화면 문구는 한국어 원문이며 i18n 런타임이 번역한다.
|
||||
*
|
||||
* 화면 구성
|
||||
* 0) (v1.5.11, 요구 1·2) 설치 점검표: 관리자 비밀번호 · AI 엔진 인증 · 공개 주소 — 하나라도 안 되면 서비스 첫 화면이 이 페이지로 넘어온다.
|
||||
* 바로 아래에 "관리자 계정"(최초 관리자 만들기 · 비밀번호 재설정 · 새 관리자 — v1.6.31 고정 비밀번호 없음) 과 "AI 엔진 인증"(Claude 서버 로그인/OAuth 토큰/API 키, OpenAI) 카드
|
||||
* 1) 상태 카드: 웹메이커 서비스 · 설정 페이지 서비스 · Gitea(소스 보관) · Arimail(메일) · Claude · Codex · Resend — 연결 확인 버튼
|
||||
* 2) 설정 그룹 카드: 항목마다 입력칸 + 출처(파일/환경 변수/기본값) 배지 + 설명. 비밀값은 저장 여부·힌트만 보이고 새 값을 넣으면 바뀐다
|
||||
* 3) 설치 안내: 연동 서비스 설치 스크립트 명령 (현재 값으로 채운 예시)
|
||||
* 저장 → PUT { values }. 재시작이 필요한 항목이 바뀌면 안내와 함께 "서비스 재시작" 버튼을 강조한다.
|
||||
* 열리는 곳 (v1.5.11): 별도 포트(standalone, 기본 8795) · 본 서비스 /setup(inline — 토큰 또는 관리자 로그인) · 관리자 콘솔 탭. 앞 둘은 API /api/setup, 탭은 /api/admin/config
|
||||
*/
|
||||
(function () {
|
||||
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const SOURCE = { file: '설정 파일', env: '환경 변수', default: '기본값', vault: '보관소' };
|
||||
const CSS = `
|
||||
.wms { font-size: 14px; }
|
||||
.wms .wms-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 10px; margin: 10px 0 16px; }
|
||||
.wms .wms-stat { border: 1px solid var(--line, #e3e6ea); border-radius: 10px; padding: 10px 12px; background: var(--panel, #fff); }
|
||||
.wms .wms-stat h3 { margin: 0 0 4px; font-size: 14px; display: flex; align-items: center; gap: 8px; }
|
||||
.wms .wms-stat .d { font-size: 12px; color: var(--muted, #6b7684); word-break: break-all; }
|
||||
.wms .wms-stat .act { margin-top: 6px; display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.wms .pill { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; background: #eef1f5; color: #475569; }
|
||||
.wms .pill.ok { background: #d9f5e6; color: #0f7a46; } .wms .pill.bad { background: #fde2e2; color: #b42318; } .wms .pill.warn { background: #fff1d6; color: #9a5b00; }
|
||||
.wms .wms-group { border: 1px solid var(--line, #e3e6ea); border-radius: 10px; padding: 12px 14px; margin-bottom: 12px; background: var(--panel, #fff); }
|
||||
.wms .wms-group h2 { margin: 0 0 4px; font-size: 16px; display: flex; align-items: center; gap: 8px; }
|
||||
.wms .wms-group .gd { color: var(--muted, #6b7684); font-size: 12px; margin-bottom: 8px; }
|
||||
.wms .wms-field { display: grid; grid-template-columns: minmax(160px, 220px) 1fr; gap: 6px 12px; padding: 8px 0; border-top: 1px dashed var(--line, #e3e6ea); align-items: start; }
|
||||
.wms .wms-field label { font-weight: 600; font-size: 13px; padding-top: 6px; }
|
||||
.wms .wms-field label .env { display: block; font-weight: 400; font-size: 11px; color: var(--muted, #6b7684); font-family: ui-monospace, monospace; }
|
||||
.wms .wms-field input[type=text], .wms .wms-field input[type=password], .wms .wms-field input[type=number], .wms .wms-field select { width: 100%; max-width: 640px; margin: 0; box-sizing: border-box; }
|
||||
.wms .wms-field input.dirty, .wms .wms-field select.dirty { border-color: #f59e0b; box-shadow: 0 0 0 2px rgba(245,158,11,.15); }
|
||||
.wms .wms-field .meta { font-size: 11px; color: var(--muted, #6b7684); margin-top: 3px; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.wms .wms-field .desc { font-size: 12px; color: var(--muted, #6b7684); margin-top: 3px; line-height: 1.45; }
|
||||
.wms .wms-field.ro input { background: #f6f7f9; color: #475569; }
|
||||
.wms .wms-bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 8px 0; }
|
||||
.wms .wms-msg { font-size: 12px; min-height: 16px; }
|
||||
.wms .wms-msg.ok { color: #0f7a46; } .wms .wms-msg.bad { color: #b42318; }
|
||||
.wms details.wms-adv summary { cursor: pointer; color: var(--muted, #6b7684); font-size: 13px; }
|
||||
.wms code.cmd { display: block; background: #0f172a; color: #e2e8f0; padding: 8px 10px; border-radius: 8px; font-size: 12px; overflow: auto; white-space: pre; margin: 4px 0 8px; }
|
||||
.wms .wms-secret { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.wms .wms-secret input { flex: 1; min-width: 200px; }
|
||||
.wms .req { color: #b42318; }
|
||||
.wms .wms-head { display: flex; gap: 14px; flex-wrap: wrap; align-items: baseline; margin-bottom: 6px; }
|
||||
.wms .wms-head .muted { font-size: 12px; }
|
||||
/* v1.5.16 (요구 1): 점검표 카드는 밝은 배경(호박색·연두색)을 쓰는데 글자 색을 정하지 않아 관리자 콘솔(어두운 테마: 글자 #e6ebf5, 제목 #ffc457)에서는
|
||||
글자가 배경과 거의 같은 밝기라 읽을 수 없었다. 배경의 보색 계열로 글자 색을 명시한다 — 호박색 배경 ↔ 짙은 파랑, 연두 배경 ↔ 짙은 자주 (어느 테마에서든 같은 색) */
|
||||
.wms .wms-check { border: 2px solid #f59e0b; border-radius: 10px; padding: 12px 14px; margin-bottom: 12px; background: #fffbeb; color: #1e3a8a; }
|
||||
.wms .wms-check h2, .wms .wms-check b { color: #1e3a8a; }
|
||||
.wms .wms-check .gd { color: #1e40af; }
|
||||
.wms .wms-check.done { border-color: #10b981; background: #ecfdf5; color: #701a75; }
|
||||
.wms .wms-check.done h2, .wms .wms-check.done b { color: #86198f; }
|
||||
.wms .wms-check.done .gd, .wms .wms-check.done li.warn { color: #86198f; }
|
||||
.wms .wms-check h2 { margin: 0 0 6px; font-size: 16px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.wms .wms-check ul { margin: 6px 0 0; padding-left: 0; list-style: none; }
|
||||
.wms .wms-check li { display: grid; grid-template-columns: 84px 1fr; gap: 8px; padding: 5px 0; border-top: 1px dashed rgba(30,58,138,.18); font-size: 13px; align-items: start; }
|
||||
.wms .wms-check li .h { color: #1d4ed8; font-size: 12px; margin-top: 2px; }
|
||||
.wms .wms-check li.warn { color: #1e40af; font-size: 12px; }
|
||||
.wms .wms-check li.warn b { color: #1e40af; }
|
||||
.wms .wms-check .pill { color: #1e293b; } .wms .wms-check .pill.ok { color: #0f7a46; } .wms .wms-check .pill.bad { color: #b42318; } .wms .wms-check .pill.warn { color: #9a5b00; }
|
||||
.wms .wms-inline { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 6px 0; }
|
||||
.wms .wms-inline input, .wms .wms-inline select { margin: 0; min-width: 160px; }
|
||||
.wms table.wms-admins { border-collapse: collapse; font-size: 13px; margin: 6px 0; }
|
||||
.wms table.wms-admins td, .wms table.wms-admins th { border-bottom: 1px solid var(--line, #e3e6ea); padding: 4px 10px 4px 0; text-align: left; }
|
||||
.wms .wms-radio { display: flex; gap: 14px; flex-wrap: wrap; margin: 6px 0; font-size: 13px; }
|
||||
.wms .wms-sub { border-top: 1px dashed var(--line, #e3e6ea); padding-top: 8px; margin-top: 8px; }
|
||||
`;
|
||||
function ensureStyle() { if (document.getElementById('wms-style')) return; const s = document.createElement('style'); s.id = 'wms-style'; s.textContent = CSS; document.head.appendChild(s); }
|
||||
function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
|
||||
function btn(label, cls, fn) { const b = el('button', cls || 'ghost', label); b.type = 'button'; b.onclick = fn; return b; }
|
||||
|
||||
/**
|
||||
* 설정 화면을 컨테이너에 붙인다.
|
||||
* @param {HTMLElement} root
|
||||
* @param {{apiBase:string, standalone?:boolean, onAuthFail?:Function}} opts
|
||||
*/
|
||||
function mount(root, opts) {
|
||||
ensureStyle(); root.classList.add('wms');
|
||||
let csrfTok = ''; const getCsrf = async () => { if (csrfTok) return csrfTok; try { const r = await fetch(opts.apiBase.replace(/\/api\/.*$/, '') + (opts.apiBase.includes('/api/setup') ? '/api/setup/session' : '/api/auth/csrf')); const b = await r.json(); csrfTok = b.csrf || ''; } catch (_) {} return csrfTok; }; // v1.6.31 CSRF
|
||||
const api = async (path, init) => {
|
||||
const tok = await getCsrf(); const r = await fetch(opts.apiBase + path, { headers: { 'Content-Type': 'application/json', ...(tok ? { 'X-WM-CSRF': tok } : {}) }, ...(init || {}) });
|
||||
const b = await r.json().catch(() => ({}));
|
||||
if (r.status === 401 && opts.onAuthFail) { opts.onAuthFail(); throw new Error('로그인이 필요합니다'); }
|
||||
if (!r.ok) throw new Error(b.error || `HTTP ${r.status}`);
|
||||
return b;
|
||||
};
|
||||
let view = null; const inputs = new Map(); // key → { def, get(): 값 또는 undefined(변경 없음), el }
|
||||
const msg = el('div', 'wms-msg'); const setMsg = (t, ok) => { msg.textContent = t || ''; msg.className = 'wms-msg ' + (t ? (ok ? 'ok' : 'bad') : ''); };
|
||||
|
||||
async function load() { root.innerHTML = '<p class="muted">설정을 불러오는 중…</p>'; try { view = await api(''); render(); } catch (e) { root.innerHTML = ''; root.appendChild(el('p', 'muted', '불러오기 실패: ' + e.message)); } }
|
||||
|
||||
function pill(ok, labelOk, labelBad) { return el('span', 'pill ' + (ok === true ? 'ok' : ok === false ? 'bad' : 'warn'), ok === true ? (labelOk || '정상') : ok === false ? (labelBad || '확인 필요') : '알 수 없음'); }
|
||||
/** 상태 카드 하나 */
|
||||
function statCard(title, st, extra) {
|
||||
const c = el('div', 'wms-stat'); const h = el('h3'); h.appendChild(document.createTextNode(title)); h.appendChild(pill(st ? !!st.ok : null)); c.appendChild(h);
|
||||
c.appendChild(el('div', 'd', st ? (st.detail || '') : '상태 없음'));
|
||||
const act = el('div', 'act'); (extra || []).forEach((b) => b && act.appendChild(b)); if (act.childNodes.length) c.appendChild(act);
|
||||
return c;
|
||||
}
|
||||
const testBtn = (kind, card) => btn('연결 확인', 'ghost', async () => { const d = card.querySelector('.d'); d.textContent = '확인 중…'; try { const r = await api(`/test/${kind}`, { method: 'POST', body: '{}' }); d.textContent = r.detail || (r.ok ? '정상' : '실패'); card.querySelector('.pill').replaceWith(pill(!!r.ok)); } catch (e) { d.textContent = '확인 실패: ' + e.message; } });
|
||||
const provBtn = (label, kind, action, card) => btn(label, 'ghost', async () => { const d = card.querySelector('.d'); d.textContent = '실행 중…'; try { const r = await api(`/provision/${kind}/${action}`, { method: 'POST', body: '{}' }); d.textContent = r.detail; } catch (e) { d.textContent = '실패: ' + e.message; } });
|
||||
const linkBtn = (label, href) => { if (!href) return null; const a = el('a', 'btn ghost', label); a.href = href; a.target = '_blank'; a.rel = 'noopener'; return a; };
|
||||
|
||||
function render() {
|
||||
root.innerHTML = ''; inputs.clear();
|
||||
const v = view; const s = v.status || {}; const cfg = v.config;
|
||||
// ---- 머리말: 서버·파일 정보 ----
|
||||
const head = el('div', 'wms-head');
|
||||
head.appendChild(el('b', '', `AI Webmaker v${v.version}`));
|
||||
head.appendChild(el('span', 'muted', `서버 ${v.server.hostname} (${(v.server.ips || []).join(', ') || '-'}) · 계정 ${v.server.user} · Node ${v.server.node}`));
|
||||
head.appendChild(el('span', 'muted', `설정 파일 ${cfg.file}${cfg.exists ? (cfg.updatedAt ? ` · 저장 ${new Date(cfg.updatedAt).toLocaleString()} (${cfg.updatedBy || ''})` : '') : ' · 아직 없음 (저장하면 만들어짐)'}`));
|
||||
if (cfg.broken) head.appendChild(el('span', 'wms-msg bad', `설정 파일을 읽을 수 없습니다: ${cfg.broken}`));
|
||||
root.appendChild(head);
|
||||
// ---- v1.5.11: 설치 점검표 · 관리자 계정 · AI 엔진 인증 ----
|
||||
if (v.ready) root.appendChild(checklistCard(v));
|
||||
if (v.admins) root.appendChild(adminCard(v));
|
||||
if (v.aiAuth) root.appendChild(aiCard(v));
|
||||
// ---- 상태 카드 ----
|
||||
const grid = el('div', 'wms-grid');
|
||||
{ const c = statCard('웹메이커 서비스', s.service ? { ok: s.service.active, detail: s.service.detail } : null); c.querySelector('.act') || c.appendChild(el('div', 'act')); c.querySelector('.act').appendChild(btn('서비스 재시작', 'ghost', () => restart())); grid.appendChild(c); }
|
||||
if (s.setup) grid.appendChild(statCard('초기 설정 페이지 서비스 (webmaker-setup)', { ok: s.setup.active, detail: s.setup.detail }));
|
||||
{ const c = statCard('소스 보관 — ' + (s.source && s.source.provider !== 'none' ? 'Gitea' : '연동 안 함'), s.source); const acts = [testBtn('source', c)]; if (s.source && s.source.apiUrl && s.source.api && !s.source.api.skipped) { acts.push(provBtn('저장소 만들기', 'source', 'ensure-repo', c)); acts.push(provBtn('공개키 등록', 'source', 'register-key', c)); } acts.push(linkBtn('Gitea 열기', s.source && s.source.web)); c.querySelector('.act') || c.appendChild(el('div', 'act')); acts.filter(Boolean).forEach((b) => c.querySelector('.act').appendChild(b)); if (s.source && s.source.publicKey) { const ta = el('textarea'); ta.readOnly = true; ta.rows = 2; ta.style.cssText = 'width:100%;font-size:11px;font-family:ui-monospace,monospace;margin-top:6px'; ta.value = `이 서버의 공개키 (${s.source.keyFile || ''}):\n${s.source.publicKey}`; c.appendChild(ta); } grid.appendChild(c); }
|
||||
{ const c = statCard('메일 서버 — ' + (s.mail && s.mail.provider !== 'none' ? 'Arimail' : '연동 안 함'), s.mail); c.querySelector('.act') || c.appendChild(el('div', 'act')); [testBtn('mail', c), linkBtn('웹메일', s.mail && s.mail.webmail), linkBtn('Arimail 관리자 페이지', s.mail && s.mail.admin)].filter(Boolean).forEach((b) => c.querySelector('.act').appendChild(b)); grid.appendChild(c); }
|
||||
{ const c = statCard('Claude Code', s.claude); c.querySelector('.act') || c.appendChild(el('div', 'act')); c.querySelector('.act').appendChild(testBtn('claude', c)); grid.appendChild(c); }
|
||||
{ const c = statCard('OpenAI Codex (선택)', s.codex); c.querySelector('.act') || c.appendChild(el('div', 'act')); c.querySelector('.act').appendChild(testBtn('codex', c)); grid.appendChild(c); }
|
||||
{ const c = statCard('메일 발송 (Resend)', s.resend); c.querySelector('.act') || c.appendChild(el('div', 'act')); c.querySelector('.act').appendChild(testBtn('resend', c)); grid.appendChild(c); }
|
||||
root.appendChild(grid);
|
||||
// ---- 저장 막대 ----
|
||||
const bar = el('div', 'wms-bar');
|
||||
bar.appendChild(btn('바뀐 값 모두 저장', 'primary', () => saveAll()));
|
||||
bar.appendChild(btn('서비스 재시작', 'ghost', () => restart()));
|
||||
bar.appendChild(btn('환경 변수에서 옮겨 적기', 'ghost', async () => { try { const r = await api('/migrate-env', { method: 'POST', body: '{}' }); setMsg(r.detail, true); await load(); } catch (e) { setMsg(e.message, false); } }));
|
||||
bar.appendChild(btn('새로고침', 'ghost', () => load()));
|
||||
bar.appendChild(msg); root.appendChild(bar);
|
||||
// ---- 설정 그룹 ----
|
||||
const advanced = [];
|
||||
for (const g of cfg.groups) { const card = groupCard(g); if (g.advanced) advanced.push(card); else root.appendChild(card); }
|
||||
if (advanced.length) { const d = el('details', 'wms-adv'); d.appendChild(el('summary', '', '고급 설정 보기 (외부 API 주소)')); advanced.forEach((c) => d.appendChild(c)); root.appendChild(d); }
|
||||
// ---- 설치 안내 ----
|
||||
const inst = el('div', 'wms-group'); inst.appendChild(el('h2', '', '연동 서비스 설치·등록 명령'));
|
||||
inst.appendChild(el('div', 'gd', '서비스 서버에서 서비스 실행 계정으로 실행합니다. 이미 설치되어 있으면 등록(연동 정보 저장)만 합니다. 실행 뒤 이 화면을 새로고침하면 상태가 바뀝니다.'));
|
||||
const cmd = (label, text) => { inst.appendChild(el('div', '', label)); const c = el('code', 'cmd', text); inst.appendChild(c); };
|
||||
cmd('Gitea (사이트 소스 보관) 설치·등록', v.install.gitea);
|
||||
cmd('Arimail (메일 서버) 새로 설치·등록', v.install.arimail);
|
||||
cmd('이미 설치된 Arimail 등록만', v.install.arimailRegister);
|
||||
cmd('설치된 연동 서비스 점검·자동 등록', v.install.services);
|
||||
cmd('명령줄로 설정 보기·바꾸기', v.install.cli + '\nnode scripts/config.js set service.publicBaseUrl https://webmaker.example.com');
|
||||
root.appendChild(inst);
|
||||
}
|
||||
|
||||
/** v1.5.11 설치 점검표 카드: 필수 항목(관리자·AI 인증·공개 주소)과 참고 항목. 끝나면 서비스로 가는 링크 */
|
||||
function checklistCard(v) {
|
||||
const r = v.ready; const c = el('div', 'wms-check' + (r.required ? '' : ' done'));
|
||||
const h = el('h2'); h.appendChild(document.createTextNode(r.required ? '설치 점검표 — 아직 운영할 수 없습니다' : '설치 점검표 — 운영할 수 있습니다'));
|
||||
h.appendChild(el('span', 'pill ' + (r.required ? 'warn' : 'ok'), r.required ? `${r.items.filter((i) => !i.ok).length}개 남음` : '완료')); c.appendChild(h);
|
||||
c.appendChild(el('div', 'gd', r.required ? '아래 항목이 모두 끝날 때까지 서비스 첫 화면(/, /login)은 이 페이지로 넘어옵니다. 끝나면 자동으로 정상 로그인 화면이 나옵니다. (점검 전에 로그인 화면을 열려면 /login?direct=1)' : '서비스 첫 화면은 더 이상 이 페이지로 넘어오지 않습니다. 설정은 관리자 콘솔 › 시스템 설정 탭이나 이 페이지에서 계속 바꿀 수 있습니다.'));
|
||||
const ul = el('ul');
|
||||
for (const it of r.items) { const li = el('li'); li.appendChild(pill(it.ok, '완료', '필요')); const d = el('div'); d.appendChild(el('b', '', it.label + ' — ')); d.appendChild(document.createTextNode(it.detail || '')); if (!it.ok && it.hint) d.appendChild(el('div', 'h', '→ ' + it.hint)); li.appendChild(d); ul.appendChild(li); }
|
||||
for (const w of r.warnings || []) { const li = el('li', 'warn'); li.appendChild(el('span', 'pill' + (w.ok ? ' ok' : ''), w.ok ? '있음' : '참고')); const d = el('div'); d.appendChild(el('b', '', w.label + ' (참고) — ')); d.appendChild(document.createTextNode(w.detail || '')); li.appendChild(d); ul.appendChild(li); }
|
||||
c.appendChild(ul);
|
||||
const bar = el('div', 'wms-bar'); bar.appendChild(btn('다시 점검', 'ghost', () => load()));
|
||||
const home = opts.standalone ? (v.server.publicBaseUrl || '') + '/' : '/';
|
||||
if (!r.required) { const a = el('a', 'btn primary', '서비스 열기 (로그인 화면)'); a.href = home + 'login?direct=1'; bar.appendChild(a); }
|
||||
else { const a = el('a', 'btn ghost', '로그인 화면 열기 (점검 전)'); a.href = home + 'login?direct=1'; bar.appendChild(a); }
|
||||
c.appendChild(bar); return c;
|
||||
}
|
||||
/** v1.5.11 관리자 계정 카드: 목록 · 기본 관리자 만들기 · 비밀번호 재설정 · 새 관리자 */
|
||||
function adminCard(v) {
|
||||
const st = v.admins; const card = el('div', 'wms-group'); card.appendChild(el('h2', '', '관리자 계정'));
|
||||
card.appendChild(el('div', 'gd', '처음 설치한 서버에는 관리자 계정이 없습니다(v1.6.31: 고정 비밀번호 admin/admin 을 만들지 않습니다). 이 화면(접속 토큰)에서 아이디·비밀번호를 정해 최초 관리자를 만드세요. 설치 점검이 끝나면 접속 토큰은 더 쓸 수 없고 관리자 로그인으로만 들어옵니다.'));
|
||||
const m = el('span', 'wms-msg');
|
||||
if (st.admins.length) { const t = el('table', 'wms-admins'); const tr = el('tr'); ['아이디', '이름', '마지막 로그인', '상태'].forEach((x) => tr.appendChild(el('th', '', x))); t.appendChild(tr); for (const u of st.admins) { const r = el('tr'); r.appendChild(el('td', '', u.id)); r.appendChild(el('td', '', u.name || '')); r.appendChild(el('td', '', u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString() : '없음')); const td = el('td'); td.appendChild(u.mustChangePassword ? el('span', 'pill warn', '기본 비밀번호 — 변경 필요') : el('span', 'pill ok', '사용 가능')); r.appendChild(td); t.appendChild(r); } card.appendChild(t); }
|
||||
else card.appendChild(el('p', 'wms-msg bad', '관리자 계정이 없습니다.'));
|
||||
const act = async (path, body, okNote) => { try { const r = await api(path, { method: 'POST', body: JSON.stringify(body || {}) }); m.textContent = r.detail || okNote; m.className = 'wms-msg ok'; await load(); } catch (e) { m.textContent = e.message; m.className = 'wms-msg bad'; } };
|
||||
const row1 = el('div', 'wms-inline');
|
||||
if (!st.admins.length) row1.appendChild(btn('관리자 admin 을 1회용 비밀번호로 만들기', 'ghost', async () => { try { const r = await api('/admin/bootstrap', { method: 'POST', body: '{}' }); m.textContent = r.password ? `관리자 admin 을 만들었습니다. 1회용 비밀번호(지금만 표시): ${r.password} — 로그인 뒤 바로 바꾸세요` : (r.detail || '이미 있습니다'); m.className = 'wms-msg ok'; await load(); } catch (e) { m.textContent = e.message; m.className = 'wms-msg bad'; } }));
|
||||
row1.appendChild(m); card.appendChild(row1);
|
||||
// 비밀번호 재설정
|
||||
const sub1 = el('div', 'wms-sub'); sub1.appendChild(el('b', '', '관리자 비밀번호 재설정')); const r1 = el('div', 'wms-inline');
|
||||
const sel = el('select'); for (const u of st.admins) { const o = el('option', '', u.id); o.value = u.id; sel.appendChild(o); } const pw1 = el('input'); pw1.type = 'password'; pw1.placeholder = '새 비밀번호 (6자 이상)'; pw1.autocomplete = 'new-password';
|
||||
r1.appendChild(sel); r1.appendChild(pw1); r1.appendChild(btn('재설정', 'ghost', () => act('/admin/password', { id: sel.value, password: pw1.value }))); sub1.appendChild(r1); if (st.admins.length) card.appendChild(sub1);
|
||||
// 새 관리자
|
||||
const sub2 = el('div', 'wms-sub'); sub2.appendChild(el('b', '', '새 관리자 만들기')); const r2 = el('div', 'wms-inline');
|
||||
const id2 = el('input'); id2.placeholder = '아이디 (영문 소문자·숫자 3~32자)'; id2.autocomplete = 'off'; const nm2 = el('input'); nm2.placeholder = '이름 (회사명)'; const pw2 = el('input'); pw2.type = 'password'; pw2.placeholder = '비밀번호 (6자 이상)'; pw2.autocomplete = 'new-password';
|
||||
r2.appendChild(id2); r2.appendChild(nm2); r2.appendChild(pw2); r2.appendChild(btn('만들기', 'ghost', () => act('/admin/create', { id: id2.value, name: nm2.value, password: pw2.value }))); sub2.appendChild(r2); card.appendChild(sub2);
|
||||
return card;
|
||||
}
|
||||
/** v1.5.11 AI 엔진 인증 카드: Claude(서버 로그인 · OAuth 토큰 · API 키) 와 OpenAI(API 키 · codex 서버 로그인) */
|
||||
function aiCard(v) {
|
||||
const a = v.aiAuth; const s = v.status || {}; const card = el('div', 'wms-group'); card.appendChild(el('h2', '', 'AI 엔진 인증'));
|
||||
card.appendChild(el('div', 'gd', 'AI 작업(사이트 생성·수정)에는 Claude Code 인증이 필요합니다. 서버 로그인은 서비스 실행 계정으로 서버에서 claude 를 한 번 실행해 브라우저로 로그인하는 방식이고, 서버에 들어가기 어려우면 OAuth 토큰(다른 PC 에서 claude setup-token)이나 API 키를 여기에 넣습니다. 관리자 콘솔 › AI 연결 탭의 "Claude 모델 연결" 과 같은 값입니다(v1.5.16).'));
|
||||
const m = el('span', 'wms-msg');
|
||||
// Claude
|
||||
const c1 = el('div'); c1.appendChild(el('b', '', 'Claude Code')); c1.appendChild(el('span', 'muted', ' — ' + (s.claude ? s.claude.detail : '')));
|
||||
const modes = [['server-login', '서버 로그인 (claude 실행 후 브라우저 로그인)'], ['oauth-token', 'OAuth 토큰 (claude setup-token 으로 발급)'], ['api-key', 'Anthropic API 키 (sk-ant-…)']];
|
||||
const rad = el('div', 'wms-radio'); const cur = (a.claude || {}).mode || 'server-login'; let mode = cur;
|
||||
const tok = el('input'); tok.type = 'password'; tok.autocomplete = 'new-password'; tok.placeholder = a.claude && a.claude.hasOauthToken ? '저장된 OAuth 토큰 있음 — 바꾸려면 새 값' : 'OAuth 토큰 (sk-ant-oat…)'; const key = el('input'); key.type = 'password'; key.autocomplete = 'new-password'; key.placeholder = a.claude && a.claude.hasApiKey ? '저장된 API 키 있음 — 바꾸려면 새 값' : 'API 키 (sk-ant-api…)';
|
||||
const show = () => { tok.classList.toggle('hidden', mode !== 'oauth-token'); key.classList.toggle('hidden', mode !== 'api-key'); };
|
||||
for (const [val, label] of modes) { const l = el('label', 'radio'); const r = el('input'); r.type = 'radio'; r.name = 'wmsClaudeMode'; r.value = val; r.checked = val === cur; r.onchange = () => { mode = val; show(); }; l.appendChild(r); l.appendChild(document.createTextNode(' ' + label)); rad.appendChild(l); }
|
||||
c1.appendChild(rad); const r1 = el('div', 'wms-inline'); r1.appendChild(tok); r1.appendChild(key); show();
|
||||
r1.appendChild(btn('Claude 인증 저장·확인', 'primary', async () => { m.textContent = '확인 중…'; m.className = 'wms-msg'; try { const r = await api('/ai/claude', { method: 'PUT', body: JSON.stringify({ mode, oauthToken: tok.value, apiKey: key.value }) }); m.textContent = r.detail; m.className = 'wms-msg ' + (r.ok ? 'ok' : 'bad'); await load(); } catch (e) { m.textContent = e.message; m.className = 'wms-msg bad'; } }));
|
||||
c1.appendChild(r1); card.appendChild(c1);
|
||||
// OpenAI (선택)
|
||||
const o = a.openai; const c2 = el('div', 'wms-sub'); c2.appendChild(el('b', '', 'OpenAI (선택)')); c2.appendChild(el('span', 'muted', ' — ' + (s.codex ? s.codex.detail : '')));
|
||||
const en = el('label', 'radio'); const cb = el('input'); cb.type = 'checkbox'; cb.checked = !!(o && o.enabled); en.appendChild(cb); en.appendChild(document.createTextNode(' OpenAI 제공자 켬'));
|
||||
const rad2 = el('div', 'wms-radio'); let am = (o && o.authMode) || 'api-key'; const okey = el('input'); okey.type = 'password'; okey.autocomplete = 'new-password'; okey.placeholder = o && o.hasKey ? `저장된 키 ${o.apiKeyMasked || ''} — 바꾸려면 새 값` : 'OpenAI API 키 (sk-…)';
|
||||
for (const [val, label] of [['api-key', 'API 키'], ['server-login', '서버 로그인 (codex login)']]) { const l = el('label', 'radio'); const r = el('input'); r.type = 'radio'; r.name = 'wmsOpenaiMode'; r.value = val; r.checked = val === am; r.onchange = () => { am = val; okey.classList.toggle('hidden', am !== 'api-key'); }; l.appendChild(r); l.appendChild(document.createTextNode(' ' + label)); rad2.appendChild(l); }
|
||||
okey.classList.toggle('hidden', am !== 'api-key');
|
||||
const r2 = el('div', 'wms-inline'); r2.appendChild(en); r2.appendChild(rad2); r2.appendChild(okey);
|
||||
r2.appendChild(btn('OpenAI 저장', 'ghost', async () => { try { const r = await api('/ai/openai', { method: 'PUT', body: JSON.stringify({ enabled: cb.checked, authMode: am, apiKey: okey.value }) }); m.textContent = r.detail; m.className = 'wms-msg ' + (r.ok ? 'ok' : 'bad'); await load(); } catch (e) { m.textContent = e.message; m.className = 'wms-msg bad'; } }));
|
||||
c2.appendChild(r2); card.appendChild(c2);
|
||||
const bar = el('div', 'wms-bar'); bar.appendChild(m); card.appendChild(bar);
|
||||
return card;
|
||||
}
|
||||
/** 그룹 카드: 항목 입력칸 */
|
||||
function groupCard(g) {
|
||||
const card = el('div', 'wms-group'); const h = el('h2', '', g.label); card.appendChild(h); card.appendChild(el('div', 'gd', g.desc));
|
||||
const ro = g.keys.every((k) => k.deployTime);
|
||||
for (const k of g.keys) card.appendChild(fieldRow(k));
|
||||
if (!ro) { const bar = el('div', 'wms-bar'); const m = el('span', 'wms-msg'); bar.appendChild(btn('이 묶음 저장', 'primary', () => saveKeys(g.keys.map((k) => k.key), m))); bar.appendChild(m); card.appendChild(bar); }
|
||||
return card;
|
||||
}
|
||||
/** 항목 한 줄 */
|
||||
function fieldRow(k) {
|
||||
const row = el('div', 'wms-field' + (k.deployTime ? ' ro' : '')); const lab = el('label'); lab.appendChild(document.createTextNode(k.label)); if (k.required) lab.appendChild(el('span', 'req', ' *')); if (k.env) lab.appendChild(el('span', 'env', k.env)); row.appendChild(lab);
|
||||
const box = el('div'); let get;
|
||||
const markDirty = (inp, dirty) => inp.classList.toggle('dirty', dirty);
|
||||
if (k.type === 'secret') {
|
||||
const wrap = el('div', 'wms-secret'); const inp = el('input'); inp.type = 'password'; inp.autocomplete = 'new-password'; inp.placeholder = k.hasSecret ? `저장됨 ${k.secretHint} — 바꾸려면 새 값 입력` : '아직 없음 — 값을 입력해 저장'; wrap.appendChild(inp);
|
||||
const clr = el('label', 'radio'); const cb = el('input'); cb.type = 'checkbox'; clr.appendChild(cb); clr.appendChild(document.createTextNode(' 지우기')); if (k.hasSecret) wrap.appendChild(clr);
|
||||
inp.oninput = () => markDirty(inp, !!inp.value); cb.onchange = () => markDirty(inp, cb.checked || !!inp.value);
|
||||
get = () => (cb.checked ? null : (inp.value ? inp.value : undefined)); box.appendChild(wrap);
|
||||
inputs.set(k.key, { def: k, get, el: inp });
|
||||
} else if (k.type === 'bool') {
|
||||
const lab2 = el('label', 'radio'); const cb = el('input'); cb.type = 'checkbox'; cb.checked = k.value === true || k.value === 'true' || k.value === 1 || k.value === '1'; const init = cb.checked; lab2.appendChild(cb); lab2.appendChild(document.createTextNode(' 켬')); cb.disabled = !!k.deployTime; cb.onchange = () => markDirty(cb, cb.checked !== init);
|
||||
get = () => (cb.checked !== init ? cb.checked : undefined); box.appendChild(lab2); inputs.set(k.key, { def: k, get, el: cb });
|
||||
} else if (k.type === 'enum') {
|
||||
const sel = el('select'); for (const [val, label] of k.options || []) { const o = el('option', '', label); o.value = val; sel.appendChild(o); } sel.value = k.value || ''; const init = sel.value; sel.disabled = !!k.deployTime; sel.onchange = () => markDirty(sel, sel.value !== init);
|
||||
get = () => (sel.value !== init ? sel.value : undefined); box.appendChild(sel); inputs.set(k.key, { def: k, get, el: sel });
|
||||
} else {
|
||||
const inp = el('input'); inp.type = k.type === 'int' ? 'number' : 'text'; if (k.type === 'int') { inp.setAttribute('data-plain', '1'); if (k.min !== undefined) inp.min = k.min; if (k.max !== undefined) inp.max = k.max; }
|
||||
inp.value = k.value === undefined || k.value === null ? '' : String(k.value); const init = inp.value; inp.readOnly = !!k.deployTime; inp.placeholder = k.default !== '' && k.default !== undefined ? `기본값: ${k.default}` : '';
|
||||
inp.oninput = () => markDirty(inp, inp.value !== init);
|
||||
get = () => (inp.value !== init ? inp.value : undefined); box.appendChild(inp); inputs.set(k.key, { def: k, get, el: inp });
|
||||
}
|
||||
const meta = el('div', 'meta'); meta.appendChild(el('span', 'pill ' + (k.source === 'file' || k.source === 'vault' ? 'ok' : k.source === 'env' ? 'warn' : ''), SOURCE[k.source] || k.source)); if (k.restart) meta.appendChild(el('span', 'pill', '재시작 후 적용')); if (k.deployTime) meta.appendChild(el('span', 'pill', '배포 때 정해짐 — 읽기 전용')); if (k.source === 'env' && !k.deployTime) meta.appendChild(el('span', '', '→ 저장하면 설정 파일 값이 환경 변수보다 우선합니다'));
|
||||
box.appendChild(meta); box.appendChild(el('div', 'desc', k.desc)); row.appendChild(box); return row;
|
||||
}
|
||||
/** 바뀐 값 모으기 */
|
||||
function collect(keys) { const values = {}; let n = 0; for (const key of keys) { const it = inputs.get(key); if (!it || it.def.deployTime) continue; const v = it.get(); if (v === undefined) continue; values[key] = v; n++; } return { values, n }; }
|
||||
async function saveKeys(keys, m) {
|
||||
const { values, n } = collect(keys); const target = m || msg; const set = (t, ok) => { target.textContent = t; target.className = 'wms-msg ' + (ok ? 'ok' : 'bad'); };
|
||||
if (!n) return set('바뀐 값이 없습니다', true);
|
||||
try { const r = await api('', { method: 'PUT', body: JSON.stringify({ values }) }); set(r.note, true); await load(); if (r.restart) setMsg('재시작해야 적용되는 항목이 바뀌었습니다 — 위의 "서비스 재시작" 을 누르세요.', true); } catch (e) { set('저장 실패: ' + e.message, false); }
|
||||
}
|
||||
function saveAll() { return saveKeys([...inputs.keys()], null); }
|
||||
async function restart() {
|
||||
if (!confirm('웹메이커 서비스를 다시 시작합니다. 실행 중인 AI 작업이 있으면 끝날 때까지(최대 10분) 기다린 뒤 재시작합니다. 계속할까요?')) return;
|
||||
setMsg('재시작 중… (AI 작업이 있으면 기다립니다)', true);
|
||||
try { const r = await api('/restart', { method: 'POST', body: '{}' }); setMsg(r.detail, !!r.ok); setTimeout(load, 1500); } catch (e) { setMsg(e.message, false); }
|
||||
}
|
||||
load();
|
||||
return { reload: load };
|
||||
}
|
||||
|
||||
/**
|
||||
* 전용 설정 페이지(setup.html)의 부팅: 로그인 상태를 확인하고, 없으면 토큰/관리자 로그인 상자를 보인다.
|
||||
* 관리자 콘솔 안에서는 쓰지 않는다(admin.js 가 mount 만 부른다).
|
||||
*/
|
||||
function boot() {
|
||||
const root = document.getElementById('setupRoot'); const login = document.getElementById('setupLogin');
|
||||
// standalone: 별도 포트(setup-server.js) · inline: 본 서비스의 /setup (v1.5.11, 토큰 또는 관리자 로그인) — 둘 다 /api/setup. 그 밖(관리자 콘솔 탭)은 /api/admin/config
|
||||
const standalone = !!document.querySelector('meta[name="wm-setup-standalone"]'); const inline = !!document.querySelector('meta[name="wm-setup-inline"]');
|
||||
const apiBase = standalone || inline ? '/api/setup' : '/api/admin/config';
|
||||
const ver = document.getElementById('setupVer');
|
||||
const showLogin = (note) => { root.innerHTML = ''; login.classList.remove('hidden'); if (note) document.getElementById('setupLoginMsg').textContent = note; };
|
||||
const start = () => { login.classList.add('hidden'); mount(root, { apiBase, standalone, onAuthFail: () => showLogin('세션이 끝났습니다. 다시 로그인하세요.') }); };
|
||||
if (!standalone && !inline) return start();
|
||||
// 주소의 ?token= 은 설치 스크립트가 알려 준 접속 토큰 — 한 번 쓰고 주소에서 지운다
|
||||
const u = new URL(location.href); const tok = u.searchParams.get('token');
|
||||
const doLogin = async (body) => { const r = await fetch('/api/setup/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const b = await r.json().catch(() => ({})); if (!r.ok) throw new Error(b.error || `HTTP ${r.status}`); return b; };
|
||||
(async () => {
|
||||
try { const r = await fetch('/api/setup/session'); const b = await r.json().catch(() => ({})); if (ver && b.version) ver.textContent = 'v' + b.version; const ta = document.getElementById('toAdmin'); if (ta && standalone && b.publicBaseUrl) ta.href = b.publicBaseUrl + '/admin'; if (b.loggedIn) return start(); } catch (_) {}
|
||||
if (tok) { try { await doLogin({ token: tok }); u.searchParams.delete('token'); history.replaceState(null, '', u.toString()); return start(); } catch (e) { showLogin('토큰이 맞지 않습니다: ' + e.message); } } else showLogin('');
|
||||
})();
|
||||
document.getElementById('setupTokenBtn').onclick = async () => { try { await doLogin({ token: document.getElementById('setupToken').value.trim() }); start(); } catch (e) { document.getElementById('setupLoginMsg').textContent = e.message; } };
|
||||
document.getElementById('setupAdminBtn').onclick = async () => { try { await doLogin({ id: document.getElementById('setupId').value.trim(), password: document.getElementById('setupPw').value }); start(); } catch (e) { document.getElementById('setupLoginMsg').textContent = e.message; } };
|
||||
const lo = document.getElementById('setupLogout'); if (lo) lo.onclick = async () => { await fetch('/api/setup/logout', { method: 'POST' }); location.reload(); };
|
||||
}
|
||||
window.WMSetup = { mount, boot };
|
||||
})();
|
||||
@@ -0,0 +1,53 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>사이트 관리자</title>
|
||||
<style>
|
||||
/* 사이트 관리자 페이지 (v1.0.3) — 빌더와 별도로 운영 가능한 독립 페이지. ?embed=1 이면 빌더 관리 탭 안에 내장되어 로그인 패널을 생략한다. */
|
||||
:root { --bg: #f4f5f7; --panel: #fff; --line: #e3e6ea; --text: #1f2933; --muted: #6b7684; --accent: #2f6fed; --ok: #1a8f5a; --warn: #c2410c; }
|
||||
* { box-sizing: border-box; } body { margin: 0; font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif; font-size: 14px; color: var(--text); background: var(--bg); }
|
||||
header { display: flex; align-items: center; gap: 12px; padding: 10px 16px; background: #0f172a; color: #fff; } header h1 { font-size: 16px; margin: 0; } header .muted { color: #94a3b8; font-size: 12px; } header .spacer { flex: 1; }
|
||||
.tabs { display: flex; flex-wrap: wrap; gap: 4px; padding: 8px 16px 0; } .tabs button { border: 1px solid var(--line); background: #fff; border-radius: 8px 8px 0 0; padding: 7px 12px; cursor: pointer; } .tabs button.active { background: var(--accent); color: #fff; border-color: var(--accent); } .tabs button .n { font-size: 11px; opacity: .8; margin-left: 4px; }
|
||||
main { padding: 12px 16px 40px; } .card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 14px; margin-bottom: 12px; }
|
||||
h2 { font-size: 15px; margin: 0 0 10px; } h3 { font-size: 13px; margin: 12px 0 6px; } .muted { color: var(--muted); } .small { font-size: 12px; }
|
||||
input, select, textarea { width: 100%; padding: 7px 9px; border: 1px solid var(--line); border-radius: 7px; font: inherit; margin: 3px 0 8px; } textarea { resize: vertical; } label { display: block; font-size: 12px; color: var(--muted); }
|
||||
button, .btn { font: inherit; padding: 7px 12px; border-radius: 7px; border: 1px solid var(--line); background: #fff; cursor: pointer; } button.primary { background: var(--accent); color: #fff; border-color: var(--accent); } button.danger { color: #b91c1c; } button.small { padding: 3px 8px; font-size: 12px; } button:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } .row > * { flex: 1; min-width: 120px; } .row > button { flex: none; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; } th, td { padding: 6px 8px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; } th { color: var(--muted); font-weight: 600; font-size: 12px; } td.act { white-space: nowrap; } td .wrap { max-width: 360px; white-space: pre-wrap; word-break: break-word; }
|
||||
.table-wrap { overflow: auto; } .pill { font-size: 11px; padding: 2px 7px; border-radius: 999px; background: #e5e7eb; } .pill.ok { background: #d1fae5; color: #065f46; } .pill.warn { background: #fef3c7; color: #92400e; } .pill.off { background: #fee2e2; color: #991b1b; }
|
||||
.switch { width: 38px; height: 22px; border-radius: 999px; background: #d1d5db; position: relative; cursor: pointer; border: 0; padding: 0; flex: none; } .switch::after { content: ''; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 50%; background: #fff; transition: left .15s; } .switch.on { background: var(--accent); } .switch.on::after { left: 18px; }
|
||||
.mod-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 10px; } .mod { border: 1px solid var(--line); border-radius: 10px; padding: 10px; display: grid; grid-template-columns: 1fr auto; gap: 6px; } .mod .d { grid-column: 1 / -1; font-size: 12px; color: var(--muted); }
|
||||
.login { max-width: 360px; margin: 60px auto; } .hidden { display: none !important; } .msg { font-size: 12px; color: var(--muted); } .msg.err { color: #b91c1c; } .msg.ok { color: var(--ok); }
|
||||
.kv { display: grid; grid-template-columns: 160px 1fr; gap: 6px 10px; align-items: center; } .kv label { margin: 0; }
|
||||
.q-row { border: 1px solid var(--line); border-radius: 8px; padding: 8px; margin-bottom: 6px; display: grid; grid-template-columns: 2fr 1fr auto auto; gap: 6px; align-items: start; } .q-row textarea { grid-column: 1 / -1; }
|
||||
dialog { border: 1px solid var(--line); border-radius: 10px; max-width: 640px; width: 95vw; } dialog::backdrop { background: rgba(0,0,0,.4); }
|
||||
.sa-url { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; padding: 6px 16px; background: #1e293b; color: #cbd5e1; font-size: 12px; } .sa-url code { color: #e2e8f0; background: transparent; word-break: break-all; } .sa-url .muted { color: #94a3b8; }
|
||||
/* v1.3.0 메일 탭 */
|
||||
.mail-banner { padding: 8px 10px; border-radius: 8px; background: #eef2ff; color: #3730a3; font-size: 13px; } .mail-banner.ok { background: #d1fae5; color: #065f46; } .mail-banner.warn { background: #fef3c7; color: #92400e; } .mail-banner.off { background: #fee2e2; color: #991b1b; }
|
||||
.mail-state { font-weight: 700; margin-bottom: 6px; } .mono { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12px; } .switch:disabled { opacity: .4; cursor: not-allowed; }
|
||||
/* v1.3.10 번역문 줄바꿈 */
|
||||
html, body { max-width: 100%; overflow-x: hidden; } main, .card, .tabs { min-width: 0; max-width: 100%; } .tabs button, button { white-space: normal; } td, th, p, label { overflow-wrap: anywhere; } .table-wrap { max-width: 100%; overflow-x: auto; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="/theme.css"><!-- v1.2.0: 관리자 콘솔에서 정한 화면 색상·외형 -->
|
||||
</head>
|
||||
<body>
|
||||
<header><h1>사이트 관리자</h1><span class="muted" id="hSite"></span><span class="spacer"></span><span class="lang-switch small" style="display:none"><select id="langSelect" title="Language" aria-label="Language"></select></span><span class="muted small" id="hWho"></span><button type="button" class="small" id="btnLogout" hidden>로그아웃</button></header>
|
||||
<div class="login card hidden" id="loginBox">
|
||||
<h2>사이트 관리자 로그인</h2>
|
||||
<p class="muted small">사이트 소유자가 빌더에 로그인되어 있으면 자동으로 들어갑니다. 아니면 사이트 관리자 계정(빌더 관리 화면에서 생성)으로 로그인하세요.</p>
|
||||
<label>아이디 또는 이메일<input id="lId" autocomplete="username"></label>
|
||||
<label>비밀번호<input id="lPw" type="password" autocomplete="current-password"></label>
|
||||
<button type="button" class="primary" id="lGo">로그인</button>
|
||||
<p class="msg" id="lMsg"></p>
|
||||
</div>
|
||||
<div id="app" class="hidden">
|
||||
<nav class="tabs" id="tabs"></nav>
|
||||
<main id="main"></main>
|
||||
</div>
|
||||
<dialog id="dlg"><form method="dialog" id="dlgForm"><h2 id="dlgTitle"></h2><div id="dlgBody"></div><div class="row"><button type="button" class="primary" id="dlgSave">저장</button><button type="button" id="dlgCancel">취소</button></div><p class="msg" id="dlgMsg"></p></form></dialog>
|
||||
<script src="/numfmt.js"></script><script src="/i18n.js"></script><script src="/pwrule.js"></script><!-- v1.3.10: 사이트 관리자 페이지도 화면 언어로 (그전에는 런타임이 없어 번역되지 않았음) -->
|
||||
<script src="/site-admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,356 @@
|
||||
'use strict';
|
||||
/* ============================================================================
|
||||
* 사이트 관리자 페이지 (v1.0.3) — /site-admin/<사이트ID>/
|
||||
* 켜진 모듈(회원·게시판·문의·설문·예약·갤러리·지도·후기·FAQ·블로그·자료실·일정관리)의 데이터와 설정을 관리한다.
|
||||
* 인증: 빌더 세션 쿠키(소유자/운영자) 가 있으면 그대로, 없으면 사이트 관리자 계정(회원 role=admin)으로 로그인해 토큰(localStorage)으로 호출한다.
|
||||
* 화면은 lib/modules.js 의 CATALOG(옵션 스키마·표 열) 로 대부분 범용 렌더링하고, 설문 양식 편집기·갤러리 업로드 같은 것만 전용 UI 를 둔다.
|
||||
* ========================================================================== */
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const SITE = (location.pathname.match(/\/site-admin\/([a-f0-9]{32})/) || [])[1];
|
||||
const EMBED = new URLSearchParams(location.search).get('embed') === '1';
|
||||
const TOKEN_KEY = 'wm_siteadmin_' + SITE;
|
||||
let token = ''; try { token = localStorage.getItem(TOKEN_KEY) || ''; } catch (_) {}
|
||||
let ov = null; let tab = 'home';
|
||||
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const dt = (s) => (s ? new Date(s).toLocaleString(uiLocale(), { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '');
|
||||
async function api(p, opt = {}) {
|
||||
const headers = opt.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }; if (token) headers.Authorization = 'Bearer ' + token;
|
||||
const r = await fetch(`/api/site-admin/${SITE}${p}`, { credentials: 'same-origin', ...opt, headers: { ...headers, ...(opt.headers || {}) } });
|
||||
if (r.status === 401) { showLogin(); throw new Error('로그인이 필요합니다'); }
|
||||
const ct = r.headers.get('content-type') || ''; const body = ct.includes('json') ? await r.json().catch(() => ({})) : await r.blob();
|
||||
if (!r.ok) throw new Error((body && body.error) || `HTTP ${r.status}`);
|
||||
return body;
|
||||
}
|
||||
function showLogin() { $('loginBox').classList.remove('hidden'); $('app').classList.add('hidden'); }
|
||||
$('lGo').onclick = async () => {
|
||||
$('lMsg').textContent = '확인 중…';
|
||||
try { const r = await fetch(`/api/m/${SITE}/members/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: $('lId').value.trim(), password: $('lPw').value }) }); const j = await r.json(); if (!r.ok) throw new Error(j.error || '로그인 실패'); if (j.member.role !== 'admin') throw new Error('사이트 관리자 권한이 없는 계정입니다'); token = j.token; try { localStorage.setItem(TOKEN_KEY, token); } catch (_) {} await boot(); }
|
||||
catch (e) { $('lMsg').textContent = e.message; $('lMsg').className = 'msg err'; }
|
||||
};
|
||||
$('btnLogout').onclick = () => { token = ''; try { localStorage.removeItem(TOKEN_KEY); } catch (_) {} showLogin(); };
|
||||
|
||||
// ---------- 공통 렌더 ----------
|
||||
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; };
|
||||
function btn(label, cls, fn) { const b = el('button', cls, label); b.type = 'button'; b.onclick = fn; return b; }
|
||||
/** 설정 스키마(옵션) → 입력 폼 */
|
||||
function settingsForm(mod, into) {
|
||||
const cat = ov.catalog.find((c) => c.id === mod); const cur = ov.settings[mod] || {}; const kv = el('div', 'kv');
|
||||
for (const o of cat.options || []) {
|
||||
kv.appendChild(el('label', '', o.label)); let inp;
|
||||
if (o.type === 'boolean') { inp = el('input'); inp.type = 'checkbox'; inp.checked = !!cur[o.key]; inp.style.width = 'auto'; }
|
||||
else if (o.type === 'select') { inp = el('select'); for (const [v, l] of o.values) inp.appendChild(new Option(l, v)); inp.value = cur[o.key] == null ? o.def : cur[o.key]; }
|
||||
else if (o.type === 'list') { inp = el('input'); inp.value = (cur[o.key] || []).join(', '); inp.placeholder = '쉼표로 구분'; }
|
||||
else if (o.type === 'time') { inp = el('input'); inp.type = 'time'; inp.value = cur[o.key] || o.def; }
|
||||
else { inp = el('input'); inp.type = o.type === 'number' ? 'number' : 'text'; inp.value = cur[o.key] == null ? '' : cur[o.key]; }
|
||||
inp.dataset.key = o.key; inp.dataset.type = o.type; kv.appendChild(inp);
|
||||
}
|
||||
into.appendChild(kv);
|
||||
const row = el('div', 'row'); const m = el('span', 'msg');
|
||||
row.appendChild(btn('설정 저장', 'primary', async () => { const body = {}; kv.querySelectorAll('[data-key]').forEach((i) => { body[i.dataset.key] = i.dataset.type === 'boolean' ? i.checked : i.value; }); try { ov.settings[mod] = await api(`/settings/${mod}`, { method: 'PUT', body: JSON.stringify(body) }); m.textContent = '저장되었습니다'; m.className = 'msg ok'; } catch (e) { m.textContent = e.message; m.className = 'msg err'; } })); row.appendChild(m); into.appendChild(row);
|
||||
}
|
||||
/** 범용 표: 열 정의대로 표시, 수정/삭제 */
|
||||
async function table(coll, into, opts = {}) {
|
||||
const items = await api(`/c/${coll}`); const cols = opts.columns || []; const wrap = el('div', 'table-wrap'); const t = el('table'); const tr = el('tr');
|
||||
for (const c of cols) tr.appendChild(el('th', '', LABEL[c] || c)); tr.appendChild(el('th', '', '')); t.appendChild(tr);
|
||||
for (const it of items) {
|
||||
const r = el('tr');
|
||||
for (const c of cols) { const td = el('td'); let v = it[c]; if (opts.format && opts.format[c]) v = opts.format[c](v, it); /* v1.4.18 format 에 항목 전체도 넘긴다 */ else if (typeof v === 'number' && Number.isFinite(v)) v = v.toLocaleString(uiLocale()); /* v1.4.5 숫자 3자리 쉼표 */ else if (c === 'createdAt' || c === 'at') v = dt(v); else if (typeof v === 'boolean') { const p = el('span', 'pill ' + (v ? 'ok' : 'warn'), v ? (c === 'approved' ? '승인' : c === 'sent' ? '발송' : '예') : (c === 'approved' ? '대기' : c === 'sent' ? '미발송' : '아니오')); td.appendChild(p); r.appendChild(td); continue; } else if (Array.isArray(v)) v = v.join(', '); else if (c === 'status') { const p = el('span', 'pill ' + (v === 'confirmed' ? 'ok' : v === 'cancelled' ? 'off' : 'warn'), STATUS[v] || v); td.appendChild(p); r.appendChild(td); continue; } else if (c === 'role') v = v === 'admin' ? '관리자' : '회원'; else if (c === 'rating') v = '★'.repeat(v || 0); const d = el('div', 'wrap', v == null ? '' : String(v)); td.appendChild(d); r.appendChild(td); }
|
||||
const act = el('td', 'act');
|
||||
for (const a of (opts.actions || [])) act.appendChild(btn(a.label, 'small' + (a.danger ? ' danger' : ''), () => a.fn(it)));
|
||||
if (opts.edit) act.appendChild(btn('수정', 'small', () => opts.edit(it))); /* v1.4.18 전용 편집기(일정) */
|
||||
else if (opts.editable !== false) act.appendChild(btn('수정', 'small', () => editDialog(coll, it, opts)));
|
||||
act.appendChild(btn('삭제', 'small danger', async () => { if (!confirm('삭제할까요?')) return; await api(`/c/${coll}/${it.id}`, { method: 'DELETE' }); render(); }));
|
||||
r.appendChild(act); t.appendChild(r);
|
||||
}
|
||||
if (!items.length) { const r = el('tr'); const td = el('td', 'muted', '아직 항목이 없습니다'); td.colSpan = cols.length + 1; r.appendChild(td); t.appendChild(r); }
|
||||
wrap.appendChild(t); into.appendChild(wrap); return items;
|
||||
}
|
||||
const LABEL = { when: '날짜·시간', unit: '단위', repeat: '반복', next: '다음 발생', location: '장소', published: '공개', pinned: '고정', summary: '요약', cover: '대표 이미지', description: '설명', originalName: '파일', size: '크기', downloads: '내려받음', mailAddress: '메일 주소', name: '이름', email: '이메일', role: '권한', approved: '승인', createdAt: '등록', title: '제목', author: '글쓴이', category: '분류', secret: '비밀', views: '조회', postId: '글', body: '내용', active: '진행', responses: '응답 수', surveyId: '설문', respondent: '응답자', date: '날짜', time: '시간', contact: '연락처', status: '상태', note: '요청', tags: '태그', src: '이미지', q: '질문', a: '답변', order: '순서', rating: '별점', text: '내용', subject: '제목', sent: '메일', at: '접수', loginId: '아이디', caption: '설명' };
|
||||
const STATUS = { pending: '대기', confirmed: '확정', cancelled: '취소' };
|
||||
/** 수정/추가 대화상자: 필드 정의에 따라 입력 생성 */
|
||||
const FIELDS = {
|
||||
members: [['name', '이름'], ['email', '이메일'], ['role', '권한', 'select', [['member', '회원'], ['admin', '관리자']]], ['approved', '승인', 'bool']],
|
||||
posts: [['title', '제목'], ['category', '분류'], ['author', '글쓴이'], ['body', '내용', 'text'], ['secret', '비밀글', 'bool'], ['pinned', '상단 고정', 'bool']],
|
||||
comments: [['body', '내용', 'text'], ['secret', '비밀 댓글', 'bool']],
|
||||
bookings: [['date', '날짜', 'date'], ['time', '시간', 'time'], ['name', '이름'], ['contact', '연락처'], ['note', '요청', 'text'], ['status', '상태', 'select', [['pending', '대기'], ['confirmed', '확정'], ['cancelled', '취소']]]],
|
||||
reviews: [['name', '이름'], ['rating', '별점(1~5)', 'number'], ['category', '분류'], ['text', '내용', 'text'], ['approved', '공개', 'bool']],
|
||||
faq: [['q', '질문'], ['a', '답변', 'text'], ['category', '분류'], ['order', '순서', 'number']],
|
||||
gallery: [['src', '이미지 주소'], ['thumb', '썸네일 주소(선택)'], ['title', '제목'], ['caption', '설명'], ['tags', '태그(쉼표)'], ['order', '순서', 'number']],
|
||||
// v1.4.0 블로그·자료실
|
||||
blogPosts: [['title', '제목'], ['summary', '요약(비우면 본문 앞부분)'], ['body', '본문 (빈 줄 = 문단, 주소는 자동 링크)', 'text'], ['cover', '대표 이미지 주소(선택)'], ['category', '분류'], ['tags', '태그(쉼표)'], ['author', '글쓴이'], ['published', '공개', 'bool'], ['pinned', '상단 고정', 'bool']],
|
||||
blogComments: [['name', '이름'], ['body', '내용', 'text'], ['approved', '공개', 'bool']],
|
||||
libraryFiles: [['title', '제목'], ['description', '설명', 'text'], ['category', '분류'], ['tags', '태그(쉼표)'], ['published', '공개', 'bool'], ['pinned', '상단 고정', 'bool']],
|
||||
};
|
||||
/** v1.4.0 바이트 → 읽기 쉬운 크기 */
|
||||
const fmtSize = (n) => { n = Number(n) || 0; return n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : n < 1073741824 ? `${(n / 1048576).toFixed(1)} MB` : `${(n / 1073741824).toFixed(2)} GB`; };
|
||||
function editDialog(coll, item, opts) {
|
||||
const fields = FIELDS[coll]; if (!fields) return; const body = $('dlgBody'); body.innerHTML = ''; $('dlgTitle').textContent = (item ? '수정' : '추가') + ' — ' + ((opts && opts.label) || coll); $('dlgMsg').textContent = '';
|
||||
const inputs = {};
|
||||
for (const [k, label, type, values] of fields) { const lab = el('label', '', label); let inp; if (type === 'text') { inp = el('textarea'); inp.rows = 5; inp.value = item ? (item[k] || '') : ''; } else if (type === 'bool') { inp = el('input'); inp.type = 'checkbox'; inp.style.width = 'auto'; inp.checked = item ? !!item[k] : (k === 'approved'); } else if (type === 'select') { inp = el('select'); for (const [v, l] of values) inp.appendChild(new Option(l, v)); if (item) inp.value = item[k]; } else { inp = el('input'); inp.type = type === 'number' ? 'number' : type === 'date' ? 'date' : type === 'time' ? 'time' : 'text'; inp.value = item ? (Array.isArray(item[k]) ? item[k].join(', ') : (item[k] == null ? '' : item[k])) : ''; } inputs[k] = [inp, type]; lab.appendChild(inp); body.appendChild(lab); }
|
||||
$('dlgSave').onclick = async () => { const data = {}; for (const [k, [inp, type]] of Object.entries(inputs)) data[k] = type === 'bool' ? inp.checked : inp.value; try { if (item) await api(`/c/${coll}/${item.id}`, { method: 'PUT', body: JSON.stringify(data) }); else await api(`/c/${coll}`, { method: 'POST', body: JSON.stringify(data) }); $('dlg').close(); render(); } catch (e) { $('dlgMsg').textContent = e.message; $('dlgMsg').className = 'msg err'; } };
|
||||
$('dlgCancel').onclick = () => $('dlg').close(); $('dlg').showModal();
|
||||
}
|
||||
|
||||
// ---------- 화면 ----------
|
||||
async function boot() {
|
||||
try { ov = await api('/overview'); } catch (e) { return; }
|
||||
$('loginBox').classList.add('hidden'); $('app').classList.remove('hidden'); $('hSite').textContent = ov.site ? ov.site.title : SITE.slice(0, 8); $('hWho').textContent = ov.who || ''; $('btnLogout').hidden = !token;
|
||||
if (EMBED) document.querySelector('header').style.display = 'none';
|
||||
// v1.1.4: 이 페이지의 직접 접근 주소 (빌더 없이 들어오면 사이트 관리자 계정으로 로그인)
|
||||
let bar = document.getElementById('saUrl'); if (!bar) { bar = document.createElement('div'); bar.id = 'saUrl'; bar.className = 'sa-url'; document.querySelector('header').insertAdjacentElement('afterend', bar); }
|
||||
const direct = `${location.origin}/site-admin/${SITE}/`; bar.innerHTML = ''; bar.appendChild(Object.assign(document.createElement('span'), { textContent: '관리자 페이지 주소:' })); const code = document.createElement('code'); code.textContent = direct; bar.appendChild(code);
|
||||
const cp = btn('복사', 'small', async () => { try { await navigator.clipboard.writeText(direct); cp.textContent = '복사됨'; setTimeout(() => (cp.textContent = '복사'), 1500); } catch (_) { prompt('주소', direct); } }); bar.appendChild(cp);
|
||||
bar.appendChild(btn('새 창으로 열기', 'small', () => window.open(direct, '_blank', 'noopener')));
|
||||
bar.appendChild(Object.assign(document.createElement('span'), { className: 'muted', textContent: '— 빌더를 거치지 않고 이 주소로 들어오면 "관리자 계정" 탭에서 만든 사이트 관리자 계정으로 로그인해야 합니다.' }));
|
||||
renderTabs(); render();
|
||||
}
|
||||
function renderTabs() {
|
||||
const tabs = $('tabs'); tabs.innerHTML = '';
|
||||
const add = (id, label, n) => { const b = btn(label, tab === id ? 'active' : '', () => { tab = id; renderTabs(); render(); }); if (n) { const s = el('span', 'n', `(${n})`); b.appendChild(s); } tabs.appendChild(b); };
|
||||
add('home', '모듈 · 설정');
|
||||
for (const c of ov.catalog) if (ov.enabled[c.id] && Object.keys(c.collections).length) add(c.id, c.label, c.id === 'booking' ? ov.counts.pendingBookings : c.id === 'reviews' ? ov.counts.pendingReviews : c.id === 'members' ? ov.counts.pendingMembers : null);
|
||||
if (ov.enabled.inquiry) add('inquiry', '문의', ov.counts.unsentInquiries || null);
|
||||
if (ov.mail && ov.mail.systemEnabled) add('mail', '메일'); // v1.3.2: 운영자가 이 사이트의 메일 시스템을 켰을 때만 (설정중 포함)
|
||||
add('account', '관리자 계정');
|
||||
}
|
||||
async function render() {
|
||||
const m = $('main'); m.innerHTML = '';
|
||||
try {
|
||||
if (tab === 'home') return renderHome(m);
|
||||
if (tab === 'account') return renderAccount(m);
|
||||
if (tab === 'mail') return renderMail(m);
|
||||
if (tab === 'inquiry') return renderInquiries(m);
|
||||
const cat = ov.catalog.find((c) => c.id === tab); if (!cat) return;
|
||||
const card = el('div', 'card'); card.appendChild(el('h2', '', cat.label + ' 설정')); settingsForm(tab, card); m.appendChild(card);
|
||||
for (const [coll, def] of Object.entries(cat.collections)) {
|
||||
const c2 = el('div', 'card'); const h = el('h2', '', def.label); c2.appendChild(h);
|
||||
if (coll === 'surveys') { h.appendChild(btn('+ 설문 만들기', 'small primary', () => surveyEditor(null))); await table(coll, c2, { columns: def.columns, editable: false, actions: [{ label: '문항 편집', fn: (it) => surveyEditor(it) }, { label: '응답 보기', fn: (it) => { tab = 'survey'; renderResponses(m, it); } }, { label: 'xlsx 내려받기', fn: (it) => { window.open(`/api/site-admin/${SITE}/export/survey/${it.id}.xlsx${token ? '?token=' + encodeURIComponent(token) : ''}`, '_blank'); } }] }); }
|
||||
else if (coll === 'responses') { c2.appendChild(el('p', 'muted small', '응답은 설문 표의 "응답 보기"에서 설문별로 확인하고 xlsx 로 내려받을 수 있습니다.')); }
|
||||
else if (coll === 'gallery') { h.appendChild(btn('+ 이미지 주소로 추가', 'small', () => editDialog(coll, null, def))); const up = el('input'); up.type = 'file'; up.accept = 'image/*'; up.multiple = true; up.style.width = 'auto'; up.onchange = async () => { const fd = new FormData(); for (const f of up.files) fd.append('files', f, f.name); try { await api('/upload', { method: 'POST', body: fd }); render(); } catch (e) { alert(e.message); } }; const lab = el('label', '', '이미지 업로드(여러 장 가능): '); lab.appendChild(up); c2.appendChild(lab); await table(coll, c2, { columns: def.columns, label: def.label }); }
|
||||
// v1.4.0 블로그 글: 추가 + 대표 이미지 올리기(올린 이미지 주소를 글의 "대표 이미지 주소" 에 붙여 넣는다)
|
||||
else if (coll === 'blogPosts') {
|
||||
h.appendChild(btn('+ 글쓰기', 'small primary', () => editDialog(coll, null, def)));
|
||||
const up = el('input'); up.type = 'file'; up.accept = 'image/*'; up.style.width = 'auto'; const out = el('span', 'muted small');
|
||||
up.onchange = async () => { const fd = new FormData(); for (const f of up.files) fd.append('files', f, f.name); try { const r = await api('/upload?to=blog', { method: 'POST', body: fd }); out.textContent = r.added.length ? `올린 이미지 주소: ${r.added.map((a) => a.url).join(' , ')} (글의 대표 이미지 주소에 붙여 넣으세요)` : '이미지 파일(15MB 이하)만 올릴 수 있습니다'; } catch (e) { alert(e.message); } };
|
||||
const lab = el('label', '', '대표 이미지 올리기: '); lab.appendChild(up); c2.appendChild(lab); c2.appendChild(out);
|
||||
await table(coll, c2, { columns: def.columns, label: def.label });
|
||||
}
|
||||
else if (coll === 'blogComments') { let titles = {}; try { titles = Object.fromEntries(((await api('/c/blogPosts')) || []).map((x) => [x.id, x.title])); } catch (_) {} await table(coll, c2, { columns: def.columns, label: def.label, format: { postId: (v) => titles[v] || v }, actions: [{ label: '공개', fn: async (it) => { await api(`/c/blogComments/${it.id}`, { method: 'PUT', body: JSON.stringify({ approved: true }) }); render(); } }] }); }
|
||||
// v1.4.0 자료실: 파일 올리기(여러 개) — 크기 상한은 자료실 설정, 파일은 사이트 폴더 밖에 저장되어 API 로만 내려받는다
|
||||
else if (coll === 'libraryFiles') {
|
||||
const box = el('div', 'row'); const up = el('input'); up.type = 'file'; up.multiple = true; up.style.width = 'auto'; const cat = el('input'); cat.placeholder = '분류(선택)'; cat.style.width = '140px'; const out = el('span', 'muted small');
|
||||
const go = btn('올리기', 'small primary', async () => { if (!up.files.length) { out.textContent = '파일을 고르세요'; return; } const fd = new FormData(); for (const f of up.files) fd.append('files', f, f.name); fd.append('category', cat.value.trim()); out.textContent = '올리는 중…'; try { const r = await api('/library', { method: 'POST', body: fd }); out.textContent = `${r.added.length}개 올림` + (r.skipped.length ? ` · 건너뜀: ${r.skipped.map((x) => `${x.name}(${x.reason})`).join(', ')}` : ''); setTimeout(render, 600); } catch (e) { out.textContent = e.message; } });
|
||||
box.appendChild(up); box.appendChild(cat); box.appendChild(go); box.appendChild(out); c2.appendChild(box);
|
||||
await table(coll, c2, { columns: def.columns, label: def.label, format: { size: fmtSize } });
|
||||
}
|
||||
else if (coll === 'events') { await renderCalendarEvents(c2, def, h); } // v1.4.18 일정관리
|
||||
else if (coll === 'members') { await table(coll, c2, { columns: def.columns, label: def.label, actions: [{ label: '승인', fn: async (it) => { await api(`/c/members/${it.id}`, { method: 'PUT', body: JSON.stringify({ approved: true }) }); render(); } }] }); }
|
||||
else if (coll === 'bookings') { h.appendChild(btn('+ 예약 추가', 'small', () => editDialog(coll, null, def))); await table(coll, c2, { columns: def.columns, label: def.label, actions: [{ label: '확정', fn: async (it) => { await api(`/c/bookings/${it.id}`, { method: 'PUT', body: JSON.stringify({ status: 'confirmed' }) }); render(); } }, { label: '취소', fn: async (it) => { await api(`/c/bookings/${it.id}`, { method: 'PUT', body: JSON.stringify({ status: 'cancelled' }) }); render(); } }] }); }
|
||||
else if (coll === 'reviews') { await table(coll, c2, { columns: def.columns, label: def.label, actions: [{ label: '공개', fn: async (it) => { await api(`/c/reviews/${it.id}`, { method: 'PUT', body: JSON.stringify({ approved: true }) }); render(); } }] }); }
|
||||
else { if (FIELDS[coll] && coll !== 'comments') h.appendChild(btn('+ 추가', 'small', () => editDialog(coll, null, def))); await table(coll, c2, { columns: def.columns, label: def.label }); }
|
||||
m.appendChild(c2);
|
||||
}
|
||||
} catch (e) { m.appendChild(el('p', 'msg err', e.message)); }
|
||||
}
|
||||
function renderHome(m) {
|
||||
const card = el('div', 'card'); card.appendChild(el('h2', '', '기능 모듈 켜기 / 끄기')); card.appendChild(el('p', 'muted small', '켜면 이 관리자 페이지에 해당 탭이 생기고, 빌더가 사이트를 만들거나 수정할 때 표준 코드가 삽입됩니다.'));
|
||||
const grid = el('div', 'mod-grid');
|
||||
for (const c of ov.catalog) { const box = el('div', 'mod'); box.appendChild(el('b', '', c.label)); const sw = el('button', 'switch' + (ov.enabled[c.id] ? ' on' : '')); sw.type = 'button'; sw.onclick = async () => { try { ov.enabled = await api('/enabled', { method: 'PUT', body: JSON.stringify({ module: c.id, on: !ov.enabled[c.id] }) }); sw.classList.toggle('on', !!ov.enabled[c.id]); renderTabs(); } catch (e) { alert(e.message); } }; box.appendChild(sw); box.appendChild(el('div', 'd', c.desc)); grid.appendChild(box); }
|
||||
card.appendChild(grid); m.appendChild(card);
|
||||
const c2 = el('div', 'card'); c2.appendChild(el('h2', '', '사이트 주소')); c2.appendChild(el('p', 'small', ov.site && ov.site.liveUrl ? `운영 주소: ${ov.site.liveUrl}` : '아직 배포되지 않았습니다.')); c2.appendChild(el('p', 'muted small', `모듈 API: ${location.origin}/api/m/${SITE}/… · 이 관리자 페이지: ${location.origin}/site-admin/${SITE}/`)); m.appendChild(c2);
|
||||
}
|
||||
async function renderInquiries(m) {
|
||||
const card = el('div', 'card'); card.appendChild(el('h2', '', '문의 접수')); settingsForm('inquiry', card); m.appendChild(card);
|
||||
const c2 = el('div', 'card'); c2.appendChild(el('h2', '', '접수 내역')); const items = await api('/inquiries');
|
||||
const t = el('table'); const tr = el('tr'); for (const h of ['접수', '유형', '이름', '이메일', '제목', '내용', '첨부', '메일']) tr.appendChild(el('th', '', h)); t.appendChild(tr);
|
||||
for (const q of items) { const r = el('tr'); for (const v of [dt(q.at), q.category || (q.extra && q.extra.category) || '-', q.name, q.email + (q.phone ? ' / ' + q.phone : ''), q.subject || '-', q.message]) { const td = el('td'); td.appendChild(el('div', 'wrap', v)); r.appendChild(td); } const fa = el('td'); for (const f of q.files || []) { const a = el('a', '', f.name); a.href = `/api/site-admin/${SITE}/files/${encodeURIComponent(f.stored)}${token ? '?token=' + encodeURIComponent(token) : ''}`; a.target = '_blank'; a.style.display = 'block'; fa.appendChild(a); } r.appendChild(fa); const td = el('td'); td.appendChild(el('span', 'pill ' + (q.sent ? 'ok' : 'warn'), q.sent ? '발송' : (q.error ? '미발송' : '기록'))); td.title = q.error || ''; r.appendChild(td); t.appendChild(r); }
|
||||
if (!items.length) { const r = el('tr'); const td = el('td', 'muted', '접수된 문의가 없습니다'); td.colSpan = 8; r.appendChild(td); t.appendChild(r); }
|
||||
const w = el('div', 'table-wrap'); w.appendChild(t); c2.appendChild(w); m.appendChild(c2);
|
||||
}
|
||||
async function renderResponses(m, survey) {
|
||||
m.innerHTML = ''; const card = el('div', 'card'); card.appendChild(el('h2', '', `응답 — ${survey.title}`)); const row = el('div', 'row'); row.appendChild(btn('← 설문 목록', '', () => render())); row.appendChild(btn('xlsx 내려받기', 'primary', () => window.open(`/api/site-admin/${SITE}/export/survey/${survey.id}.xlsx${token ? '?token=' + encodeURIComponent(token) : ''}`, '_blank'))); card.appendChild(row);
|
||||
const items = (await api('/c/responses')).filter((r) => r.surveyId === survey.id); const t = el('table'); const tr = el('tr'); for (const h of ['응답 일시', '응답자', ...survey.questions.map((q) => q.label)]) tr.appendChild(el('th', '', h)); tr.appendChild(el('th')); t.appendChild(tr);
|
||||
for (const r of items) { const row2 = el('tr'); row2.appendChild(el('td', '', dt(r.createdAt))); row2.appendChild(el('td', '', r.respondent)); for (const q of survey.questions) { const v = r.answers[q.id]; const td = el('td'); td.appendChild(el('div', 'wrap', Array.isArray(v) ? v.join(', ') : (v == null ? '' : String(v)))); row2.appendChild(td); } const act = el('td', 'act'); act.appendChild(btn('삭제', 'small danger', async () => { if (confirm('이 응답을 삭제할까요?')) { await api(`/c/responses/${r.id}`, { method: 'DELETE' }); renderResponses(m, survey); } })); row2.appendChild(act); t.appendChild(row2); }
|
||||
if (!items.length) { const r = el('tr'); const td = el('td', 'muted', '응답이 없습니다'); td.colSpan = survey.questions.length + 3; r.appendChild(td); t.appendChild(r); }
|
||||
const w = el('div', 'table-wrap'); w.appendChild(t); card.appendChild(w); m.appendChild(card);
|
||||
}
|
||||
/** 설문 양식 편집기 (구글폼식 문항) */
|
||||
function surveyEditor(item) {
|
||||
const body = $('dlgBody'); body.innerHTML = ''; $('dlgTitle').textContent = item ? '설문 문항 편집' : '새 설문'; $('dlgMsg').textContent = '';
|
||||
const title = el('input'); title.placeholder = '설문 제목'; title.value = item ? item.title : ''; const desc = el('textarea'); desc.rows = 2; desc.placeholder = '설명(선택)'; desc.value = item ? item.description : ''; const thanks = el('input'); thanks.placeholder = '제출 후 안내 문구'; thanks.value = item ? item.thanks : '응답해 주셔서 감사합니다.'; const active = el('input'); active.type = 'checkbox'; active.style.width = 'auto'; active.checked = item ? item.active !== false : true;
|
||||
body.appendChild(el('label', '', '제목')).appendChild(title); body.appendChild(el('label', '', '설명')).appendChild(desc); body.appendChild(el('label', '', '제출 후 문구')).appendChild(thanks); const al = el('label', '', '진행 중 (끄면 사이트에서 응답을 받지 않음) '); al.appendChild(active); body.appendChild(al);
|
||||
body.appendChild(el('h3', '', '문항')); const qbox = el('div'); body.appendChild(qbox); const qs = item ? JSON.parse(JSON.stringify(item.questions)) : [];
|
||||
const TYPES = [['short', '단답'], ['long', '장문'], ['single', '객관식(하나)'], ['multi', '객관식(여러 개)'], ['scale', '척도(1~5)'], ['date', '날짜'], ['email', '이메일'], ['number', '숫자']];
|
||||
const draw = () => { qbox.innerHTML = ''; qs.forEach((q, i) => { const r = el('div', 'q-row'); const lab = el('input'); lab.placeholder = '질문'; lab.value = q.label || ''; lab.oninput = () => { q.label = lab.value; }; const ty = el('select'); for (const [v, l] of TYPES) ty.appendChild(new Option(l, v)); ty.value = q.type || 'short'; ty.onchange = () => { q.type = ty.value; draw(); }; const req = el('label', '', '필수 '); const rc = el('input'); rc.type = 'checkbox'; rc.style.width = 'auto'; rc.checked = !!q.required; rc.onchange = () => { q.required = rc.checked; }; req.appendChild(rc); const rm = btn('×', 'small danger', () => { qs.splice(i, 1); draw(); }); r.appendChild(lab); r.appendChild(ty); r.appendChild(req); r.appendChild(rm); if (q.type === 'single' || q.type === 'multi') { const op = el('textarea'); op.rows = 3; op.placeholder = '선택지 (줄바꿈으로 구분)'; op.value = (q.options || []).join('\n'); op.oninput = () => { q.options = op.value.split('\n').map((x) => x.trim()).filter(Boolean); }; r.appendChild(op); } qbox.appendChild(r); }); qbox.appendChild(btn('+ 문항 추가', 'small', () => { qs.push({ type: 'short', label: '', required: false, options: [] }); draw(); })); };
|
||||
draw();
|
||||
$('dlgSave').onclick = async () => { const data = { title: title.value, description: desc.value, thanks: thanks.value, active: active.checked, questions: qs }; try { if (item) await api(`/c/surveys/${item.id}`, { method: 'PUT', body: JSON.stringify(data) }); else await api('/c/surveys', { method: 'POST', body: JSON.stringify(data) }); $('dlg').close(); render(); } catch (e) { $('dlgMsg').textContent = e.message; $('dlgMsg').className = 'msg err'; } };
|
||||
$('dlgCancel').onclick = () => $('dlg').close(); $('dlg').showModal();
|
||||
}
|
||||
async function renderAccount(m) {
|
||||
const card = el('div', 'card'); card.appendChild(el('h2', '', '사이트 관리자 계정')); card.appendChild(el('p', 'muted small', '이 관리자 페이지에 빌더 로그인 없이 들어올 수 있는 계정입니다(회원 목록에 "관리자" 권한으로 표시). 사이트 소유자가 직원 등에게 운영을 맡길 때 만듭니다.'));
|
||||
const id = el('input'); id.placeholder = '아이디 (영문·숫자, 로그인에 사용)'; const pw = el('input'); pw.type = 'password'; pw.placeholder = window.pwRuleText ? `비밀번호(${window.pwRuleText})` : '비밀번호(6자 이상)'; const name = el('input'); name.placeholder = '표시 이름'; const msg = el('span', 'msg');
|
||||
const row = el('div', 'row'); row.appendChild(id); row.appendChild(pw); row.appendChild(name); row.appendChild(btn('만들기', 'primary', async () => { try { const r = await api('/admin-account', { method: 'POST', body: JSON.stringify({ loginId: id.value.trim(), password: pw.value, name: name.value.trim() }) }); id.value = pw.value = name.value = ''; await render(); const m2 = document.getElementById('saAccMsg'); if (m2) { m2.textContent = `관리자 계정 "${r.name}" 을(를) 만들었습니다. 위 주소로 들어와 아이디·비밀번호로 로그인할 수 있습니다.`; m2.className = 'msg ok'; } } catch (e) { msg.textContent = e.message; msg.className = 'msg err'; } })); card.appendChild(row); msg.id = 'saAccMsg'; card.appendChild(msg); m.appendChild(card);
|
||||
const c2 = el('div', 'card'); c2.appendChild(el('h2', '', '관리자 권한 계정 목록'));
|
||||
// v1.3.0: 메일 시스템이 켜져 있으면 계정마다 "메일 사용" 을 바꿀 수 있다(같은 아이디·비밀번호로 웹메일 로그인)
|
||||
const mailOn = ov.mail && ov.mail.systemEnabled;
|
||||
if (mailOn) c2.appendChild(el('p', 'muted small', '"메일 사용" 을 켠 계정은 같은 아이디·비밀번호로 웹메일(아이디@사이트도메인)을 씁니다. 끈 계정은 관리용으로만 쓰입니다. 먼저 "메일" 탭에서 이 사이트의 웹메일을 켜야 합니다.'));
|
||||
await table('members', c2, { columns: ['name', 'loginId', 'email', 'role', ...(mailOn ? ['mailAddress'] : []), 'createdAt'], label: '회원', actions: mailOn ? [{ label: '메일 사용 켜기/끄기', fn: (it) => (it.loginId ? toggleMemberMail(it) : alert('사이트 관리자 계정(아이디가 있는 계정)만 메일을 쓸 수 있습니다')) }] : [] }); m.appendChild(c2);
|
||||
}
|
||||
if (!SITE) { document.body.innerHTML = '<p style="padding:40px">잘못된 주소입니다.</p>'; } else boot();
|
||||
|
||||
// ---------- 메일 (v1.3.0) ----------
|
||||
/**
|
||||
* 메일 탭: 이 사이트 웹메일 켜기/끄기, DNS 등록 안내·확인, 계정별 메일 사용.
|
||||
* 서버 API: GET/POST /api/site-admin/<id>/mail[/enable|/disable|/check], PUT /mail/account/<회원ID>
|
||||
*/
|
||||
const MAIL_LEVEL_CLS = { ready: 'ok', 'send-only': 'ok', 'setting-up': 'warn', off: 'off' };
|
||||
async function renderMail(m) {
|
||||
const v = await api('/mail'); const st = v.status;
|
||||
const top = el('div', 'card'); top.appendChild(el('h2', '', '웹메일'));
|
||||
const banner = el('p', 'mail-banner ' + (MAIL_LEVEL_CLS[st.level] || ''), st.message); top.appendChild(banner);
|
||||
// v1.3.2: 켜기·끄기와 도메인 설정은 운영자가 관리자 콘솔에서 한다. 여기서는 상태·DNS 안내·계정만
|
||||
top.insertBefore(el('div', 'mail-state', `메일 시스템: ${st.label}`), banner);
|
||||
if (st.enabled) {
|
||||
const info = el('div', 'kv');
|
||||
info.appendChild(el('label', '', '메일 도메인')); info.appendChild(el('div', '', st.domain));
|
||||
info.appendChild(el('label', '', '웹메일 주소')); const links = el('div'); const a1 = el('a', '', v.siteWebmailUrl || v.webmailUrl); a1.href = v.siteWebmailUrl || v.webmailUrl; a1.target = '_blank'; a1.rel = 'noopener'; links.appendChild(a1);
|
||||
if (v.siteWebmailUrl) links.appendChild(el('div', 'muted small', '사이트 주소/webmail 은 메일 시스템이 켜진 뒤 한 번 다시 배포해야 운영 주소에 생깁니다. 바로 쓰려면: ' + v.webmailUrl));
|
||||
info.appendChild(links); top.appendChild(info);
|
||||
const row = el('div', 'row');
|
||||
row.appendChild(btn('웹메일 열기', 'primary', () => window.open(v.webmailUrl, '_blank', 'noopener')));
|
||||
row.appendChild(btn('DNS 다시 확인', '', async () => { try { await api('/mail/check', { method: 'POST', body: '{}' }); render(); } catch (e) { alert(e.message); } }));
|
||||
top.appendChild(row);
|
||||
}
|
||||
m.appendChild(top);
|
||||
// DNS 안내 표
|
||||
if (v.records || v.recordsError) {
|
||||
const c = el('div', 'card'); c.appendChild(el('h2', '', `DNS 등록 안내${v.previewDomain ? ' — ' + v.previewDomain : ''}`));
|
||||
if (v.recordsError) c.appendChild(el('p', 'msg err', v.recordsError));
|
||||
else {
|
||||
const dnsMap = {}; for (const r of ((st.dns && st.dns.records) || [])) dnsMap[r.key] = r;
|
||||
c.appendChild(el('p', 'muted small', `도메인 관리 화면에서 아래 레코드를 추가하세요. 반영에는 몇 분~최대 하루가 걸릴 수 있습니다.${st.dns ? ' 마지막 확인: ' + dt(st.dns.checkedAt) : ''}`));
|
||||
const t = el('table'); const tr = el('tr'); for (const h of ['종류', '호스트(이름)', '값', '우선순위', '필수', '확인 결과']) tr.appendChild(el('th', '', h)); t.appendChild(tr);
|
||||
for (const r of v.records) {
|
||||
const row = el('tr'); const chk = dnsMap[r.key];
|
||||
for (const val of [r.type, r.host, r.value, r.priority || '', r.required ? '필수' : '권장']) { const td = el('td'); td.appendChild(el('div', 'wrap mono', String(val))); row.appendChild(td); }
|
||||
const td = el('td'); if (chk) { td.appendChild(el('span', 'pill ' + (chk.ok ? 'ok' : (r.required ? 'off' : 'warn')), chk.ok ? '확인됨' : '미확인')); td.appendChild(el('div', 'muted small wrap', '현재: ' + chk.found)); } else td.appendChild(el('span', 'muted small', '켠 뒤 확인')); row.appendChild(td);
|
||||
row.title = r.why; t.appendChild(row);
|
||||
}
|
||||
const w = el('div', 'table-wrap'); w.appendChild(t); c.appendChild(w);
|
||||
c.appendChild(el('p', 'muted small', st.resend && st.resend.error ? `보내기용 도메인 등록 실패: ${st.resend.error}` : '보내기용 레코드(DKIM 등, Resend)는 웹메일을 켜면 함께 표시되며 Resend 가 확인합니다. 받기용 MX·SPF 는 이 서비스가 직접 조회합니다.'));
|
||||
}
|
||||
m.appendChild(c);
|
||||
}
|
||||
// 계정별 메일 사용
|
||||
const ac = el('div', 'card'); ac.appendChild(el('h2', '', '메일 계정'));
|
||||
ac.appendChild(el('p', 'muted small', '"관리자 계정" 탭에서 만든 사이트 관리자 계정만 메일을 쓸 수 있습니다. 메일 사용을 켜면 같은 아이디·비밀번호로 웹메일에 로그인합니다(관리자 계정과 메일 계정은 같은 계정). 끄면 관리용으로만 쓰이고, 그 계정의 메일은 모두 지워집니다.'));
|
||||
// v1.6.39 (요구 3): 메일 서버(Arimail)가 다른 서버에 있어도 비밀번호가 같게 유지된다 — 관리자가 이 페이지에 로그인할 때마다 메일 서버 비밀번호를 맞춘다. 그 상태를 열로 보여 준다
|
||||
const arimail = st.backend === 'arimail';
|
||||
if (arimail) ac.appendChild(el('p', 'muted small', `메일 서버(${st.mailServer && st.mailServer.host ? st.mailServer.host : 'Arimail'})의 비밀번호는 이 계정의 사이트 관리자 비밀번호와 같게 유지됩니다 — 관리자가 이 페이지에 로그인하면 자동으로 맞춰지므로 비밀번호를 따로 기억할 필요가 없습니다. "동기화" 가 "다음 로그인 때" 이면 그 관리자가 한 번 로그인하면 됩니다.`));
|
||||
const syncText = (a) => { const s = a.sync || { state: 'n/a' }; if (!arimail || !a.mail) return '-'; if (s.state === 'synced') return `관리자 비밀번호와 같음${s.at ? ' · ' + new Date(s.at).toLocaleString() : ''}`; if (s.state === 'pending') return '다음 로그인 때 맞춰짐 (지금은 켤 때 넣은 임시 비밀번호)'; if (s.state === 'error') return `실패: ${s.error || ''} — 관리자 비밀번호를 메일 서버 규칙에 맞게 바꾸거나 운영자에게 알리세요`; return '-'; };
|
||||
const t = el('table'); const tr = el('tr'); for (const h of ['이름', '아이디', '메일 주소', '메일 사용', '비밀번호 동기화']) tr.appendChild(el('th', '', h)); t.appendChild(tr);
|
||||
for (const a of v.accounts) {
|
||||
const row = el('tr'); row.appendChild(el('td', '', a.name)); row.appendChild(el('td', '', a.loginId)); row.appendChild(el('td', '', a.mailAddress || (st.domain ? `${a.loginId}@${st.domain} (사용 안 함)` : '-')));
|
||||
const syncTd = el('td', a.sync && a.sync.state === 'error' ? 'msg err small' : 'muted small', syncText(a)); row.appendChild(syncTd);
|
||||
const td = el('td'); const sw = el('button', 'switch' + (a.mail ? ' on' : '')); sw.type = 'button'; sw.title = a.mail ? '메일 사용 중 — 눌러서 끄기' : '관리용 계정 — 눌러서 메일 사용 켜기'; sw.disabled = !st.enabled && !a.mail; sw.onclick = () => toggleMemberMail(a, st); td.appendChild(sw); row.appendChild(td); t.appendChild(row);
|
||||
}
|
||||
if (!v.accounts.length) { const r = el('tr'); const td = el('td', 'muted', '사이트 관리자 계정이 없습니다. "관리자 계정" 탭에서 먼저 만드세요.'); td.colSpan = 5; r.appendChild(td); t.appendChild(r); }
|
||||
const w = el('div', 'table-wrap'); w.appendChild(t); ac.appendChild(w); m.appendChild(ac);
|
||||
}
|
||||
/**
|
||||
* 계정 메일 사용 켜기/끄기.
|
||||
* 끌 때는 메일함이 지워지므로 ① 삭제 안내 확인 ② "삭제" 입력 ③ 지금 로그인한 관리자의 비밀번호 재입력을 받는다.
|
||||
* @param {{id, loginId, mail}} acc 계정
|
||||
*/
|
||||
async function toggleMemberMail(acc, st) {
|
||||
if (!acc.mail) {
|
||||
// v1.5.7: 받는 메일 서버(Arimail)가 연결된 사이트는 켤 때 메일 비밀번호를 받아 Arimail 계정을 만든다(같은 로그인 ID).
|
||||
// v1.6.39 (요구 3): 메일 비밀번호 = 사이트 관리자 비밀번호. 본인 계정이면 지금 로그인한 비밀번호를 넣어야 하고(다르면 서버가 거절),
|
||||
// 소유자·운영자가 다른 관리자의 계정을 대신 켤 때는 임시 비밀번호를 넣는다 — 그 관리자가 다음에 로그인하면 자동으로 관리자 비밀번호로 맞춰진다.
|
||||
if (st && st.backend === 'arimail') {
|
||||
const body = $('dlgBody'); body.innerHTML = ''; $('dlgTitle').textContent = `메일 사용 켜기 — ${acc.loginId}`; $('dlgMsg').textContent = '';
|
||||
body.appendChild(el('p', 'small', `메일 서버(${st.mailServer && st.mailServer.host ? st.mailServer.host : 'Arimail'})에 ${acc.loginId}@${st.domain} 계정을 만듭니다. 메일 비밀번호는 이 계정의 사이트 관리자 비밀번호와 같게 유지됩니다 — 본인 계정이면 지금 로그인한 비밀번호를 그대로 입력하세요. 다른 관리자의 계정을 대신 켜는 경우에는 임시 비밀번호를 넣으면 되고, 그 관리자가 다음에 로그인할 때 자동으로 맞춰집니다.`));
|
||||
const p1 = el('input'); p1.type = 'password'; p1.placeholder = '사이트 관리자 비밀번호 (대신 켤 때는 임시 비밀번호)'; p1.autocomplete = 'current-password'; const p2 = el('input'); p2.type = 'password'; p2.placeholder = '비밀번호 다시 입력'; p2.autocomplete = 'current-password';
|
||||
body.appendChild(el('label', '', '비밀번호')).appendChild(p1); body.appendChild(el('label', '', '다시 입력')).appendChild(p2);
|
||||
$('dlgSave').textContent = '계정 만들고 켜기';
|
||||
$('dlgSave').onclick = async () => { if (!p1.value) { $('dlgMsg').textContent = '메일 비밀번호를 입력하세요'; return; } if (p1.value !== p2.value) { $('dlgMsg').textContent = '두 비밀번호가 다릅니다'; return; } try { const r = await api(`/mail/account/${acc.id}`, { method: 'PUT', body: JSON.stringify({ on: true, password: p1.value }) }); $('dlg').close(); $('dlgSave').textContent = '저장'; if (r.note) alert(`메일 사용을 켰습니다.\n${r.note}`); render(); } catch (e) { $('dlgMsg').textContent = e.message; } };
|
||||
$('dlgCancel').onclick = () => { $('dlg').close(); $('dlgSave').textContent = '저장'; }; $('dlg').showModal(); return;
|
||||
}
|
||||
try { const r = await api(`/mail/account/${acc.id}`, { method: 'PUT', body: JSON.stringify({ on: true }) }); if (r.note) alert(`메일 사용을 켰습니다.\n${r.note}`); render(); } catch (e) { alert(e.message); } return;
|
||||
}
|
||||
if (!confirm(`"${acc.loginId}" 계정의 메일 사용을 끕니다.\n\n이 계정의 메일(${acc.mailAddress || ''})과 메일함이 모두 지워지며 되돌릴 수 없습니다. 관리자 계정은 그대로 남아 관리용으로만 쓰입니다.\n\n계속할까요?`)) return;
|
||||
const body = $('dlgBody'); body.innerHTML = ''; $('dlgTitle').textContent = '메일 사용 끄기 — 다시 확인'; $('dlgMsg').textContent = '';
|
||||
body.appendChild(el('p', 'small', `${acc.mailAddress || acc.loginId} 의 메일이 모두 삭제됩니다. 확인란에 "삭제" 를 입력하고, 지금 로그인한 관리자의 비밀번호를 입력하세요.`));
|
||||
const c = el('input'); c.placeholder = '삭제'; const pw = el('input'); pw.type = 'password'; pw.placeholder = '관리자 비밀번호'; pw.autocomplete = 'current-password';
|
||||
body.appendChild(el('label', '', '확인 문구')).appendChild(c); body.appendChild(el('label', '', '관리자 비밀번호')).appendChild(pw);
|
||||
$('dlgSave').textContent = '메일 삭제 후 끄기';
|
||||
$('dlgSave').onclick = async () => { try { const r = await api(`/mail/account/${acc.id}`, { method: 'PUT', body: JSON.stringify({ on: false, confirm: c.value, password: pw.value }) }); $('dlg').close(); $('dlgSave').textContent = '저장'; alert(`메일 사용을 껐습니다. 지운 메일 ${r.deletedMessages || 0}통.`); render(); } catch (e) { $('dlgMsg').textContent = e.message; $('dlgMsg').className = 'msg err'; } };
|
||||
$('dlgCancel').onclick = () => { $('dlg').close(); $('dlgSave').textContent = '저장'; }; $('dlg').showModal();
|
||||
}
|
||||
|
||||
// ---------- 일정관리 (v1.4.18) ----------
|
||||
/**
|
||||
* 일정관리 탭의 일정 카드: 날짜 조회(양력 → 음력·간지·절기), 일정 표(다음 발생일 포함), 일정 편집기.
|
||||
* 일정 데이터 규칙은 lib/calendar.js(normalizeEvent) — 시·분/일/주 단위, 양력·음력 시작일, 반복(매일·매주·매월·매년).
|
||||
*/
|
||||
const CAL_UNIT = { time: '시간 단위', day: '일 단위', week: '주 단위' };
|
||||
const CAL_FREQ = { none: '반복 없음', daily: '매일', weekly: '매주', monthly: '매월', yearly: '매년' };
|
||||
const CAL_WD = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
const CAL_COLORS = [['', '기본(파랑)'], ['green', '초록'], ['red', '빨강'], ['orange', '주황'], ['purple', '보라'], ['gray', '회색']];
|
||||
/** 일정 한 건의 날짜 표시: 음력 일정이면 "음력 2026.9.4 (양 2026-10-14)" */
|
||||
const calWhen = (it) => (it.cal === 'lunar' && it.lunar ? `음력 ${it.lunar.year}.${it.lunar.leap ? '윤' : ''}${it.lunar.month}.${it.lunar.day} (양 ${it.date})` : it.date) + (it.unit === 'time' ? ` ${it.time}${it.endTime ? '–' + it.endTime : ''}` : it.length > 1 ? ` · ${it.length}${it.unit === 'week' ? '주' : '일'}` : it.unit === 'week' ? ' · 1주' : '');
|
||||
const calRepeat = (it) => { const r = it.repeat || {}; if (!r.freq || r.freq === 'none') return '-'; let s = (r.interval > 1 ? `${r.interval}${{ daily: '일', weekly: '주', monthly: '개월', yearly: '년' }[r.freq]}마다` : CAL_FREQ[r.freq]) + (it.cal === 'lunar' && (r.freq === 'monthly' || r.freq === 'yearly') ? '(음력)' : ''); if (r.freq === 'weekly' && (r.weekdays || []).length) s += ' ' + r.weekdays.map((d) => CAL_WD[d]).join('·'); if (r.until) s += ` ~${r.until}`; return s; };
|
||||
async function renderCalendarEvents(card, def, h) {
|
||||
h.appendChild(btn('+ 일정 추가', 'small primary', () => eventEditor(null)));
|
||||
// 날짜 조회 도구: 캘린더에 보이는 음력·간지가 맞는지 바로 확인할 수 있다 (공개 API /api/m/<사이트>/calendar/day)
|
||||
const tool = el('div', 'row'); const di = el('input'); di.type = 'date'; di.style.width = '170px'; di.value = new Date().toISOString().slice(0, 10); const out = el('span', 'small');
|
||||
const look = async () => { out.textContent = '조회 중…'; try { const r = await fetch(`/api/m/${SITE}/calendar/day?date=${encodeURIComponent(di.value)}`); const j = await r.json(); if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`); out.textContent = `음력 ${j.lunar.year}.${j.lunar.leap ? '윤' : ''}${j.lunar.month}.${j.lunar.day} (${j.lunar.monthDays}일까지) · ${j.ganji.text} (${j.ganji.textHanja}, ${j.ganji.zodiac}띠)` + (j.term ? ` · 절기: ${j.term}` : ''); out.className = 'small ok'; } catch (e) { out.textContent = e.message; out.className = 'small msg err'; } };
|
||||
const lab = el('label', '', '날짜 조회(양력 → 음력·만세력 간지·절기): '); lab.appendChild(di); tool.appendChild(lab); tool.appendChild(btn('조회', 'small', look)); tool.appendChild(out); card.appendChild(tool);
|
||||
card.appendChild(el('p', 'muted small', '일정은 시·분 단위(하루 안의 시작~종료 시각) / 일 단위(며칠) / 주 단위(몇 주)로 등록하고, 반복(매일·매주·매월·매년)과 음력 기준(음력 생일·제사·명절 등)을 정할 수 있습니다. "다음 발생" 은 오늘 이후 처음 캘린더에 나타나는 날입니다.'));
|
||||
await table('events', card, { columns: ['title', 'when', 'unit', 'repeat', 'category', 'next', 'published', 'createdAt'], label: def.label, edit: (it) => eventEditor(it), format: { when: (v, it) => calWhen(it), unit: (v) => CAL_UNIT[v] || v, repeat: (v, it) => calRepeat(it), next: (v) => v || '(2년 안에 없음)' } });
|
||||
}
|
||||
/** 일정 추가·수정 대화상자. 단위·달력·반복 선택에 따라 보이는 입력이 달라진다. */
|
||||
function eventEditor(item) {
|
||||
const body = $('dlgBody'); body.innerHTML = ''; $('dlgTitle').textContent = (item ? '일정 수정' : '일정 추가'); $('dlgMsg').textContent = '';
|
||||
const cats = ((ov.settings.calendar || {}).categories || []);
|
||||
const field = (label, inp, hint) => { const l = el('label', '', label); l.appendChild(inp); if (hint) l.appendChild(el('span', 'muted small', ' ' + hint)); body.appendChild(l); return l; };
|
||||
// 숫자 칸(연도·월·일·기간·간격)은 3자리 쉼표를 붙이지 않는다(numfmt.js 의 data-plain) — 2026 이 2,026 으로 보이면 안 된다
|
||||
const inp = (type, val, w) => { const i = el('input'); i.type = type; if (type === 'number') i.setAttribute('data-plain', ''); if (val != null) i.value = val; if (w) i.style.width = w; return i; };
|
||||
const sel = (pairs, val) => { const s = el('select'); for (const [v, l] of pairs) s.appendChild(new Option(l, v)); s.value = val; return s; };
|
||||
const title = inp('text', item ? item.title : ''); title.maxLength = 120; field('제목 *', title);
|
||||
const cat = inp('text', item ? item.category : ''); cat.setAttribute('list', 'calCats'); let dl = document.getElementById('calCats'); if (!dl) { dl = document.createElement('datalist'); dl.id = 'calCats'; document.body.appendChild(dl); } dl.innerHTML = cats.map((c) => `<option value="${esc(c)}">`).join(''); field('분류', cat, cats.length ? `(설정의 분류: ${cats.join(', ')})` : '');
|
||||
const color = sel(CAL_COLORS, item ? item.color || '' : ''); field('색', color);
|
||||
const unit = sel([['time', '시간 단위(시·분)'], ['day', '일 단위'], ['week', '주 단위']], item ? item.unit : 'day'); field('일정 단위', unit);
|
||||
const cal = sel([['solar', '양력'], ['lunar', '음력(만세력)']], item ? item.cal : 'solar'); field('달력', cal);
|
||||
const date = inp('date', item ? item.date : new Date().toISOString().slice(0, 10)); const dateLab = field('시작일(양력)', date);
|
||||
const ln = item && item.lunar ? item.lunar : { year: new Date().getFullYear(), month: 1, day: 1, leap: false };
|
||||
const ly = inp('number', ln.year, '90px'); ly.min = 1900; ly.max = 2100; const lm = inp('number', ln.month, '64px'); lm.min = 1; lm.max = 12; const ld = inp('number', ln.day, '64px'); ld.min = 1; ld.max = 30; const leap = inp('checkbox'); leap.checked = !!ln.leap; leap.style.width = 'auto';
|
||||
const lrow = el('div', 'row'); lrow.appendChild(ly); lrow.appendChild(el('span', '', '년')); lrow.appendChild(lm); lrow.appendChild(el('span', '', '월')); lrow.appendChild(ld); lrow.appendChild(el('span', '', '일')); const ll = el('label', '', '윤달 '); ll.appendChild(leap); ll.style.width = 'auto'; lrow.appendChild(ll); const lprev = el('span', 'muted small'); lrow.appendChild(lprev);
|
||||
const lunLab = el('label', '', '시작일(음력)'); lunLab.appendChild(lrow); body.appendChild(lunLab);
|
||||
const time = inp('time', item ? item.time : '10:00'); const endTime = inp('time', item ? item.endTime : ''); const trow = el('div', 'row'); trow.appendChild(time); trow.appendChild(el('span', '', '~')); trow.appendChild(endTime); const timeLab = el('label', '', '시작 ~ 종료 시각 (종료는 선택)'); timeLab.appendChild(trow); body.appendChild(timeLab);
|
||||
const len = inp('number', item ? item.length || 1 : 1, '90px'); len.min = 1; const lenLab = field('기간', len);
|
||||
const freq = sel(Object.entries(CAL_FREQ), item && item.repeat ? item.repeat.freq : 'none'); field('반복', freq);
|
||||
const iv = inp('number', item && item.repeat ? item.repeat.interval || 1 : 1, '80px'); iv.min = 1; iv.max = 99; const ivLab = field('반복 간격', iv);
|
||||
const wdBox = el('div', 'row'); const wdInputs = CAL_WD.map((n, i) => { const l = el('label', '', n + ' '); l.style.width = 'auto'; const c = inp('checkbox'); c.style.width = 'auto'; c.checked = !!(item && item.repeat && (item.repeat.weekdays || []).includes(i)); l.appendChild(c); wdBox.appendChild(l); return c; }); const wdLab = el('label', '', '반복 요일 (비우면 시작일의 요일)'); wdLab.appendChild(wdBox); body.appendChild(wdLab);
|
||||
const until = inp('date', item && item.repeat ? item.repeat.until || '' : ''); const untilLab = field('반복 종료일 (비우면 계속)', until);
|
||||
const loc = inp('text', item ? item.location : ''); loc.maxLength = 200; field('장소', loc);
|
||||
const desc = el('textarea'); desc.rows = 4; desc.value = item ? item.body || '' : ''; field('설명', desc);
|
||||
const pub = inp('checkbox'); pub.style.width = 'auto'; pub.checked = item ? item.published !== false : true; const pl = el('label', '', '사이트에 공개 '); pl.appendChild(pub); body.appendChild(pl);
|
||||
const hint = el('p', 'muted small'); body.appendChild(hint);
|
||||
/** 음력 입력 → 양력 미리보기 (그 해 윤달 여부·달 길이도 함께) */
|
||||
let lt = null; const preview = () => { if (cal.value !== 'lunar') return; clearTimeout(lt); lt = setTimeout(async () => { try { const r = await fetch(`/api/m/${SITE}/calendar/lunar?year=${ly.value}&month=${lm.value}&day=${ld.value}&leap=${leap.checked ? 1 : 0}`); const j = await r.json(); if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`); lprev.textContent = (j.solar ? `→ 양력 ${j.solar}` : `→ 그 날이 없습니다(이 달은 ${j.monthDays}일까지)`) + (leap.checked && !j.hasLeap ? ` · ${ly.value}년에는 윤${lm.value}월이 없어 평달로 봅니다` : '') + (j.leapMonths.length ? ` · ${ly.value}년 윤달: ${j.leapMonths.map((m) => '윤' + m + '월').join(', ')}` : ''); lprev.className = 'small ok'; } catch (e) { lprev.textContent = e.message; lprev.className = 'small msg err'; } }, 250); };
|
||||
const sync = () => {
|
||||
const u = unit.value; timeLab.style.display = u === 'time' ? '' : 'none'; lenLab.style.display = u === 'time' ? 'none' : ''; lenLab.firstChild.textContent = u === 'week' ? '기간(몇 주)' : '기간(며칠)';
|
||||
const lunar = cal.value === 'lunar'; dateLab.style.display = lunar ? 'none' : ''; lunLab.style.display = lunar ? '' : 'none';
|
||||
const f = freq.value; ivLab.style.display = f === 'none' ? 'none' : ''; untilLab.style.display = f === 'none' ? 'none' : ''; wdLab.style.display = f === 'weekly' ? '' : 'none';
|
||||
ivLab.firstChild.textContent = `반복 간격(${{ daily: '일', weekly: '주', monthly: '개월', yearly: '년' }[f] || ''} 단위, 1 = 매번)`;
|
||||
hint.textContent = lunar && (f === 'monthly' || f === 'yearly') ? (f === 'yearly' ? '음력 매년: 해마다 같은 음력 달·날에 표시됩니다(그 해에 같은 윤달이 없으면 평달, 30일이 없으면 29일). 양력 날짜는 해마다 달라집니다.' : '음력 매월: 음력 달마다 같은 날에 표시됩니다(윤달도 한 달로 셉니다).') : lunar ? '음력 시작일은 양력으로 바꿔 저장되고, 매일·매주 반복은 그 양력 날짜부터 이어집니다.' : f === 'monthly' || f === 'yearly' ? '31일·2월 29일처럼 없는 날은 그 달의 마지막 날에 표시됩니다.' : '';
|
||||
if (lunar) preview();
|
||||
};
|
||||
[unit, cal, freq].forEach((s) => { s.onchange = sync; }); [ly, lm, ld].forEach((i) => { i.oninput = preview; }); leap.onchange = preview; sync();
|
||||
$('dlgSave').onclick = async () => {
|
||||
const data = { title: title.value, category: cat.value, color: color.value, unit: unit.value, cal: cal.value, date: date.value, lunar: { year: ly.value, month: lm.value, day: ld.value, leap: leap.checked }, time: time.value, endTime: endTime.value, length: len.value, repeat: { freq: freq.value, interval: iv.value, until: until.value, weekdays: wdInputs.map((c, i) => (c.checked ? i : -1)).filter((i) => i >= 0) }, location: loc.value, body: desc.value, published: pub.checked };
|
||||
try { if (item) await api(`/c/events/${item.id}`, { method: 'PUT', body: JSON.stringify(data) }); else await api('/c/events', { method: 'POST', body: JSON.stringify(data) }); $('dlg').close(); render(); } catch (e) { $('dlgMsg').textContent = e.message; $('dlgMsg').className = 'msg err'; }
|
||||
};
|
||||
$('dlgCancel').onclick = () => $('dlg').close(); $('dlg').showModal();
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
'use strict';
|
||||
/* ============================================================================
|
||||
* 사이트 정보 페이지 (v1.7.2, 요구 2) — 운영 화면의 오른쪽 캔버스 전체를 쓰는 그래픽 대시보드
|
||||
*
|
||||
* 1.4.14~1.6.40 의 "사이트 정보" 카드(가운데 작업 칸의 좁은 카드)와 "사이트 현황" 보기를 합쳐 오른쪽 캔버스(#mvStatus)에 크게 그린다.
|
||||
* 구성(위→아래):
|
||||
* ① 사이트 상태 타일: 상태 · 배포 · 운영 주소 · 도메인 · 버전 · 사용 캐시 · 문의 · 메일 · 켜진 모듈
|
||||
* ② 접속 통계: 오늘/어제/최근 7일/이번 달/누적 타일 + 최근 30일 방문자·페이지뷰 막대 그래프(SVG)
|
||||
* ③ 네트워크: 최근 30일 송신·수신 선 그래프(SVG, 따로 두 선) + 최근 7일 합계 막대(송신·수신·전체) + 월간 막대(직전 2개월 + 이번 달 누적, 달력 기준 1일~말일)
|
||||
* ④ 저장 공간: 웹 데이터(사이트 파일·첨부) · DB 데이터(사이트 정보·문의·통계·버전 저장소) · 메일 데이터(메일 미설정이면 숨김). 허용량은 아직 없어 표시하지 않는다
|
||||
* ⑤ 많이 본 페이지 · 유입 경로 (30일)
|
||||
* 그래프는 외부 라이브러리 없이 SVG 를 직접 만든다 — 값마다 <title> 로 정확한 숫자를 보인다.
|
||||
* 자료: app.js 의 statsData(GET /api/sites/<ID>/stats — stats.summary: series(30일), series7, months(3개월), disk{web,mail}, db), current(사이트), manage-ui 가 넘기는 문의·모듈.
|
||||
* 전역 의존: $, T, TF, fmt, fmtBytes, krwFmt, uiLocale
|
||||
* ========================================================================== */
|
||||
(() => {
|
||||
const NS = 'http://www.w3.org/2000/svg';
|
||||
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined && text !== null) e.textContent = text; return e; };
|
||||
const svg = (tag, attrs) => { const e = document.createElementNS(NS, tag); for (const [k, v] of Object.entries(attrs || {})) e.setAttribute(k, v); return e; };
|
||||
const dtm = (s) => (s ? new Date(s).toLocaleString(uiLocale(), { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '');
|
||||
const COLOR = { out: '#2f6fed', in: '#15803d', total: '#64748b', pv: '#c7d2fe', v: '#2f6fed', web: '#2f6fed', db: '#7c3aed', mail: '#0e9f6e' };
|
||||
const C = { bar: '#c7d2fe' };
|
||||
|
||||
/** 바이트 단위 눈금용: 최대값에 맞는 단위와 나누는 수 */
|
||||
function unitFor(max) { if (max >= 1024 ** 3) return ['GB', 1024 ** 3]; if (max >= 1024 ** 2) return ['MB', 1024 ** 2]; if (max >= 1024) return ['KB', 1024]; return ['B', 1]; }
|
||||
/** 눈금 값(0 포함 4~5개)을 보기 좋은 수로 */
|
||||
function ticks(max, n = 4) { if (!(max > 0)) return [0]; const raw = max / n; const pow = Math.pow(10, Math.floor(Math.log10(raw))); const step = [1, 2, 2.5, 5, 10].map((m) => m * pow).find((s) => s >= raw) || raw; const out = []; for (let v = 0; v <= max + step * 0.001; v += step) out.push(v); if (out[out.length - 1] < max) out.push(out[out.length - 1] + step); return out; }
|
||||
|
||||
/**
|
||||
* 선 그래프 (여러 선). series: [{ key, label, color, values: number[] }], labels: x 축 이름(일자). 바이트면 bytes=true 로 단위 눈금.
|
||||
*/
|
||||
function lineChart({ series, labels, bytes, height = 180, empty, width = 720 }) {
|
||||
const W = width, H = height, L = 56, R = 12, Tp = 10, B = 26; const n = labels.length; const max = Math.max(0, ...series.flatMap((s) => s.values)); const tk = ticks(max); const top = tk[tk.length - 1] || 1;
|
||||
const root = svg('svg', { viewBox: `0 0 ${W} ${H}`, class: 'si-chart', role: 'img' });
|
||||
const [u, div] = bytes ? unitFor(top) : ['', 1];
|
||||
const x = (i) => L + (n <= 1 ? 0 : (i * (W - L - R)) / (n - 1)); const y = (v) => Tp + (H - Tp - B) * (1 - v / top);
|
||||
for (const t of tk) { root.appendChild(svg('line', { x1: L, x2: W - R, y1: y(t), y2: y(t), stroke: '#e5e7eb', 'stroke-width': 1 })); const tx = svg('text', { x: L - 6, y: y(t) + 4, 'text-anchor': 'end', class: 'si-tick' }); tx.textContent = bytes ? `${(t / div).toLocaleString(uiLocale(), { maximumFractionDigits: 1 })}${u}` : fmt(t); root.appendChild(tx); }
|
||||
const every = n > 16 ? Math.ceil(n / 8) : 1;
|
||||
// 마지막 날짜는 앞 라벨과 겹치지 않을 때만 보인다
|
||||
let lastShown = -every; labels.forEach((lb, i) => { if (!(i % every === 0 || (i === n - 1 && i - lastShown >= Math.max(2, every / 2)))) return; lastShown = i; const tx = svg('text', { x: x(i), y: H - 8, 'text-anchor': 'middle', class: 'si-tick' }); tx.textContent = lb.slice(5); root.appendChild(tx); });
|
||||
if (!(max > 0)) { const tx = svg('text', { x: (W + L) / 2, y: H / 2, 'text-anchor': 'middle', class: 'si-empty' }); tx.textContent = empty || T('아직 기록이 없습니다'); root.appendChild(tx); }
|
||||
for (const s of series) {
|
||||
const d = s.values.map((v, i) => `${i ? 'L' : 'M'}${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ');
|
||||
root.appendChild(svg('path', { d, fill: 'none', stroke: s.color, 'stroke-width': 2, 'stroke-linejoin': 'round', 'stroke-linecap': 'round' }));
|
||||
s.values.forEach((v, i) => { const c = svg('circle', { cx: x(i), cy: y(v), r: 2.5, fill: s.color }); const t = svg('title'); t.textContent = `${labels[i]} · ${s.label}: ${bytes ? fmtBytes(v) : fmt(v)}`; c.appendChild(t); root.appendChild(c); });
|
||||
}
|
||||
return root;
|
||||
}
|
||||
/**
|
||||
* 묶음 막대 그래프. groups: [{ label, sub?, bars: [{ key, label, color, value }] }]. bytes 면 단위 눈금.
|
||||
*/
|
||||
function barChart({ groups, bytes, height = 190, empty, width = 720 }) {
|
||||
const W = width, H = height, L = 56, R = 12, Tp = 10, B = 34; const max = Math.max(0, ...groups.flatMap((g) => g.bars.map((b) => b.value))); const tk = ticks(max); const top = tk[tk.length - 1] || 1;
|
||||
const root = svg('svg', { viewBox: `0 0 ${W} ${H}`, class: 'si-chart', role: 'img' }); const [u, div] = bytes ? unitFor(top) : ['', 1];
|
||||
const y = (v) => Tp + (H - Tp - B) * (1 - v / top);
|
||||
for (const t of tk) { root.appendChild(svg('line', { x1: L, x2: W - R, y1: y(t), y2: y(t), stroke: '#e5e7eb' })); const tx = svg('text', { x: L - 6, y: y(t) + 4, 'text-anchor': 'end', class: 'si-tick' }); tx.textContent = bytes ? `${(t / div).toLocaleString(uiLocale(), { maximumFractionDigits: 1 })}${u}` : fmt(t); root.appendChild(tx); }
|
||||
const gw = (W - L - R) / Math.max(1, groups.length); const nb = Math.max(1, ...groups.map((g) => g.bars.length)); const bw = Math.min(42, (gw * 0.7) / nb);
|
||||
groups.forEach((g, gi) => {
|
||||
const gx = L + gi * gw + (gw - bw * g.bars.length) / 2;
|
||||
g.bars.forEach((b, bi) => { const h = Math.max(0, (H - Tp - B) * (b.value / top)); const r = svg('rect', { x: gx + bi * bw + 2, y: y(b.value), width: Math.max(1, bw - 4), height: h, rx: 3, fill: b.color }); const t = svg('title'); t.textContent = `${g.label} · ${b.label}: ${bytes ? fmtBytes(b.value) : fmt(b.value)}`; r.appendChild(t); root.appendChild(r);
|
||||
if (b.value > 0 && bw >= 28) { const tx = svg('text', { x: gx + bi * bw + bw / 2, y: y(b.value) - 3, 'text-anchor': 'middle', class: 'si-val' }); tx.textContent = bytes ? fmtBytes(b.value) : fmt(b.value); root.appendChild(tx); } });
|
||||
const every = groups.length > 12 ? Math.ceil(groups.length / 8) : 1; // 묶음이 많으면(30일) 이름을 띄엄띄엄
|
||||
if (gi % every === 0 || (gi === groups.length - 1 && gi % every >= Math.max(2, every / 2))) { const lx = svg('text', { x: L + gi * gw + gw / 2, y: H - 18, 'text-anchor': 'middle', class: 'si-xl' }); lx.textContent = g.label; root.appendChild(lx); }
|
||||
if (g.sub) { const sx = svg('text', { x: L + gi * gw + gw / 2, y: H - 6, 'text-anchor': 'middle', class: 'si-tick' }); sx.textContent = g.sub; root.appendChild(sx); }
|
||||
});
|
||||
if (!(max > 0)) { const tx = svg('text', { x: (W + L) / 2, y: H / 2 - 10, 'text-anchor': 'middle', class: 'si-empty' }); tx.textContent = empty || T('아직 기록이 없습니다'); root.appendChild(tx); }
|
||||
return root;
|
||||
}
|
||||
/** 범례 */
|
||||
function legend(items) { const l = el('div', 'si-legend'); for (const it of items) { const s = el('span'); const sw = el('i'); sw.style.background = it.color; s.appendChild(sw); s.appendChild(document.createTextNode(it.label)); l.appendChild(s); } return l; }
|
||||
/** 가로 누적 막대 (저장 공간) */
|
||||
function stackBar(parts) { const total = parts.reduce((a, p) => a + (p.value || 0), 0); const bar = el('div', 'si-stack'); for (const p of parts) { const seg = el('div', 'si-seg'); seg.style.width = total ? `${((p.value || 0) / total) * 100}%` : '0'; seg.style.background = p.color; seg.title = `${p.label}: ${fmtBytes(p.value || 0)}`; bar.appendChild(seg); } return bar; }
|
||||
|
||||
const tile = (grid, k, v, d, cls) => { const t = el('div', 'tot' + (cls ? ' ' + cls : '')); t.appendChild(el('span', 'k', k)); const vv = el('span', 'v'); vv.textContent = v; t.appendChild(vv); if (d) t.appendChild(el('span', 'd', d)); grid.appendChild(t); return t; };
|
||||
const section = (root, title, note) => { const h = el('section', 'si-sec'); const head = el('div', 'si-head'); head.appendChild(el('h3', null, title)); if (note) head.appendChild(el('span', 'muted small', note)); h.appendChild(head); root.appendChild(h); return h; };
|
||||
|
||||
/**
|
||||
* 페이지 그리기.
|
||||
* @param {HTMLElement} root #mvStatus
|
||||
* @param {{site, stats, inquiries, modules}} ctx 사이트(current)·통계(statsData)·문의 목록 응답·모듈 상태
|
||||
*/
|
||||
function render(root, ctx) {
|
||||
const { site, stats: st, inquiries, modules } = ctx; root.innerHTML = ''; if (!site) return;
|
||||
root.appendChild(el('h2', 'si-title', `${site.title} — ${T('사이트 정보')}`));
|
||||
// ① 상태 타일
|
||||
{ const s = section(root, T('사이트 상태')); const g = el('div', 'stat-grid si-grid'); s.appendChild(g);
|
||||
const p = site.plan; const stageTxt = site.mode === 'import' ? '가져옴' : site.mode === 'copy' ? '복사함' : (p && !['generated', 'published'].includes(p.stage) ? '생성 중' : (p && p.stage === 'published' ? '발행됨' : '생성 완료'));
|
||||
tile(g, T('상태'), T(stageTxt), site.editLimit && site.editLimit.max ? TF('수정 {1}/{2}회', site.editLimit.used, site.editLimit.max) : '');
|
||||
const ds = site.deployState || {}; const lastDep = (site.deployments || []).slice(-1)[0];
|
||||
tile(g, T('배포'), lastDep ? (lastDep.ok ? T({ 'vhost-wait': '웹서버 설정 대기', 'dns-wait': 'DNS 설정 대기', 'cert-wait': '인증서 준비 중', 'check-failed': '접속 확인 실패', 'vhost-failed': '도메인 연결 실패' }[ds.phase] || '배포됨') : T('배포 실패')) : T('배포 전'), lastDep ? `${dtm(lastDep.at)}${ds.state === 'pending' && ds.lastChangeAt && ds.lastDeployAt && ds.lastChangeAt > ds.lastDeployAt ? ' · ' + T('변경 있음(재배포 필요)') : ''}` : (site.unpublishedAt ? T('발행 취소됨') : ''));
|
||||
tile(g, T('운영 주소'), site.liveUrl ? T('연결됨') : T('미리보기만'), site.liveUrl || site.url);
|
||||
tile(g, T('도메인'), (site.domains || []).length ? TF('{1}개', site.domains.length) : T('없음'), (site.domains || []).join(', '));
|
||||
tile(g, T('버전'), TF('{1}개', (site.versions || []).length), site.versions && site.versions.length ? site.versions[site.versions.length - 1].message : '');
|
||||
tile(g, T('사용 캐시'), krwFmt((site.totals || {}).chargeKrw || 0), TF('작업 {1}건', (site.commands || []).length));
|
||||
const q = inquiries && inquiries.inquiries ? inquiries.inquiries : null; const unsent = q ? q.filter((x) => !x.sent).length : 0;
|
||||
tile(g, T('문의 접수'), q ? TF('{1}건', q.length) : '-', q ? (unsent ? TF('미발송 {1}건', unsent) : (inquiries.contactEmail ? `${T('수신')} ${inquiries.contactEmail}` : T('수신 이메일 없음'))) : '');
|
||||
const m = site.mail; tile(g, T('메일'), m && m.level !== 'off' ? m.label : T('꺼짐'), m && m.level !== 'off' ? m.domain : '');
|
||||
const on = modules ? (modules.catalog || []).filter((c) => modules.enabled && modules.enabled[c.id]) : [];
|
||||
tile(g, T('켜진 모듈'), modules ? TF('{1}개', on.length) : '-', on.map((c) => T(c.label)).join(', ')); }
|
||||
if (!st) { root.appendChild(el('p', 'muted small', T('통계를 불러오는 중입니다…'))); return; }
|
||||
const useLog = st.sources && st.sources.log; const pick = (a) => (useLog ? { v: a.visitors || 0, pv: a.pv || 0 } : { v: a.beaconVisitors || 0, pv: a.beaconPv || 0 });
|
||||
const outOf = (a) => (useLog ? a.bytesOut || 0 : a.beaconBytesOut || 0); const inOf = (a) => (useLog ? a.bytesIn || 0 : 0);
|
||||
// ② 접속 통계
|
||||
{ const s = section(root, T('접속 통계'), st.updatedAt ? `(${T('집계')} ${dtm(st.updatedAt)})` : `(${T('아직 집계된 접속이 없습니다')})`); const g = el('div', 'stat-grid si-grid'); s.appendChild(g);
|
||||
for (const [label, a] of [['오늘', st.today], ['어제', st.yesterday], ['최근 7일', st.last7], ['이번 달', st.month], ['누적', st.total]]) { const x = pick(a || {}); tile(g, T(label), `${fmt(x.v)}${T('명')}`, `${T('페이지뷰')} ${fmt(x.pv)}`); }
|
||||
const series = st.series || []; const labels = series.map((x) => x.day);
|
||||
s.appendChild(el('div', 'field-label', T('최근 30일 방문자 · 페이지뷰')));
|
||||
s.appendChild(barChart({ groups: series.map((x) => ({ label: x.day.slice(5), bars: [{ key: 'v', label: T('방문자'), color: COLOR.v, value: pick(x).v }, { key: 'pv', label: T('페이지뷰'), color: COLOR.pv, value: pick(x).pv }] })), height: 200 }));
|
||||
s.appendChild(legend([{ label: T('방문자'), color: COLOR.v }, { label: T('페이지뷰'), color: COLOR.pv }]));
|
||||
s.appendChild(el('p', 'muted small', useLog ? T('방문자·페이지뷰·네트워크는 사이트가 올라간 서버의 접속 로그로 집계합니다(5분 주기, 검색엔진 봇 제외). 방문자는 하루 단위로 같은 기기·브라우저를 한 명으로 셉니다(IP 는 저장하지 않음).') : (st.sources && st.sources.beacon ? T('이 사이트는 서버 로그를 읽을 수 없어 페이지에 삽입된 통계 스크립트가 보고한 값(추정)으로 집계합니다. 수신량은 측정할 수 없습니다.') : T('배포 후 방문이 생기면 여기에 집계됩니다.'))));
|
||||
void labels; }
|
||||
// ③ 네트워크
|
||||
{ const s = section(root, T('네트워크 사용량'), useLog ? T('서버 로그 측정값 (요청·응답 전체 바이트)') : T('브라우저 보고 기반 추정치 (송신만)'));
|
||||
const series = st.series || []; const labels = series.map((x) => x.day);
|
||||
s.appendChild(el('div', 'field-label', T('최근 30일 송신 · 수신 (일별)')));
|
||||
s.appendChild(lineChart({ series: [{ key: 'out', label: T('송신'), color: COLOR.out, values: series.map(outOf) }, { key: 'in', label: T('수신'), color: COLOR.in, values: series.map(inOf) }], labels, bytes: true }));
|
||||
s.appendChild(legend([{ label: T('송신(아웃바운드)'), color: COLOR.out }, { label: T('수신(인바운드)'), color: COLOR.in }]));
|
||||
const two = el('div', 'si-two');
|
||||
const w = el('div'); w.appendChild(el('div', 'field-label', T('최근 7일 합계'))); const l7 = st.last7 || {}; const o7 = outOf(l7), i7 = inOf(l7);
|
||||
w.appendChild(barChart({ groups: [{ label: T('최근 7일'), sub: `${labels[Math.max(0, labels.length - 7)] || ''} ~ ${labels[labels.length - 1] || ''}`, bars: [{ key: 'out', label: T('송신'), color: COLOR.out, value: o7 }, { key: 'in', label: T('수신'), color: COLOR.in, value: i7 }, { key: 'all', label: T('전체'), color: COLOR.total, value: o7 + i7 }] }], bytes: true, height: 220, width: 400 })); two.appendChild(w);
|
||||
const mo = el('div'); mo.appendChild(el('div', 'field-label', T('월간 합계 (1일~말일, 직전 2개월 + 이번 달 누적)')));
|
||||
const months = st.months || [];
|
||||
mo.appendChild(barChart({ groups: months.map((m) => { const o = outOf(m), i = inOf(m); return { label: m.month, sub: m.current ? T('이번 달 (누적 중)') : `${m.days}${T('일')}`, bars: [{ key: 'out', label: T('송신'), color: COLOR.out, value: o }, { key: 'in', label: T('수신'), color: COLOR.in, value: i }, { key: 'all', label: T('전체'), color: COLOR.total, value: o + i }] }; }), bytes: true, height: 220, width: 400 })); two.appendChild(mo);
|
||||
s.appendChild(two); s.appendChild(legend([{ label: T('송신'), color: COLOR.out }, { label: T('수신'), color: COLOR.in }, { label: T('전체'), color: COLOR.total }]));
|
||||
const tb = el('table', 'stat-table si-table'); const hr = el('tr'); for (const h of ['', '수신(인바운드)', '송신(아웃바운드)', '전체', '요청 수']) hr.appendChild(el('th', null, T(h))); tb.appendChild(hr);
|
||||
const rows = [['최근 7일', st.last7], ...months.map((m) => [m.month + (m.current ? ' (' + T('누적 중') + ')' : ''), m]), ['누적', st.total]];
|
||||
for (const [label, a] of rows) { const tr = el('tr'); const o = outOf(a || {}), i = inOf(a || {}); [T(label), useLog ? fmtBytes(i) : '-', fmtBytes(o) + (useLog ? '' : ' ' + T('(추정)')), useLog ? fmtBytes(o + i) : '-', useLog ? fmt((a || {}).req || 0) : fmt((a || {}).beaconPv || 0)].forEach((v) => tr.appendChild(el('td', null, v))); tb.appendChild(tr); }
|
||||
const tw = el('div', 'table-wrap'); tw.appendChild(tb); s.appendChild(tw); }
|
||||
// ④ 저장 공간
|
||||
{ const s = section(root, T('디스크 사용량'), T('허용량은 아직 정해지지 않아 표시하지 않습니다')); const d = st.disk || {}; const db = st.db || {};
|
||||
const parts = [{ label: T('웹 데이터'), value: d.web != null ? d.web : (d.total || 0), color: COLOR.web }, { label: T('DB 데이터'), value: db.total || 0, color: COLOR.db }];
|
||||
if (d.mail != null) parts.push({ label: T('메일 데이터'), value: d.mail, color: COLOR.mail });
|
||||
const total = parts.reduce((a, p) => a + p.value, 0);
|
||||
s.appendChild(el('div', 'si-disk-total', TF('전체 {1}', fmtBytes(total)))); s.appendChild(stackBar(parts));
|
||||
const g = el('div', 'stat-grid si-grid'); s.appendChild(g);
|
||||
tile(g, T('웹 데이터'), fmtBytes(parts[0].value), `${T('사이트 파일')} ${fmtBytes(d.site || 0)} · ${T('첨부')} ${fmtBytes(d.uploads || 0)}`, 'si-web');
|
||||
tile(g, T('DB 데이터'), fmtBytes(db.total || 0), `${T('사이트 정보')} ${fmtBytes(db.site || 0)} · ${T('문의')} ${fmtBytes(db.inquiries || 0)} · ${T('통계')} ${fmtBytes(db.stats || 0)} · ${T('버전 저장소')} ${fmtBytes(db.git || 0)}`, 'si-db');
|
||||
// v1.7.3 (요구 2): 메일 서버(Arimail) 사용량 API 가 연결되면 "메일 데이터" 는 서버 메일함 용량(mailSource 'arimail'), 아니면 이 서비스의 메일함 파일 크기 + 미연동 이유
|
||||
if (d.mail != null) { const mu = st.mailUsage; const srv = d.mailSource === 'arimail' && mu && mu.ok;
|
||||
tile(g, T('메일 데이터'), fmtBytes(d.mail), srv ? `${TF('메일 서버 {1} 의 메일함 용량', (mu.server && (mu.server.nickname || mu.server.host)) || 'Arimail')} · ${TF('메일 {1}통', fmt(mu.messages.total))} · ${TF('계정 {1}개', fmt(mu.accounts.total))}${d.mailLocal ? ` · ${T('이 서비스 메일함 파일')} ${fmtBytes(d.mailLocal)}` : ''}` : (T('이 서비스에 저장된 메일함 파일') + (mu && !mu.ok && mu.message ? ` — ${T('메일 서버 사용량 미연동')}: ${mu.message}` : ` (${T('메일 서버 쪽 용량은 포함하지 않음')})`)), 'si-mail'); }
|
||||
s.appendChild(legend(parts)); }
|
||||
// ④-2 메일 사용량 (v1.7.3 요구 2): 메일 서버(Arimail 1.0.9 사용량 API) — 메일함 용량·메일 수·이달 발송/수신 타일 + 최근 30일 발송/수신 통수 막대. 미연동이면 구역 자체를 숨긴다
|
||||
if (st.mailUsage && st.mailUsage.ok) { const mu = st.mailUsage; const s = section(root, T('메일 사용량'), `${mu.server ? (mu.server.nickname || mu.server.host) : ''} · ${mu.domain || ''}`);
|
||||
const g = el('div', 'stat-grid si-grid'); s.appendChild(g); const tr = mu.traffic || {}; const mo = tr.month || {}; const dy = tr.day || {};
|
||||
tile(g, T('메일함 용량'), fmtBytes(mu.storage.bytes), TF('계정 {1}개', fmt(mu.accounts.total)) + (mu.accounts.mine ? ` · ${TF('이 사이트 {1}개', mu.accounts.mine)} ${fmtBytes(mu.myBytes || 0)}` : ''), 'si-mail');
|
||||
tile(g, T('메일 수'), fmt(mu.messages.total), `${T('받은 메일')} ${fmt(mu.messages.inbound)} · ${T('보낸 메일')} ${fmt(mu.messages.outbound)}${mu.messages.failed ? ` · ${T('실패')} ${fmt(mu.messages.failed)}` : ''}`);
|
||||
tile(g, T('이달 발송'), fmt(mo.sent), `${fmtBytes(mo.sentBytes)} · ${T('오늘')} ${fmt(dy.sent)}`);
|
||||
tile(g, T('이달 수신'), fmt(mo.received), `${fmtBytes(mo.receivedBytes)} · ${T('오늘')} ${fmt(dy.received)}`);
|
||||
const daily = mu.daily || [];
|
||||
s.appendChild(barChart({ groups: daily.map((x) => ({ label: String(x.date || '').slice(5), bars: [{ key: 'sent', label: T('발송'), color: COLOR.out, value: x.sent }, { key: 'recv', label: T('수신'), color: COLOR.in, value: x.received }] })), height: 170, empty: T('아직 메일 송수신 기록이 없습니다') }));
|
||||
s.appendChild(legend([{ label: T('발송(통)'), color: COLOR.out }, { label: T('수신(통)'), color: COLOR.in }]));
|
||||
s.appendChild(el('p', 'muted small', `${T('메일 서버 사용량 API 기준 (5분 캐시)')} · ${dtm(mu.at)}`)); }
|
||||
// ⑤ 페이지 · 유입
|
||||
{ const s = section(root, T('많이 본 페이지 · 유입 경로 (30일)')); const two = el('div', 'si-two'); s.appendChild(two);
|
||||
const list = (title, items, empty) => { const w = el('div'); w.appendChild(el('div', 'field-label', title)); const ul = el('ul', 'git-list stat-list'); if (!(items || []).length) { const li = el('li', 'muted'); li.textContent = empty; ul.appendChild(li); } for (const it of items || []) { const li = el('li'); const b = el('span', 'bname', it.key); b.style.fontFamily = 'inherit'; li.appendChild(b); li.appendChild(el('span', 'st', fmt(it.n))); ul.appendChild(li); } w.appendChild(ul); return w; };
|
||||
two.appendChild(list(T('많이 본 페이지'), st.topPages, T('아직 기록이 없습니다'))); two.appendChild(list(T('유입 경로'), st.topRefs, T('아직 기록이 없습니다'))); }
|
||||
}
|
||||
|
||||
window.SiteInfoUI = { render, lineChart, barChart };
|
||||
})();
|
||||
@@ -0,0 +1,757 @@
|
||||
:root {
|
||||
--bg: #f4f5f7; --panel: #ffffff; --line: #e3e6ea; --text: #1f2933; --muted: #6b7684;
|
||||
--accent: #2f6fed; --accent-2: #1d4fd7; --ok: #1a8f5a; --warn: #c2410c; --run: #b45309;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; }
|
||||
body { font-family: -apple-system, "Segoe UI", "Noto Sans KR", "Apple SD Gothic Neo", Roboto, sans-serif; font-size: 14px; color: var(--text); background: var(--bg); }
|
||||
.app { display: grid; grid-template-columns: 460px 1fr; height: 100vh; }
|
||||
.left { border-right: 1px solid var(--line); background: var(--panel); overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.right { display: flex; flex-direction: column; min-width: 0; }
|
||||
.hdr { display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; }
|
||||
h1 { font-size: 18px; margin: 0 0 2px; }
|
||||
h2 { font-size: 14px; margin: 0 0 10px; display: flex; align-items: center; gap: 8px; }
|
||||
.sub { color: var(--muted); font-size: 12px; }
|
||||
.badge { background: #eef2ff; color: #3730a3; padding: 2px 8px; border-radius: 999px; font-weight: 600; }
|
||||
.card { border: 1px solid var(--line); border-radius: 10px; padding: 12px; background: #fff; }
|
||||
label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 8px; }
|
||||
.req { color: var(--warn); }
|
||||
input, textarea, select { width: 100%; margin-top: 4px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; font: inherit; color: var(--text); background: #fff; }
|
||||
textarea { resize: vertical; }
|
||||
input:focus, textarea:focus { outline: 2px solid #c7d7ff; border-color: var(--accent); }
|
||||
button, .btn { font: inherit; padding: 8px 14px; border-radius: 8px; border: 1px solid var(--line); background: #fff; cursor: pointer; text-decoration: none; color: var(--text); display: inline-block; }
|
||||
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; width: 100%; }
|
||||
button.primary:hover { background: var(--accent-2); }
|
||||
button:disabled, .btn.disabled { opacity: .5; cursor: not-allowed; pointer-events: none; }
|
||||
.ghost { background: #fff; }
|
||||
.row { display: flex; gap: 8px; }
|
||||
.row .primary { flex: 1; }
|
||||
.hidden { display: none !important; }
|
||||
.muted { color: var(--muted); }
|
||||
.small { font-size: 12px; }
|
||||
.site-meta { margin-bottom: 10px; }
|
||||
code { font-family: var(--mono); font-size: 12px; background: #f1f3f5; padding: 1px 5px; border-radius: 4px; }
|
||||
.log { margin: 0; background: #0f172a; color: #e2e8f0; border-radius: 8px; padding: 10px; height: 200px; overflow: auto; font-family: var(--mono); font-size: 12px; white-space: pre-wrap; word-break: break-word; }
|
||||
.log .info { color: #93c5fd; } .log .tool { color: #a7f3d0; } .log .assistant { color: #fde68a; } .log .warn { color: #fca5a5; } .log .t { color: #64748b; }
|
||||
.pill { font-size: 11px; padding: 2px 8px; border-radius: 999px; font-weight: 600; }
|
||||
.pill.idle { background: #e5e7eb; color: #374151; } .pill.running { background: #fef3c7; color: var(--run); } .pill.error { background: #fee2e2; color: #991b1b; } .pill.done { background: #d1fae5; color: var(--ok); }
|
||||
.totals { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; margin-bottom: 10px; }
|
||||
.tot { border: 1px solid var(--line); border-radius: 8px; padding: 8px; display: flex; flex-direction: column; gap: 2px; }
|
||||
.tot .k { font-size: 11px; color: var(--muted); } .tot .v { font-size: 16px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.tot.big { grid-column: 1 / -1; background: #eef2ff; border-color: #c7d2fe; } .tot.big .v { font-size: 24px; color: #1e3a8a; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
th, td { padding: 6px 6px; border-bottom: 1px solid var(--line); text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
th:nth-child(-n+3), td:nth-child(-n+3) { text-align: left; }
|
||||
td.instr { max-width: 140px; overflow: hidden; text-overflow: ellipsis; }
|
||||
tfoot td { font-weight: 700; background: #f8fafc; }
|
||||
tr.err td { color: #991b1b; }
|
||||
.toolbar { display: flex; gap: 8px; align-items: center; padding: 10px 12px; border-bottom: 1px solid var(--line); background: #fff; }
|
||||
.toolbar .url { flex: 1; font-family: var(--mono); font-size: 13px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.toolbar select { width: auto; margin: 0; }
|
||||
.preview { flex: 1; position: relative; background: #fff; display: flex; flex-direction: column; min-height: 0; }
|
||||
iframe { width: 100%; flex: 1 1 auto; min-height: 80px; border: 0; }
|
||||
.empty { position: absolute; inset: 0; display: grid; place-items: center; color: var(--muted); }
|
||||
.empty-inner { max-width: 460px; text-align: center; }
|
||||
.empty h3 { color: var(--text); }
|
||||
@media (max-width: 900px) { .app { grid-template-columns: 1fr; grid-template-rows: auto 70vh; height: auto; } .left { border-right: 0; border-bottom: 1px solid var(--line); } }
|
||||
|
||||
input[type=file] { padding: 6px; background: #f8fafc; }
|
||||
.hint { display: block; margin-top: 4px; }
|
||||
.attach-list { list-style: none; margin: 0 0 10px; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.attach-list li { display: flex; align-items: center; gap: 8px; border: 1px solid var(--line); border-radius: 8px; padding: 6px 8px; }
|
||||
.attach-list .thumb { width: 40px; height: 40px; object-fit: cover; border-radius: 6px; background: #f1f3f5; flex: none; }
|
||||
.attach-list .doc { width: 40px; height: 40px; border-radius: 6px; background: #eef2ff; color: #3730a3; display: grid; place-items: center; font-size: 11px; font-weight: 700; flex: none; }
|
||||
.attach-list .info { flex: 1; min-width: 0; }
|
||||
.attach-list .name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.attach-list .name a { color: var(--text); text-decoration: none; } .attach-list .name a:hover { text-decoration: underline; }
|
||||
.attach-list .meta { font-size: 11px; color: var(--muted); }
|
||||
.attach-list .tag { font-size: 10px; padding: 1px 6px; border-radius: 999px; background: #fef3c7; color: #92400e; margin-left: 4px; }
|
||||
.attach-list .del { border: 0; background: transparent; color: var(--muted); cursor: pointer; font-size: 16px; padding: 2px 6px; } .attach-list .del:hover { color: #991b1b; }
|
||||
.upload-now { margin-top: 6px; }
|
||||
|
||||
/* 소스 편집기 (우측 하단) */
|
||||
.splitter { height: 6px; background: var(--line); cursor: row-resize; flex: none; }
|
||||
.splitter:hover { background: #c7d2fe; }
|
||||
.editor { height: 45%; min-height: 120px; flex: none; display: flex; flex-direction: column; border-top: 1px solid var(--line); background: #0f172a; }
|
||||
.editor-bar { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: #fff; border-bottom: 1px solid var(--line); }
|
||||
.editor-bar select { width: auto; margin: 0; max-width: 260px; }
|
||||
.editor-bar .spacer { flex: 1; }
|
||||
.editor-bar .inline { display: inline-flex; align-items: center; gap: 4px; margin: 0; color: var(--muted); font-size: 12px; }
|
||||
.editor-bar .inline input { width: auto; margin: 0; }
|
||||
.small-btn { padding: 5px 10px; font-size: 12px; }
|
||||
.small-btn.primary { width: auto; }
|
||||
.edit-state { font-size: 12px; color: var(--ok); }
|
||||
.edit-state.dirty { color: var(--warn); font-weight: 600; }
|
||||
.editor-body { flex: 1; display: flex; min-height: 0; }
|
||||
.line-nos { margin: 0; padding: 10px 6px 10px 10px; color: #64748b; background: #0b1220; text-align: right; font-family: var(--mono); font-size: 12px; line-height: 1.5; overflow: hidden; user-select: none; flex: none; }
|
||||
#editor { flex: 1; margin: 0; padding: 10px; border: 0; border-radius: 0; resize: none; background: #0f172a; color: #e2e8f0; font-family: var(--mono); font-size: 12px; line-height: 1.5; tab-size: 2; outline: none; }
|
||||
.toolbar button.active { background: #eef2ff; border-color: #c7d2fe; color: #3730a3; }
|
||||
|
||||
/* 드래그앤드롭 첨부 */
|
||||
.field-label { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
|
||||
.visually-hidden { position: absolute; width: 1px; height: 1px; opacity: 0; overflow: hidden; pointer-events: none; }
|
||||
.dropzone { position: relative; border: 2px dashed #c9d1dc; border-radius: 10px; padding: 12px; background: #f8fafc; margin-bottom: 8px; transition: border-color .15s, background .15s; }
|
||||
.dropzone.dragover { border-color: var(--accent); background: #eef2ff; }
|
||||
.dz-text { font-size: 12px; color: var(--muted); text-align: center; }
|
||||
.dz-pick { color: var(--accent); cursor: pointer; text-decoration: underline; display: inline; margin: 0; font-size: 12px; }
|
||||
.pending { list-style: none; margin: 8px 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.pending:empty { display: none; }
|
||||
.pending li { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; background: #fff; border: 1px solid var(--line); border-radius: 999px; padding: 3px 6px 3px 10px; font-size: 12px; }
|
||||
.pending li .pn { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 220px; }
|
||||
.pending li .ps { color: var(--muted); }
|
||||
.pending li button { border: 0; background: transparent; color: var(--muted); cursor: pointer; padding: 0 4px; font-size: 14px; line-height: 1; }
|
||||
.pending li button:hover { color: #991b1b; }
|
||||
.drop-overlay { position: fixed; inset: 0; background: rgba(47, 111, 237, .12); border: 3px dashed var(--accent); z-index: 1000; display: grid; place-items: center; pointer-events: none; }
|
||||
.drop-overlay-box { background: #fff; border: 1px solid #c7d2fe; color: #1e3a8a; font-weight: 700; padding: 14px 22px; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,.12); }
|
||||
|
||||
/* 사용자 / git 보관 */
|
||||
.hdr-right { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
|
||||
.hdr-right select { width: auto; margin: 0; max-width: 260px; }
|
||||
.user { display: flex; align-items: center; gap: 6px; white-space: nowrap; }
|
||||
.git-list { list-style: none; margin: 8px 0; padding: 0; display: flex; flex-direction: column; gap: 4px; max-height: 180px; overflow: auto; }
|
||||
.git-list li { display: flex; align-items: center; gap: 8px; font-size: 12px; border: 1px solid var(--line); border-radius: 6px; padding: 5px 8px; }
|
||||
.git-list .kind { font-size: 10px; padding: 1px 6px; border-radius: 999px; background: #e0e7ff; color: #3730a3; flex: none; }
|
||||
.git-list .kind.manual-edit { background: #fef3c7; color: #92400e; } .git-list .kind.create { background: #d1fae5; color: #065f46; }
|
||||
.git-list .bname { font-family: var(--mono); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.git-list .bname a { color: var(--text); text-decoration: none; } .git-list .bname a:hover { text-decoration: underline; }
|
||||
.git-list .st { flex: none; } .git-list .st.ok { color: var(--ok); } .git-list .st.fail { color: #991b1b; }
|
||||
|
||||
/* 기본 사항 수정 */
|
||||
.basics { border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; margin-bottom: 10px; background: #fafbfc; }
|
||||
.basics summary { cursor: pointer; font-size: 13px; font-weight: 600; color: var(--text); }
|
||||
.basics[open] summary { margin-bottom: 8px; }
|
||||
.basics-state { font-size: 11px; font-weight: 600; margin-left: 6px; color: var(--warn); }
|
||||
.basics-state.pending { color: #92400e; }
|
||||
|
||||
/* 모드 선택 / 위저드 */
|
||||
.mode-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 8px; }
|
||||
.mode-opt { text-align: left; padding: 12px; border: 2px solid var(--line); border-radius: 10px; background: #fff; display: flex; flex-direction: column; gap: 6px; }
|
||||
.mode-opt:hover { border-color: var(--accent); background: #f5f8ff; }
|
||||
.mode-title { font-weight: 700; font-size: 14px; } .mode-desc { font-size: 12px; color: var(--muted); line-height: 1.45; }
|
||||
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.refs { display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; }
|
||||
.refs .ref { display: grid; grid-template-columns: 1fr auto; gap: 4px 6px; border: 1px solid var(--line); border-radius: 8px; padding: 6px; }
|
||||
.refs .ref input, .refs .ref textarea { margin: 0; font-size: 12px; padding: 6px 8px; }
|
||||
.refs .ref textarea { grid-column: 1 / -1; }
|
||||
.refs .ref .rm { border: 0; background: transparent; color: var(--muted); cursor: pointer; font-size: 16px; }
|
||||
.refs .add { align-self: flex-start; }
|
||||
.steps { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; }
|
||||
.steps button { padding: 3px 8px; font-size: 11px; border-radius: 999px; }
|
||||
.steps button.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
.steps button.done { background: #d1fae5; border-color: #a7f3d0; color: #065f46; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 4px 0 10px; }
|
||||
.chip { border: 1px solid var(--line); border-radius: 999px; padding: 4px 10px; font-size: 12px; background: #fff; cursor: pointer; }
|
||||
.chip.on { background: #eef2ff; border-color: #c7d2fe; color: #3730a3; font-weight: 600; }
|
||||
.chip.disabled { opacity: .45; cursor: not-allowed; text-decoration: line-through; }
|
||||
.card-list { display: flex; flex-direction: column; gap: 8px; margin: 6px 0 10px; }
|
||||
.opt-card { border: 2px solid var(--line); border-radius: 10px; padding: 10px; cursor: pointer; background: #fff; }
|
||||
.opt-card.on { border-color: var(--accent); background: #f5f8ff; }
|
||||
.opt-card .t { font-weight: 700; font-size: 13px; } .opt-card .d { font-size: 12px; color: var(--muted); margin-top: 2px; }
|
||||
.swatches { display: flex; gap: 4px; margin-top: 6px; } .swatches span { width: 22px; height: 22px; border-radius: 6px; border: 1px solid rgba(0,0,0,.08); }
|
||||
.tone-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 6px; }
|
||||
.origin { font-size: 10px; padding: 1px 5px; border-radius: 999px; margin-left: 4px; vertical-align: middle; }
|
||||
.origin.user { background: #d1fae5; color: #065f46; } .origin.ai { background: #fef3c7; color: #92400e; }
|
||||
.kv { display: grid; grid-template-columns: 110px 1fr; gap: 4px 8px; font-size: 12px; margin-bottom: 8px; align-items: start; }
|
||||
.kv .k { color: var(--muted); padding-top: 6px; } .kv input, .kv textarea, .kv select { margin: 0; font-size: 12px; padding: 5px 8px; }
|
||||
.warn-list { list-style: none; padding: 0; margin: 6px 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.warn-list li { font-size: 12px; padding: 5px 8px; border-radius: 6px; background: #fef3c7; color: #92400e; }
|
||||
.warn-list li.error { background: #fee2e2; color: #991b1b; } .warn-list li.info { background: #e0e7ff; color: #3730a3; }
|
||||
.pages-edit { display: flex; flex-direction: column; gap: 6px; margin: 6px 0 10px; }
|
||||
.page-row { border: 1px solid var(--line); border-radius: 8px; padding: 6px 8px; background: #fff; }
|
||||
.page-row.dragover { border-color: var(--accent); }
|
||||
.page-row .ph { display: flex; align-items: center; gap: 6px; }
|
||||
.page-row .ph .handle { cursor: grab; color: var(--muted); }
|
||||
.page-row .ph input { margin: 0; font-size: 12px; padding: 4px 6px; flex: 1; }
|
||||
.page-row .ph .rm { border: 0; background: transparent; color: var(--muted); cursor: pointer; }
|
||||
.page-row .secs { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
|
||||
.page-row .secs .chip { font-size: 11px; padding: 2px 8px; }
|
||||
.facts-table { width: 100%; font-size: 12px; border-collapse: collapse; margin-bottom: 8px; }
|
||||
.facts-table td { padding: 4px 4px; border-bottom: 1px solid var(--line); vertical-align: top; text-align: left; white-space: normal; }
|
||||
.facts-table input, .facts-table textarea, .facts-table select { margin: 0; font-size: 12px; padding: 4px 6px; }
|
||||
.facts-table .src { font-size: 10px; color: var(--muted); }
|
||||
.report { font-size: 12px; }
|
||||
.report .sum { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0; }
|
||||
.report .sum span { border: 1px solid var(--line); border-radius: 6px; padding: 3px 8px; }
|
||||
.report .sum span.bad { background: #fee2e2; border-color: #fecaca; color: #991b1b; } .report .sum span.ok { background: #d1fae5; border-color: #a7f3d0; color: #065f46; }
|
||||
.report ul { margin: 4px 0 8px; padding-left: 18px; }
|
||||
.report li.fail { color: #991b1b; } .report li.partial { color: #92400e; } .report li.pass { color: #065f46; }
|
||||
.publish-box { border: 1px solid #a7f3d0; background: #ecfdf5; border-radius: 8px; padding: 10px; font-size: 12px; }
|
||||
.publish-box .later { opacity: .6; }
|
||||
.brief-json { max-height: 220px; overflow: auto; background: #0f172a; color: #e2e8f0; font-family: var(--mono); font-size: 11px; padding: 8px; border-radius: 8px; white-space: pre-wrap; }
|
||||
.qbox { border: 1px solid #c7d2fe; background: #eef2ff; border-radius: 8px; padding: 8px; margin-bottom: 8px; }
|
||||
.summary-list { list-style: none; padding: 0; margin: 0 0 10px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.summary-list li { display: grid; grid-template-columns: 90px 1fr auto; gap: 6px; font-size: 12px; border-bottom: 1px solid var(--line); padding: 4px 0; }
|
||||
.summary-list li .k { color: var(--muted); } .summary-list li button { padding: 1px 6px; font-size: 11px; }
|
||||
.wf-note { font-size: 11px; color: var(--muted); }
|
||||
|
||||
.progress-bar { margin-top: 12px; padding-top: 10px; border-top: 1px dashed var(--line); display: flex; flex-direction: column; gap: 6px; }
|
||||
.git-list .kind.restore { background: #fce7f3; color: #9d174d; }
|
||||
|
||||
/* 진행 표시기 */
|
||||
.progress { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: #f8fafc; }
|
||||
.progress .pi { width: 34px; height: 34px; display: grid; place-items: center; position: relative; flex: none; }
|
||||
.progress .pi > * { display: none; }
|
||||
.progress .spinner { width: 26px; height: 26px; border-radius: 50%; border: 3px solid #c7d2fe; border-top-color: var(--accent); animation: spin .9s linear infinite; }
|
||||
.progress .check { width: 28px; height: 28px; border-radius: 50%; background: var(--ok); color: #fff; font-weight: 700; display: none; place-items: center; }
|
||||
.progress .bang { width: 28px; height: 28px; border-radius: 50%; background: #dc2626; color: #fff; font-weight: 700; display: none; place-items: center; }
|
||||
.progress .dot { width: 12px; height: 12px; border-radius: 50%; background: #cbd5e1; }
|
||||
.progress.running .spinner { display: block; } .progress.idle .dot { display: block; } .progress.done .check { display: grid; } .progress.error .bang { display: grid; }
|
||||
.progress.running { background: #eef2ff; border-color: #c7d2fe; } .progress.done { background: #ecfdf5; border-color: #a7f3d0; } .progress.error { background: #fef2f2; border-color: #fecaca; }
|
||||
.progress .pt { min-width: 0; } #progressText { font-weight: 600; font-size: 13px; }
|
||||
.progress-deploy { margin-top: 8px; font-size: 12px; border-radius: 8px; padding: 6px 10px; }
|
||||
.progress-deploy.ok { background: #ecfdf5; color: #065f46; border: 1px solid #a7f3d0; } .progress-deploy.fail { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
|
||||
.progress-deploy .pd-head { font-weight: 700; } .progress-deploy ul { margin: 4px 0 0; padding-left: 16px; } .progress-deploy a { color: inherit; } .progress-deploy .pd-note { margin-top: 4px; }
|
||||
.progress-warn { margin-top: 8px; font-size: 12px; color: #991b1b; background: #fee2e2; border-radius: 8px; padding: 6px 10px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.progress .ptok { margin-left: auto; text-align: right; flex: none; min-width: 120px; }
|
||||
.progress .ptok .k { font-size: 11px; color: var(--muted); }
|
||||
.progress .ptok .v { font-size: 20px; font-weight: 700; font-variant-numeric: tabular-nums; color: #1e3a8a; transition: transform .15s; }
|
||||
.progress .ptok .v.bump { transform: scale(1.08); }
|
||||
.progress .ptok .d { color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
.progress.running .ptok .k::after { content: ' · 집계 중'; color: var(--accent); }
|
||||
|
||||
.progress .cancel { border-color: #fecaca; color: #991b1b; background: #fff; flex: none; }
|
||||
.progress .cancel:hover { background: #fee2e2; }
|
||||
.progress.cancelled { background: #fff7ed; border-color: #fed7aa; } .progress.cancelled .bang { display: grid; background: #ea580c; }
|
||||
.pill.cancelled { background: #ffedd5; color: #9a3412; }
|
||||
tr.cancelled td { color: #9a3412; }
|
||||
|
||||
.pmsg { font-size: 12px; color: #1e3a8a; min-height: 16px; margin: 2px 0; transition: opacity .2s; }
|
||||
.pmsg.flash { opacity: .35; }
|
||||
.progress .ptok .v.amt { font-size: 16px; color: #065f46; }
|
||||
.progress.running .ptok .k::after { content: ''; }
|
||||
.progress.running .ptok .k.live::after { content: ' · 집계 중'; color: var(--accent); }
|
||||
|
||||
.git-list .kind.deploy { background: #d1fae5; color: #065f46; }
|
||||
|
||||
.model-row { display: flex; align-items: center; gap: 6px; } .model-row select { width: auto; max-width: 260px; margin: 0; font-size: 12px; padding: 5px 8px; }
|
||||
#myConnList li .kind.conn { background: #e0e7ff; color: #3730a3; }
|
||||
|
||||
/* 기존 사이트 가져오기 */
|
||||
.radio-row { display: flex; flex-wrap: wrap; gap: 12px; margin: 2px 0 8px; font-size: 13px; }
|
||||
.radio-row .inline { display: inline-flex; align-items: center; gap: 4px; margin: 0; }
|
||||
textarea.mono { font-family: var(--mono); font-size: 11px; }
|
||||
.opt-card .cand-path { font-family: var(--mono); font-size: 12px; font-weight: 600; word-break: break-all; }
|
||||
.opt-card .cand-meta { font-size: 11px; color: var(--muted); margin-top: 2px; }
|
||||
.opt-card .cand-match { display: inline-block; font-size: 10px; padding: 1px 6px; border-radius: 999px; margin-left: 6px; background: #e5e7eb; color: #374151; }
|
||||
.opt-card .cand-match.exact { background: #d1fae5; color: #065f46; } .opt-card .cand-match.title { background: #fef3c7; color: #92400e; }
|
||||
.git-list .kind.import { background: #ede9fe; color: #5b21b6; }
|
||||
#imInfo b { font-weight: 600; } #imDeployMsg.ok { color: var(--ok); } #imDeployMsg.fail { color: #991b1b; }
|
||||
|
||||
/* 캐시 / 등급 */
|
||||
.pill.waiting { background: #fef3c7; color: #92400e; } .progress.waiting-credit { background: #fffbeb; border-color: #fde68a; } .progress.waiting-credit .bang { display: grid; background: #d97706; }
|
||||
.badge.neg { background: #fee2e2; color: #991b1b; }
|
||||
|
||||
/* 사이트 정보(통계) 카드 */
|
||||
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(96px, 1fr)); gap: 6px; }
|
||||
.stat-grid .tot .v small { font-size: 11px; font-weight: 500; color: var(--muted); margin-left: 2px; }
|
||||
.stat-grid .tot .d { display: block; font-size: 11px; color: var(--muted); margin-top: 2px; }
|
||||
.bars { display: flex; align-items: flex-end; gap: 4px; height: 76px; margin: 8px 0 10px; padding: 0 2px; }
|
||||
.bar-col { flex: 1; display: flex; flex-direction: column; justify-content: flex-end; align-items: center; position: relative; height: 100%; }
|
||||
.bar-col .bar { width: 70%; border-radius: 3px 3px 0 0; position: absolute; bottom: 16px; }
|
||||
.bar-col .bar.pv { background: #c7d2fe; } .bar-col .bar.v { background: var(--accent); width: 40%; }
|
||||
.bar-col .bl { font-size: 9px; color: var(--muted); margin-top: 60px; white-space: nowrap; }
|
||||
.stat-table { width: 100%; border-collapse: collapse; font-size: 12px; margin-bottom: 8px; }
|
||||
.stat-table th, .stat-table td { padding: 4px 6px; border-bottom: 1px solid var(--line); text-align: right; } .stat-table th:first-child, .stat-table td:first-child { text-align: left; }
|
||||
.stat-list { max-height: 150px; } .stat-list li .st { font-variant-numeric: tabular-nums; color: var(--muted); }
|
||||
|
||||
/* v1.0.3: 생성/관리/설정 탭 */
|
||||
.main-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--line); padding-bottom: 6px; }
|
||||
.main-tabs button { flex: 1; border: 1px solid transparent; background: transparent; color: var(--muted); font-weight: 600; padding: 8px 10px; border-radius: 8px 8px 0 0; }
|
||||
.main-tabs button.active { background: #eef2ff; color: #1e3a8a; border-color: #c7d2fe; border-bottom-color: #eef2ff; }
|
||||
.main-tabs button:hover { background: #f5f8ff; }
|
||||
.tabpane { display: flex; flex-direction: column; gap: 12px; }
|
||||
.credit-footer { margin-top: auto; display: flex; flex-wrap: wrap; gap: 6px 14px; padding: 8px 10px; border-top: 1px solid var(--line); font-size: 12px; color: var(--muted); background: #f8fafc; border-radius: 8px; position: sticky; bottom: 0; }
|
||||
.credit-footer b { color: var(--text); font-variant-numeric: tabular-nums; }
|
||||
.color-row { display: flex; gap: 6px; align-items: center; } .color-row input[type=color] { width: 44px; height: 36px; padding: 2px; margin-top: 4px; }
|
||||
.module-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.module-row { display: grid; grid-template-columns: auto 1fr auto; gap: 8px; align-items: center; border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; }
|
||||
.module-row .mt { font-weight: 600; font-size: 13px; } .module-row .md { font-size: 11px; color: var(--muted); }
|
||||
.module-row .switch { width: 38px; height: 22px; border-radius: 999px; background: #d1d5db; position: relative; cursor: pointer; border: 0; padding: 0; }
|
||||
.module-row .switch::after { content: ''; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 50%; background: #fff; transition: left .15s; }
|
||||
.module-row .switch.on { background: var(--accent); } .module-row .switch.on::after { left: 18px; }
|
||||
.pill.small { font-size: 10px; padding: 1px 6px; }
|
||||
.toolbar .url.live { color: var(--ok); font-weight: 600; }
|
||||
|
||||
/* ===== v1.1.0 ===== */
|
||||
/* 제품명 옆 버전 */
|
||||
h1 .ver { font-size: 11px; font-weight: 600; color: #3730a3; background: #eef2ff; border-radius: 999px; padding: 2px 8px; vertical-align: middle; margin-left: 4px; }
|
||||
/* ? 도움말: 마우스오버/클릭 시 설명 풍선 */
|
||||
.help { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; border-radius: 50%; border: 1px solid #c7d2fe; background: #eef2ff; color: #3730a3; font-size: 11px; font-weight: 700; padding: 0; margin-left: 4px; cursor: help; position: relative; vertical-align: middle; line-height: 1; }
|
||||
/* v1.1.4: 풍선은 body 끝의 #helpTip 하나를 공유하고 JS 가 화면 안에 들어오도록 위치를 계산한다 */
|
||||
.help-tip { position: fixed; z-index: 1000; width: 300px; max-width: calc(100vw - 16px); background: #0f172a; color: #e2e8f0; font-size: 12px; line-height: 1.55; padding: 10px 12px; border-radius: 8px; box-shadow: 0 6px 20px rgba(0,0,0,.3); white-space: normal; pointer-events: none; }
|
||||
.help-tip::before { content: ''; position: absolute; width: 10px; height: 10px; background: #0f172a; transform: rotate(45deg); left: var(--ax, 50%); margin-left: -5px; }
|
||||
.help-tip.below::before { top: -5px; } .help-tip.above::before { bottom: -5px; }
|
||||
/* 수정 지시 카드 강조 */
|
||||
.update-card { border: 2px solid var(--accent); background: #f8faff; }
|
||||
.update-card h2 { color: #1e3a8a; }
|
||||
.update-card #fInstruction { font-size: 15px; line-height: 1.55; min-height: 150px; border: 2px solid #c7d2fe; background: #fff; }
|
||||
.update-card #fInstruction:focus { border-color: var(--accent); outline: 3px solid #dbe4ff; }
|
||||
.update-card button.big { padding: 12px; font-size: 15px; margin: 6px 0 10px; }
|
||||
.update-tools { display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center; margin: 6px 0; }
|
||||
.inline-row { display: flex; align-items: center; gap: 6px; } .inline-row select { width: auto; margin: 0; }
|
||||
.dropzone.slim { padding: 8px; } .dropzone.slim .dz-text { font-size: 12px; }
|
||||
.spacer { flex: 1; }
|
||||
.ghost.danger { color: #991b1b; border-color: #fecaca; }
|
||||
/* 등급 안내 표 */
|
||||
.tier-guide { border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; background: #fff; font-size: 12px; }
|
||||
.tier-guide table { width: 100%; border-collapse: collapse; } .tier-guide th, .tier-guide td { padding: 4px 6px; text-align: left; border-bottom: 1px solid var(--line); vertical-align: top; }
|
||||
.tier-guide tr.on td { background: #eef2ff; } .tier-guide .cost { white-space: nowrap; font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.tier-guide .note { color: var(--muted); margin-top: 4px; }
|
||||
/* 작업 단계 표시 */
|
||||
.steps-strip { display: flex; gap: 4px; list-style: none; margin: 0 0 8px; padding: 0; counter-reset: s; }
|
||||
.steps-strip li { flex: 1; font-size: 11px; color: var(--muted); border-top: 3px solid #e5e7eb; padding-top: 4px; }
|
||||
.steps-strip li.done { border-top-color: var(--ok); color: var(--ok); } .steps-strip li.now { border-top-color: var(--accent); color: var(--accent); font-weight: 600; }
|
||||
/* 모바일 미리보기 */
|
||||
.frame-wrap { flex: 1; min-height: 0; display: flex; }
|
||||
.frame-wrap iframe { width: 100%; height: 100%; border: 0; flex: 1; }
|
||||
.preview.mobile .frame-wrap { background: #e5e7eb; justify-content: center; align-items: flex-start; overflow: auto; padding: 14px 0; }
|
||||
.preview.mobile .frame-wrap iframe { flex: none; width: 390px; height: 820px; max-height: calc(100% - 28px); border: 12px solid #111827; border-radius: 34px; background: #fff; box-shadow: 0 12px 40px rgba(0,0,0,.3); }
|
||||
|
||||
/* ===== v1.1.2 ===== */
|
||||
.lang-switch select { width: auto; margin: 0; padding: 3px 6px; font-size: 12px; }
|
||||
.tier-guide table { table-layout: fixed; } .tier-guide td, .tier-guide th { overflow-wrap: anywhere; } .tier-guide .cost { white-space: normal; text-align: right; }
|
||||
.tier-guide .tg-name { width: 48px; } .tier-guide .tg-cost { width: 92px; }
|
||||
.done-banner { border: 2px solid #a7f3d0; background: #ecfdf5; }
|
||||
.done-banner .db-head { display: flex; align-items: center; gap: 10px; } .done-banner .db-icon { width: 28px; height: 28px; border-radius: 50%; background: var(--ok); color: #fff; display: grid; place-items: center; font-weight: 700; flex: none; }
|
||||
.done-banner #dbSummary { margin-top: 8px; white-space: pre-wrap; color: #065f46; }
|
||||
.wm-dialog { border: 1px solid var(--line); border-radius: 12px; padding: 16px; width: 480px; max-width: 95vw; } .wm-dialog.wide { width: 720px; } .wm-dialog::backdrop { background: rgba(15,23,42,.45); } .wm-dialog h2 { margin-bottom: 10px; }
|
||||
.detail-files li { display: flex; gap: 8px; font-size: 12px; padding: 3px 0; border-bottom: 1px solid var(--line); } .detail-files .fs { width: 34px; font-weight: 700; } .detail-files .A { color: var(--ok); } .detail-files .M { color: #1d4ed8; } .detail-files .D { color: #991b1b; }
|
||||
.usage-group td { background: #f8fafc; font-weight: 600; } .usage-sub td { color: var(--muted); font-size: 11px; }
|
||||
#usageTable tr.usage-group { cursor: pointer; }
|
||||
|
||||
/* ===== v1.1.4: 좌측 상하 분할 ===== */
|
||||
.left { padding: 0; gap: 0; overflow: hidden; }
|
||||
.top-pane { flex: none; padding: 12px 14px 8px; border-bottom: 2px solid var(--line); background: #fbfcfe; display: flex; flex-direction: column; gap: 8px; }
|
||||
.bottom-pane { flex: 1; min-height: 0; overflow-y: auto; padding: 12px 14px 20px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.main-tabs { padding-bottom: 0; border-bottom: 0; }
|
||||
.status-strip { border: 1px solid var(--line); border-radius: 10px; padding: 8px 10px; background: #fff; }
|
||||
.status-strip .ss-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } .status-strip .ss-site { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.status-strip .progress { padding: 6px 8px; } .status-strip .ptok { min-width: 96px; } .status-strip .ptok .v { font-size: 14px; }
|
||||
.status-strip .steps-strip { margin-bottom: 4px; }
|
||||
.usage-scroll { max-height: 190px; overflow-y: auto; } .usage-scroll table thead th { position: sticky; top: 0; background: #fff; z-index: 1; } .usage-scroll table tfoot td { position: sticky; bottom: 0; background: #f8fafc; }
|
||||
.usage-detail table { width: 100%; border-collapse: collapse; font-size: 12px; } .usage-detail th, .usage-detail td { padding: 4px 6px; border-bottom: 1px solid var(--line); text-align: left; } .usage-detail td.num, .usage-detail th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.publish-line { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13px; } .publish-line a { font-weight: 600; word-break: break-all; }
|
||||
|
||||
|
||||
/* ===== v1.1.6 ===== */
|
||||
/* 캐시 상세: 넓은 대화상자 + 고정 레이아웃으로 좌우 스크롤 없이 */
|
||||
#usageDialog { width: min(1040px, 96vw); }
|
||||
#usageDialog .table-wrap { overflow-x: hidden; }
|
||||
.usage-detail table { table-layout: fixed; }
|
||||
.usage-detail th, .usage-detail td { white-space: normal; overflow-wrap: anywhere; vertical-align: top; }
|
||||
.usage-periods { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
|
||||
@media (max-width: 720px) { .usage-periods { grid-template-columns: 1fr; } }
|
||||
.usage-detail col.c-time { width: 92px; } .usage-detail col.c-amt { width: 92px; } .usage-detail col.c-kind { width: 64px; } .usage-detail col.c-st { width: 70px; }
|
||||
.fb-env-note { background: #f8fafc; border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; margin: 6px 0; color: var(--muted); }
|
||||
.fb-env-note pre { font-size: 11px; white-space: pre-wrap; margin: 6px 0 0; max-height: 160px; overflow: auto; color: var(--text); }
|
||||
|
||||
/* ===================== v1.2.0 ===================== */
|
||||
/* 왼쪽 패널 폭은 관리자 테마(--left-w)로 바꿀 수 있다 (기본 460px) */
|
||||
.app { grid-template-columns: var(--left-w, 460px) 1fr; }
|
||||
@media (max-width: 900px) { .app { grid-template-columns: 1fr; } }
|
||||
/* 헤더: 1줄 = 제품명·버전 ··· 잔여 캐시(오른쪽 정렬), 2줄 = 사용자·관리·제안·언어·로그아웃 */
|
||||
.hdr.v12 { display: flex; flex-direction: column; align-items: stretch; gap: 4px; }
|
||||
.hdr-line { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.hdr-line .spacer { flex: 1; }
|
||||
.hdr-line h1 { margin: 0; white-space: nowrap; }
|
||||
.hdr-line.small { font-size: 12px; flex-wrap: wrap; row-gap: 4px; }
|
||||
.hdr-line.small select { width: auto; margin: 0; padding: 3px 6px; font-size: 12px; }
|
||||
.credit-pill { display: inline-flex; align-items: baseline; gap: 6px; padding: 3px 10px; border: 1px solid #c7d2fe; border-radius: 999px; background: var(--panel, #fff); font-size: 12px; color: var(--muted); white-space: nowrap; transition: transform .15s; }
|
||||
.credit-pill b { font-size: 15px; color: #1e3a8a; font-variant-numeric: tabular-nums; }
|
||||
.credit-pill.neg { border-color: #fecaca; } .credit-pill.neg b { color: #b91c1c; }
|
||||
.credit-pill.live b::after { content: ' ·'; color: var(--accent); animation: wmBlink 1s infinite; }
|
||||
.credit-pill.bump { transform: scale(1.06); }
|
||||
@keyframes wmBlink { 50% { opacity: .2; } }
|
||||
/* 상단 사이트 선택: 고르면 아래 작업 영역과 오른쪽 미리보기가 함께 바뀐다 */
|
||||
.site-picker { display: flex; align-items: center; gap: 6px; }
|
||||
.site-picker select { flex: 1; min-width: 0; margin: 0; font-weight: 600; border: 2px solid color-mix(in srgb, var(--accent) 35%, var(--line)); }
|
||||
.site-picker .pill:empty { display: none; }
|
||||
.site-meta-top { display: flex; flex-direction: column; gap: 1px; font-size: 12px; min-width: 0; }
|
||||
.site-meta-top b { display: none; } /* 제목은 선택 상자에 이미 보이므로 운영 주소 줄만 */
|
||||
.site-meta-top span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
/* 생성 탭: 1 어떻게 만들까요? / 2 기존 사이트 관리 */
|
||||
#existingCard .mode-opt { border-style: dashed; }
|
||||
/* AI에게 맡기기 입력창: 크게, 구분·강조 */
|
||||
.ai-main-label { font-size: 14px; font-weight: 700; color: var(--text); margin-bottom: 14px; }
|
||||
.ai-main-input { min-height: 220px; font-size: 15px; line-height: 1.6; padding: 12px 14px; border: 2px solid #9db8f5; background: #fafcff; box-shadow: 0 0 0 4px rgba(47,111,237,.06); }
|
||||
.ai-main-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 4px rgba(47,111,237,.15); }
|
||||
/* 사이트 현황 분석 카드 */
|
||||
#anBody .an-line { margin: 2px 0; } #anBody .an-line b { color: var(--muted); font-weight: 600; margin-right: 2px; }
|
||||
#anBody .an-summary { margin: 8px 0 4px; line-height: 1.55; }
|
||||
#anBody .an-list { margin: 2px 0 6px; padding-left: 18px; } #anBody .an-list li { margin: 1px 0; }
|
||||
/* 내 제안·오류 신고: 상태 배지 + 운영팀 답변 펼침 */
|
||||
#myFeedbackList .fb-item { display: block; padding: 6px 0; }
|
||||
.fb-head { display: flex; align-items: center; gap: 6px; } .fb-head .fb-title { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||
.fb-st { font-size: 11px; padding: 1px 7px; border-radius: 999px; white-space: nowrap; background: #eef2ff; color: #3730a3; }
|
||||
.fb-st.reviewing { background: #fef3c7; color: #92400e; } .fb-st.done, .fb-st.rejected { background: #d1fae5; color: #065f46; }
|
||||
.fb-answer { margin: 4px 0 0 4px; padding: 6px 8px; border-left: 3px solid var(--ok); background: #f0fdf4; font-size: 12px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
/* 캐시 상세 내역 (사용자 제안 반영): 가로 스크롤 없음, 위 요약·합계와 아래 닫기는 고정, 가운데 상세 내역만 세로 스크롤 */
|
||||
#usageDialog { width: min(1040px, 96vw); max-height: 92vh; padding: 0; overflow: hidden; }
|
||||
#usageDialog[open] { display: flex; }
|
||||
#usageDialog > form { display: flex; flex-direction: column; width: 100%; max-height: 92vh; min-height: 0; padding: 14px 16px 12px; }
|
||||
#usageDialog h2 { flex: none; }
|
||||
#usageDialogBody { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
#usageDialog .ud-head { flex: none; }
|
||||
#usageDialog .ud-head .usage-periods .table-wrap { max-height: 30vh; overflow-y: auto; }
|
||||
#usageDialog .ud-scroll { flex: 1; min-height: 120px; overflow-y: auto; overflow-x: hidden; border-top: 1px solid var(--line); padding-top: 4px; }
|
||||
#usageDialog .ud-scroll th { position: sticky; top: 0; background: var(--panel, #fff); z-index: 1; }
|
||||
#usageDialog > form > .row { flex: none; padding-top: 8px; border-top: 1px solid var(--line); margin-top: 6px; }
|
||||
#usageDialog .usage-detail th, #usageDialog .usage-detail td { padding: 3px 4px; }
|
||||
#usageDialog .usage-detail col.c-time { width: 84px; } #usageDialog .usage-detail col.c-amt { width: 80px; } #usageDialog .usage-detail col.c-kind { width: 58px; } #usageDialog .usage-detail col.c-st { width: 62px; }
|
||||
@media (max-width: 720px) { #usageDialog .ud-head .usage-periods .table-wrap { max-height: 18vh; } }
|
||||
|
||||
/* v1.3.2 관리 탭 메일 상태 줄 */
|
||||
.mail-line { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
|
||||
|
||||
/* v1.3.4 사이트 복사하기 설명 */
|
||||
.copy-explain { border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; margin: 6px 0 10px; font-size: 13px; line-height: 1.6; background: color-mix(in srgb, var(--accent, #2f6fed) 4%, var(--panel, #fff)); }
|
||||
.copy-explain p { margin: 0 0 6px; } .copy-explain ul { margin: 0 0 6px; padding-left: 18px; } .copy-explain li { margin: 2px 0; }
|
||||
|
||||
/* v1.3.5 검색·AI 최적화 생성 옵션 */
|
||||
.seo-opts { border: 1px dashed var(--line); border-radius: 8px; padding: 8px 10px; margin: 8px 0 12px; }
|
||||
.seo-opts .chk, .opt-sec .chk, .reg-engine .chk, #optBody .chk { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text); margin: 4px 0; }
|
||||
.seo-opts .chk input, #optBody .chk input { width: auto; margin: 0; }
|
||||
/* 검토 화면 */
|
||||
.opt-sec { border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; margin: 8px 0; } .opt-sec > summary { font-weight: 700; cursor: pointer; }
|
||||
.opt-page { border-top: 1px solid var(--line); padding: 8px 0; } .opt-page-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 4px; } .opt-page-head .chk { margin: 0; }
|
||||
.opt-field { display: block; margin: 4px 0; } .opt-field > span:first-child { font-size: 12px; color: var(--muted); }
|
||||
.opt-count { font-size: 11px; margin-left: 6px; } .opt-count.ok { color: var(--ok, #1a8f5a); } .opt-count.warn { color: #b45309; } .opt-count.bad { color: #b91c1c; font-weight: 700; }
|
||||
.opt-more > summary { font-size: 12px; color: var(--muted); cursor: pointer; }
|
||||
/* 검색사이트 등록 */
|
||||
.reg-engine { border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; margin: 8px 0; }
|
||||
.reg-steps { margin: 6px 0; padding-left: 20px; } .reg-steps li { margin: 6px 0; } .reg-steps li.done > b { text-decoration: line-through; color: var(--muted); }
|
||||
.reg-actor { font-size: 10px; padding: 1px 6px; border-radius: 999px; background: #eef2ff; color: #3730a3; } .reg-actor.manual { background: #fef3c7; color: #92400e; }
|
||||
.reg-steps .muted { overflow-wrap: anywhere; }
|
||||
|
||||
/* ===================== v1.3.10: 번역문 길이와 무관하게 화면 폭 유지 =====================
|
||||
* 1.3.5 일본어 빌더(390×844)는 약 878px 로 가로 넘침이 있었다. 단어마다 길이를 제한하는 대신 "창(컨테이너)" 폭을
|
||||
* 화면에 고정하고, 그 안의 글자·버튼·탭은 줄바꿈하게 한다 — 번역자가 글자 수를 맞추지 않아도 된다.
|
||||
* 표·막대그래프처럼 줄바꿈할 수 없는 것만 자기 상자 안에서 가로 스크롤한다. */
|
||||
html, body { max-width: 100%; overflow-x: hidden; }
|
||||
.app, .app > *, .left, .right, .top-pane, .bottom-pane, .tabpane, .card, .status-strip, .toolbar, .preview, .hdr, .hdr-line, .site-picker, .mode-grid, .two, .row { min-width: 0; max-width: 100%; }
|
||||
.card, .status-strip, label, p, li, td, th, h1, h2, h3, .muted, .small, .mode-opt, .mode-title, .mode-desc, .opt-card, .pill, .badge, .credit-pill, .field-label, summary { overflow-wrap: anywhere; word-break: normal; }
|
||||
button, .btn, .main-tabs button, .small-btn { white-space: normal; max-width: 100%; overflow-wrap: anywhere; }
|
||||
.toolbar, .hdr-line, .row, .site-picker { flex-wrap: wrap; }
|
||||
select, input, textarea { max-width: 100%; }
|
||||
.table-wrap, .bars, .usage-scroll { max-width: 100%; overflow-x: auto; }
|
||||
.status-strip .ss-head { flex-wrap: wrap; } .status-strip .ss-site { white-space: normal; }
|
||||
@media (max-width: 900px) { .toolbar .url { flex-basis: 100%; } .hdr-line h1 { white-space: normal; } }
|
||||
|
||||
/* v1.3.10 템플릿 고르기 */
|
||||
.tpl-layout { display: grid; grid-template-columns: minmax(120px, 34%) 1fr; gap: 8px; margin: 8px 0; }
|
||||
@media (max-width: 520px) { .tpl-layout { grid-template-columns: 1fr; } }
|
||||
.tpl-tree { display: flex; flex-direction: column; gap: 2px; max-height: 320px; overflow: auto; border: 1px solid var(--line); border-radius: 8px; padding: 4px; }
|
||||
.tpl-dir { text-align: start; border: 0; background: transparent; padding: 4px 8px; font-size: 12px; border-radius: 6px; } .tpl-dir.on { background: color-mix(in srgb, var(--accent, #2f6fed) 14%, transparent); font-weight: 700; }
|
||||
.tpl-list { display: flex; flex-direction: column; gap: 8px; max-height: 420px; overflow: auto; }
|
||||
.tpl-card { border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; } .tpl-card.on { border-color: var(--accent, #2f6fed); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, #2f6fed) 25%, transparent); }
|
||||
.tpl-tags { display: flex; flex-wrap: wrap; gap: 4px; margin: 4px 0; } .tpl-tag { font-size: 11px; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--line); background: transparent; }
|
||||
/* v1.3.10 다국어 생성 옵션·관리 탭 다국어 카드·최적화 언어 탭 */
|
||||
.lang-opts { border: 1px dashed var(--line); border-radius: 8px; padding: 8px 10px; margin: 8px 0 12px; }
|
||||
.lang-opts .lang-default select { max-width: 320px; }
|
||||
.lang-chips { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 6px; }
|
||||
.lang-chip { display: inline-flex !important; align-items: center; gap: 4px; font-size: 13px; color: var(--text); margin: 0; }
|
||||
.lang-chip input { width: auto; margin: 0; }
|
||||
.lang-chip.off { opacity: .45; }
|
||||
.opt-langs { display: flex; flex-wrap: wrap; gap: 4px; margin: 4px 0 10px; }
|
||||
.opt-langs button.on { background: var(--accent, #2f6fed); color: #fff; border-color: transparent; }
|
||||
#langBody .lang-now { display: flex; flex-wrap: wrap; gap: 6px; margin: 4px 0 8px; }
|
||||
#langBody .lang-now a { font-size: 13px; }
|
||||
/* v1.3.16 다국어 품질 게이트·로케일 메타 표 */
|
||||
.q-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.q-table th, .q-table td { text-align: start; padding: 4px 6px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
||||
.q-table td.ok { color: #059669; } .q-table td.bad { color: #dc2626; font-weight: 600; }
|
||||
.q-table td .small-btn { margin: 0 2px 2px 0; }
|
||||
/* v1.4.0 (요구 13): 언어 선택 칩 — 줄 간격을 고르게 하고, 마지막 줄 아래에도 줄 사이 간격만큼 여백을 둔다
|
||||
(예전에는 마지막 줄이 상자 끝·다음 항목에 붙어 아래가 잘린 것처럼 보였다) */
|
||||
.lang-chips { row-gap: 8px; margin-bottom: 8px; align-items: center; }
|
||||
.lang-chip { line-height: 1.6; min-height: 1.6em; }
|
||||
.lang-opts { padding-bottom: 10px; }
|
||||
.lang-opts > :last-child, .lang-opts select { margin-bottom: 0; }
|
||||
.lang-opts .lang-xdef { display: block; margin-top: 4px; }
|
||||
#langBody .lang-chips { margin-bottom: 12px; }
|
||||
|
||||
/* ---- v1.4.0 도메인 탭 ---- */
|
||||
#pane-domains .card { margin-bottom: 12px; }
|
||||
.dom-sub-row { align-items: center; flex-wrap: wrap; }
|
||||
.dom-sub-row input { flex: 1 1 140px; min-width: 0; }
|
||||
.dom-suffix { font-weight: 600; color: var(--muted, #64748b); }
|
||||
.dom-tlds { display: flex; flex-wrap: wrap; gap: 6px 14px; margin: 8px 0; }
|
||||
.dom-results, .dom-table-wrap { overflow-x: auto; max-width: 100%; }
|
||||
.dom-table { width: 100%; font-size: 13px; }
|
||||
.dom-table .dom-val { word-break: break-all; max-width: 260px; }
|
||||
.dom-item { border-top: 1px solid var(--line); padding: 10px 0; }
|
||||
.dom-item:first-of-type { border-top: 0; }
|
||||
.dom-top { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; }
|
||||
.dom-row { flex-wrap: wrap; margin-top: 6px; }
|
||||
.dom-row select { flex: 1 1 160px; min-width: 0; }
|
||||
.dom-row .primary { flex: 0 0 auto; }
|
||||
.dom-wait { background: #fef9c3; border: 1px solid #fde68a; border-radius: 8px; padding: 6px 10px; color: #713f12; }
|
||||
.dom-add { flex-wrap: wrap; margin-top: 8px; }
|
||||
.dom-add input { flex: 1 1 120px; min-width: 0; }
|
||||
.dom-add .dom-prio { flex: 0 1 90px; }
|
||||
.dom-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 6px 10px; margin: 8px 0; }
|
||||
.dom-cost { font-size: 18px; font-weight: 700; margin: 6px 0; }
|
||||
details.dom-contact > summary { cursor: pointer; font-weight: 600; }
|
||||
.dom-search { align-items: center; }
|
||||
.dom-search input { flex: 1 1 0; width: auto; min-width: 0; }
|
||||
.dom-search .primary { flex: 0 0 auto; }
|
||||
.dom-tlds label.inline { display: inline-flex; align-items: center; gap: 4px; margin: 0; width: auto; }
|
||||
.dom-tlds label.inline input { width: auto; margin: 0; }
|
||||
.dom-table td, .dom-table th { white-space: normal; vertical-align: middle; padding: 4px 6px; }
|
||||
.dom-table .dom-val { max-width: 200px; font-family: ui-monospace, monospace; font-size: 12px; }
|
||||
.dom-table td:first-child, .dom-table th, .dom-table td:last-child, .dom-table .small-btn { white-space: nowrap; overflow-wrap: normal; }
|
||||
|
||||
/* v1.4.5 사용자 첫 화면 페이지 (관리자 지정) — 오른쪽 미리보기 영역 안에서만 전체를 채움 (v1.4.15: 전용 상자 #welcomeBox, .preview 기준 절대 위치) */
|
||||
.welcome-box { position: absolute; inset: 0; background: #fff; display: flex; }
|
||||
.welcome-frame { width: 100%; height: 100%; border: 0; display: block; flex: 1; }
|
||||
|
||||
/* ===================== v1.4.14 ===================== */
|
||||
/* (요구 4) 상태·운영 주소 줄: 사이트를 고르지 않아도 같은 높이를 차지해 탭 줄이 위아래로 움직이지 않는다 */
|
||||
.site-meta-top { min-height: 18px; }
|
||||
.site-meta-top span { line-height: 18px; }
|
||||
/* v1.4.15: 클래스 이름 no-site (1.4.14 의 .empty 는 미리보기 빈 화면의 전체 덮개 규칙과 겹쳐 클릭을 막았다) */
|
||||
.site-meta-top.no-site span { color: var(--muted); font-style: italic; }
|
||||
/* (요구 2) 관리 탭 오른쪽 "사이트 관리 창": 위 도구 줄(보기 전환·새로고침·새 탭) + 본문(사이트 관리자 iframe 또는 현황) */
|
||||
.manage-view { flex: 1; display: flex; flex-direction: column; min-height: 0; background: var(--panel, #fff); }
|
||||
.mv-bar .url { font-family: inherit; color: var(--text); font-weight: 600; }
|
||||
.mv-bar button.active { background: #eef2ff; border-color: #c7d2fe; color: #3730a3; }
|
||||
.mv-body { flex: 1; min-height: 0; position: relative; display: flex; flex-direction: column; }
|
||||
#mvAdminFrame { flex: 1; width: 100%; height: 100%; border: 0; background: #fff; }
|
||||
.mv-status { flex: 1; overflow: auto; padding: 14px 18px 24px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.mv-status .mv-sec { display: flex; flex-direction: column; gap: 6px; }
|
||||
.mv-status .field-label { font-size: 13px; font-weight: 700; color: var(--text); }
|
||||
.mv-grid { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); margin-bottom: 0; }
|
||||
.mv-grid .tot .v { font-size: 15px; } .mv-grid .tot .d { display: block; font-size: 11px; color: var(--muted); margin-top: 2px; overflow-wrap: anywhere; }
|
||||
.mv-status .stat-list { max-height: 220px; }
|
||||
@media (max-width: 900px) { .manage-view { min-height: 60vh; } }
|
||||
|
||||
/* v1.5.27 (요구 2) 메시지창: 높이 고정(약 4줄), 최신 메시지가 맨 위, 지난 메시지는 아래로 스크롤. 종류별 색: 오류/경고 빨강, 성공 초록, 안내 회색 */
|
||||
.msg-box { margin-top: 8px; border: 1px solid var(--line); border-radius: 8px; background: #fafbfc; font-size: 12px; }
|
||||
.msg-box .mb-head { display: flex; align-items: center; gap: 8px; padding: 4px 8px; border-bottom: 1px solid var(--line); }
|
||||
.msg-box .mb-list { list-style: none; margin: 0; padding: 0; max-height: 112px; overflow-y: auto; }
|
||||
.msg-box .mb-list li { display: flex; align-items: flex-start; gap: 6px; padding: 5px 8px; border-bottom: 1px dashed var(--line); line-height: 1.4; }
|
||||
.msg-box .mb-list li:last-child { border-bottom: 0; }
|
||||
.msg-box .mb-list li .t { flex: none; color: var(--muted); font-variant-numeric: tabular-nums; font-size: 11px; padding-top: 1px; }
|
||||
.msg-box .mb-list li .x { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||
.msg-box .mb-list li .x a { color: inherit; }
|
||||
.msg-box .mb-list li .del { flex: none; border: 0; background: transparent; color: var(--muted); cursor: pointer; font-size: 14px; line-height: 1; padding: 0 2px; } .msg-box .mb-list li .del:hover { color: #991b1b; }
|
||||
.msg-box .mb-list li.warn { background: #fef2f2; color: #991b1b; } .msg-box .mb-list li.ok { background: #ecfdf5; color: #065f46; } .msg-box .mb-list li.info { color: var(--text); }
|
||||
.msg-box .mb-list li.new { animation: mbflash .5s ease-out; } @keyframes mbflash { from { background: #fef3c7; } }
|
||||
|
||||
/* v1.5.31 (요구 1·3) 진행 상자 한 줄: 상태 왼쪽, 보조 문구 오른쪽 정렬. 아이콘도 작게 */
|
||||
.status-strip .progress { padding: 5px 8px; gap: 8px; }
|
||||
.status-strip .progress .pi { width: 24px; height: 24px; } .status-strip .progress .check, .status-strip .progress .bang { width: 20px; height: 20px; font-size: 12px; } .status-strip .progress .spinner { width: 18px; height: 18px; border-width: 2px; }
|
||||
.progress .pt { flex: 1; min-width: 0; } .progress .pt-line { display: flex; align-items: baseline; gap: 8px; min-width: 0; } .progress .pt-line #progressText { white-space: nowrap; } .progress .pt-line #progressSub { margin-left: auto; text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.progress .pmsg { min-height: 0; margin: 0; } .progress .pmsg:empty { display: none; }
|
||||
.msg-box .mb-list li .x .ln { margin-top: 2px; padding-left: 8px; font-size: 11px; opacity: .92; }
|
||||
.msg-box .mb-list { max-height: 132px; }
|
||||
|
||||
/* v1.5.36 (요구 1) 좌측 위·아래 영역 구분: 위쪽(기본 정보·탭·진행 상태)은 옅은 청회색 바탕과 굵은 경계선, 아래쪽(작업 영역)은 흰 바탕 */
|
||||
.top-pane { background: #e9eef5; border-bottom: 3px solid #b6c2d6; box-shadow: inset 0 -6px 10px -8px rgba(15, 23, 42, .25); }
|
||||
.top-pane .hdr, .top-pane .site-picker { background: transparent; }
|
||||
.top-pane .status-strip { background: #fff; border-color: #cbd5e1; }
|
||||
.top-pane .main-tabs button { background: #f8fafc; }
|
||||
.top-pane .main-tabs button.active { background: #fff; }
|
||||
.bottom-pane { background: #fff; }
|
||||
/* v1.5.36 (요구 3) 생성 탭 등급 표: 특징 칸은 줄바꿈(최대 2줄), ? 도움말 버튼은 글자 뒤에 inline 으로 — 1.5.31 까지는 float 버튼이 긴 문구와 겹쳤다 */
|
||||
.tier-guide td.tg-feat { white-space: normal; overflow-wrap: anywhere; line-height: 1.35; font-size: 11px; }
|
||||
.tier-guide td.tg-feat .help { float: none; display: inline-flex; vertical-align: middle; margin-left: 4px; }
|
||||
.tier-guide .tg-name { width: 34px; } .tier-guide .tg-cost { width: 74px; font-size: 11px; } .tier-guide td.cost { font-size: 11px; }
|
||||
/* v1.6.35 (요구 1-2) 도메인 탭 프로세스 개편: 준비 방법 선택, 블록, 네임서버 안내, 잠금 표시, 상태 표 */
|
||||
.dom-ways { flex-wrap: wrap; gap: 6px; margin-bottom: 6px; }
|
||||
.dom-block { border-top: 1px dashed var(--line); padding-top: 8px; margin-top: 8px; }
|
||||
.dom-block h3 { margin: 0 0 6px; font-size: 15px; }
|
||||
.dom-ns { background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 8px; padding: 8px 10px; margin: 6px 0; }
|
||||
.dom-ns-list { margin: 4px 0; padding: 6px 8px; background: #fff; border: 1px solid var(--line); border-radius: 6px; font-size: 13px; user-select: all; }
|
||||
.pill.dom-lock { background: #fef9c3; color: #713f12; border: 1px solid #fde68a; }
|
||||
.dom-refreshing { opacity: .6; }
|
||||
.dom-status .dom-table td { vertical-align: top; }
|
||||
details.dom-contact > summary { cursor: pointer; font-weight: 600; }
|
||||
.dom-status .dom-table { table-layout: auto; }
|
||||
.dom-status td.dom-st-icon { white-space: nowrap; font-weight: 700; width: 1.6em; }
|
||||
.dom-status td.dom-st-label { white-space: nowrap; font-weight: 600; vertical-align: top; }
|
||||
.dom-status td.dom-st-detail { white-space: normal; overflow-wrap: anywhere; font-family: inherit; font-size: 13px; }
|
||||
|
||||
/* v1.6.38 (요구 3): 도메인 탭 — 네임서버 정보·DNS 레코드 정보 팝업, 복사 가능한 값 */
|
||||
.dom-guide-box { margin: 6px 0; padding: 8px 10px; border: 1px dashed var(--line); border-radius: 8px; }
|
||||
.dom-guide-box .row { margin-top: 4px; }
|
||||
.dom-dialog-body { max-height: 70vh; overflow: auto; user-select: text; }
|
||||
.dom-dialog-body .dom-guide { margin: 4px 0 10px 18px; padding: 0; font-size: 13px; line-height: 1.5; }
|
||||
.dom-dialog-body .dom-guide li { margin: 2px 0; }
|
||||
.dom-dialog-h { font-size: 14px; margin: 12px 0 4px; }
|
||||
.dom-dialog-foot { justify-content: flex-end; margin-top: 10px; }
|
||||
code.dom-copy { user-select: all; cursor: text; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; word-break: break-all; padding: 1px 4px; border-radius: 4px; background: color-mix(in srgb, var(--panel) 85%, var(--text)); }
|
||||
.dom-copy-btn { margin-left: 6px; padding: 0 6px; font-size: 11px; line-height: 18px; vertical-align: middle; }
|
||||
.dom-val { max-width: 420px; }
|
||||
|
||||
/* v1.6.39 (요구 4): 관리자 콘솔 › 메일 서버 연동 — 비밀번호 [보기/숨기기] 로 보이는 값 (클릭 한 번에 전체 선택) */
|
||||
code.mv-pw { user-select: all; -webkit-user-select: all; background: #fff7ed; border: 1px solid #fdba74; border-radius: 4px; padding: 1px 6px; font-size: 12px; }
|
||||
.adm-t button.small, .act button.small { padding: 1px 6px; font-size: 11px; }
|
||||
|
||||
/* ===================================================================================================
|
||||
* v1.6.40 (요구 1): 사용자 화면 전면 재구성 — "작업 흐름" 중심의 세 칸 구조 (레일 · 작업 칸 · 캔버스)
|
||||
* - 레일(.rail): 제품명, 작업 중인 사이트, 작업 흐름 내비게이션(홈 / 사이트 / 주소·메일 / 계정), 잔여 캐시·계정. 항목마다 상태 점(data-state)과 배지(.nb).
|
||||
* - 작업 칸(.left): 위 머리(화면 이름·설명) + 진행 상태·메시지창, 아래 작업 카드. 1.4.14~1.6.39 의 가로 탭 줄(.main-tabs)은 쓰지 않는다.
|
||||
* - 캔버스(.right): 미리보기 / 사이트 관리 창 (그대로).
|
||||
* 디자인 원칙: 시스템 글꼴, 넉넉한 여백, 14px 둥근 모서리, 옅은 그림자, 색은 상태(완료 초록 · 진행 파랑 · 대기 노랑 · 주의 빨강)에만 쓴다.
|
||||
* =================================================================================================== */
|
||||
:root { --rail-w: 232px; --rail-bg: #f5f5f7; --r-lg: 14px; --shadow-1: 0 1px 2px rgba(15, 23, 42, .04), 0 6px 18px -8px rgba(15, 23, 42, .18); }
|
||||
.app { grid-template-columns: var(--rail-w) var(--left-w, 460px) 1fr; }
|
||||
/* ---- 레일 ---- */
|
||||
.rail { display: flex; flex-direction: column; min-height: 0; height: 100vh; background: var(--rail-bg); border-right: 1px solid var(--line); padding: 14px 10px 12px; gap: 10px; overflow-y: auto; min-width: 0; }
|
||||
.rail-brand { display: flex; align-items: center; gap: 9px; padding: 2px 6px 4px; }
|
||||
.rail-logo { width: 26px; height: 26px; border-radius: 8px; background: linear-gradient(135deg, var(--accent), var(--accent-2)); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .28), 0 2px 6px -2px rgba(47, 111, 237, .5); flex: none; }
|
||||
.rail-brand h1 { font-size: 15px; margin: 0; letter-spacing: -.01em; white-space: nowrap; }
|
||||
.rail-site { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 8px 10px 9px; display: flex; flex-direction: column; gap: 6px; box-shadow: var(--shadow-1); }
|
||||
.rail-label { font-size: 11px; font-weight: 600; color: var(--muted); display: flex; align-items: center; gap: 4px; }
|
||||
.rail-site .site-picker select { border-width: 1px; font-size: 13px; padding: 7px 9px; border-color: var(--line); }
|
||||
.rail-site .site-meta-top { gap: 3px; min-height: 0; } .rail-site .site-meta-top b { display: none; } .rail-site .site-meta-top .pill { align-self: flex-start; } .rail-site .site-meta-top .pill:empty { display: none; }
|
||||
.rail-site .site-meta-top span { white-space: normal; line-height: 1.4; }
|
||||
.rail-nav { display: flex; flex-direction: column; gap: 2px; padding: 0; border: 0; }
|
||||
.rail-group { font-size: 11px; font-weight: 600; color: var(--muted); letter-spacing: .02em; padding: 10px 10px 4px; }
|
||||
.rail-nav button { display: grid; grid-template-columns: 18px minmax(0, 1fr) auto; align-items: center; gap: 10px; width: 100%; text-align: left; border: 0; background: transparent; padding: 7px 10px 7px 12px; border-radius: 9px; font-weight: 500; font-size: 13.5px; color: var(--text); position: relative; white-space: nowrap; }
|
||||
.rail-nav button svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; opacity: .78; }
|
||||
.rail-nav button span { overflow: hidden; text-overflow: ellipsis; }
|
||||
.rail-nav button:hover { background: rgba(0, 0, 0, .05); }
|
||||
.rail-nav button.active { background: var(--panel); box-shadow: var(--shadow-1); font-weight: 600; color: var(--accent-2); }
|
||||
.rail-nav button.active svg { opacity: 1; }
|
||||
.rail-nav button .nb { font-style: normal; font-size: 10.5px; font-weight: 600; color: var(--muted); padding: 1px 7px; border-radius: 999px; background: rgba(0, 0, 0, .055); white-space: nowrap; max-width: 92px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.rail-nav button .nb:empty { display: none; }
|
||||
/* 상태 점 (왼쪽 가장자리): done 초록 · active 파랑 · wait 노랑 · warn 빨강 · todo 회색 */
|
||||
.rail-nav button[data-state]::before { content: ''; position: absolute; left: 4px; top: 50%; width: 5px; height: 5px; margin-top: -2.5px; border-radius: 50%; background: transparent; }
|
||||
.rail-nav button[data-state=done]::before { background: var(--ok); } .rail-nav button[data-state=active]::before { background: var(--accent); } .rail-nav button[data-state=wait]::before { background: #d97706; } .rail-nav button[data-state=warn]::before { background: #dc2626; } .rail-nav button[data-state=todo]::before { background: #cbd5e1; }
|
||||
.rail-nav button[data-state=done] .nb { background: #dcfce7; color: #166534; } .rail-nav button[data-state=warn] .nb { background: #fee2e2; color: #991b1b; } .rail-nav button[data-state=wait] .nb { background: #fef3c7; color: #92400e; } .rail-nav button[data-state=active] .nb { background: color-mix(in srgb, var(--accent) 14%, #fff); color: var(--accent-2); }
|
||||
.rail-foot { margin-top: auto; display: flex; flex-direction: column; gap: 6px; padding: 10px 4px 0; border-top: 1px solid var(--line); }
|
||||
.rail-foot .credit-pill { display: flex; justify-content: space-between; align-items: baseline; width: 100%; cursor: pointer; padding: 8px 12px; border-radius: 10px; background: var(--panel); border-color: var(--line); box-shadow: var(--shadow-1); color: var(--muted); }
|
||||
.rail-foot .credit-pill:hover { border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); }
|
||||
.rail-foot .credit-pill b { font-size: 15px; }
|
||||
.rail-foot #creditNote:empty { display: none; }
|
||||
.rail-user { font-size: 12px; padding: 0 2px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.rail-actions { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.rail-actions .small-btn, .rail-actions .btn { padding: 3px 8px; font-size: 11.5px; }
|
||||
/* ---- 작업 칸 머리 (1.5.36 의 청회색 윗칸을 흰색·얇은 경계로) ---- */
|
||||
.top-pane { background: var(--top-bg, #fff); border-bottom: 1px solid var(--line); box-shadow: none; padding: 14px 16px 10px; gap: 10px; }
|
||||
.pane-head .hdr-line h1.pane-title { font-size: 20px; letter-spacing: -.015em; line-height: 1.2; white-space: normal; }
|
||||
.pane-desc { margin: 0; line-height: 1.5; }
|
||||
.pane-desc:empty { display: none; }
|
||||
.pane-site { max-width: 55%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.top-pane .status-strip { border-color: var(--line); }
|
||||
.bottom-pane { padding: 14px 16px 24px; }
|
||||
.card { border-radius: var(--r-lg); box-shadow: 0 1px 2px rgba(15, 23, 42, .03); }
|
||||
/* ---- 홈: 작업 흐름 지도 (flow-ui.js) ---- */
|
||||
.flow { display: flex; flex-direction: column; gap: 10px; }
|
||||
.flow-hero { background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 10%, var(--panel, #fff)), var(--panel, #fff) 70%); border: 1px solid var(--line); border-radius: var(--r-lg); padding: 16px; }
|
||||
.flow-hero h3 { margin: 0 0 4px; font-size: 17px; letter-spacing: -.01em; }
|
||||
.flow-hero p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.55; }
|
||||
.flow-hero .row { margin-top: 10px; flex-wrap: wrap; } .flow-hero .row .primary { flex: 0 0 auto; }
|
||||
.flow-entry { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 8px; }
|
||||
.flow-entry button { text-align: left; display: flex; flex-direction: column; gap: 4px; padding: 12px; border-radius: 12px; border: 1px solid var(--line); background: var(--panel); }
|
||||
.flow-entry button:hover { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 4%, var(--panel, #fff)); }
|
||||
.flow-entry .fe-t { font-weight: 600; font-size: 14px; } .flow-entry .fe-d { font-size: 12px; color: var(--muted); line-height: 1.4; }
|
||||
.flow-steps { display: flex; flex-direction: column; gap: 8px; }
|
||||
.flow-step { display: grid; grid-template-columns: 32px minmax(0, 1fr) auto; gap: 10px; align-items: start; padding: 12px; border: 1px solid var(--line); border-radius: 12px; background: var(--panel); position: relative; }
|
||||
.flow-step.optional { border-style: dashed; }
|
||||
.flow-step .fs-icon { width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; font-weight: 700; font-size: 13px; background: #eef2ff; color: #3730a3; }
|
||||
.flow-step[data-state=done] .fs-icon { background: #dcfce7; color: #166534; } .flow-step[data-state=active] .fs-icon { background: color-mix(in srgb, var(--accent) 16%, #fff); color: var(--accent-2); } .flow-step[data-state=wait] .fs-icon { background: #fef3c7; color: #92400e; } .flow-step[data-state=warn] .fs-icon { background: #fee2e2; color: #991b1b; } .flow-step[data-state=todo] .fs-icon, .flow-step[data-state=na] .fs-icon { background: #f1f5f9; color: #64748b; }
|
||||
.flow-step .fs-title { font-weight: 600; font-size: 14px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.flow-step .fs-title .pill { font-size: 10.5px; }
|
||||
.flow-step .fs-detail { font-size: 12.5px; color: var(--text); margin-top: 3px; line-height: 1.45; overflow-wrap: anywhere; }
|
||||
.flow-step .fs-dep { font-size: 11.5px; color: var(--muted); margin-top: 4px; line-height: 1.4; }
|
||||
.flow-step .fs-act { display: flex; flex-direction: column; gap: 4px; align-items: stretch; }
|
||||
.flow-step .fs-act button { white-space: nowrap; }
|
||||
.flow-map { border: 1px solid var(--line); border-radius: 12px; padding: 10px 12px; background: var(--panel); }
|
||||
.flow-map .fm-row { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; font-size: 12px; }
|
||||
.flow-map .fm-node { border: 1px solid var(--line); border-radius: 999px; padding: 2px 9px; background: #fafbfc; font-weight: 600; }
|
||||
.flow-map .fm-node.dim { font-weight: 500; color: var(--muted); border-style: dashed; }
|
||||
.flow-map .fm-arrow { color: var(--muted); }
|
||||
.flow-map .fm-note { font-size: 11.5px; color: var(--muted); margin-top: 6px; line-height: 1.45; }
|
||||
/* ---- 배포 점검표 (flow-ui.js) ---- */
|
||||
.chk-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.chk-list li { display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; gap: 8px; align-items: center; font-size: 13px; }
|
||||
.chk-list li .ci { width: 20px; height: 20px; border-radius: 50%; display: grid; place-items: center; font-size: 12px; font-weight: 700; background: #f1f5f9; color: #64748b; }
|
||||
.chk-list li[data-state=done] .ci { background: #dcfce7; color: #166534; } .chk-list li[data-state=wait] .ci { background: #fef3c7; color: #92400e; } .chk-list li[data-state=warn] .ci { background: #fee2e2; color: #991b1b; } .chk-list li[data-state=todo] .ci { background: #eef2ff; color: #3730a3; }
|
||||
.chk-list li .cd { font-size: 11.5px; color: var(--muted); display: block; }
|
||||
.chk-list li button { white-space: nowrap; }
|
||||
/* ---- 캐시·충전 (credits-ui.js) ---- */
|
||||
.cr-hero { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: center; }
|
||||
.cr-balance { font-size: 30px; font-weight: 700; letter-spacing: -.02em; font-variant-numeric: tabular-nums; line-height: 1.1; }
|
||||
.cr-balance.neg { color: #b91c1c; }
|
||||
.cr-sub { font-size: 12px; color: var(--muted); margin-top: 4px; }
|
||||
.pk-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; margin: 8px 0; }
|
||||
.pk { text-align: left; border: 1.5px solid var(--line); border-radius: 12px; padding: 12px; background: var(--panel); display: flex; flex-direction: column; gap: 4px; cursor: pointer; }
|
||||
.pk:hover { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); }
|
||||
.pk.on { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent); }
|
||||
.pk .pk-name { font-weight: 600; font-size: 13px; } .pk .pk-amt { font-size: 18px; font-weight: 700; letter-spacing: -.01em; font-variant-numeric: tabular-nums; }
|
||||
.pk .pk-bonus { font-size: 11px; color: #166534; background: #dcfce7; border-radius: 999px; padding: 1px 7px; align-self: flex-start; } .pk .pk-credit { font-size: 12px; color: var(--muted); }
|
||||
.cr-summary { border: 1px solid var(--line); border-radius: 12px; padding: 12px; background: #fafbfc; display: grid; gap: 4px; margin: 8px 0; }
|
||||
.cr-summary .cs-row { display: flex; justify-content: space-between; gap: 8px; font-size: 13px; } .cr-summary .cs-row span:last-child { font-variant-numeric: tabular-nums; }
|
||||
.cr-summary .cs-row.total { font-weight: 700; font-size: 15px; border-top: 1px dashed var(--line); padding-top: 6px; margin-top: 4px; }
|
||||
.cr-notice { font-size: 12px; color: #92400e; background: #fef3c7; border-radius: 8px; padding: 8px 10px; line-height: 1.5; }
|
||||
.cr-done { border: 1px solid #a7f3d0; background: #ecfdf5; border-radius: 12px; padding: 12px; font-size: 13px; line-height: 1.5; }
|
||||
.order-list .ol-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px 10px; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--line); font-size: 13px; }
|
||||
.order-list .ol-row:last-child { border-bottom: 0; }
|
||||
.order-list .ol-meta { font-size: 11.5px; color: var(--muted); }
|
||||
.order-list .ol-act { display: flex; gap: 4px; align-items: center; }
|
||||
.pill.paid { background: #d1fae5; color: #065f46; } .pill.pending { background: #fef3c7; color: #92400e; } .pill.cancelled { background: #e5e7eb; color: #374151; }
|
||||
/* ---- DNS · 메일 화면의 도메인 항목 (domains-ui.js view=dns|mail) ---- */
|
||||
.dom-head-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.dom-head-row .spacer { flex: 1; }
|
||||
.dom-mailstat { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 6px; font-size: 12.5px; }
|
||||
/* ---- 반응형: 레일을 위 가로 띠로 ---- */
|
||||
@media (max-width: 1180px) { :root { --rail-w: 204px; } .rail-nav button { font-size: 13px; } }
|
||||
@media (max-width: 900px) {
|
||||
.app { grid-template-columns: 1fr; grid-template-rows: auto auto 70vh; height: auto; }
|
||||
.rail { height: auto; flex-direction: row; flex-wrap: wrap; align-items: center; border-right: 0; border-bottom: 1px solid var(--line); padding: 10px 12px; }
|
||||
.rail-brand { flex: 1 1 auto; } .rail-site { flex: 1 1 100%; } .rail-nav { flex-direction: row; flex-wrap: wrap; width: 100%; } .rail-group { display: none; } .rail-nav button { width: auto; grid-template-columns: 18px auto auto; padding-left: 10px; } .rail-nav button[data-state]::before { display: none; }
|
||||
.rail-foot { margin-top: 0; border-top: 0; flex: 1 1 100%; flex-direction: row; align-items: center; flex-wrap: wrap; padding-top: 0; } .rail-foot .credit-pill { width: auto; }
|
||||
}
|
||||
|
||||
/* ===================================================================================================
|
||||
* v1.7.2 (요구 1): 도메인 — 네임서버 이전 단계 표시(.ns-stepper), DNS — 사이트 ↔ 도메인 연결 표(.dom-conn), 메일 — 한눈에 보기(.mail-glance)
|
||||
* =================================================================================================== */
|
||||
.ns-stepper { border: 1px solid var(--line); border-radius: 12px; padding: 10px 12px; margin: 6px 0; background: #fafbfc; }
|
||||
.ns-steps { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; }
|
||||
.ns-step { display: flex; align-items: flex-start; gap: 6px; padding: 6px; border-radius: 8px; border: 1px solid transparent; min-width: 0; }
|
||||
.ns-step .ns-dot { flex: none; width: 22px; height: 22px; border-radius: 50%; display: grid; place-items: center; font-size: 11px; font-weight: 700; background: #e5e7eb; color: #475569; }
|
||||
.ns-step .ns-t { font-size: 12px; font-weight: 600; line-height: 1.3; } .ns-step .ns-s { font-size: 11px; color: var(--muted); line-height: 1.3; }
|
||||
.ns-step.done .ns-dot { background: #dcfce7; color: #166534; } .ns-step.now { border-color: color-mix(in srgb, var(--accent) 40%, var(--line)); background: color-mix(in srgb, var(--accent) 6%, #fff); } .ns-step.now .ns-dot { background: var(--accent); color: #fff; } .ns-step.todo { opacity: .6; }
|
||||
.ns-detail { margin-top: 8px; border-top: 1px dashed var(--line); padding-top: 8px; }
|
||||
.conn-site { border: 1px solid var(--line); border-radius: 12px; padding: 10px 12px; margin: 8px 0; background: var(--panel, #fff); }
|
||||
.conn-site.on { border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); background: color-mix(in srgb, var(--accent) 5%, #fff); }
|
||||
.conn-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 14px; }
|
||||
.conn-addr { margin: 6px 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.conn-line { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 13px; } .conn-line a { font-weight: 600; overflow-wrap: anywhere; } .conn-arrow { color: var(--muted); }
|
||||
.dom-conn-row { flex-wrap: wrap; align-items: center; gap: 6px; margin: 4px 0 0; }
|
||||
.dom-conn-row select { flex: 1 1 160px; min-width: 0; margin: 0; font-size: 12px; padding: 5px 6px; }
|
||||
.dom-conn-row input.dom-host { flex: 1 1 140px; min-width: 0; margin: 0; font-size: 12px; padding: 5px 6px; }
|
||||
.dom-conn-row .primary { flex: 0 0 auto; }
|
||||
details.dom-rec-details > summary { cursor: pointer; font-size: 13px; font-weight: 600; margin-top: 6px; }
|
||||
.mail-glance { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
|
||||
.mail-glance .mg-row { display: grid; grid-template-columns: 110px minmax(0, 1fr); gap: 8px; padding: 6px 0; border-top: 1px dashed var(--line); font-size: 13px; }
|
||||
.mail-glance .mg-k { color: var(--muted); font-size: 12px; font-weight: 600; padding-top: 2px; }
|
||||
.mail-glance .mg-v { min-width: 0; overflow-wrap: anywhere; line-height: 1.5; }
|
||||
.mail-glance .mg-v .btn { margin: 2px 4px 2px 0; }
|
||||
.mail-glance .mg-admin { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin: 2px 0; }
|
||||
/* ---- v1.7.2 (요구 2): 오른쪽 캔버스 사이트 정보 페이지 (site-info.js) ---- */
|
||||
.mv-status { max-width: 1100px; margin: 0 auto; width: 100%; }
|
||||
.si-title { font-size: 20px; letter-spacing: -.015em; margin: 4px 0 2px; }
|
||||
.si-sec { border: 1px solid var(--line); border-radius: var(--r-lg, 14px); padding: 14px 16px; background: var(--panel, #fff); display: flex; flex-direction: column; gap: 8px; }
|
||||
.si-head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; } .si-head h3 { margin: 0; font-size: 15px; }
|
||||
.si-grid { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); margin-bottom: 0; }
|
||||
.si-grid .tot .v { font-size: 17px; } .si-grid .tot .d { display: block; font-size: 11px; color: var(--muted); margin-top: 2px; overflow-wrap: anywhere; }
|
||||
.si-grid .tot.si-web { border-left: 4px solid #2f6fed; } .si-grid .tot.si-db { border-left: 4px solid #7c3aed; } .si-grid .tot.si-mail { border-left: 4px solid #0e9f6e; }
|
||||
.si-chart { width: 100%; height: auto; display: block; background: #fff; border: 1px solid var(--line); border-radius: 10px; }
|
||||
.si-chart .si-tick { font-size: 10px; fill: #6b7684; } .si-chart .si-xl { font-size: 11px; fill: #1f2933; font-weight: 600; } .si-chart .si-val { font-size: 10px; fill: #1f2933; } .si-chart .si-empty { font-size: 12px; fill: #9aa3af; }
|
||||
.si-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 12px; color: var(--muted); } .si-legend i { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 5px; vertical-align: middle; }
|
||||
.si-two { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } @media (max-width: 1100px) { .si-two { grid-template-columns: 1fr; } }
|
||||
.si-stack { display: flex; height: 18px; border-radius: 999px; overflow: hidden; background: #eef2f7; border: 1px solid var(--line); } .si-seg { height: 100%; min-width: 0; }
|
||||
.si-disk-total { font-size: 22px; font-weight: 700; letter-spacing: -.01em; }
|
||||
.si-table th, .si-table td { white-space: nowrap; }
|
||||
|
||||
/* ===== v1.7.3: 메일 사용량 (메일 화면 한눈에 보기 · 사이트 정보) ===== */
|
||||
.mg-usage .git-list { margin: 6px 0 4px; } .mg-usage .git-list .st { white-space: normal; text-align: right; }
|
||||
.mg-usage > div + div { margin-top: 3px; }
|
||||
@@ -0,0 +1,79 @@
|
||||
'use strict';
|
||||
/* ============================================================================
|
||||
* 사이트 템플릿 화면 (v1.3.10)
|
||||
* - 생성 탭 "템플릿에서 시작" (#templateStartCard): 관리자가 사용을 허용한 템플릿을 디렉터리(업종·별도)와
|
||||
* 해시태그로 찾아 미리보고, 이름을 정해 새 사이트로 시작한다 (GET /api/templates, POST /api/sites/template).
|
||||
* - 관리자 빌더(/builder)의 관리 탭 "템플릿으로 등록" (#tplRegisterCard): 지금 사이트를 템플릿으로 담는다
|
||||
* (POST /api/admin/templates). 사용자 사용 허용·수정은 관리자 콘솔 › 템플릿 탭에서 한다.
|
||||
* 전역: window.TemplatesUI = { showStart, onSiteOpened, dirOptions }
|
||||
* ========================================================================== */
|
||||
(() => {
|
||||
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; };
|
||||
const btn = (label, cls, fn) => { const b = el('button', cls || 'ghost small-btn', label); b.type = 'button'; b.onclick = fn; return b; };
|
||||
let curDir = ''; let chosen = null; let timer = null;
|
||||
|
||||
/** 디렉터리 이름 표시: ind:음식점·카페/한식 → 음식점·카페 › 한식 (업종 이름은 번역 사전으로) */
|
||||
function dirLabel(dir, tree) {
|
||||
if (!dir) return T('전체');
|
||||
if (dir.startsWith('ind:')) return dir.slice(4).split('/').map((x) => T(x)).join(' › ');
|
||||
const find = (nodes) => { for (const n of nodes || []) { if (n.id === dir) return n.name; const f = find(n.children); if (f) return f; } return null; };
|
||||
return find(tree && tree.custom) || dir;
|
||||
}
|
||||
/** 디렉터리 선택 상자 옵션 (관리자 등록·수정 화면에서도 쓴다) */
|
||||
function dirOptions(tree, sel, value) {
|
||||
sel.innerHTML = ''; sel.appendChild(new Option(T('디렉터리 선택'), ''));
|
||||
const g1 = document.createElement('optgroup'); g1.label = T('업종');
|
||||
for (const i of tree.industries) { g1.appendChild(new Option(T(i.name), i.id)); for (const m of i.children) g1.appendChild(new Option(` ${T(i.name)} › ${T(m.name)}`, m.id)); }
|
||||
sel.appendChild(g1);
|
||||
if ((tree.custom || []).length) { const g2 = document.createElement('optgroup'); g2.label = T('별도 디렉터리'); const walk = (nodes, depth) => { for (const n of nodes) { g2.appendChild(new Option(`${' '.repeat(depth)}${n.name}`, n.id)); walk(n.children || [], depth + 1); } }; walk(tree.custom, 0); sel.appendChild(g2); }
|
||||
if (value) sel.value = value;
|
||||
}
|
||||
|
||||
// ---------- 생성 탭: 템플릿에서 시작 ----------
|
||||
async function load() {
|
||||
const q = $('tplQ').value.trim(); let d; try { d = await api(`/api/templates?q=${encodeURIComponent(q)}&dir=${encodeURIComponent(curDir)}`); } catch (e) { $('tplList').textContent = e.message; return; }
|
||||
// 디렉터리 트리 (템플릿이 있는 곳만)
|
||||
const tr = $('tplTree'); tr.innerHTML = '';
|
||||
const node = (n, depth) => { if (!n.count) return; const b = el('button', 'tpl-dir' + (curDir === n.id ? ' on' : ''), `${n.kind === 'industry' ? T(n.name) : n.name} (${n.count})`); b.type = 'button'; b.style.paddingLeft = (8 + depth * 14) + 'px'; b.onclick = () => { curDir = curDir === n.id ? '' : n.id; load(); }; tr.appendChild(b); for (const c of n.children || []) node(c, depth + 1); };
|
||||
const all = el('button', 'tpl-dir' + (curDir ? '' : ' on'), `${T('전체')} (${d.tree.total})`); all.type = 'button'; all.onclick = () => { curDir = ''; load(); }; tr.appendChild(all);
|
||||
for (const n of d.tree.industries) node(n, 0); for (const n of d.tree.custom) node(n, 0);
|
||||
// 해시태그
|
||||
const tg = $('tplTags'); tg.innerHTML = ''; for (const t of d.tags.slice(0, 16)) { const c = el('button', 'tpl-tag', `#${t.tag}`); c.type = 'button'; c.onclick = () => { const v = $('tplQ').value; if (!v.includes('#' + t.tag)) $('tplQ').value = (v + ' #' + t.tag).trim(); load(); }; tg.appendChild(c); }
|
||||
// 목록
|
||||
const list = $('tplList'); list.innerHTML = '';
|
||||
for (const t of d.items) {
|
||||
const card = el('div', 'tpl-card' + (chosen === t.id ? ' on' : ''));
|
||||
card.appendChild(el('b', '', t.title)); card.appendChild(el('div', 'muted small', dirLabel(t.dir, d.tree)));
|
||||
if (t.description) card.appendChild(el('div', 'small', t.description));
|
||||
const tags = el('div', 'tpl-tags'); for (const g of t.tags) tags.appendChild(el('span', 'tpl-tag', '#' + g)); card.appendChild(tags);
|
||||
const row = el('div', 'row'); const pv = el('a', 'btn ghost small-btn', T('미리보기')); pv.href = `/api/templates/${t.id}/preview/`; pv.target = '_blank'; pv.rel = 'noopener'; row.appendChild(pv);
|
||||
row.appendChild(btn(T('이 템플릿 고르기'), chosen === t.id ? 'primary small-btn' : 'ghost small-btn', () => { chosen = t.id; $('tplChosen').textContent = TF('선택한 템플릿: {1}', t.title); $('tplStart').disabled = false; if (!$('tplTitle').value) $('tplTitle').placeholder = t.title; load(); }));
|
||||
card.appendChild(row); list.appendChild(card);
|
||||
}
|
||||
if (!d.items.length) list.appendChild(el('p', 'muted small', q || curDir ? T('조건에 맞는 템플릿이 없습니다.') : T('아직 사용할 수 있는 템플릿이 없습니다.')));
|
||||
}
|
||||
function showStart() { $('templateStartCard').classList.remove('hidden'); chosen = null; $('tplStart').disabled = true; $('tplChosen').textContent = ''; load(); }
|
||||
$('tplQ').oninput = () => { clearTimeout(timer); timer = setTimeout(load, 250); };
|
||||
$('tplStart').onclick = async () => {
|
||||
if (!chosen) return; $('tplStart').disabled = true;
|
||||
try { const site = await api('/api/sites/template', { method: 'POST', body: JSON.stringify({ templateId: chosen, title: $('tplTitle').value.trim(), contactEmail: $('tplEmail').value.trim(), tier: $('tierSelect').value || '', optimize: seoOptsOf('templateStartCard') }) }); $('tplTitle').value = ''; $('tplEmail').value = ''; await openSite(site.id); await refreshSiteList(); }
|
||||
catch (e) { alert(e.message); $('tplStart').disabled = false; }
|
||||
};
|
||||
|
||||
// ---------- 관리자 빌더: 이 사이트를 템플릿으로 등록 ----------
|
||||
let tree = null;
|
||||
async function onSiteOpened(site) {
|
||||
const card = $('tplRegisterCard'); const admin = config && config.user && config.user.role === 'admin';
|
||||
const ready = admin && site && (!site.plan || ['generated', 'published'].includes(site.plan.stage)) && (site.files || []).some((f) => /\.html?$/i.test(f));
|
||||
card.classList.toggle('hidden', !ready); if (!ready) return;
|
||||
if (!tree) { try { tree = (await api('/api/templates')).tree; } catch (_) { return; } }
|
||||
dirOptions(tree, $('tplRegDir')); $('tplRegTitle').value = site.title; $('tplRegMsg').textContent = '';
|
||||
}
|
||||
$('tplRegGo').onclick = async () => {
|
||||
if (!current) return; $('tplRegMsg').textContent = T('담는 중…');
|
||||
try { const t = await api('/api/admin/templates', { method: 'POST', body: JSON.stringify({ siteId: current.id, title: $('tplRegTitle').value, tags: $('tplRegTags').value, dir: $('tplRegDir').value, description: $('tplRegDesc').value }) }); $('tplRegMsg').textContent = TF('템플릿 "{1}" 으로 등록했습니다 (파일 {2}개). 사용자 사용 허용은 관리자 콘솔 › 템플릿 에서 켭니다.', t.title, t.files); }
|
||||
catch (e) { $('tplRegMsg').textContent = e.message; }
|
||||
};
|
||||
|
||||
window.TemplatesUI = { showStart, onSiteOpened, dirOptions, dirLabel };
|
||||
})();
|
||||
@@ -0,0 +1,58 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>웹메일</title>
|
||||
<!--
|
||||
웹메일 (v1.3.0) — /webmail/<사이트ID>/
|
||||
사이트 관리자 계정 중 "메일 사용" 을 켠 계정이 같은 아이디·비밀번호로 로그인한다.
|
||||
메일 시스템 상태(운영자 꺼짐 / 사이트 꺼짐 / DNS 미완료 / 게이트웨이 미연결 / 사용 가능)를 맨 위 띠에 항상 표시한다.
|
||||
실제 메일 송수신은 메일 게이트웨이(mail-gateway) 연동 후 동작한다 (lib/mailsys.js 참고).
|
||||
-->
|
||||
<style>
|
||||
:root { --bg: #f4f5f7; --panel: #fff; --line: #e3e6ea; --text: #1f2933; --muted: #6b7684; --accent: #2f6fed; }
|
||||
* { box-sizing: border-box; } body { margin: 0; font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif; font-size: 14px; color: var(--text); background: var(--bg); }
|
||||
header { display: flex; align-items: center; gap: 10px; padding: 10px 16px; background: #0f172a; color: #fff; } header h1 { font-size: 16px; margin: 0; } header .spacer { flex: 1; } header .muted { color: #94a3b8; font-size: 12px; }
|
||||
button { font: inherit; padding: 7px 12px; border-radius: 7px; border: 1px solid var(--line); background: #fff; cursor: pointer; } button.primary { background: var(--accent); color: #fff; border-color: var(--accent); } button:disabled { opacity: .45; cursor: not-allowed; }
|
||||
input, textarea { width: 100%; padding: 8px 10px; border: 1px solid var(--line); border-radius: 7px; font: inherit; margin: 3px 0 10px; } label { display: block; font-size: 12px; color: var(--muted); }
|
||||
.hidden { display: none !important; } .muted { color: var(--muted); } .small { font-size: 12px; }
|
||||
.banner { padding: 9px 16px; font-size: 13px; background: #fef3c7; color: #92400e; border-bottom: 1px solid #fde68a; } .banner.ok { background: #d1fae5; color: #065f46; border-color: #a7f3d0; } .banner.off { background: #fee2e2; color: #991b1b; border-color: #fecaca; }
|
||||
.login { max-width: 360px; margin: 60px auto; background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 22px; } .login h2 { margin: 0 0 4px; font-size: 18px; } .msg { min-height: 18px; color: #c2410c; font-size: 12px; }
|
||||
.mail { display: grid; grid-template-columns: 180px 360px 1fr; height: calc(100vh - 92px); } .mail > div { border-right: 1px solid var(--line); background: var(--panel); overflow: auto; }
|
||||
.folders { padding: 12px; display: flex; flex-direction: column; gap: 6px; } .folders button { text-align: left; border: 0; background: transparent; } .folders button.active { background: #eef2ff; color: #3730a3; font-weight: 600; }
|
||||
.list .item { padding: 10px 12px; border-bottom: 1px solid var(--line); cursor: pointer; } .list .item:hover { background: #f8fafc; } .list .item.unread b { color: #111; } .list .item .sub { color: var(--muted); font-size: 12px; }
|
||||
.read { padding: 16px; } .read pre { white-space: pre-wrap; font-family: inherit; }
|
||||
@media (max-width: 820px) { .mail { grid-template-columns: 1fr; height: auto; } .mail > div { border-right: 0; border-bottom: 1px solid var(--line); } }
|
||||
</style>
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<header><h1>웹메일</h1><span class="muted" id="hSite"></span><span class="spacer"></span><span id="hAddr" class="small"></span><button type="button" id="btnCompose" class="hidden">메일 쓰기</button><button type="button" id="btnLogout" class="hidden">로그아웃</button></header>
|
||||
<div class="banner hidden" id="banner"></div>
|
||||
<section class="login hidden" id="loginBox">
|
||||
<h2>웹메일 로그인</h2>
|
||||
<p class="muted small">사이트 관리자 계정의 아이디와 비밀번호로 로그인합니다. 메일 사용이 켜진 계정만 들어올 수 있습니다.</p>
|
||||
<label>아이디 (또는 메일 주소)<input id="lId" autocomplete="username"></label>
|
||||
<label>비밀번호<input id="lPw" type="password" autocomplete="current-password"></label>
|
||||
<p class="msg" id="lMsg"></p>
|
||||
<button type="button" class="primary" id="lGo">로그인</button>
|
||||
</section>
|
||||
<main class="mail hidden" id="mailBox">
|
||||
<div class="folders" id="folders"><button type="button" data-folder="inbox" class="active">받은 편지함</button><button type="button" data-folder="sent">보낸 편지함</button></div>
|
||||
<div class="list" id="list"></div>
|
||||
<div class="read" id="read"><p class="muted">메일을 고르면 내용이 여기에 표시됩니다.</p></div>
|
||||
</main>
|
||||
<dialog id="composeDlg"><form method="dialog" style="width:min(560px,90vw)">
|
||||
<h3>메일 쓰기</h3>
|
||||
<label>받는 사람<input id="cTo" type="email" placeholder="name@example.com"></label>
|
||||
<label>제목<input id="cSubject"></label>
|
||||
<label>내용<textarea id="cText" rows="10"></textarea></label>
|
||||
<p class="msg" id="cMsg"></p>
|
||||
<button type="button" class="primary" id="cSend">보내기</button> <button type="button" id="cCancel">닫기</button>
|
||||
</form></dialog>
|
||||
<script src="/i18n.js"></script>
|
||||
<script src="/webmail.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,65 @@
|
||||
'use strict';
|
||||
/* ============================================================================
|
||||
* 웹메일 화면 스크립트 (v1.3.0) — /webmail/<사이트ID>/
|
||||
* - 로그인: POST /api/webmail/<id>/login {id, password} → 사이트 관리자 토큰 (localStorage 'wm_webmail_<id>')
|
||||
* - 상태 : 모든 응답에 status {level, message, canMail} 가 들어 있어 맨 위 띠에 표시한다.
|
||||
* level 이 ready 가 아니면(운영자 꺼짐·DNS 미완료·게이트웨이 미연결) "메일 쓰기" 를 막는다.
|
||||
* - 메일함: GET /api/webmail/<id>/box?folder=inbox|sent, 보내기: POST /api/webmail/<id>/send
|
||||
* ========================================================================== */
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const SITE = (location.pathname.match(/\/webmail\/([a-f0-9]{32})/) || [])[1];
|
||||
const KEY = 'wm_webmail_' + SITE;
|
||||
let token = ''; try { token = localStorage.getItem(KEY) || ''; } catch (_) {}
|
||||
let folder = 'inbox'; let status = null;
|
||||
const dt = (s) => (s ? new Date(s).toLocaleString(uiLocale(), { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '');
|
||||
|
||||
/** API 호출. 401 이면 로그인 화면으로 */
|
||||
async function api(p, opt = {}) {
|
||||
const headers = { 'Content-Type': 'application/json' }; if (token) headers.Authorization = 'Bearer ' + token;
|
||||
const r = await fetch(`/api/webmail/${SITE}${p}`, { ...opt, headers });
|
||||
const b = await r.json().catch(() => ({}));
|
||||
if (b.status) showStatus(b.status); if (b.site) $('hSite').textContent = b.site.title || '';
|
||||
if (r.status === 401) { showLogin(); throw new Error(b.error || '로그인이 필요합니다'); }
|
||||
if (!r.ok) throw new Error(b.error || `HTTP ${r.status}`);
|
||||
return b;
|
||||
}
|
||||
/** 상태 띠: ready 는 초록, 운영자 꺼짐은 빨강, 나머지(DNS·게이트웨이)는 노랑 */
|
||||
function showStatus(st) {
|
||||
status = st; const b = $('banner'); b.classList.remove('hidden'); b.className = 'banner ' + (st.level === 'ready' ? 'ok' : st.level === 'off' ? 'off' : '');
|
||||
b.textContent = st.message; $('btnCompose').disabled = !st.canMail; $('btnCompose').title = st.canMail ? '' : st.message;
|
||||
}
|
||||
function showLogin() { $('loginBox').classList.remove('hidden'); $('mailBox').classList.add('hidden'); $('btnLogout').classList.add('hidden'); $('btnCompose').classList.add('hidden'); $('hAddr').textContent = ''; }
|
||||
$('lGo').onclick = async () => {
|
||||
$('lMsg').textContent = '확인 중…';
|
||||
try { const r = await api('/login', { method: 'POST', body: JSON.stringify({ id: $('lId').value.trim(), password: $('lPw').value }) }); token = r.token; try { localStorage.setItem(KEY, token); } catch (_) {} $('lPw').value = ''; $('lMsg').textContent = ''; openBox(); }
|
||||
catch (e) { $('lMsg').textContent = e.message; }
|
||||
};
|
||||
$('lPw').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('lGo').click(); });
|
||||
$('btnLogout').onclick = () => { token = ''; try { localStorage.removeItem(KEY); } catch (_) {} showLogin(); };
|
||||
document.querySelectorAll('#folders button').forEach((b) => (b.onclick = () => { folder = b.dataset.folder; document.querySelectorAll('#folders button').forEach((x) => x.classList.toggle('active', x === b)); openBox(); }));
|
||||
|
||||
/** 메일함 열기: 목록을 그리고 첫 화면 상태를 맞춘다 */
|
||||
async function openBox() {
|
||||
let r; try { r = await api(`/box?folder=${folder}`); } catch (_) { return; }
|
||||
$('loginBox').classList.add('hidden'); $('mailBox').classList.remove('hidden'); $('btnLogout').classList.remove('hidden'); $('btnCompose').classList.remove('hidden'); $('hAddr').textContent = r.address || '';
|
||||
const list = $('list'); list.innerHTML = '';
|
||||
for (const m of r.messages) {
|
||||
const it = document.createElement('div'); it.className = 'item' + (m.read ? '' : ' unread');
|
||||
const b = document.createElement('b'); b.textContent = m.subject || '(제목 없음)'; const sub = document.createElement('div'); sub.className = 'sub'; sub.textContent = `${folder === 'sent' ? '받는 사람 ' + (m.to || '') : (m.from || '')} · ${dt(m.at)}`;
|
||||
it.appendChild(b); it.appendChild(sub); it.onclick = () => showMessage(m); list.appendChild(it);
|
||||
}
|
||||
if (!r.messages.length) { const p = document.createElement('p'); p.className = 'muted small'; p.style.padding = '12px'; p.textContent = status && !status.canMail ? '메일 수발신이 제한된 상태라 메일함이 비어 있습니다. 위 안내를 확인하세요.' : '메일이 없습니다.'; list.appendChild(p); }
|
||||
}
|
||||
function showMessage(m) {
|
||||
const box = $('read'); box.innerHTML = ''; const h = document.createElement('h3'); h.textContent = m.subject || '(제목 없음)'; const meta = document.createElement('p'); meta.className = 'muted small'; meta.textContent = `보낸 사람 ${m.from || ''} · 받는 사람 ${m.to || ''} · ${dt(m.at)}`; const pre = document.createElement('pre'); pre.textContent = m.text || '';
|
||||
box.appendChild(h); box.appendChild(meta); box.appendChild(pre);
|
||||
}
|
||||
$('btnCompose').onclick = () => { $('cMsg').textContent = ''; $('composeDlg').showModal(); };
|
||||
$('cCancel').onclick = () => $('composeDlg').close();
|
||||
$('cSend').onclick = async () => { $('cMsg').textContent = '보내는 중…'; try { await api('/send', { method: 'POST', body: JSON.stringify({ to: $('cTo').value.trim(), subject: $('cSubject').value, text: $('cText').value }) }); $('composeDlg').close(); folder = 'sent'; openBox(); } catch (e) { $('cMsg').textContent = e.message; } };
|
||||
|
||||
(async () => {
|
||||
if (!SITE) { document.body.innerHTML = '<p style="padding:40px">잘못된 주소입니다.</p>'; return; }
|
||||
try { await api('/info'); } catch (_) {}
|
||||
if (token) openBox(); else showLogin();
|
||||
})();
|
||||
@@ -0,0 +1,608 @@
|
||||
'use strict';
|
||||
// 기획 위저드: 방식 선택 → [AI에게 맡기기] 또는 [내가 고르기] → Brief 확정 → 생성 → 검증 → 수정 → 발행
|
||||
(() => {
|
||||
let CAT = null; let running = false; let editingBrief = false; let manualStep = 1; let structEdit = false; let showBriefJson = false;
|
||||
const refs = { ai: [], manual: [] };
|
||||
let saveTimer = null;
|
||||
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; };
|
||||
const B = () => current && current.plan && current.plan.brief;
|
||||
const stageOf = () => (current && current.plan ? current.plan.stage : null);
|
||||
const POST_STAGES = ['generating', 'generated', 'published'];
|
||||
const lbl = (list, id) => { const x = (list || []).find((i) => i.id === id); return x ? x.label : id; };
|
||||
|
||||
async function loadCatalog() { if (!CAT) CAT = await api('/api/catalog'); return CAT; }
|
||||
/**
|
||||
* 기획 단계 요청. v1.3.17 (B26): 요청을 보낸 사이트 ID 를 붙잡아 두고, 응답이 왔을 때 화면이 이미 다른 사이트로 바뀌었으면
|
||||
* 그 응답을 지금 사이트(current)에 합치지 않는다 (A 의 응답이 B 화면에 섞이던 문제). r.stale 로 알려 준다.
|
||||
*/
|
||||
async function planReq(method, step, body, siteId) { const id = siteId || current.id; const r = await api(`/api/sites/${id}/plan/${step}`, { method, body: JSON.stringify(body || {}) }); if (!current || current.id !== id) return { ...r, stale: true }; if (r.plan) current.plan = r.plan; if (r.site) Object.assign(current, r.site); return r; }
|
||||
const planPost = (step, body) => planReq('POST', step, body);
|
||||
const planPut = (step, body, siteId) => planReq('PUT', step, body, siteId);
|
||||
function saveBriefSoon(immediate) {
|
||||
clearTimeout(saveTimer);
|
||||
// v1.3.17 (B26): 저장할 사이트와 기획(brief)을 예약할 때 붙잡는다 — 0.5초 안에 다른 사이트로 바꿔도 원래 사이트에 저장된다
|
||||
const id = current && current.id; const brief = B(); if (!id || !brief) return;
|
||||
const run = async () => { try { const r = await planPut('brief', { brief }, id); if (!r.stale) renderWarnings(); } catch (e) { logLine({ level: 'warn', text: '기획 저장 실패: ' + e.message }); } };
|
||||
if (immediate) return run(); saveTimer = setTimeout(run, 500);
|
||||
}
|
||||
|
||||
// ---------- 참고 사이트 편집기 (최대 10개) ----------
|
||||
function renderRefs(key, box) {
|
||||
box = box || $(key === 'ai' ? 'refsAi' : 'refsManual'); box.innerHTML = '';
|
||||
refs[key].forEach((r, i) => {
|
||||
const d = el('div', 'ref');
|
||||
const u = el('input'); u.placeholder = 'https://example.com'; u.value = r.url; u.oninput = () => { r.url = u.value; };
|
||||
const rm = el('button', 'rm', '×'); rm.type = 'button'; rm.title = '삭제'; rm.onclick = () => { refs[key].splice(i, 1); renderRefs(key, box); };
|
||||
const n = el('textarea'); n.rows = 2; n.placeholder = '이 사이트에서 참고·반영할 점 (예: 상단 메뉴 구성, 색감, 가격표 형식, 예약 버튼 위치)'; n.value = r.notes; n.oninput = () => { r.notes = n.value; };
|
||||
d.appendChild(u); d.appendChild(rm); d.appendChild(n); box.appendChild(d);
|
||||
});
|
||||
if (refs[key].length < 10) { const add = el('button', 'ghost small-btn add', `+ 참고 사이트 추가 (${refs[key].length}/10)`); add.type = 'button'; add.onclick = () => { refs[key].push({ url: '', notes: '' }); renderRefs(key, box); }; box.appendChild(add); }
|
||||
else box.appendChild(el('div', 'muted small', '최대 10개까지 추가할 수 있습니다.'));
|
||||
}
|
||||
const cleanRefs = (list) => list.filter((r) => r.url.trim()).map((r) => ({ url: r.url.trim(), notes: r.notes.trim() })).slice(0, 10);
|
||||
|
||||
// ---------- 방식 선택 / 시작 폼 ----------
|
||||
// v1.2.0: 방식 선택은 "1 어떻게 만들까요?"(modeCard) 와 "2 기존 사이트 관리"(existingCard) 두 카드로 나뉜다
|
||||
const START_CARDS = ['aiStartCard', 'manualStartCard', 'copyStartCard', 'templateStartCard']; // v1.3.4: importStartCard 삭제, v1.3.10: 템플릿
|
||||
function showModeChooser() { for (const id of [...START_CARDS, 'planCard']) $(id).classList.add('hidden'); $('modeCard').classList.remove('hidden'); $('existingCard').classList.remove('hidden'); editingBrief = false; }
|
||||
function hideStart() { for (const id of ['modeCard', 'existingCard', ...START_CARDS]) $(id).classList.add('hidden'); }
|
||||
function fillSelect(sel, items, value, placeholder) { sel.innerHTML = ''; if (placeholder) sel.appendChild(new Option(placeholder, '')); for (const it of items) sel.appendChild(new Option(it, it)); if (value !== undefined) sel.value = value; }
|
||||
document.querySelectorAll('.mode-opt').forEach((b) => b.onclick = async () => {
|
||||
$('modeCard').classList.add('hidden'); $('existingCard').classList.add('hidden');
|
||||
if (b.dataset.mode === 'copy') { if (window.Importer) Importer.showCopyStart(); return; }
|
||||
if (b.dataset.mode === 'template') { if (window.TemplatesUI) TemplatesUI.showStart(); return; }
|
||||
await loadCatalog();
|
||||
if (b.dataset.mode === 'ai') { $('aiStartCard').classList.remove('hidden'); renderRefs('ai'); const ts = $('aiTone'); if (ts.options.length <= 1) for (const t of CAT.TONES) ts.appendChild(new Option(`${T(t.label)} — ${T(t.desc)}`, t.id)); }
|
||||
else {
|
||||
$('manualStartCard').classList.remove('hidden'); renderRefs('manual');
|
||||
fillSelect($('mMajor'), CAT.INDUSTRIES.map((i) => i.major), '', '선택'); fillSelect($('mMinor'), [], '', '선택');
|
||||
fillSelect($('mSido'), Object.keys(CAT.REGIONS), '', '선택'); fillSelect($('mSigungu'), [], '', '선택');
|
||||
}
|
||||
});
|
||||
$('aiColor').addEventListener('input', () => { $('aiColor').dataset.touched = '1'; $('aiColorText').value = $('aiColor').value; });
|
||||
$('btnBackMode').onclick = showModeChooser; $('btnBackMode2').onclick = showModeChooser; $('btnBackMode4').onclick = showModeChooser; $('btnBackMode5').onclick = showModeChooser;
|
||||
$('mMajor').onchange = () => { const ind = CAT.INDUSTRIES.find((i) => i.major === $('mMajor').value); fillSelect($('mMinor'), ind ? ind.minors : [], '', '선택'); };
|
||||
$('mSido').onchange = () => fillSelect($('mSigungu'), CAT.REGIONS[$('mSido').value] || [], '', '선택');
|
||||
|
||||
$('btnAiStart').onclick = async () => {
|
||||
const text = $('aiText').value.trim(), url = $('aiExistingUrl').value.trim();
|
||||
if (!text && !url && !pending.create.length) { alert('만들고 싶은 사이트를 설명하거나 자료를 첨부해 주세요.'); return; }
|
||||
const fd = new FormData(); fd.append('mode', 'ai'); fd.append('tier', $('tierSelect').value || ''); fd.append('text', text); fd.append('existingUrl', url); fd.append('references', JSON.stringify(cleanRefs(refs.ai))); fd.append('optimize', JSON.stringify(seoOptsOf('aiStartCard'))); fd.append('langs', JSON.stringify(langOptsOf('aiStartCard'))); appendFiles(fd, 'create');
|
||||
const design = { color: ($('aiColorText').value.trim() || ($('aiColor').dataset.touched ? $('aiColor').value : '')), tone: $('aiTone').value, direction: $('aiDesignDir').value.trim() };
|
||||
fd.append('design', JSON.stringify(design)); fd.append('pageCount', $('aiPageCount').value || '0');
|
||||
$('btnAiStart').disabled = true;
|
||||
try {
|
||||
const site = await api('/api/sites', { method: 'POST', body: fd }); clearPending('create');
|
||||
$('aiText').value = ''; $('aiExistingUrl').value = ''; $('aiDesignDir').value = ''; $('aiColorText').value = ''; refs.ai = [];
|
||||
await openSite(site.id); await refreshSiteList();
|
||||
if (design.color || design.tone || design.direction) { try { await planPut('input', { design }); } catch (_) {} }
|
||||
await runStep('analyze');
|
||||
} catch (e) { alert('시작 실패: ' + e.message); }
|
||||
$('btnAiStart').disabled = false;
|
||||
};
|
||||
$('btnManualStart').onclick = async () => {
|
||||
const name = $('mName').value.trim(); if (!name) { alert('상호명을 입력하세요.'); $('mName').focus(); return; }
|
||||
$('btnManualStart').disabled = true;
|
||||
try {
|
||||
const site = await api('/api/sites', { method: 'POST', body: JSON.stringify({ mode: 'manual', tier: $('tierSelect').value || '', businessName: name, references: cleanRefs(refs.manual), optimize: seoOptsOf('manualStartCard'), langs: langOptsOf('manualStartCard') }) });
|
||||
await openSite(site.id); await refreshSiteList();
|
||||
await planPut('brief', { brief: { business: { name, industry: { major: $('mMajor').value, minor: $('mMinor').value }, region: { sido: $('mSido').value, sigungu: $('mSigungu').value } } }, applyIndustryDefaults: !!$('mMajor').value });
|
||||
$('mName').value = ''; refs.manual = []; manualStep = $('mMajor').value ? 2 : 1;
|
||||
render();
|
||||
} catch (e) { alert('시작 실패: ' + e.message); }
|
||||
$('btnManualStart').disabled = false;
|
||||
};
|
||||
|
||||
async function runStep(step, body) {
|
||||
setBusy(true, step);
|
||||
try { await planPost(step, body); } catch (e) { const cancelled = /중단/.test(e.message); logLine({ level: 'warn', text: `${step} ${cancelled ? '중단됨' : '실패: ' + e.message}` }); if (!autoRunning && !cancelled) alert(e.message); if (cancelled) autoRunning = false; }
|
||||
setBusy(false); render();
|
||||
}
|
||||
let busy = null;
|
||||
function setBusy(on, step) { busy = on ? step : null; render(); }
|
||||
function setRunning(r) { if (running === r) return; running = r; if ($('planCard') && !$('planCard').classList.contains('hidden')) render(); }
|
||||
let pendingRender = false; let localDirty = false;
|
||||
function typingInPlan() { const a = document.activeElement; return !!(a && $('planBody').contains(a) && /^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName)); }
|
||||
$('planBody').addEventListener('input', () => { localDirty = true; });
|
||||
$('planBody').addEventListener('focusout', () => { setTimeout(() => { if (pendingRender && !typingInPlan()) { pendingRender = false; render(); } }, 50); });
|
||||
/** 서버에서 온 plan 이벤트: 편집 중(미저장)이고 단계가 같으면 로컬 편집을 유지 */
|
||||
function onPlanEvent(ev) {
|
||||
if (!current || !ev.plan) return;
|
||||
const sameStage = current.plan && current.plan.stage === ev.plan.stage;
|
||||
if (sameStage && localDirty) { current.plan.warnings = ev.plan.warnings; current.plan.verification = ev.plan.verification; return; }
|
||||
current.plan = ev.plan; if (ev.site) { const keep = current.plan; Object.assign(current, ev.site); current.plan = keep; }
|
||||
localDirty = false; onSiteOpened(current);
|
||||
}
|
||||
|
||||
// ---------- 진입점 ----------
|
||||
function onSiteOpened(site) {
|
||||
hideStart();
|
||||
if (site && current && site.id !== current.id) localDirty = false;
|
||||
if (!site || !site.plan) { $('planCard').classList.add('hidden'); $('publishCard').classList.add('hidden'); if (site) $('updateCard').classList.remove('hidden'); return; }
|
||||
const st = site.plan.stage;
|
||||
// v1.2.0: 복사한 사이트(plan.mode 'copy')는 기획 단계가 없다 — 기획 카드는 숨기고, 복사가 끝나면 수정·발행만 보인다
|
||||
if (site.plan.mode === 'copy' || site.plan.mode === 'template') { /* v1.3.10: 템플릿으로 시작한 사이트도 기획 단계가 없다 */ $('planCard').classList.add('hidden'); $('updateCard').classList.toggle('hidden', st === 'importing'); if (POST_STAGES.includes(st)) renderPublishCard(); else $('publishCard').classList.add('hidden'); return; }
|
||||
$('updateCard').classList.toggle('hidden', !(st === 'generated' || st === 'published'));
|
||||
$('planCard').classList.remove('hidden');
|
||||
render();
|
||||
if (POST_STAGES.includes(st)) renderPublishCard(); else $('publishCard').classList.add('hidden');
|
||||
}
|
||||
|
||||
// ---------- 렌더 ----------
|
||||
const STAGE_LABEL = { input: '요구 입력', questions: '추가 질문', understanding: '이해 확인', structure: '구조 제안', design: '디자인 선택', brief: 'Brief 확인', confirmed: 'Brief 확정', generating: '생성 중', generated: '생성 완료', published: '최종 확정됨', manual: '직접 선택' };
|
||||
function render() {
|
||||
if (!current || !current.plan) return;
|
||||
if (typingInPlan()) { pendingRender = true; return; } // 입력 중에는 다시 그리지 않음(포커스 유지)
|
||||
const plan = current.plan; const body = $('planBody'); body.innerHTML = '';
|
||||
$('planTitle').textContent = plan.mode === 'ai' ? 'AI에게 맡기기' : '내가 고르기';
|
||||
const pill = $('planStage'); pill.textContent = busy ? '처리 중…' : (STAGE_LABEL[plan.stage] || plan.stage); pill.className = 'pill ' + (busy || running || plan.stage === 'generating' ? 'running' : plan.stage === 'published' ? 'done' : 'idle');
|
||||
const auto = plan.autoRun && !['done', 'error'].includes(plan.autoRun.stage);
|
||||
if (busy || running || plan.stage === 'generating' || auto) {
|
||||
const AUTO = { structure: '구조 제안', design: '디자인 결정', confirm: 'Brief 확정', generate: '페이지 생성', verify: '검증', 'waiting-credit': '캐시 충전 대기 (충전되면 멈춘 단계부터 자동으로 이어서 진행)' };
|
||||
body.appendChild(el('p', 'muted small', current.status === 'waiting-credit' ? '캐시가 소진되어 작업이 잠시 멈췄습니다. 지금까지의 작업은 저장되었고, 캐시가 충전되면 자동으로 이어서 진행합니다.' : auto ? `자동 생성 진행 중 — ${AUTO[plan.autoRun.stage] || plan.autoRun.stage} 단계. 끝나면 자동으로 갱신됩니다.` : busy === 'publish' ? '배포가 진행 중입니다. 아래 "진행 상태" 상자에서 단계와 완료 여부를 확인할 수 있습니다.' : busy ? 'AI 가 처리하고 있습니다. 아래 진행 로그를 확인하세요.' : '작업이 실행 중입니다. 완료되면 자동으로 갱신됩니다.'));
|
||||
if (plan.stage === 'generating' || busy || auto) return;
|
||||
}
|
||||
if (plan.autoRun && plan.autoRun.stage === 'error') { const w = el('div', 'progress-warn'); w.textContent = `자동 생성이 "${plan.autoRun.stage}" 에서 멈췄습니다: ${plan.autoRun.error || ''} — 아래 버튼으로 이어서 진행하세요.`; body.appendChild(w); }
|
||||
const st = plan.stage;
|
||||
try {
|
||||
if (editingBrief && POST_STAGES.includes(st)) { renderManualWizard(body, true); }
|
||||
else if (plan.mode === 'ai') {
|
||||
if (st === 'input') renderAiInput(body);
|
||||
else if (st === 'questions') renderQuestions(body);
|
||||
else if (st === 'understanding') renderUnderstanding(body);
|
||||
else if (st === 'structure') renderStructure(body);
|
||||
else if (st === 'design') renderDesign(body);
|
||||
else if (st === 'brief' || st === 'confirmed') renderBriefConfirm(body);
|
||||
else renderPost(body);
|
||||
} else {
|
||||
if (st === 'manual') renderManualWizard(body, false);
|
||||
else if (st === 'confirmed') renderBriefConfirm(body);
|
||||
else renderPost(body);
|
||||
}
|
||||
if (POST_STAGES.includes(st) && !editingBrief) renderPublishCard();
|
||||
} catch (e) {
|
||||
body.appendChild(el('p', 'small', '화면을 그리는 중 오류가 났습니다: ' + e.message + ' — 아래 버튼으로 계속 진행할 수 있습니다.'));
|
||||
console.error(e);
|
||||
}
|
||||
if (plan.mode === 'ai' && !POST_STAGES.includes(st) && !editingBrief && !['input', 'understanding', 'questions'].includes(st)) body.appendChild(progressBar(st));
|
||||
try { if (!POST_STAGES.includes(st)) showWireframe(B()); } catch (_) {}
|
||||
}
|
||||
// 항상 보이는 진행 바: 현재 단계 → 다음 단계 / 끝까지 자동 진행
|
||||
const NEXT_LABEL = { input: 'AI 이해 확인 요청', questions: '답변 없이 다음 단계', understanding: '다음: 구조 제안 받기', structure: '다음: 디자인 3안 받기', design: '다음: Brief 확인', brief: 'Brief 확정', confirmed: '생성 시작', manual: 'Brief 확정' };
|
||||
async function generateNow() {
|
||||
if (!current || running) return;
|
||||
const hasGen = (current.commands || []).some((c) => c.kind === 'create' && c.status === 'done');
|
||||
if (!confirm(hasGen ? '현재 설정(지금까지 입력·수정한 내용)으로 전체 페이지를 다시 생성할까요?' : '아직 확정하지 않은 항목은 기본값으로 채워 현재 설정으로 바로 생성할까요?')) return;
|
||||
setBusy(true, 'generate');
|
||||
try { await planPost('generate', { force: true, brief: B() }); localDirty = false; }
|
||||
catch (e) { logLine({ level: 'warn', text: '생성 요청 실패: ' + e.message }); alert(e.message); }
|
||||
setBusy(false); render();
|
||||
}
|
||||
async function nextStep() {
|
||||
const st = stageOf(); const plan = current.plan;
|
||||
if (st === 'input') return runStep('analyze');
|
||||
if (st === 'questions') { plan.stage = 'understanding'; return render(); }
|
||||
if (st === 'understanding') return runStep('understanding', { brief: B() });
|
||||
if (st === 'structure') return runStep('structure', { pages: B().pages, force: true });
|
||||
if (st === 'design') { const d = plan.designOptions || {}; return runStep('design', { optionId: B().design.optionId || d.recommended || (d.options && d.options[0] && d.options[0].id) }); }
|
||||
if (st === 'brief' || st === 'manual') { if (st === 'manual') await saveBriefSoon(true); return runStep('confirm'); }
|
||||
if (st === 'confirmed') return runStep('generate');
|
||||
}
|
||||
let autoRunning = false;
|
||||
async function autoRun() {
|
||||
if (autoRunning) return; autoRunning = true;
|
||||
try {
|
||||
let guard = 0;
|
||||
while (current && current.plan && !POST_STAGES.includes(current.plan.stage) && guard++ < 10) {
|
||||
const before = current.plan.stage;
|
||||
await nextStep();
|
||||
if (current.plan.stage === before && before !== 'questions') break; // 진행 실패 시 중단
|
||||
}
|
||||
} finally { autoRunning = false; render(); }
|
||||
}
|
||||
function progressBar(st) {
|
||||
const bar = el('div', 'progress-bar');
|
||||
const order = current.plan.mode === 'ai' ? ['input', 'understanding', 'structure', 'design', 'brief', 'confirmed'] : ['manual', 'confirmed'];
|
||||
const idx = Math.max(0, order.indexOf(st === 'questions' ? 'understanding' : st));
|
||||
bar.appendChild(el('span', 'muted small', current.plan.mode === 'ai' ? `기획 ${idx + 1}/${order.length} 단계` : (st === 'manual' ? `직접 선택 ${manualStep}/9` : 'Brief 확정됨')));
|
||||
const row = el('div', 'row');
|
||||
row.appendChild(btn(autoRunning ? '자동 진행 중…' : (NEXT_LABEL[st] || '다음 단계'), 'primary small-btn', nextStep, autoRunning));
|
||||
row.appendChild(btn('나머지 자동 진행', 'ghost small-btn', autoRun, autoRunning));
|
||||
bar.appendChild(row);
|
||||
return bar;
|
||||
}
|
||||
function btn(text, cls, fn, disabled) { const b = el('button', cls || 'ghost small-btn', text); b.type = 'button'; b.onclick = fn; b.disabled = !!disabled || !!busy || running; return b; }
|
||||
function h3(text) { return el('div', 'field-label', text); }
|
||||
|
||||
// ---- AI: 입력 재편집 / 질문 ----
|
||||
function renderAiInput(body) {
|
||||
const inp = current.plan.input;
|
||||
body.appendChild(h3('만들고 싶은 사이트 (자유 서술)'));
|
||||
const t = el('textarea'); t.rows = 4; t.value = inp.text || ''; body.appendChild(t);
|
||||
body.appendChild(h3('기존 사이트 URL')); const u = el('input'); u.value = inp.existingUrl || ''; body.appendChild(u);
|
||||
body.appendChild(h3('참고 사이트 (최대 10개)')); refs.ai = (inp.references || []).map((r) => ({ ...r })); const box = el('div', 'refs'); body.appendChild(box); renderRefs('ai', box);
|
||||
body.appendChild(btn('AI 이해 확인 요청', 'primary small-btn', async () => { await planPut('input', { text: t.value, existingUrl: u.value, references: cleanRefs(refs.ai) }); await runStep('analyze'); }));
|
||||
}
|
||||
function renderQuestions(body) {
|
||||
const qs = (current.plan.understanding && current.plan.understanding.questions) || [];
|
||||
body.appendChild(el('p', 'small', '업종이나 목표가 분명하지 않아 AI 가 확인을 요청했습니다. 답변 후 다시 분석합니다.'));
|
||||
const inputs = qs.map((q) => { const box = el('div', 'qbox'); box.appendChild(el('div', 'small', q)); const i = el('input'); i.placeholder = '답변'; box.appendChild(i); body.appendChild(box); return { q, i }; });
|
||||
body.appendChild(btn('답변 보내고 다시 분석', 'primary small-btn', () => runStep('answers', { answers: inputs.map((x) => ({ q: x.q, a: x.i.value })) })));
|
||||
body.appendChild(btn('답변 없이 진행', 'ghost small-btn', () => { current.plan.stage = 'understanding'; render(); }));
|
||||
}
|
||||
|
||||
// ---- 공통 위젯 ----
|
||||
function originBadge(key) { const o = (B().origin || {})[key]; if (!o) return null; return el('span', 'origin ' + o, o === 'user' ? '사용자 명시' : 'AI 추론'); }
|
||||
function chips(container, items, selected, onToggle, disabledIds = []) {
|
||||
const box = el('div', 'chips');
|
||||
for (const it of items) { const id = typeof it === 'string' ? it : it.id; const label = typeof it === 'string' ? it : it.label; const c = el('span', 'chip' + (selected.includes(id) ? ' on' : '') + (disabledIds.includes(id) ? ' disabled' : ''), label); if (!disabledIds.includes(id)) c.onclick = () => onToggle(id); box.appendChild(c); }
|
||||
container.appendChild(box); return box;
|
||||
}
|
||||
function kvRow(grid, k, node) { grid.appendChild(el('div', 'k', k)); grid.appendChild(node); }
|
||||
function regulatedDisabled() { const r = B().regulated; return r && r.flag && CAT.REGULATIONS[r.category] ? CAT.REGULATIONS[r.category].disableFeatures : []; }
|
||||
function renderWarnings(box) {
|
||||
const w = (current.plan && current.plan.warnings) || []; box = box || $('planWarnings'); if (!box) return; box.innerHTML = '';
|
||||
for (const x of w) box.appendChild(el('li', x.level, x.text));
|
||||
}
|
||||
function businessFields(body) {
|
||||
const b = B(); const g = el('div', 'kv');
|
||||
const name = el('input'); name.value = b.business.name || ''; name.oninput = () => { b.business.name = name.value; };
|
||||
const nk = el('div', 'k', '상호명'); const ob = originBadge('business.name'); if (ob) nk.appendChild(ob); g.appendChild(nk); g.appendChild(name);
|
||||
const maj = el('select'); fillSelect(maj, CAT.INDUSTRIES.map((i) => i.major), b.business.industry.major || '', '선택');
|
||||
const min = el('select'); const fillMin = () => { const ind = CAT.INDUSTRIES.find((i) => i.major === maj.value); fillSelect(min, ind ? ind.minors : [], b.business.industry.minor || '', '선택'); }; fillMin();
|
||||
maj.onchange = () => { b.business.industry.major = maj.value; b.business.industry.minor = ''; fillMin(); onIndustryChanged && onIndustryChanged(); }; min.onchange = () => { b.business.industry.minor = min.value; };
|
||||
const ik = el('div', 'k', '업종'); const oi = originBadge('business.industry'); if (oi) ik.appendChild(oi); g.appendChild(ik); const iw = el('div', 'two'); iw.appendChild(maj); iw.appendChild(min); g.appendChild(iw);
|
||||
const sido = el('select'); fillSelect(sido, Object.keys(CAT.REGIONS), b.business.region.sido || '', '선택'); const sgg = el('select'); fillSelect(sgg, CAT.REGIONS[b.business.region.sido] || [], b.business.region.sigungu || '', '선택');
|
||||
sido.onchange = () => { b.business.region.sido = sido.value; b.business.region.sigungu = ''; fillSelect(sgg, CAT.REGIONS[sido.value] || [], '', '선택'); }; sgg.onchange = () => { b.business.region.sigungu = sgg.value; };
|
||||
kvRow(g, '지역', (() => { const w = el('div', 'two'); w.appendChild(sido); w.appendChild(sgg); return w; })());
|
||||
const desc = el('textarea'); desc.rows = 2; desc.value = b.business.description || ''; desc.oninput = () => { b.business.description = desc.value; }; kvRow(g, '사업 요약', desc);
|
||||
body.appendChild(g);
|
||||
return { maj };
|
||||
}
|
||||
let onIndustryChanged = null;
|
||||
function goalsAndFeatures(body, onChange) {
|
||||
const b = B();
|
||||
body.appendChild(h3('목표 (복수 선택)'));
|
||||
const rerender = () => { body.querySelectorAll('.gf').forEach((x) => x.remove()); draw(); };
|
||||
const draw = () => {
|
||||
const wrap = el('div', 'gf');
|
||||
chips(wrap, CAT.GOALS, b.goals, (id) => { b.goals = b.goals.includes(id) ? b.goals.filter((x) => x !== id) : [...b.goals, id]; for (const f of CAT.GOALS.find((g) => g.id === id).features) if (b.goals.includes(id)) { b.features[f] = true; addFeatureSection(f); } onChange(); rerender(); });
|
||||
wrap.appendChild(h3('기능 on/off (목표에 따라 자동 체크됨)'));
|
||||
const dis = regulatedDisabled(); for (const f of dis) b.features[f] = false;
|
||||
chips(wrap, CAT.FEATURES, Object.keys(b.features).filter((k) => b.features[k]), (id) => { b.features[id] = !b.features[id]; if (b.features[id]) addFeatureSection(id); else removeFeatureSections(id); onChange(); rerender(); }, dis);
|
||||
if (dis.length) wrap.appendChild(el('div', 'muted small', `규제 업종 제한으로 비활성: ${dis.map((d) => lbl(CAT.FEATURES, d)).join(', ')}`));
|
||||
body.appendChild(wrap);
|
||||
};
|
||||
draw();
|
||||
}
|
||||
function addFeatureSection(fid) { const b = B(); if (!b.pages.length) return; if (b.pages.some((p) => p.sections.some((s) => s.feature === fid))) return; const f = CAT.FEATURES.find((x) => x.id === fid); const page = b.pages[b.pages.length - 1]; page.sections.push({ id: f.section, title: CAT.SECTIONS[f.section].label, feature: fid }); }
|
||||
function removeFeatureSections(fid) { for (const p of B().pages) p.sections = p.sections.filter((s) => s.feature !== fid); }
|
||||
function regulatedField(body, onChange) {
|
||||
const b = B(); body.appendChild(h3('규제 업종'));
|
||||
const sel = el('select'); sel.appendChild(new Option('해당 없음', '')); for (const [k, v] of Object.entries(CAT.REGULATIONS)) sel.appendChild(new Option(v.label, k)); sel.value = b.regulated.category || '';
|
||||
sel.onchange = () => { const c = sel.value; b.regulated = c ? { flag: true, category: c, constraints: CAT.REGULATIONS[c].constraints } : { flag: false, category: null, constraints: [] }; for (const f of regulatedDisabled()) b.features[f] = false; onChange(); render(); };
|
||||
body.appendChild(sel);
|
||||
if (b.regulated.flag) { const ul = el('ul', 'warn-list'); for (const c of b.regulated.constraints) ul.appendChild(el('li', 'info', c)); body.appendChild(ul); }
|
||||
}
|
||||
function customersField(body, onChange) {
|
||||
const b = B(); body.appendChild(h3('고객 유형'));
|
||||
const base = CAT.INDUSTRIES.find((i) => i.major === b.business.industry.major); const types = [...new Set([...(base ? base.defaults.customers : []), ...b.customers.types])];
|
||||
const redraw = () => { body.querySelectorAll('.cu').forEach((x) => x.remove()); draw(); };
|
||||
const draw = () => { const w = el('div', 'cu'); chips(w, types, b.customers.types, (id) => { b.customers.types = b.customers.types.includes(id) ? b.customers.types.filter((x) => x !== id) : [...b.customers.types, id]; onChange(); redraw(); });
|
||||
const add = el('input'); add.placeholder = '직접 입력 후 Enter'; add.onkeydown = (e) => { if (e.key === 'Enter' && add.value.trim()) { types.push(add.value.trim()); b.customers.types.push(add.value.trim()); onChange(); redraw(); } }; w.appendChild(add);
|
||||
w.appendChild(h3('연령대')); chips(w, CAT.AGES, b.customers.ages, (id) => { b.customers.ages = b.customers.ages.includes(id) ? b.customers.ages.filter((x) => x !== id) : [...b.customers.ages, id]; onChange(); redraw(); }); body.appendChild(w); };
|
||||
draw();
|
||||
}
|
||||
/**
|
||||
* 사실정보 입력 표 (v1.0.3): 값을 입력한 항목만 홈페이지에 들어가고, 비워 둔 항목은 홈페이지에서 빠진다.
|
||||
* (이전의 내용유지/빈항목유지/항목삭제 선택은 없앴다 — 규칙이 하나뿐이라 선택이 필요 없음)
|
||||
*/
|
||||
function factsTable(body, onChange) {
|
||||
const b = B(); const t = el('table', 'facts-table');
|
||||
const hdr = el('tr'); for (const h of ['항목', '내용 (비우면 홈페이지에서 제외)']) { const th = el('td', 'muted small', h); hdr.appendChild(th); } t.appendChild(hdr);
|
||||
for (const f of CAT.FACT_FIELDS) {
|
||||
const fact = b.facts[f.id] || { value: '', source: null, status: 'removed' }; b.facts[f.id] = fact;
|
||||
const tr = el('tr'); const td1 = el('td', '', f.label); td1.style.width = '96px';
|
||||
const td2 = el('td'); const inp = el(f.id === 'prices' || f.id === 'reviews' || f.id === 'hours' ? 'textarea' : 'input'); if (inp.tagName === 'TEXTAREA') inp.rows = 2; inp.value = fact.value || ''; inp.dataset.k = 'fact-' + f.id;
|
||||
inp.placeholder = f.id === 'reviews' ? '실제 고객 후기만 (없으면 비워 두세요)' : '없으면 비워 두세요';
|
||||
inp.oninput = () => { fact.value = inp.value; fact.status = inp.value.trim() ? 'provided' : 'removed'; if (inp.value.trim() && !fact.source) fact.source = 'user_text'; onChange(); };
|
||||
td2.appendChild(inp); if (fact.source && fact.value) td2.appendChild(el('div', 'src', `출처: ${fact.source}`));
|
||||
tr.appendChild(td1); tr.appendChild(td2); t.appendChild(tr);
|
||||
}
|
||||
body.appendChild(t);
|
||||
body.appendChild(el('p', 'muted small', '입력한 값만 홈페이지에 그대로 사용합니다. 비워 둔 항목은 홈페이지에서 해당 항목 자체가 빠지며, AI 가 값을 지어내지 않습니다.'));
|
||||
}
|
||||
|
||||
// ---- AI 2단계: 이해 확인 카드 ----
|
||||
/**
|
||||
* AI에게 맡기기 2단계 (v1.0.3): AI 가 이해한 요약을 짧게 보여 주고, 사용자는 "추가 입력이 필요한 항목"(사실정보)만 채운다.
|
||||
* 입력 완료를 누르면 구조·디자인·확정·생성·검증이 서버에서 자동으로 이어진다(POST /plan/auto).
|
||||
*/
|
||||
function renderUnderstanding(body) {
|
||||
const u = current.plan.understanding || {}; const b = B();
|
||||
body.appendChild(el('p', 'small', `AI 이해: ${b.business.name || '(상호 미정)'} · ${b.business.industry.major}${b.business.industry.minor ? '/' + b.business.industry.minor : ''} · 목표 ${b.goals.map((g) => lbl(CAT.GOALS, g)).join(', ') || '-'}${u.recommendedStyle ? ' · 추천 톤 ' + lbl(CAT.TONES, u.recommendedStyle.tone) : ''}`));
|
||||
const det = el('details'); det.appendChild(el('summary', 'small', '이해한 내용 자세히 보기 / 고치기 (상호·업종·지역·목표·기능·고객)')); const inner = el('div');
|
||||
onIndustryChanged = null; businessFields(inner); goalsAndFeatures(inner, () => {}); customersField(inner, () => {}); regulatedField(inner, () => {}); det.appendChild(inner); body.appendChild(det);
|
||||
body.appendChild(h3('추가 입력이 필요한 항목 — 입력한 것만 홈페이지에 들어갑니다'));
|
||||
factsTable(body, () => {});
|
||||
if (u.referenceInsights && u.referenceInsights.length) { body.appendChild(h3('참고 사이트에서 반영할 점')); const ul = el('ul', 'warn-list'); for (const r of u.referenceInsights) ul.appendChild(el('li', 'info', `${r.url}: ${r.adopt}`)); body.appendChild(ul); }
|
||||
const row = el('div', 'row');
|
||||
row.appendChild(btn('입력 완료 — 끝까지 자동 생성', 'primary small-btn', async () => { setBusy(true, 'auto'); try { await planPost('auto', { brief: B() }); localDirty = false; } catch (e) { alert(e.message); } setBusy(false); render(); }));
|
||||
row.appendChild(btn('다시 분석', 'ghost small-btn', () => runStep('analyze')));
|
||||
body.appendChild(row);
|
||||
body.appendChild(el('p', 'muted small', '자동 생성은 구조 제안 → 디자인(입력한 컨셉 반영) → 확정 → 생성 → 검증 순서로 진행되며 중간에 멈추지 않습니다. 끝난 뒤에는 "수정" 탭에서 수정 지시를 보낼 수 있습니다.'));
|
||||
}
|
||||
|
||||
// ---- 페이지 구조 편집기 (AI 3단계 / 내가 고르기 4단계 공통) ----
|
||||
function pagesEditor(body, onChange) {
|
||||
const b = B(); const box = el('div', 'pages-edit'); let dragIdx = null;
|
||||
const draw = () => {
|
||||
box.innerHTML = '';
|
||||
b.pages.forEach((p, i) => {
|
||||
const row = el('div', 'page-row'); row.draggable = true;
|
||||
row.ondragstart = () => { dragIdx = i; }; row.ondragover = (e) => { e.preventDefault(); row.classList.add('dragover'); }; row.ondragleave = () => row.classList.remove('dragover');
|
||||
row.ondrop = (e) => { e.preventDefault(); row.classList.remove('dragover'); if (dragIdx === null || dragIdx === i) return; const [m] = b.pages.splice(dragIdx, 1); b.pages.splice(i, 0, m); dragIdx = null; onChange(); draw(); };
|
||||
const ph = el('div', 'ph'); ph.appendChild(el('span', 'handle', '⋮⋮'));
|
||||
const t = el('input'); t.value = p.title; t.oninput = () => { p.title = t.value; onChange(); }; ph.appendChild(t);
|
||||
ph.appendChild(el('span', 'muted small', i === 0 ? 'index.html' : `${p.id}.html`));
|
||||
const rm = el('button', 'rm', '×'); rm.type = 'button'; rm.onclick = () => { b.pages.splice(i, 1); onChange(); draw(); }; ph.appendChild(rm); row.appendChild(ph);
|
||||
if (p.purpose) row.appendChild(el('div', 'muted small', p.purpose));
|
||||
const secs = el('div', 'secs'); const dis = regulatedDisabled();
|
||||
for (const [sid, sd] of Object.entries(CAT.SECTIONS)) {
|
||||
const on = p.sections.some((s) => s.id === sid); const disabled = sd.feature && (dis.includes(sd.feature) || !b.features[sd.feature]);
|
||||
const c = el('span', 'chip' + (on ? ' on' : '') + (disabled && !on ? ' disabled' : ''), sd.label + (sd.feature ? ' ⚙' : '')); c.title = sd.feature ? `기능: ${lbl(CAT.FEATURES, sd.feature)}` : '';
|
||||
if (!(disabled && !on)) c.onclick = () => { if (on) p.sections = p.sections.filter((s) => s.id !== sid); else p.sections.push({ id: sid, title: sd.label, feature: sd.feature || null }); onChange(); draw(); };
|
||||
secs.appendChild(c);
|
||||
}
|
||||
row.appendChild(secs); box.appendChild(row);
|
||||
});
|
||||
const add = el('button', 'ghost small-btn', '+ 페이지 추가'); add.type = 'button'; add.onclick = () => { const id = prompt('페이지 ID (영문, 예: about)'); if (!id) return; const cid = id.replace(/[^a-z0-9-]/gi, '').toLowerCase() || 'page'; b.pages.push({ id: cid, title: CAT.PAGE_TITLES[cid] || id, sections: [] }); onChange(); draw(); }; box.appendChild(add);
|
||||
};
|
||||
draw(); body.appendChild(box);
|
||||
}
|
||||
function renderStructure(body) {
|
||||
const s = current.plan.structure || {};
|
||||
if (s.rationale) body.appendChild(el('p', 'small', s.rationale));
|
||||
const wl = el('ul', 'warn-list'); wl.id = 'planWarnings'; body.appendChild(wl); renderWarnings(wl);
|
||||
if (structEdit) { pagesEditor(body, () => { showWireframe(B()); }); }
|
||||
else { const ul = el('ul', 'summary-list'); for (const p of B().pages) { const li = el('li'); li.appendChild(el('span', 'k', p.title)); li.appendChild(el('span', '', p.sections.map((x) => x.title + (x.goal ? `→${lbl(CAT.GOALS, x.goal)}` : '')).join(' · '))); li.appendChild(el('span', 'muted small', p.sections.filter((x) => x.feature).map((x) => lbl(CAT.FEATURES, x.feature)).join(', '))); ul.appendChild(li); } body.appendChild(ul); }
|
||||
const row = el('div', 'row');
|
||||
row.appendChild(btn('이 구조로 진행 — 디자인 3안 받기', 'primary small-btn', async () => { structEdit = false; await runStep('structure', { pages: B().pages }); }));
|
||||
row.appendChild(btn(structEdit ? '편집 닫기' : '페이지 수정', 'ghost small-btn', () => { structEdit = !structEdit; render(); }));
|
||||
body.appendChild(row);
|
||||
}
|
||||
function renderDesign(body) {
|
||||
const d = current.plan.designOptions || { options: [] }; const b = B();
|
||||
body.appendChild(el('p', 'small', `3안 중 하나를 고르세요. 추천안(${d.recommended || 'A'})이 기본 선택되어 있습니다.`));
|
||||
const list = el('div', 'card-list'); let selected = b.design.optionId || d.recommended || (d.options[0] && d.options[0].id);
|
||||
const draw = () => { list.innerHTML = ''; for (const o of d.options) { const c = el('div', 'opt-card' + (o.id === selected ? ' on' : '')); c.appendChild(el('div', 't', `${o.id}. ${o.name} (${lbl(CAT.TONES, o.tone)}${o.id === d.recommended ? ' · 추천' : ''})`)); c.appendChild(el('div', 'd', o.summary || '')); const sw = el('div', 'swatches'); for (const col of (o.palette && o.palette.colors) || []) { const s = el('span'); s.style.background = col; sw.appendChild(s); } c.appendChild(sw); c.appendChild(el('div', 'd', `${o.palette ? o.palette.name : ''} · 폰트 ${lbl(CAT.FONT_SETS, o.fonts)} · ${lbl(CAT.DENSITY, o.density)} · ${lbl(CAT.IMAGERY, o.imagery)}`)); if (o.heroIdea) c.appendChild(el('div', 'd', '히어로: ' + o.heroIdea)); c.onclick = () => { selected = o.id; const tone = CAT.TONES.find((t) => t.id === o.tone) || CAT.TONES[0]; b.design = { ...b.design, tone: tone.id, palette: o.palette, fonts: CAT.FONT_SETS.find((f) => f.id === o.fonts) || CAT.FONT_SETS[0], density: o.density, imagery: o.imagery, optionId: o.id }; showWireframe(b); draw(); }; list.appendChild(c); } };
|
||||
draw(); body.appendChild(list);
|
||||
body.appendChild(btn('이 디자인으로 — Brief 확인', 'primary small-btn', () => runStep('design', { optionId: selected })));
|
||||
}
|
||||
function briefSummary(body, gotoStep) {
|
||||
const b = B(); const ul = el('ul', 'summary-list');
|
||||
const row = (k, v, step) => { const li = el('li'); li.appendChild(el('span', 'k', k)); li.appendChild(el('span', '', v || '-')); if (gotoStep && step) { const e = el('button', 'ghost', '수정'); e.type = 'button'; e.onclick = () => gotoStep(step); li.appendChild(e); } else li.appendChild(el('span')); ul.appendChild(li); };
|
||||
row('상호·업종', `${b.business.name} · ${b.business.industry.major}${b.business.industry.minor ? '/' + b.business.industry.minor : ''} · ${b.business.region.sido} ${b.business.region.sigungu}`, 1);
|
||||
row('목표·기능', `${b.goals.map((g) => lbl(CAT.GOALS, g)).join(', ')} / ${Object.keys(b.features).filter((k) => b.features[k]).map((k) => lbl(CAT.FEATURES, k)).join(', ')}`, 2);
|
||||
row('고객', `${b.customers.types.join(', ')} ${b.customers.ages.join('·')}`, 3);
|
||||
row('페이지', b.pages.map((p) => `${p.title}(${p.sections.length})`).join(' → '), 4);
|
||||
row('디자인', `${lbl(CAT.TONES, b.design.tone)} · ${b.design.palette ? b.design.palette.name : ''} · ${b.design.fonts ? b.design.fonts.label : ''} · ${lbl(CAT.DENSITY, b.design.density)} · ${lbl(CAT.IMAGERY, b.design.imagery)}`, 5);
|
||||
row('문구', `${lbl(CAT.VOICE, b.copy.voice)} · ${lbl(CAT.LENGTH, b.copy.length)} · ${lbl(CAT.LANGUAGE, b.copy.language)}`, 6);
|
||||
row('사실정보', CAT.FACT_FIELDS.map((f) => { const x = b.facts[f.id] || {}; return `${f.label}:${x.status === 'provided' ? '✓' : (x.status === 'removed' ? '삭제' : '빈칸')}`; }).join(' '), 7);
|
||||
row('자료', `로고 ${b.assets.logo || '없음'} · 사진 ${b.assets.photos.length}장${b.assets.useStock ? ' · 스톡 이미지 사용' : ''}`, 8);
|
||||
row('참고 사이트', (b.references || []).length ? b.references.map((r) => r.url).join(', ') : '없음');
|
||||
if (b.regulated.flag) row('규제', CAT.REGULATIONS[b.regulated.category].label);
|
||||
body.appendChild(ul);
|
||||
}
|
||||
function renderBriefConfirm(body) {
|
||||
const st = stageOf();
|
||||
body.appendChild(el('p', 'small', st === 'confirmed' ? 'Brief 가 확정되었습니다. 이 JSON 만을 입력으로 페이지를 생성합니다.' : '2~4단계 결과를 JSON 명세(Site Brief)로 고정합니다. 확정 후 생성 에이전트는 이 Brief 만 참고합니다.'));
|
||||
briefSummary(body, null);
|
||||
const wl = el('ul', 'warn-list'); wl.id = 'planWarnings'; body.appendChild(wl); renderWarnings(wl);
|
||||
const tg = btn(showBriefJson ? 'JSON 숨기기' : 'Brief JSON 보기', 'ghost small-btn', () => { showBriefJson = !showBriefJson; render(); }); body.appendChild(tg);
|
||||
if (showBriefJson) body.appendChild(el('pre', 'brief-json', JSON.stringify(B(), null, 1)));
|
||||
const row = el('div', 'row');
|
||||
if (st === 'confirmed') row.appendChild(btn('생성', 'primary small-btn', () => runStep('generate')));
|
||||
else row.appendChild(btn('Brief 확정', 'primary small-btn', () => runStep('confirm')));
|
||||
if (current.plan.mode === 'ai') row.appendChild(btn('이해 확인으로 돌아가기', 'ghost small-btn', () => { current.plan.stage = 'understanding'; render(); }));
|
||||
else row.appendChild(btn('선택 수정', 'ghost small-btn', () => { current.plan.stage = 'manual'; manualStep = 1; render(); }));
|
||||
body.appendChild(row);
|
||||
}
|
||||
|
||||
// ---- 내가 고르기: 9단계 ----
|
||||
const MSTEPS = ['기본', '목적', '고객', '페이지', '디자인', '문구', '사실정보', '자료', '요약'];
|
||||
function renderManualWizard(body, reopened) {
|
||||
const b = B(); const nav = el('div', 'steps');
|
||||
MSTEPS.forEach((n, i) => { const s = i + 1; const bt = el('button', s === manualStep ? 'active' : (s < manualStep ? 'done' : ''), `${s}. ${n}`); bt.type = 'button'; bt.onclick = () => { manualStep = s; render(); }; nav.appendChild(bt); });
|
||||
body.appendChild(nav);
|
||||
const change = () => { saveBriefSoon(); showWireframe(b); };
|
||||
if (manualStep === 1) { onIndustryChanged = async () => { await planPut('brief', { brief: { business: b.business }, applyIndustryDefaults: true }); render(); }; businessFields(body); }
|
||||
else if (manualStep === 2) { goalsAndFeatures(body, change); regulatedField(body, change); }
|
||||
else if (manualStep === 3) customersField(body, change);
|
||||
else if (manualStep === 4) { body.appendChild(el('p', 'small', '추천 페이지가 체크되어 있습니다. 드래그로 순서를 바꾸고, 페이지별 섹션을 고르세요.')); if (!b.pages.length) body.appendChild(btn('업종 추천 페이지 불러오기', 'ghost small-btn', async () => { await planPut('brief', { brief: {}, applyIndustryDefaults: true }); render(); })); pagesEditor(body, change); }
|
||||
else if (manualStep === 5) renderDesignPicker(body, change);
|
||||
else if (manualStep === 6) {
|
||||
// 단일 선택 칩: 전체 화면을 다시 그리지 않고 선택 표시만 바꾼다 (느린 반응 개선)
|
||||
const single = (items, get, set) => { const box = chips(body, items, [get()], (id) => { set(id); change(); box.querySelectorAll('.chip').forEach((c, i) => c.classList.toggle('on', items[i].id === id)); }); };
|
||||
body.appendChild(h3('어조')); single(CAT.VOICE, () => b.copy.voice, (id) => { b.copy.voice = id; });
|
||||
body.appendChild(h3('문장 길이')); single(CAT.LENGTH, () => b.copy.length, (id) => { b.copy.length = id; });
|
||||
body.appendChild(h3('언어')); single(CAT.LANGUAGE, () => b.copy.language, (id) => { b.copy.language = id; });
|
||||
}
|
||||
else if (manualStep === 7) { body.appendChild(el('p', 'small', '항목별로 내용을 입력하거나 처리 방식을 고르세요. 입력하지 않은 값은 지어내지 않습니다.')); factsTable(body, change); }
|
||||
else if (manualStep === 8) {
|
||||
body.appendChild(el('p', 'small', '로고·사진은 위 첨부 파일 패널이나 아래 드롭존으로 올리면 자동으로 자료에 연결됩니다.'));
|
||||
const g = el('div', 'kv'); const logo = el('select'); logo.appendChild(new Option('없음', '')); for (const a of current.attachments.filter((a) => a.kind === 'image')) logo.appendChild(new Option(a.name, a.rel)); logo.value = b.assets.logo || ''; logo.onchange = () => { b.assets.logo = logo.value; b.assets.photos = b.assets.photos.filter((p) => p !== logo.value); change(); }; kvRow(g, '로고', logo);
|
||||
const ph = el('div', 'small', current.attachments.filter((a) => a.kind === 'image' && a.rel !== b.assets.logo).map((a) => a.name).join(', ') || '없음'); kvRow(g, '사진', ph); body.appendChild(g);
|
||||
{ const box = chips(body, [{ id: 'stock', label: '스톡 이미지 사용(플레이스홀더 대신)' }], b.assets.useStock ? ['stock'] : [], () => { b.assets.useStock = !b.assets.useStock; change(); box.querySelector('.chip').classList.toggle('on', b.assets.useStock); }); }
|
||||
const dz = el('div', 'dropzone'); dz.dataset.target = 'now'; dz.appendChild(el('div', 'dz-text', '여기에 로고·사진을 끌어다 놓으면 바로 업로드됩니다')); dz.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); dz.classList.add('dragover'); }); dz.addEventListener('dragleave', () => dz.classList.remove('dragover')); dz.addEventListener('drop', async (e) => { e.preventDefault(); e.stopPropagation(); dz.classList.remove('dragover'); await uploadNow([...e.dataTransfer.files]); const r = await api(`/api/sites/${current.id}`); Object.assign(current, r); render(); }); body.appendChild(dz);
|
||||
}
|
||||
else if (manualStep === 9) {
|
||||
body.appendChild(el('p', 'small', '전체 선택값입니다. 항목의 [수정]을 누르면 해당 단계로 돌아갑니다.'));
|
||||
briefSummary(body, (s) => { manualStep = s; render(); });
|
||||
const wl = el('ul', 'warn-list'); wl.id = 'planWarnings'; body.appendChild(wl); renderWarnings(wl);
|
||||
}
|
||||
const row = el('div', 'row');
|
||||
if (manualStep < 9) { row.appendChild(btn('다음', 'primary small-btn', async () => { await saveBriefSoon(true); manualStep += 1; render(); })); row.appendChild(btn('건너뛰기', 'ghost small-btn', () => { manualStep += 1; render(); })); if (manualStep > 1) row.appendChild(btn('이전', 'ghost small-btn', () => { manualStep -= 1; render(); })); }
|
||||
else if (reopened) { row.appendChild(btn('생성 (현재 선택으로 재생성)', 'primary small-btn', async () => { await saveBriefSoon(true); editingBrief = false; await generateNow(); })); row.appendChild(btn('취소', 'ghost small-btn', () => { editingBrief = false; render(); })); }
|
||||
else {
|
||||
// v1.0.3: 요약 단계는 '선택 수정' 과 '생성' 두 가지만 — 선택한 내용 그대로 확정하고 끝까지 생성한다
|
||||
row.appendChild(btn('생성 (선택한 내용으로 끝까지)', 'primary small-btn', async () => { await saveBriefSoon(true); setBusy(true, 'generate'); try { await planPost('confirm', {}); await planPost('generate', {}); localDirty = false; } catch (e) { alert(e.message); } setBusy(false); render(); }));
|
||||
row.appendChild(btn('선택 수정', 'ghost small-btn', () => { manualStep = 1; render(); }));
|
||||
}
|
||||
body.appendChild(row);
|
||||
}
|
||||
/**
|
||||
* 디자인 선택 (내가 고르기 5단계). 선택할 때 전체 카드를 다시 그리지 않고 해당 영역만 갱신한다.
|
||||
* 톤을 바꾸면 팔레트 목록만 다시 그리고, 팔레트·폰트·밀도·이미지는 선택 표시(.on)만 바꾼다. 와이어프레임은 디바운스된다.
|
||||
*/
|
||||
function renderDesignPicker(body, change) {
|
||||
const b = B();
|
||||
body.appendChild(h3('톤 (5종)')); const tg = el('div', 'tone-grid'); body.appendChild(tg);
|
||||
body.appendChild(h3('컬러 팔레트 (3안)')); const pl = el('div', 'card-list'); body.appendChild(pl);
|
||||
const drawPalettes = () => {
|
||||
pl.innerHTML = ''; const tone = CAT.TONES.find((t) => t.id === b.design.tone) || CAT.TONES[0];
|
||||
for (const p of tone.palettes) { const c = el('div', 'opt-card' + (b.design.palette && b.design.palette.name === p.name ? ' on' : '')); c.appendChild(el('div', 't', p.name)); const sw = el('div', 'swatches'); for (const col of p.colors) { const sp = el('span'); sp.style.background = col; sw.appendChild(sp); } c.appendChild(sw); c.onclick = () => { b.design.palette = p; change(); pl.querySelectorAll('.opt-card').forEach((x) => x.classList.toggle('on', x === c)); }; pl.appendChild(c); }
|
||||
};
|
||||
const drawTones = () => {
|
||||
tg.innerHTML = '';
|
||||
for (const t of CAT.TONES) { const c = el('div', 'opt-card' + (b.design.tone === t.id ? ' on' : '')); c.appendChild(el('div', 't', t.label + (b.design.recommendedTone === t.id ? ' · 추천' : ''))); c.appendChild(el('div', 'd', t.desc)); c.onclick = () => { b.design.tone = t.id; b.design.palette = t.palettes[0]; change(); tg.querySelectorAll('.opt-card').forEach((x) => x.classList.toggle('on', x === c)); drawPalettes(); }; tg.appendChild(c); }
|
||||
};
|
||||
if (!b.design.tone) b.design.tone = (CAT.TONES[0] || {}).id;
|
||||
drawTones(); drawPalettes();
|
||||
const single = (items, get, set) => { const box = chips(body, items, [get()], (id) => { set(id); change(); box.querySelectorAll('.chip').forEach((c, i) => c.classList.toggle('on', items[i].id === id)); }); };
|
||||
body.appendChild(h3('폰트 세트 (3안)')); single(CAT.FONT_SETS, () => (b.design.fonts ? b.design.fonts.id : ''), (id) => { b.design.fonts = CAT.FONT_SETS.find((f) => f.id === id); });
|
||||
body.appendChild(h3('레이아웃 밀도')); single(CAT.DENSITY, () => b.design.density, (id) => { b.design.density = id; });
|
||||
body.appendChild(h3('이미지 유형')); single(CAT.IMAGERY, () => b.design.imagery, (id) => { b.design.imagery = id; });
|
||||
}
|
||||
|
||||
// ---- 생성 후: 검증 리포트 / 수정 / 발행 ----
|
||||
/** 생성 탭의 생성 완료 안내. 발행·배포와 수정 지시는 수정 탭(발행·배포 카드 / 수정 지시 카드)에서 한다 (v1.4.14: 관리 탭 → 수정 탭) */
|
||||
function renderPost(body) {
|
||||
const plan = current.plan; const st = plan.stage;
|
||||
body.appendChild(el('p', 'small', st === 'published' ? `발행 완료 · ${new Date(plan.published.at).toLocaleString(uiLocale())}. 수정과 재배포는 "수정" 탭에서 합니다.` : '페이지가 생성되었습니다. 우측 미리보기로 확인한 뒤 "수정" 탭에서 수정 지시를 보내거나 발행(배포)하세요.'));
|
||||
const row = el('div', 'row');
|
||||
row.appendChild(btn('수정 탭으로 이동', 'primary small-btn', () => { if (window.finishCreate) finishCreate(current.id); else showTab('edit'); }));
|
||||
row.appendChild(btn('새 사이트 만들기', 'ghost small-btn', () => { if (window.resetCreate) resetCreate(); }));
|
||||
if (current.mode !== 'ai') row.appendChild(btn('선택값 수정 후 재생성', 'ghost small-btn', () => { editingBrief = true; manualStep = 1; render(); }));
|
||||
body.appendChild(row);
|
||||
}
|
||||
/**
|
||||
* 수정 탭 "발행 · 배포" 카드 (v1.1.0, v1.4.14 부터 수정 탭)
|
||||
* - 발행됨 + 변경 없음(deployState=deployed): 결과(운영 주소·접속 확인)와 "발행 취소"만 표시 — 같은 배포를 두 번 하지 않게
|
||||
* - 발행됨 + 수정으로 변경 생김(pending): "재배포(변경 내용 반영)" 표시
|
||||
* - 발행 취소됨(unpublishedAt): 운영 주소에 "수정 중" 안내가 올라가 있음을 알리고 "발행" 으로 되돌림
|
||||
*/
|
||||
function renderPublishCard() {
|
||||
const card = $('publishCard'); const body = $('publishBody'); if (!current || !current.plan || !POST_STAGES.includes(current.plan.stage)) { card.classList.add('hidden'); return; }
|
||||
card.classList.remove('hidden'); body.innerHTML = '';
|
||||
const plan = current.plan; const v = plan.verification; const st = plan.stage; const s = v ? v.rules.summary : null; const issues = s ? (s.pagesMissing + s.extraneous + s.regulated) : 0;
|
||||
const dsObj = current.deployState || {}; const changedAfterDeploy = !!(dsObj.lastChangeAt && dsObj.lastDeployAt && dsObj.lastChangeAt > dsObj.lastDeployAt) || !dsObj.lastDeployAt;
|
||||
const ds = dsObj.state === 'pending' && changedAfterDeploy ? 'pending' : (dsObj.state === 'pending' ? 'deployed' : dsObj.state); const unpublished = !!current.unpublishedAt; const locked = current.editLimit && current.editLimit.locked;
|
||||
const pill = $('publishState');
|
||||
// v1.6.31 (요구 14·16): 전송만 되고 준비가 안 된 단계는 그 단계 이름으로 보인다 (발행됨으로 보이지 않게)
|
||||
const PHASE = { 'vhost-wait': '발행됨 · 웹서버 설정 적용 대기', 'dns-wait': '발행됨 · DNS 설정 대기', 'cert-wait': '발행됨 · 인증서 준비 중(HTTP)', 'check-failed': '발행됨 · 접속 확인 실패', 'vhost-failed': '발행됨 · 도메인 연결 실패', failed: '배포 실패' };
|
||||
if (st === 'published') { pill.textContent = ds === 'pending' ? '발행됨 · 변경 있음' : (PHASE[dsObj.phase] || '발행됨'); pill.className = 'pill ' + (ds === 'pending' || (dsObj.phase && dsObj.phase !== 'ready' && dsObj.phase !== 'transferred') ? 'waiting' : 'done'); if (PHASE[dsObj.phase]) pill.title = dsObj.phaseDetail || ''; }
|
||||
else if (unpublished) { pill.textContent = '발행 취소됨'; pill.className = 'pill error'; }
|
||||
else { pill.textContent = locked ? '발행 대기' : '발행 전'; pill.className = 'pill ' + (locked ? 'waiting' : 'idle'); }
|
||||
if (st === 'published') {
|
||||
// v1.1.4: 운영 주소 한 줄 + "이 주소로 배포되었습니다" 만 보이고, 세부(도메인·SSL·접속 확인·취소 효과)는 ? 풍선으로
|
||||
const box = el('div', 'publish-box'); const dep = plan.published.deploy || {}; const acc = (dep.access && dep.access.items) || [];
|
||||
const line = el('div', 'publish-line'); line.appendChild(el('span', '', '운영 주소'));
|
||||
if (current.liveUrl) { const a = el('a'); a.href = current.liveUrl; a.target = '_blank'; a.rel = 'noopener'; a.textContent = current.liveUrl; line.appendChild(a); } else line.appendChild(el('span', 'muted', '(공개 주소 없음)'));
|
||||
const tipParts = [`${new Date(plan.published.at).toLocaleString(uiLocale())} 에 이 주소로 배포되었습니다${plan.published.forced ? ' (점검 경고 무시)' : ''}.`, dep.domains && dep.domains.length ? `연결 도메인: ${dep.domains.join(', ')}${dep.vhostOk === false ? ' (연결 실패 — 관리자 문의)' : ''}.` : '연결한 도메인 없음(서버 주소로 접속).', dep.sslOk === true ? 'HTTPS(SSL) 적용됨.' : (dep.sslOk === false && dep.sslDetail && !/건너뜀/.test(dep.sslDetail) ? 'SSL 발급 실패(관리자 문의).' : ''), acc.length ? '접속 확인: ' + acc.map((it) => `${it.url} — ${it.ok ? '접속 가능' : '접속 불가(' + it.note + ')'}`).join(' / ') : '', ds === 'pending' ? '발행 후 수정된 내용이 있어 재배포하면 반영됩니다.' : '수정 지시로 변경이 생기면 재배포 버튼이 나타납니다.', '발행 취소를 누르면 운영 주소에는 "사이트 수정 중입니다" 안내만 보이고 사이트 내용은 보이지 않습니다. 다시 발행하면 정상 내용이 올라갑니다.'].filter(Boolean);
|
||||
const hp = el('button', 'help', '?'); hp.type = 'button'; hp.dataset.tip = tipParts.join(' '); line.appendChild(hp); box.appendChild(line);
|
||||
box.appendChild(el('div', 'small', dep.ok === false ? '배포 서버 전송 실패 — 관리자에게 문의하세요' : (PHASE[dsObj.phase] ? `${PHASE[dsObj.phase].replace('발행됨 · ', '')}: ${dsObj.phaseDetail || ''}` : '이 주소로 배포되었습니다.')));
|
||||
const row = el('div', 'row');
|
||||
if (ds === 'pending') row.appendChild(btn('재배포 (변경 내용 반영)', 'primary small-btn', async () => { setBusy(true, 'publish'); try { await planPost('publish', { force: true, domains: current.domains || [] }); } catch (e) { alert(e.message); } setBusy(false); render(); }));
|
||||
if (PHASE[dsObj.phase] && dsObj.phase !== 'failed') row.appendChild(btn('다시 확인', 'ghost small-btn', async () => { setBusy(true, 'publish'); try { const r = await planPost('recheck', {}); pushMsg && pushMsg(r.ready ? 'info' : 'warn', (r.changed ? '상태가 바뀌었습니다: ' : '아직 같은 상태입니다: ') + (r.detail || r.phase)); } catch (e) { alert(e.message); } setBusy(false); render(); })); // v1.6.33: 해결된 오류가 계속 표시되지 않게
|
||||
if (dsObj.hasPrev) row.appendChild(btn('이전 배포본으로', 'ghost small-btn', async () => { if (!confirm('운영 주소를 직전 배포본으로 되돌릴까요? (여기서 보는 사이트 내용은 바뀌지 않고, 다음 발행 때 다시 올라갑니다)')) return; setBusy(true, 'publish'); try { const r = await planPost('rollback', {}); pushMsg && pushMsg('info', '이전 배포본으로 되돌렸습니다 — ' + (r.detail || '')); } catch (e) { alert(e.message); } setBusy(false); render(); })); // v1.6.31 (요구 15)
|
||||
row.appendChild(btn('발행 취소', 'ghost small-btn', async () => { if (!confirm('발행을 취소하면 운영 주소에는 "사이트 수정 중" 안내만 보이고 사이트 내용은 보이지 않습니다. 계속할까요?')) return; await runStep('unpublish'); }));
|
||||
box.appendChild(row); body.appendChild(box);
|
||||
} else if (unpublished) {
|
||||
const box = el('div', 'publish-box'); box.appendChild(el('div', '', `발행 취소됨 · ${new Date(current.unpublishedAt).toLocaleString(uiLocale())}`));
|
||||
if (current.liveUrl) box.appendChild(el('div', 'small', `운영 주소(${current.liveUrl})에는 "사이트 수정 중입니다" 안내가 표시되고 있습니다. 수정을 마친 뒤 아래에서 다시 발행하세요.`));
|
||||
body.appendChild(box);
|
||||
}
|
||||
const line = el('div', 'small');
|
||||
if (!v) line.textContent = '검증: 아직 실행되지 않음'; else if (v.stale) line.textContent = '검증: 수정 후 재검증 필요'; else line.textContent = issues || s.placeholders ? `검증: 점검 항목 ${issues}건${s.placeholders ? ` · 입력이 필요한 곳 ${s.placeholders}곳` : ''}` : '검증: 이상 없음';
|
||||
body.appendChild(line);
|
||||
const row = el('div', 'row');
|
||||
const rep = el('a', 'btn ghost small-btn', '검증 리포트 보기'); rep.href = `/report/${current.id}`; rep.target = '_blank'; rep.rel = 'noopener'; row.appendChild(rep);
|
||||
row.appendChild(btn('재검증', 'ghost small-btn', () => runStep('verify')));
|
||||
row.appendChild(btn('검증 결과 자동 수정', 'ghost small-btn', () => runStep('fix'), !(v && (issues || (v.ai && v.ai.fixes && v.ai.fixes.length)))));
|
||||
body.appendChild(row);
|
||||
if (st !== 'published') {
|
||||
const hl = h3('연결할 도메인 (선택, 여러 개 가능)'); const hp = el('button', 'help', '?'); hp.type = 'button'; hp.dataset.tip = '도메인을 넣으면 배포 서버의 nginx 에 함께 등록되어 어느 주소로 접속해도 이 사이트가 열립니다. 도메인의 DNS(A 레코드)가 배포 서버를 가리키고 있어야 하며, 가리키는 도메인에는 HTTPS 인증서가 자동 발급됩니다. 비우면 서버 주소로만 접속합니다.'; hl.appendChild(hp); body.appendChild(hl);
|
||||
const dwrap = el('div'); renderDomainChips(dwrap); body.appendChild(dwrap);
|
||||
const row3 = el('div', 'row'); row3.appendChild(btn(unpublished ? '다시 발행 (배포)' : '발행 (배포)', 'primary small-btn', publish)); body.appendChild(row3);
|
||||
}
|
||||
if (current.mode !== 'ai') { const row2 = el('div', 'row'); row2.appendChild(btn('선택값 수정 후 재생성', 'ghost small-btn', () => { editingBrief = true; manualStep = 1; if (window.setCreating) setCreating(current.id); showTab('create'); $('planCard').classList.remove('hidden'); render(); })); body.appendChild(row2); }
|
||||
}
|
||||
function renderDomainChips(wrap) {
|
||||
wrap.innerHTML = ''; const list = current.domains || [];
|
||||
const chipsBox = el('div', 'chips');
|
||||
for (const d of list) { const c = el('span', 'chip on', d + ' ×'); c.title = '제거'; c.onclick = async () => { current.domains = list.filter((x) => x !== d); await saveDomains(); renderDomainChips(wrap); }; chipsBox.appendChild(c); }
|
||||
wrap.appendChild(chipsBox);
|
||||
const inp = el('input'); inp.placeholder = '도메인 입력 후 Enter (쉼표로 여러 개 가능)'; inp.dataset.k = 'domain-input';
|
||||
inp.onkeydown = async (e) => { if (e.key !== 'Enter' && e.key !== ',') return; e.preventDefault(); const v = inp.value.trim(); if (!v) return; try { current.domains = [...(current.domains || []), ...v.split(/[\s,]+/).filter(Boolean)]; await saveDomains(); inp.value = ''; renderDomainChips(wrap); } catch (err) { alert(err.message); current.domains = list; } };
|
||||
inp.onblur = () => { if (inp.value.trim()) inp.onkeydown({ key: 'Enter', preventDefault() {} }); };
|
||||
wrap.appendChild(inp);
|
||||
|
||||
}
|
||||
async function saveDomains() { const r = await api(`/api/sites/${current.id}/domains`, { method: 'PUT', body: JSON.stringify({ domains: current.domains || [] }) }); current.domains = r.domains; }
|
||||
async function publish() {
|
||||
const firstDeploy = !((current.deploy && current.deploy.history) || []).some((d) => d.ok);
|
||||
if (firstDeploy) {
|
||||
// 첫 배포: 배포 정보 확인 단계
|
||||
const info = `배포 서버: ${(current.deploy && current.deploy.targetName) || '운영자가 지정한 배포 서버'}\n도메인: ${(current.domains || []).length ? current.domains.join(', ') : '(없음 — 서버 주소로만 접속)'}\n\n이 정보로 최종 확정하고 배포할까요? 도메인을 바꾸려면 취소 후 위의 도메인 칸을 수정하세요.`;
|
||||
if (!confirm(info)) return;
|
||||
}
|
||||
setBusy(true, 'publish');
|
||||
try { await planPost('publish', { domains: current.domains || [] }); }
|
||||
catch (e) {
|
||||
const msg = e.message; let bl = null; try { const r = await fetch(`/api/sites/${current.id}/plan/publish`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); bl = (await r.json()).blockers; } catch (_) {}
|
||||
const lines = bl ? [...bl.placeholders.map((p) => `${p.file}: [입력 필요] ${p.count}곳`), ...bl.pagesMissing.map((p) => `누락 페이지 ${p.file}`), ...bl.regulated.map((p) => `${p.file}: "${p.value}"`)] : [msg];
|
||||
setBusy(false);
|
||||
if (confirm(`최종 확정이 보류되었습니다:\n- ${lines.join('\n- ')}\n\n그래도 확정할까요?`)) { setBusy(true, 'publish'); try { await planPost('publish', { force: true, domains: current.domains || [] }); } catch (e2) { alert(e2.message); } }
|
||||
}
|
||||
setBusy(false); render();
|
||||
}
|
||||
|
||||
// ---------- 와이어프레임 ----------
|
||||
function esc(s) { return String(s || '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
|
||||
function wireframeHtml(b) {
|
||||
const tone = CAT.TONES.find((t) => t.id === b.design.tone) || CAT.TONES[0];
|
||||
const pal = (b.design.palette && b.design.palette.colors) || tone.palettes[0].colors; const [text, sub, bg, accent] = pal;
|
||||
const fonts = b.design.fonts || CAT.FONT_SETS[0]; const gap = b.design.density === 'wide' ? 56 : b.design.density === 'dense' ? 20 : 36;
|
||||
const pages = b.pages.length ? b.pages : [{ id: 'home', title: '홈', sections: [{ id: 'hero', title: '히어로' }] }];
|
||||
const nav = pages.map((p, i) => `<a href="#" data-p="${i}" class="${i === 0 ? 'on' : ''}">${esc(p.title)}</a>`).join('');
|
||||
const secHtml = (s) => { const f = s.feature ? ` <em>⚙ ${esc(lbl(CAT.FEATURES, s.feature))}</em>` : ''; const g = s.goal ? ` <em>→ ${esc(lbl(CAT.GOALS, s.goal))}</em>` : '';
|
||||
if (s.id === 'hero') return `<section class="hero"><div class="ph-img"></div><h1>${esc(b.business.name || '상호명')}</h1><p>${esc(b.business.description || '대표 문구 (어조: ' + lbl(CAT.VOICE, b.copy.voice) + ')')}</p><button>${esc(b.goals[0] ? lbl(CAT.GOALS, b.goals[0]) : 'CTA')}</button>${f}${g}</section>`;
|
||||
const fact = { map: 'address', contact: 'phone', hours: 'hours', pricing: 'prices', credentials: 'credentials', reviews: 'reviews' }[s.id]; const missing = fact && !(b.facts[fact] && (b.facts[fact].status === 'provided' || b.facts[fact].status === 'removed'));
|
||||
return `<section><h2>${esc(s.title)}${f}${g}</h2><div class="blocks">${['gallery', 'portfolio', 'products', 'services', 'menu', 'team'].includes(s.id) ? '<div class="b"></div><div class="b"></div><div class="b"></div>' : '<div class="line"></div><div class="line short"></div>'}</div>${missing ? '<div class="need">[입력 필요] ' + esc(lbl(CAT.FACT_FIELDS, fact)) + '</div>' : ''}</section>`; };
|
||||
const pageHtml = pages.map((p, i) => `<main data-p="${i}" ${i ? 'hidden' : ''}>${p.sections.map(secHtml).join('')}</main>`).join('');
|
||||
return `<!doctype html><html lang="ko"><head><meta charset="utf-8"><link href="https://fonts.googleapis.com/css2?family=${fonts.google}&display=swap" rel="stylesheet"><style>
|
||||
body{margin:0;background:${bg};color:${text};font-family:'${fonts.body}',sans-serif}h1,h2{font-family:'${fonts.heading}',sans-serif;margin:0 0 8px}header{display:flex;align-items:center;gap:16px;padding:14px 24px;border-bottom:1px solid ${sub}33}header b{font-size:18px}nav a{color:${sub};text-decoration:none;margin-right:12px;font-size:14px;padding:4px 0}nav a.on{color:${accent};border-bottom:2px solid ${accent}}
|
||||
main{max-width:960px;margin:0 auto;padding:0 24px}section{padding:${gap}px 0;border-bottom:1px dashed ${sub}44}.hero{text-align:center}.hero h1{font-size:34px}.hero p{color:${sub}}.hero button{background:${accent};color:#fff;border:0;padding:12px 22px;border-radius:8px;font-size:15px}.ph-img{height:${b.design.density === 'dense' ? 120 : 200}px;border-radius:12px;background:linear-gradient(135deg,${accent}55,${sub}33);margin-bottom:18px}
|
||||
h2{font-size:20px}em{font-size:11px;color:${accent};font-style:normal;background:${accent}18;padding:1px 6px;border-radius:999px;margin-left:6px}.blocks{display:flex;gap:12px}.b{flex:1;height:110px;border-radius:10px;background:${sub}22}.line{height:12px;border-radius:6px;background:${sub}33;margin:6px 0;width:90%}.short{width:55%}.need{margin-top:8px;font-size:12px;color:#b45309;background:#fef3c7;display:inline-block;padding:3px 8px;border-radius:6px}
|
||||
footer{padding:20px 24px;color:${sub};font-size:12px;text-align:center}.wf{position:fixed;top:8px;right:8px;background:#0f172a;color:#fff;font-size:11px;padding:3px 8px;border-radius:999px;opacity:.85}
|
||||
</style></head><body><div class="wf">와이어프레임 미리보기 · ${esc(tone.label)}</div><header><b>${esc(b.business.name || '상호명')}</b><nav>${nav}</nav></header>${pageHtml}<footer>${esc(b.business.name || '')} · ${esc([b.business.region.sido, b.business.region.sigungu].filter(Boolean).join(' '))} · ${b.facts.phone && b.facts.phone.status === 'provided' ? esc(b.facts.phone.value) : '전화: [입력 필요]'}</footer>
|
||||
<script>document.querySelectorAll('nav a').forEach(a=>a.onclick=e=>{e.preventDefault();document.querySelectorAll('nav a').forEach(x=>x.classList.remove('on'));a.classList.add('on');document.querySelectorAll('main').forEach(m=>m.hidden=m.dataset.p!==a.dataset.p)});</script></body></html>`;
|
||||
}
|
||||
// 와이어프레임은 iframe srcdoc 교체(폰트 재로딩 포함)라 비싸다. 짧은 시간의 연속 선택은 한 번만 반영하고, 내용이 같으면 건너뛴다.
|
||||
let wfTimer = null; let wfLast = '';
|
||||
function showWireframe(b) {
|
||||
if (!b || !CAT) return;
|
||||
clearTimeout(wfTimer);
|
||||
wfTimer = setTimeout(() => {
|
||||
let html; try { html = wireframeHtml(b); } catch (_) { return; }
|
||||
if (html === wfLast) return; wfLast = html;
|
||||
$('emptyState').classList.add('hidden'); $('frame').classList.remove('hidden');
|
||||
$('frame').removeAttribute('src'); $('frame').srcdoc = html;
|
||||
$('siteUrl').textContent = '와이어프레임 (기획 단계) — 생성 후 실제 페이지가 표시됩니다';
|
||||
}, 180);
|
||||
}
|
||||
|
||||
loadCatalog().catch(() => {});
|
||||
window.Wizard = { onSiteOpened, showModeChooser, hideStart, setRunning, render, onPlanEvent };
|
||||
})();
|
||||
@@ -0,0 +1,365 @@
|
||||
/* ============================================================================
|
||||
* 웹메이커 공통 모듈 런타임 (v1.0.3, v1.3.10 다국어 — 페이지 언어 날짜 형식·번역표 LT(), v1.4.18 일정관리 캘린더 R.calendar)
|
||||
* 생성 사이트에 삽입된 모듈 위젯(data-wm-module)을 찾아 API 와 통신하고 내용을 그린다.
|
||||
* 사이트별 설정은 모두 data-* 속성과 관리자 설정(API 응답)으로 들어오므로 이 파일에는 사이트 고유 값이 없다.
|
||||
* - data-site : 사이트 ID - data-api : 모듈 API 기본 주소 (…/api/m/<siteId>)
|
||||
* 회원 토큰은 localStorage 'wm_member_<siteId>' 에 저장되어 게시판·후기·예약·설문이 함께 쓴다.
|
||||
* 이 파일을 사이트 안에 복사해 쓰려면 그대로 js/wm-modules.js 로 두고 <script src> 만 바꾸면 된다.
|
||||
* ========================================================================== */
|
||||
(function () {
|
||||
if (window.__wmModulesLoaded) return; window.__wmModulesLoaded = true;
|
||||
var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); };
|
||||
var nl2br = function (s) { return esc(s).replace(/\n/g, '<br>'); };
|
||||
// v1.3.10 다국어: 페이지 언어(<html lang>)로 날짜를 쓰고, 화면 문구는 페이지에 들어 있는 번역표(<script type="application/json" data-wm-i18n>)로 바꾼다.
|
||||
// 번역표는 다국어 사이트의 다른 언어 페이지에 서비스가 넣는다(lib/multilang.js). 없으면(기본 언어) 원문 그대로.
|
||||
var PAGE_LANG = (document.documentElement.getAttribute('lang') || 'ko');
|
||||
var DICT = (function () { try { var el = document.querySelector('script[data-wm-i18n]'); return el ? JSON.parse(el.textContent) : {}; } catch (e) { return {}; } })();
|
||||
// v1.3.16: 문구는 코드에 직접 쓰지 않고 id 로만 쓴다. BASE 는 서비스가 이 파일을 보낼 때 채우는 원문(한국어) 표
|
||||
// (server.js GET /wm-modules.js ← locales/site/ko.json). 페이지 번역표(DICT)에 그 언어 글자가 있으면 그것을 쓴다.
|
||||
var BASE = /*@WM_BASE_MESSAGES@*/{};
|
||||
/** LT('board.empty') / LT('members.welcome', { name: '홍길동' }) — {이름} 자리를 채운다 */
|
||||
// v1.3.17 (B01): 1.3.16 에서는 이 함수 이름이 L 이라 지도 모듈의 Leaflet(window.L)을 가려 L.map 이 오류가 났다 → LT 로 바꿈
|
||||
// v1.4.5 숫자 값(조회 수·댓글 수·남은 자리 등)은 3자리마다 쉼표 (페이지 언어 형식)
|
||||
var numStr = function (v) { return typeof v === 'number' && isFinite(v) ? v.toLocaleString(document.documentElement.lang || undefined) : String(v); };
|
||||
var LT = function (k, vars) { var s = DICT[k] || BASE[k] || k; return vars ? String(s).replace(/\{(\w+)\}/g, function (m, n) { return vars[n] !== undefined ? numStr(vars[n]) : m; }) : s; };
|
||||
/** 관리자가 정한 설정 값(문의 유형·후기 분류 등)의 그 언어 번역 — 없으면 그대로 */
|
||||
var LV = function (v) { return (v && DICT['v:' + v]) || v; };
|
||||
/** v1.5.36 (요구 2): 위젯 속성(data-title 등)의 표시 글자. 속성이 없으면 문구표, 속성이 한국어 원문과 같은데 이 언어 번역이 있으면 번역 — 번역 단계를 거치지 않은 옛 페이지도 다른 언어에서 바르게 보이게 */
|
||||
var AT = function (w, name, id) { var v = w.getAttribute(name); if (!v || !v.trim()) return LT(id); if (BASE[id] === v && DICT[id]) return DICT[id]; return v; };
|
||||
/** v1.5.36: <input type=file> 의 브라우저 기본 글자("파일 선택")는 페이지 언어와 무관하게 브라우저 언어를 따르므로, 보이는 버튼을 따로 만들어 문구표 글자를 쓴다 */
|
||||
var fileButton = function (input) {
|
||||
if (!input || input.__wmFile) return; input.__wmFile = 1; var wrap = document.createElement('span'); wrap.className = 'wm-file'; var btn = document.createElement('button'); btn.type = 'button'; btn.className = 'wm-btn wm-file-btn'; btn.textContent = LT('inquiry.chooseFile'); var lab = document.createElement('span'); lab.className = 'wm-file-names'; lab.textContent = LT('inquiry.noFile');
|
||||
input.parentNode.insertBefore(wrap, input); wrap.appendChild(btn); wrap.appendChild(lab); wrap.appendChild(input); input.style.position = 'absolute'; input.style.width = '1px'; input.style.height = '1px'; input.style.opacity = '0'; input.style.overflow = 'hidden'; input.tabIndex = -1;
|
||||
btn.onclick = function () { input.click(); }; input.addEventListener('change', function () { var n = input.files ? input.files.length : 0; lab.textContent = n ? (n === 1 ? input.files[0].name : LT('inquiry.filesChosen', { n: n })) : LT('inquiry.noFile'); });
|
||||
};
|
||||
var fmtDate = function (s) { try { return new Date(s).toLocaleString(PAGE_LANG, { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); } catch (e) { return s; } };
|
||||
var tokenKey = function (site) { return 'wm_member_' + site; };
|
||||
var getToken = function (site) { try { return localStorage.getItem(tokenKey(site)) || ''; } catch (e) { return ''; } };
|
||||
var setToken = function (site, t) { try { if (t) localStorage.setItem(tokenKey(site), t); else localStorage.removeItem(tokenKey(site)); } catch (e) {} };
|
||||
/** API 호출: JSON 또는 FormData. 회원 토큰이 있으면 Authorization 헤더로 보낸다 */
|
||||
function call(w, method, pathname, body, opts) {
|
||||
var api = w.getAttribute('data-api'); var site = w.getAttribute('data-site'); var headers = {}; var tok = getToken(site);
|
||||
if (tok) headers['Authorization'] = 'Bearer ' + tok;
|
||||
headers['X-WM-Lang'] = PAGE_LANG; // v1.3.16 서버가 오류·안내 문구를 이 페이지 언어로 돌려준다
|
||||
var init = { method: method, headers: headers, mode: 'cors', credentials: 'omit' };
|
||||
if (body instanceof FormData) init.body = body; else if (body !== undefined) { headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(body); }
|
||||
return fetch(api + pathname, init).then(function (r) { return r.json().catch(function () { return {}; }).then(function (j) { if (!r.ok) { var e = new Error((j.error && LT(j.error)) || ('HTTP ' + r.status)); e.status = r.status; throw e; } return j; }); });
|
||||
}
|
||||
/** 폼 안의 name 필드 찾기 (form.elements 가 아닌 하위 어디에 있어도) */
|
||||
var fld = function (form, name) { return form ? form.querySelector('[name="' + name + '"]') : null; };
|
||||
var msg = function (w, text, ok) { var m = w.querySelector('.wm-msg'); if (m) { m.textContent = text || ''; m.className = 'wm-msg' + (ok ? ' ok' : (text ? ' err' : '')); } };
|
||||
var baseCss = '.wm-btn{cursor:pointer}.wm-ghost{background:transparent}.wm-msg.err{color:#b91c1c}.wm-msg.ok{color:#047857}.wm-row{display:flex;gap:8px;flex-wrap:wrap}.wm-row>*{flex:1;min-width:140px}.wm-board table{width:100%;border-collapse:collapse}.wm-board td,.wm-board th{padding:8px 6px;border-bottom:1px solid #e5e7eb;text-align:left}.wm-board .wm-post-card{border:1px solid #e5e7eb;border-radius:10px;padding:14px;margin:10px 0}.wm-board .wm-comment{border-top:1px solid #eee;padding:8px 0}.wm-board .wm-comment.reply{margin-left:24px}.wm-pager button{margin:0 2px}.wm-pager .on{font-weight:700;text-decoration:underline}.wm-gallery-grid{display:grid;gap:10px}.wm-gallery-grid img{width:100%;aspect-ratio:4/3;object-fit:cover;border-radius:8px;cursor:zoom-in}.wm-lightbox{position:fixed;inset:0;background:rgba(0,0,0,.85);display:flex;align-items:center;justify-content:center;z-index:9999;flex-direction:column;color:#fff}.wm-lightbox img{max-width:92vw;max-height:80vh}.wm-slots{display:flex;flex-wrap:wrap;gap:6px;margin:8px 0}.wm-slot{padding:6px 10px;border:1px solid #cbd5e1;border-radius:8px;background:#fff;cursor:pointer}.wm-slot.full{opacity:.4;cursor:not-allowed;text-decoration:line-through}.wm-slot.on{outline:2px solid #2563eb}.wm-kanban{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(220px,1fr);gap:10px;overflow:auto}.wm-kanban-col{background:#f8fafc;border-radius:10px;padding:10px}.wm-kanban-col h4{margin:0 0 8px}.wm-review{border:1px solid #e5e7eb;border-radius:10px;padding:10px;margin:8px 0;background:#fff}.wm-stars{color:#f59e0b}.wm-faq details{border-bottom:1px solid #e5e7eb;padding:6px 0}.wm-faq summary{cursor:pointer;font-weight:600}.wm-map-canvas{width:100%;border-radius:10px;overflow:hidden}.wm-survey .wm-q{margin:12px 0}.wm-survey .wm-q label{display:block;font-weight:600;margin-bottom:4px}.wm-survey .wm-opt{display:block;font-weight:400}.wm-cats button{margin:0 4px 6px 0}.wm-cats .on{font-weight:700;text-decoration:underline}.wm-blog-card{border-bottom:1px solid #e5e7eb;padding:14px 0}.wm-blog-cover{max-width:100%;border-radius:8px;display:block;margin:6px 0}.wm-blog-search,.wm-library-search{display:flex;gap:6px;margin:8px 0}.wm-blog-search input,.wm-library-search input{flex:1}.wm-blog-body p{margin:0 0 1em}.wm-blog-tag{margin-right:4px;opacity:.8}.wm-library-item{display:flex;gap:12px;align-items:center;justify-content:space-between;border-bottom:1px solid #e5e7eb;padding:10px 0}.wm-library-info{min-width:0;flex:1}.wm-library-desc{margin:4px 0 0}.wm-lang-select{font:inherit;padding:2px 6px}.wm-blog-view .wm-cform{display:grid;gap:8px;max-width:640px;margin-top:10px}.wm-blog-view .wm-cform input,.wm-blog-view .wm-cform textarea{font:inherit;padding:6px 8px;width:100%;box-sizing:border-box}.wm-blog-view .wm-cform button{justify-self:start}.wm-blog-view .wm-meta{margin:4px 0 8px}' +
|
||||
/* v1.4.1 게시판 목록 [검색][글쓰기] 한 줄: 버튼 글자가 줄바꿈되며(글쓰/기) 두 줄이 되던 것 — 버튼은 줄이지 않고 검색칸이 남은 폭을 쓴다 (블로그·자료실 검색 줄도 같게) */
|
||||
'.wm-board-tools{display:flex;align-items:center;flex-wrap:nowrap;gap:8px;min-width:0;max-width:100%}.wm-board-tools .wm-board-search{flex:1 1 auto;min-width:0;width:auto}.wm-board-tools .wm-btn,.wm-blog-search .wm-btn,.wm-library-search .wm-btn{white-space:nowrap;flex:0 0 auto;width:auto;word-break:keep-all}.wm-blog-search,.wm-library-search{align-items:center}' +
|
||||
/* v1.4.18 일정관리 캘린더: 월간 격자·주간/일간 시간표·일정 상세 팝업. 색은 .wm-cal-c-<색> 으로, 사이트는 .wm-cal 아래 규칙만 덧씌우면 된다 */
|
||||
'.wm-cal-tools{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin:8px 0}.wm-cal-tools .wm-cal-caption{font-weight:700;margin:0 8px;flex:1;min-width:120px}.wm-cal-tools .wm-cal-view.on{font-weight:700;text-decoration:underline}.wm-cal-grid{display:grid;grid-template-columns:repeat(7,1fr);border-left:1px solid #e5e7eb;border-top:1px solid #e5e7eb}.wm-cal-wd{padding:6px 4px;text-align:center;font-weight:600;font-size:13px;border-right:1px solid #e5e7eb;border-bottom:1px solid #e5e7eb;background:#f8fafc}.wm-cal-cell{min-height:92px;padding:4px;border-right:1px solid #e5e7eb;border-bottom:1px solid #e5e7eb;font-size:12px;overflow:hidden;cursor:pointer;min-width:0}.wm-cal-cell.other{opacity:.45}.wm-cal-cell.today .wm-cal-num,.wm-cal-head .today .wm-cal-num{background:#2563eb;color:#fff;border-radius:999px;padding:0 6px}.wm-cal-sun .wm-cal-num{color:#dc2626}.wm-cal-sat .wm-cal-num{color:#2563eb}.wm-cal-cell.today.wm-cal-sun .wm-cal-num,.wm-cal-cell.today.wm-cal-sat .wm-cal-num{color:#fff}.wm-cal-date{display:flex;align-items:center;gap:4px;flex-wrap:wrap}.wm-cal-num{font-weight:600}.wm-cal-term{font-size:10px;color:#059669;border:1px solid #a7f3d0;border-radius:4px;padding:0 3px;white-space:nowrap}.wm-cal-sub{color:#6b7280;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.wm-cal-ev{display:block;width:100%;text-align:left;border:0;border-left:3px solid #2563eb;background:#eff6ff;color:inherit;font:inherit;font-size:11px;padding:1px 4px;margin:2px 0;border-radius:3px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.wm-cal-c-green{border-left-color:#059669;background:#ecfdf5}.wm-cal-c-red{border-left-color:#dc2626;background:#fef2f2}.wm-cal-c-orange{border-left-color:#ea580c;background:#fff7ed}.wm-cal-c-purple{border-left-color:#7c3aed;background:#f5f3ff}.wm-cal-c-gray{border-left-color:#6b7280;background:#f3f4f6}.wm-cal-more{background:none;border:0;color:#2563eb;font-size:11px;cursor:pointer;padding:0 4px}.wm-cal-week{overflow:auto}.wm-cal-week .wm-cal-row{display:grid;grid-template-columns:56px repeat(var(--wm-cal-cols,7),minmax(0,1fr));min-width:560px}.wm-cal-week.one .wm-cal-row{min-width:0}.wm-cal-head>div,.wm-cal-allday>div{border-right:1px solid #e5e7eb;border-bottom:1px solid #e5e7eb;padding:4px;font-size:12px;min-height:28px;min-width:0}.wm-cal-head{border-top:1px solid #e5e7eb}.wm-cal-head .wm-cal-sub{white-space:normal}.wm-cal-timegrid>div{border-right:1px solid #e5e7eb;position:relative;min-width:0}.wm-cal-hour{height:44px;border-bottom:1px solid #f1f5f9;box-sizing:border-box;font-size:10px;color:#6b7280;padding:2px 4px}.wm-cal-tev{position:absolute;box-sizing:border-box;overflow:hidden;white-space:normal;line-height:1.25;margin:0}.wm-cal-list{margin-top:10px}.wm-cal-list .wm-cal-ev{white-space:normal;padding:6px 8px;font-size:13px}.wm-cal-info{font-size:13px;color:#374151;margin:6px 0}.wm-cal-pop{position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;z-index:9999}.wm-cal-pop .wm-cal-card{background:#fff;color:#111;border-radius:12px;padding:18px;max-width:420px;width:92vw;max-height:80vh;overflow:auto}.wm-cal-card h3{margin:0 0 8px}.wm-cal-card .wm-meta{color:#6b7280;font-size:13px;margin:4px 0}.wm-cal-card .wm-body{margin-top:8px}.wm-cal-empty{color:#6b7280;padding:12px 0}';
|
||||
var st = document.createElement('style'); st.textContent = baseCss; document.head.appendChild(st);
|
||||
|
||||
var R = {};
|
||||
// ---------------- 회원 ----------------
|
||||
R.members = function (w) {
|
||||
var site = w.getAttribute('data-site'); var status = w.querySelector('.wm-members-status'); var fl = w.querySelector('.wm-members-login'); var fs = w.querySelector('.wm-members-signup'); if (!status || !fl || !fs) { console.warn('wm members: 표준 마크업(.wm-members-status/.wm-members-login/.wm-members-signup)이 필요합니다'); return; }
|
||||
function show(me) {
|
||||
if (me) { status.innerHTML = '<span>' + esc(LT('members.welcome', { name: me.name })) + '</span> <button type="button" class="wm-btn wm-ghost wm-logout">' + LT('members.logout') + '</button>'; fl.hidden = true; fs.hidden = true; status.querySelector('.wm-logout').onclick = function () { setToken(site, ''); show(null); document.dispatchEvent(new CustomEvent('wm:member', { detail: null })); }; }
|
||||
else { status.innerHTML = ''; fl.hidden = false; fs.hidden = true; }
|
||||
}
|
||||
function me() { if (!getToken(site)) return show(null); call(w, 'GET', '/members/me').then(function (r) { show(r.member); }).catch(function () { setToken(site, ''); show(null); }); }
|
||||
w.querySelector('.wm-to-signup').onclick = function () { fl.hidden = true; fs.hidden = false; }; w.querySelector('.wm-to-login').onclick = function () { fs.hidden = true; fl.hidden = false; };
|
||||
fl.onsubmit = function (e) { e.preventDefault(); var d = Object.fromEntries(new FormData(fl)); call(w, 'POST', '/members/login', d).then(function (r) { setToken(site, r.token); show(r.member); document.dispatchEvent(new CustomEvent('wm:member', { detail: r.member })); }).catch(function (err) { msg(fl, err.message); }); };
|
||||
fs.onsubmit = function (e) { e.preventDefault(); var d = Object.fromEntries(new FormData(fs)); call(w, 'POST', '/members/signup', d).then(function (r) { if (r.pending) { msg(fs, LT('members.pending'), true); fs.reset(); } else { setToken(site, r.token); show(r.member); document.dispatchEvent(new CustomEvent('wm:member', { detail: r.member })); } }).catch(function (err) { msg(fs, err.message); }); };
|
||||
me();
|
||||
};
|
||||
// ---------------- 게시판 ----------------
|
||||
R.board = function (w) {
|
||||
var site = w.getAttribute('data-site'); var style = w.getAttribute('data-style'); var fixedCat = w.getAttribute('data-category') || ''; var page = 1; var cat = fixedCat; var q = ''; var settings = null; var canWrite = false;
|
||||
var bt = w.querySelector('.wm-board-title'); if (bt && !bt.textContent.trim()) bt.textContent = AT(w, 'data-title', 'board.title');
|
||||
var mk = function (cls) { var d = document.createElement('div'); d.className = cls; w.appendChild(d); return d; };
|
||||
var list = w.querySelector('.wm-board-list') || mk('wm-board-list'), pager = w.querySelector('.wm-board-pager') || mk('wm-board-pager'), view = w.querySelector('.wm-board-view') || mk('wm-board-view'), form = w.querySelector('.wm-board-form') || w.querySelector('form') || mk('wm-board-form'), cats = w.querySelector('.wm-board-cats') || mk('wm-board-cats');
|
||||
function load() {
|
||||
var qs = '?page=' + page + (cat ? '&cat=' + encodeURIComponent(cat) : '') + (q ? '&q=' + encodeURIComponent(q) : '') + (w.getAttribute('data-size') ? '&size=' + w.getAttribute('data-size') : '');
|
||||
call(w, 'GET', '/board/posts' + qs).then(function (r) {
|
||||
settings = r.settings; canWrite = r.canWrite; if (!style) style = settings.style || 'list';
|
||||
if (wb) wb.style.display = canWrite ? '' : 'none';
|
||||
if (!fixedCat && settings.categories && settings.categories.length) { cats.className = 'wm-board-cats wm-cats'; cats.innerHTML = '<button type="button" class="wm-btn wm-ghost' + (!cat ? ' on' : '') + '" data-c="">' + LT('common.all') + '</button>' + settings.categories.map(function (c) { return '<button type="button" class="wm-btn wm-ghost' + (cat === c ? ' on' : '') + '" data-c="' + esc(c) + '">' + esc(LV(c)) + '</button>'; }).join(''); cats.querySelectorAll('button').forEach(function (b) { b.onclick = function () { cat = b.getAttribute('data-c'); page = 1; load(); }; }); }
|
||||
if (!r.items.length) list.innerHTML = '<p class="wm-empty">' + LT('board.empty') + '</p>';
|
||||
else if (style === 'forum') list.innerHTML = r.items.map(function (p) { return '<article class="wm-post-card" data-id="' + p.id + '">' + (p.pinned ? '<span class="wm-pin">📌</span> ' : '') + '<h3 class="wm-post-title">' + (p.category ? '<span class="wm-cat">[' + esc(p.category) + ']</span> ' : '') + esc(p.title) + '</h3><p class="wm-excerpt">' + esc(p.excerpt) + '</p><div class="wm-meta">' + esc(p.author) + ' · ' + fmtDate(p.createdAt) + ' ' + esc(LT('board.metaViews', { n: p.views })) + ' ' + esc(LT('board.metaComments', { n: p.comments })) + '</div></article>'; }).join('');
|
||||
else list.innerHTML = '<table><thead><tr><th>' + LT('common.subject') + '</th><th>' + LT('board.author') + '</th><th>' + LT('common.date') + '</th><th>' + LT('board.views') + '</th></tr></thead><tbody>' + r.items.map(function (p) { return '<tr data-id="' + p.id + '"><td>' + (p.pinned ? '📌 ' : '') + (p.category ? '<span class="wm-cat">[' + esc(p.category) + ']</span> ' : '') + esc(p.title) + (p.comments ? ' <span class="wm-cc">[' + p.comments + ']</span>' : '') + '</td><td>' + esc(p.author) + '</td><td>' + fmtDate(p.createdAt).slice(0, 12) + '</td><td>' + numStr(p.views || 0) + '</td></tr>'; }).join('') + '</tbody></table>';
|
||||
list.querySelectorAll('[data-id]').forEach(function (el) { el.style.cursor = 'pointer'; el.onclick = function () { open(el.getAttribute('data-id')); }; });
|
||||
var pages = Math.max(1, Math.ceil(r.total / r.size)); pager.className = 'wm-board-pager wm-pager'; pager.innerHTML = ''; for (var i = 1; i <= pages && i <= 50; i++) { (function (i) { var b = document.createElement('button'); b.type = 'button'; b.className = 'wm-btn wm-ghost' + (i === page ? ' on' : ''); b.textContent = i; b.onclick = function () { page = i; load(); }; pager.appendChild(b); })(i); }
|
||||
}).catch(function (err) { list.innerHTML = '<p class="wm-msg err">' + esc(err.message) + '</p>'; if (err.status === 401) list.innerHTML += '<p>' + LT('members.loginRequired') + '</p>'; });
|
||||
}
|
||||
function open(id, pw) {
|
||||
call(w, 'GET', '/board/posts/' + id + (pw ? '?pw=' + encodeURIComponent(pw) : '')).then(function (r) {
|
||||
list.hidden = true; pager.hidden = true; cats.hidden = true; form.hidden = true; view.hidden = false;
|
||||
if (r.locked) { view.innerHTML = '<h3>' + esc(r.post.title) + '</h3><p>' + (r.needPassword ? LT('board.secretAskPw') : LT('board.secretOnlyOwner')) + '</p>' + (r.needPassword ? '<form class="wm-pw"><input name="pw" type="password" placeholder="' + LT('common.password') + '"><button class="wm-btn">' + LT('common.confirm') + '</button></form>' : '') + '<button type="button" class="wm-btn wm-ghost wm-back">' + LT('common.list') + '</button>'; var f = view.querySelector('.wm-pw'); if (f) f.onsubmit = function (e) { e.preventDefault(); open(id, f.pw.value); }; view.querySelector('.wm-back').onclick = back; return; }
|
||||
var p = r.post;
|
||||
var cm = r.comments.map(function (c) { return '<div class="wm-comment' + (c.parentId ? ' reply' : '') + '" data-id="' + c.id + '"><b>' + esc(c.author) + '</b> <span class="wm-meta">' + fmtDate(c.createdAt) + '</span><div>' + nl2br(c.body) + '</div>' + (r.canComment ? '<button type="button" class="wm-link wm-reply">' + LT('board.reply') + '</button> ' : '') + '<button type="button" class="wm-link wm-del-c">' + LT('common.delete') + '</button></div>'; }).join('');
|
||||
view.innerHTML = '<article class="wm-post"><h3>' + (p.category ? '[' + esc(p.category) + '] ' : '') + esc(p.title) + '</h3><div class="wm-meta">' + esc(p.author) + ' · ' + fmtDate(p.createdAt) + ' ' + esc(LT('board.metaViews', { n: p.views })) + '</div><div class="wm-body">' + nl2br(p.body) + '</div></article><div class="wm-row"><button type="button" class="wm-btn wm-ghost wm-back">' + LT('common.list') + '</button>' + (r.canEdit ? '<button type="button" class="wm-btn wm-ghost wm-del">' + LT('common.delete') + '</button>' : '<button type="button" class="wm-btn wm-ghost wm-del">' + LT('board.deleteWithPw') + '</button>') + '</div><section class="wm-comments"><h4>' + esc(LT('board.comments', { n: r.comments.length })) + '</h4>' + cm + (r.canComment ? '<form class="wm-cform"><input type="hidden" name="parentId"><textarea name="body" rows="3" placeholder="' + LT('board.commentPlaceholder') + '" required></textarea><div class="wm-row wm-guest"><input name="name" placeholder="' + LT('common.name') + '"><input name="password" type="password" placeholder="' + LT('board.guestPwDelete') + '"></div><label class="wm-check"><input type="checkbox" name="secret"> ' + LT('board.commentSecret') + '</label><button class="wm-btn">' + LT('board.commentSubmit') + '</button><p class="wm-msg"></p></form>' : '') + '</section>';
|
||||
view.querySelector('.wm-back').onclick = back;
|
||||
view.querySelector('.wm-del').onclick = function () { var pwd = r.canEdit ? (pw || '') : prompt(LT('board.deleteAskPw')); /* v1.3.17 (B02): 비밀번호로 연 비회원 글은 그 비밀번호로 삭제 (회원·관리자는 빈 값) */ if (!r.canEdit && !pwd) return; if (!confirm(LT('board.deleteConfirm'))) return; call(w, 'DELETE', '/board/posts/' + id, { password: pwd }).then(function () { back(); load(); }).catch(function (e) { alert(e.message); }); };
|
||||
var cf = view.querySelector('.wm-cform'); if (cf) { if (getToken(site)) cf.querySelector('.wm-guest').style.display = 'none'; cf.onsubmit = function (e) { e.preventDefault(); var d = Object.fromEntries(new FormData(cf)); d.secret = !!cf.secret.checked; call(w, 'POST', '/board/posts/' + id + '/comments', d).then(function () { open(id, pw); }).catch(function (err) { msg(cf, err.message); }); }; }
|
||||
view.querySelectorAll('.wm-reply').forEach(function (b) { b.onclick = function () { cf.parentId.value = b.closest('.wm-comment').getAttribute('data-id'); cf.body.focus(); cf.body.placeholder = LT('board.replyPlaceholder'); }; });
|
||||
view.querySelectorAll('.wm-del-c').forEach(function (b) { b.onclick = function () { var cid = b.closest('.wm-comment').getAttribute('data-id'); var pwd = getToken(site) ? '' : prompt(LT('board.commentPw')); if (!getToken(site) && !pwd) return; call(w, 'DELETE', '/board/comments/' + cid, { password: pwd }).then(function () { open(id, pw); }).catch(function (e) { alert(e.message); }); }; });
|
||||
}).catch(function (err) { alert(err.message); });
|
||||
}
|
||||
function back() { view.hidden = true; form.hidden = true; list.hidden = false; pager.hidden = false; cats.hidden = false; }
|
||||
var wb = w.querySelector('.wm-board-write'); if (wb) wb.onclick = function () { list.hidden = true; pager.hidden = true; cats.hidden = true; view.hidden = true; form.hidden = false; var sel = fld(form, 'category'); if (sel) sel.innerHTML = (settings && settings.categories || []).map(function (c) { return '<option value="' + esc(c) + '">' + esc(LV(c)) + '</option>'; }).join(''); var g = form.querySelector('.wm-board-guest'); if (g) g.style.display = getToken(site) ? 'none' : ''; var ck = form.querySelector('.wm-check'); if (ck) ck.style.display = settings && settings.allowSecret ? '' : 'none'; };
|
||||
var cb = w.querySelector('.wm-board-cancel'); if (cb) cb.onclick = back;
|
||||
form.onsubmit = function (e) { e.preventDefault(); var d = Object.fromEntries(new FormData(form)); var sc = fld(form, 'secret'); d.secret = !!(sc && sc.checked); call(w, 'POST', '/board/posts', d).then(function () { form.reset(); back(); page = 1; load(); }).catch(function (err) { msg(form, err.message); }); };
|
||||
var search = w.querySelector('.wm-board-search'); if (search) search.onkeydown = function (e) { if (e.key === 'Enter') { q = search.value.trim(); page = 1; load(); } };
|
||||
document.addEventListener('wm:member', function () { load(); });
|
||||
load();
|
||||
};
|
||||
// ---------------- 문의폼 ----------------
|
||||
R.inquiry = function (w) {
|
||||
var it = w.querySelector('.wm-inquiry-title'); if (it && !it.textContent.trim()) it.textContent = AT(w, 'data-title', 'inquiry.title'); var sb = w.querySelector('.wm-inquiry-submit'); if (sb && !sb.textContent.trim()) sb.textContent = AT(w, 'data-button', 'common.send');
|
||||
var form = w.querySelector('.wm-inquiry-form') || w.querySelector('form'); if (!form) return; var catEl = fld(form, 'category'), filesEl = fld(form, 'files');
|
||||
call(w, 'GET', '/inquiry/settings').then(function (s) { if (catEl) catEl.innerHTML = (s.categories || []).map(function (c) { return '<option value="' + esc(c) + '">' + esc(LV(c)) + '</option>'; }).join(''); if (!s.allowFiles) { var fw = w.querySelector('.wm-inquiry-files'); if (fw) fw.style.display = 'none'; } else if (filesEl) { filesEl.setAttribute('data-max', s.maxFileMb || 10); fileButton(filesEl); } }).catch(function () {});
|
||||
form.onsubmit = function (e) {
|
||||
e.preventDefault(); if (!form.checkValidity()) { msg(form, LT('common.requiredCheck')); return; }
|
||||
var fd = new FormData(form); fd.append('kind', w.getAttribute('data-kind') || 'contact'); fd.append('page', location.href);
|
||||
var max = (parseInt(filesEl && filesEl.getAttribute('data-max'), 10) || 10) * 1048576; if (filesEl) for (var i = 0; i < filesEl.files.length; i++) if (filesEl.files[i].size > max) { msg(form, LT('inquiry.fileTooBig', { mb: max / 1048576 })); return; }
|
||||
var b = form.querySelector('.wm-inquiry-submit') || form.querySelector('button[type=submit]') || form.querySelector('button'); if (b) b.disabled = true; msg(form, LT('common.sending'));
|
||||
call(w, 'POST', '/inquiry/submit', fd).then(function (r) { msg(form, (r.message && LT(r.message)) || LT('common.receivedContact'), true); form.reset(); }).catch(function (err) { msg(form, err.message); }).then(function () { if (b) b.disabled = false; });
|
||||
};
|
||||
};
|
||||
// ---------------- 설문 ----------------
|
||||
R.survey = function (w) {
|
||||
var form = w.querySelector('.wm-survey-form') || w.querySelector('form'); if (!form) return; var box = w.querySelector('.wm-survey-questions') || (function () { var d = document.createElement('div'); d.className = 'wm-survey-questions'; form.insertBefore(d, form.firstChild); return d; })(); var id = w.getAttribute('data-survey');
|
||||
(id ? call(w, 'GET', '/survey/forms/' + id) : call(w, 'GET', '/survey/forms').then(function (r) { if (!r.items || !r.items.length) throw new Error(LT('survey.none')); return call(w, 'GET', '/survey/forms/' + r.items[0].id); })).then(function (r) {
|
||||
var f = r.survey; var stt = w.querySelector('.wm-survey-title'); if (stt && !stt.textContent.trim()) stt.textContent = w.getAttribute('data-title') || f.title; var sd = w.querySelector('.wm-survey-desc'); if (sd && !sd.textContent.trim()) sd.textContent = f.description || '';
|
||||
box.innerHTML = f.questions.map(function (q) {
|
||||
var req = q.required ? ' <span class="wm-req">*</span>' : ''; var inner = '';
|
||||
if (q.type === 'long') inner = '<textarea name="' + q.id + '" rows="3"' + (q.required ? ' required' : '') + '></textarea>';
|
||||
else if (q.type === 'single') inner = q.options.map(function (o) { return '<label class="wm-opt"><input type="radio" name="' + q.id + '" value="' + esc(o) + '"' + (q.required ? ' required' : '') + '> ' + esc(o) + '</label>'; }).join('');
|
||||
else if (q.type === 'multi') inner = q.options.map(function (o) { return '<label class="wm-opt"><input type="checkbox" name="' + q.id + '" value="' + esc(o) + '"> ' + esc(o) + '</label>'; }).join('');
|
||||
else if (q.type === 'scale') { inner = '<div class="wm-row wm-scale">'; for (var i = q.min; i <= q.max; i++) inner += '<label class="wm-opt"><input type="radio" name="' + q.id + '" value="' + i + '"' + (q.required ? ' required' : '') + '> ' + i + '</label>'; inner += '</div>'; }
|
||||
else inner = '<input name="' + q.id + '" type="' + (q.type === 'date' ? 'date' : q.type === 'email' ? 'email' : q.type === 'number' ? 'number' : 'text') + '"' + (q.required ? ' required' : '') + '>';
|
||||
return '<div class="wm-q"><label>' + esc(q.label) + req + '</label>' + inner + '</div>';
|
||||
}).join('');
|
||||
form.onsubmit = function (e) { e.preventDefault(); var answers = {}; f.questions.forEach(function (q) { if (q.type === 'multi') answers[q.id] = Array.from(form.querySelectorAll('[name="' + q.id + '"]:checked')).map(function (x) { return x.value; }); else { var el = form.querySelector('[name="' + q.id + '"]' + (q.type === 'single' || q.type === 'scale' ? ':checked' : '')); answers[q.id] = el ? el.value : ''; } }); call(w, 'POST', '/survey/forms/' + f.id + '/responses', { answers: answers, respondent: (fld(form, 'respondent') || {}).value || '' }).then(function (r) { form.innerHTML = '<p class="wm-msg ok">' + esc((r.message && LT(r.message)) || f.thanks) + '</p>'; }).catch(function (err) { msg(form, err.message); }); };
|
||||
}).catch(function (err) { var t = w.querySelector('.wm-survey-title'); if (t) t.textContent = err.message; form.hidden = true; });
|
||||
};
|
||||
// ---------------- 예약 ----------------
|
||||
R.booking = function (w) {
|
||||
var form = w.querySelector('.wm-booking-form') || w.querySelector('form'); if (!form) return; var slots = w.querySelector('.wm-booking-slots') || (function () { var d = document.createElement('div'); d.className = 'wm-booking-slots'; form.insertBefore(d, form.firstChild); return d; })(); var tt = w.querySelector('.wm-booking-title'); if (tt && !tt.textContent.trim()) tt.textContent = AT(w, 'data-title', 'booking.title'); var nl = w.querySelector('.wm-booking-note-label'); if (nl && !nl.textContent.trim()) nl.insertBefore(document.createTextNode(AT(w, 'data-note-label', 'booking.note')), nl.firstChild);
|
||||
var dateEl = fld(form, 'date'), timeEl = fld(form, 'time'); if (!dateEl) { console.warn('wm booking: name="date" 입력이 없습니다'); return; } if (!timeEl) { timeEl = document.createElement('input'); timeEl.type = 'hidden'; timeEl.name = 'time'; form.appendChild(timeEl); }
|
||||
var submit = form.querySelector('button[type=submit]') || form.querySelector('button');
|
||||
call(w, 'GET', '/booking/settings').then(function (s) { var d = new Date(); d.setDate(d.getDate() + (s.leadDays || 0)); dateEl.min = d.toISOString().slice(0, 10); var m = new Date(); m.setDate(m.getDate() + (s.maxDays || 60)); dateEl.max = m.toISOString().slice(0, 10); }).catch(function () {});
|
||||
dateEl.onchange = function () { timeEl.value = ''; submit.disabled = true; slots.innerHTML = LT('common.loading'); call(w, 'GET', '/booking/slots?date=' + dateEl.value).then(function (r) { slots.className = 'wm-booking-slots wm-slots'; if (r.closed) { slots.innerHTML = '<p>' + LT('booking.closedDate') + '</p>'; return; } if (!r.slots.length) { slots.innerHTML = '<p>' + LT('booking.noSlots') + '</p>'; return; } slots.innerHTML = r.slots.map(function (s) { return '<button type="button" class="wm-slot' + (s.left ? '' : ' full') + '" data-t="' + s.time + '"' + (s.left ? '' : ' disabled') + '>' + s.time + (s.left && s.left < 3 ? ' <small>' + esc(LT('booking.seatsLeft', { n: s.left })) + '</small>' : '') + '</button>'; }).join(''); slots.querySelectorAll('.wm-slot:not(.full)').forEach(function (b) { b.onclick = function () { slots.querySelectorAll('.wm-slot').forEach(function (x) { x.classList.remove('on'); }); b.classList.add('on'); timeEl.value = b.getAttribute('data-t'); submit.disabled = false; }; }); }).catch(function (err) { slots.innerHTML = '<p class="wm-msg err">' + esc(err.message) + '</p>'; }); };
|
||||
form.onsubmit = function (e) { e.preventDefault(); var d = Object.fromEntries(new FormData(form)); submit.disabled = true; call(w, 'POST', '/booking/reserve', d).then(function (r) { msg(form, (r.message && LT(r.message)) || (r.booking.status === 'confirmed' ? LT('booking.confirmed') : LT('booking.received')), true); form.reset(); slots.innerHTML = ''; }).catch(function (err) { msg(form, err.message); submit.disabled = false; }); };
|
||||
};
|
||||
// ---------------- 갤러리 ----------------
|
||||
R.gallery = function (w) {
|
||||
var grid = w.querySelector('.wm-gallery-grid'); if (!grid) return; var tagsBox = w.querySelector('.wm-gallery-tags') || document.createElement('div'); var gt = w.querySelector('.wm-gallery-title'); if (gt && !gt.textContent.trim()) gt.textContent = AT(w, 'data-title', 'gallery.title'); var tag = w.getAttribute('data-tag') || '';
|
||||
call(w, 'GET', '/gallery/list').then(function (r) {
|
||||
var cols = parseInt(w.getAttribute('data-columns'), 10) || parseInt(r.settings.columns, 10) || 3; grid.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)';
|
||||
var all = r.items; var tags = []; all.forEach(function (i) { (i.tags || []).forEach(function (t) { if (tags.indexOf(t) < 0) tags.push(t); }); });
|
||||
function draw() { var items = tag ? all.filter(function (i) { return (i.tags || []).indexOf(tag) >= 0; }) : all; grid.innerHTML = items.length ? items.map(function (i, idx) { return '<figure class="wm-gallery-item" data-i="' + idx + '"><img src="' + esc(i.thumb || i.src) + '" alt="' + esc(i.title) + '" loading="lazy">' + (i.title ? '<figcaption>' + esc(i.title) + (i.caption ? '<small> ' + esc(i.caption) + '</small>' : '') + '</figcaption>' : '') + '</figure>'; }).join('') : '<p class="wm-empty">' + LT('gallery.empty') + '</p>'; if (r.settings.lightbox !== false) grid.querySelectorAll('img').forEach(function (img, idx) { img.onclick = function () { var it = items[idx]; var lb = document.createElement('div'); lb.className = 'wm-lightbox'; lb.innerHTML = '<img src="' + esc(it.src) + '" alt=""><p>' + esc(it.title || '') + ' ' + esc(it.caption || '') + '</p>'; lb.onclick = function () { lb.remove(); }; document.body.appendChild(lb); }; }); }
|
||||
if (!w.getAttribute('data-tag') && tags.length) { tagsBox.className = 'wm-gallery-tags wm-cats'; tagsBox.innerHTML = '<button type="button" class="wm-btn wm-ghost on" data-t="">' + LT('common.all') + '</button>' + tags.map(function (t) { return '<button type="button" class="wm-btn wm-ghost" data-t="' + esc(t) + '">' + esc(t) + '</button>'; }).join(''); tagsBox.querySelectorAll('button').forEach(function (b) { b.onclick = function () { tag = b.getAttribute('data-t'); tagsBox.querySelectorAll('button').forEach(function (x) { x.classList.toggle('on', x === b); }); draw(); }; }); }
|
||||
draw();
|
||||
}).catch(function (err) { grid.innerHTML = '<p class="wm-msg err">' + esc(err.message) + '</p>'; });
|
||||
};
|
||||
// ---------------- 지도 (Leaflet + OSM) ----------------
|
||||
R.map = function (w) {
|
||||
var canvas = w.querySelector('.wm-map-canvas') || (function () { var d = document.createElement('div'); d.className = 'wm-map-canvas'; w.insertBefore(d, w.firstChild); return d; })(); canvas.style.height = (parseInt(w.getAttribute('data-height'), 10) || 360) + 'px';
|
||||
function ensureLeaflet(cb) { if (window.L) return cb(); var css = document.createElement('link'); css.rel = 'stylesheet'; css.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'; document.head.appendChild(css); var s = document.createElement('script'); s.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'; s.onload = cb; document.head.appendChild(s); }
|
||||
function draw(lat, lng, label, address) { ensureLeaflet(function () { var zoom = parseInt(w.getAttribute('data-zoom'), 10) || 16; var map = L.map(canvas).setView([lat, lng], zoom); L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '© OpenStreetMap' }).addTo(map); var mk = L.marker([lat, lng]).addTo(map); if (label || address) mk.bindPopup(esc(label || address)).openPopup(); var ad = w.querySelector('.wm-map-address'); if (ad && !ad.textContent.trim()) ad.textContent = address || ''; }); }
|
||||
var lat = parseFloat(w.getAttribute('data-lat')), lng = parseFloat(w.getAttribute('data-lng')); var address = w.getAttribute('data-address') || ''; var label = w.getAttribute('data-label') || '';
|
||||
var go = function (a, la, ln, lb) { if (isFinite(la) && isFinite(ln)) draw(la, ln, lb, a); else if (a) call(w, 'GET', '/map/geocode?q=' + encodeURIComponent(a)).then(function (r) { if (r && r.lat) draw(r.lat, r.lng, lb, a); else canvas.textContent = LT('map.notFound', { address: a }); }).catch(function () { canvas.textContent = LT('map.loadFailed'); }); else canvas.textContent = LT('map.noAddress'); };
|
||||
if (!address && !(isFinite(lat) && isFinite(lng))) call(w, 'GET', '/map/settings').then(function (s) { go(s.address, parseFloat(s.lat), parseFloat(s.lng), s.label); }).catch(function () { go('', NaN, NaN, ''); }); else go(address, lat, lng, label);
|
||||
};
|
||||
// ---------------- 후기 ----------------
|
||||
R.reviews = function (w) {
|
||||
var list = w.querySelector('.wm-reviews-list'); var form = w.querySelector('.wm-reviews-form') || w.querySelector('form'); var rt = w.querySelector('.wm-reviews-title'); if (rt && !rt.textContent.trim()) rt.textContent = AT(w, 'data-title', 'reviews.title'); if (form && w.getAttribute('data-form') === '0') form.hidden = true; if (!list) return;
|
||||
var stars = function (n) { return '<span class="wm-stars">' + '★★★★★'.slice(0, n) + '<span style="color:#d1d5db">' + '★★★★★'.slice(n) + '</span></span>'; };
|
||||
function load() { call(w, 'GET', '/reviews/list').then(function (r) { var style = w.getAttribute('data-style') || r.settings.style || 'list'; var rc = fld(form, 'category'); if (rc) rc.innerHTML = '<option value="">' + LT('common.category') + '</option>' + (r.settings.categories || []).map(function (c) { return '<option value="' + esc(c) + '">' + esc(LV(c)) + '</option>'; }).join(''); var card = function (x) { return '<div class="wm-review">' + stars(x.rating) + ' <b>' + esc(x.name) + '</b>' + (x.category ? ' <span class="wm-cat">' + esc(x.category) + '</span>' : '') + '<p>' + nl2br(x.text) + '</p><div class="wm-meta">' + fmtDate(x.createdAt).slice(0, 12) + '</div></div>'; };
|
||||
if (!r.items.length) list.innerHTML = '<p class="wm-empty">' + LT('reviews.empty') + '</p>';
|
||||
else if (style === 'kanban') { var by = r.settings.kanbanBy || 'rating'; var cols = by === 'rating' ? [['5', '★5'], ['4', '★4'], ['3', LT('reviews.low')]] : (r.settings.categories || []).map(function (c) { return [c, LV(c)]; }).concat([['', LT('common.other')]]); list.innerHTML = '<div class="wm-kanban">' + cols.map(function (c) { var items = r.items.filter(function (x) { return by === 'rating' ? (c[0] === '3' ? x.rating <= 3 : String(x.rating) === c[0]) : (x.category || '') === c[0]; }); return '<div class="wm-kanban-col"><h4>' + esc(c[1]) + ' <small>' + items.length + '</small></h4>' + items.map(card).join('') + '</div>'; }).join('') + '</div>'; }
|
||||
else list.innerHTML = r.items.map(card).join(''); }).catch(function (err) { list.innerHTML = '<p class="wm-msg err">' + esc(err.message) + '</p>'; }); }
|
||||
if (form) form.onsubmit = function (e) { e.preventDefault(); var d = Object.fromEntries(new FormData(form)); call(w, 'POST', '/reviews/submit', d).then(function (r) { msg(form, r.pending ? LT('reviews.pending') : LT('reviews.posted'), true); form.reset(); load(); }).catch(function (err) { msg(form, err.message); }); };
|
||||
load();
|
||||
};
|
||||
// ---------------- 블로그 (v1.4.0) ----------------
|
||||
/** 글 본문: 관리자가 쓴 글자를 그대로 보이되(HTML 해석 안 함) 빈 줄은 문단, 줄바꿈은 <br>, http(s) 주소는 링크로 */
|
||||
var blogBody = function (t) { return String(t || '').split(/\n{2,}/).map(function (p) { return '<p>' + esc(p).replace(/\n/g, '<br>').replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" rel="noopener" target="_blank">$1</a>') + '</p>'; }).join(''); };
|
||||
R.blog = function (w) {
|
||||
var list = w.querySelector('.wm-blog-list'), pager = w.querySelector('.wm-blog-pager'), view = w.querySelector('.wm-blog-view'), cats = w.querySelector('.wm-blog-cats'), form = w.querySelector('.wm-blog-search'); if (!list || !view) return;
|
||||
var bt = w.querySelector('.wm-blog-title'); if (bt && !bt.textContent.trim()) bt.textContent = AT(w, 'data-title', 'blog.title');
|
||||
var fixedCat = w.getAttribute('data-category') || ''; var st = { q: '', cat: fixedCat, tag: '', page: 1 }; var settings = null;
|
||||
function showList(on) { list.hidden = !on; if (pager) pager.hidden = !on; if (cats) cats.hidden = !on; if (form) form.hidden = !on; view.hidden = on; }
|
||||
function load() {
|
||||
var qs = '?page=' + st.page + (st.q ? '&q=' + encodeURIComponent(st.q) : '') + (st.cat ? '&cat=' + encodeURIComponent(st.cat) : '') + (st.tag ? '&tag=' + encodeURIComponent(st.tag) : '') + (w.getAttribute('data-size') ? '&size=' + encodeURIComponent(w.getAttribute('data-size')) : '');
|
||||
call(w, 'GET', '/blog/posts' + qs).then(function (r) {
|
||||
settings = r.settings;
|
||||
if (cats && !fixedCat) { var c = (settings.categories || []).map(function (x) { return '<button type="button" class="wm-btn wm-ghost' + (st.cat === x ? ' on' : '') + '" data-c="' + esc(x) + '">' + esc(LV(x)) + '</button>'; }).join(''); var tg = (r.tags || []).slice(0, 12).map(function (x) { return '<button type="button" class="wm-btn wm-ghost wm-blog-tag' + (st.tag === x.tag ? ' on' : '') + '" data-t="' + esc(x.tag) + '">#' + esc(x.tag) + '</button>'; }).join(''); cats.innerHTML = (c || tg) ? '<button type="button" class="wm-btn wm-ghost' + (!st.cat && !st.tag ? ' on' : '') + '" data-c="">' + LT('common.all') + '</button>' + c + tg : ''; cats.querySelectorAll('button').forEach(function (b) { b.onclick = function () { if (b.hasAttribute('data-t')) { st.tag = b.getAttribute('data-t'); st.cat = ''; } else { st.cat = b.getAttribute('data-c'); st.tag = ''; } st.page = 1; load(); }; }); }
|
||||
if (!r.items.length) { list.innerHTML = '<p class="wm-empty">' + LT(st.q || st.tag ? 'blog.noResults' : 'blog.empty') + '</p>'; }
|
||||
else list.innerHTML = r.items.map(function (p) { return '<article class="wm-blog-card" data-id="' + p.id + '">' + (p.cover ? '<img class="wm-blog-cover" src="' + esc(p.cover) + '" alt="" loading="lazy">' : '') + '<h3 class="wm-blog-card-title"><a href="#post-' + p.id + '">' + esc(p.title) + '</a></h3><div class="wm-meta">' + (p.category ? '<span class="wm-cat">' + esc(LV(p.category)) + '</span> · ' : '') + (p.author ? esc(p.author) + ' · ' : '') + fmtDate(p.createdAt).slice(0, 12) + ' · ' + esc(LT('blog.views', { n: p.views })) + ' · ' + esc(LT('blog.comments', { n: p.comments })) + '</div><p class="wm-excerpt">' + esc(p.summary) + '</p><a class="wm-link" href="#post-' + p.id + '">' + LT('blog.readMore') + '</a></article>'; }).join('');
|
||||
var pages = Math.max(1, Math.ceil(r.total / r.size)); if (pager) { pager.innerHTML = pages > 1 ? Array.from({ length: pages }, function (_, i) { return '<button type="button" class="wm-btn wm-ghost' + (i + 1 === r.page ? ' on' : '') + '" data-p="' + (i + 1) + '">' + (i + 1) + '</button>'; }).join('') : ''; pager.querySelectorAll('button').forEach(function (b) { b.onclick = function () { st.page = Number(b.getAttribute('data-p')); load(); }; }); }
|
||||
}).catch(function (err) { list.innerHTML = '<p class="wm-msg err">' + esc(err.message) + '</p>'; });
|
||||
}
|
||||
function open(id) {
|
||||
call(w, 'GET', '/blog/posts/' + encodeURIComponent(id)).then(function (r) {
|
||||
var p = r.post; showList(false);
|
||||
var cm = r.comments.map(function (c) { return '<div class="wm-comment"><b>' + esc(c.name) + '</b> <span class="wm-meta">' + fmtDate(c.createdAt) + '</span><div>' + esc(c.body).replace(/\n/g, '<br>') + '</div></div>'; }).join('');
|
||||
var canWrite = r.canComment; var guest = !getToken(w.getAttribute('data-site'));
|
||||
view.innerHTML = '<button type="button" class="wm-btn wm-ghost wm-blog-back">' + LT('blog.back') + '</button>' + (p.cover ? '<img class="wm-blog-cover" src="' + esc(p.cover) + '" alt="">' : '') + '<h3 class="wm-blog-view-title">' + esc(p.title) + '</h3><div class="wm-meta">' + (p.category ? '<span class="wm-cat">' + esc(LV(p.category)) + '</span> · ' : '') + (p.author ? esc(p.author) + ' · ' : '') + fmtDate(p.createdAt) + ' · ' + esc(LT('blog.views', { n: p.views })) + '</div>' + ((p.tags || []).length ? '<div class="wm-blog-tags">' + p.tags.map(function (t) { return '<span class="wm-blog-tag">#' + esc(t) + '</span>'; }).join(' ') + '</div>' : '') + '<div class="wm-blog-body">' + blogBody(p.body) + '</div>'
|
||||
+ '<section class="wm-comments"><h4>' + esc(LT('blog.comments', { n: r.comments.length })) + '</h4>' + cm + (canWrite ? '<form class="wm-cform">' + (guest ? '<input name="name" placeholder="' + esc(LT('common.name')) + '" maxlength="40" required>' : '') + '<textarea name="body" rows="3" placeholder="' + esc(LT('blog.commentPh')) + '" maxlength="3000" required></textarea><button class="wm-btn">' + LT('blog.commentSubmit') + '</button><p class="wm-msg" aria-live="polite"></p></form>' : '<p class="wm-muted">' + LT('blog.commentsClosed') + '</p>') + '</section>';
|
||||
view.querySelector('.wm-blog-back').onclick = function () { if (location.hash) history.pushState('', document.title, location.pathname + location.search); showList(true); load(); };
|
||||
var cf = view.querySelector('.wm-cform'); if (cf) cf.onsubmit = function (e) { e.preventDefault(); var d = Object.fromEntries(new FormData(cf)); call(w, 'POST', '/blog/posts/' + encodeURIComponent(id) + '/comments', d).then(function (x) { if (x.pending) { msg(cf, x.message || LT('blog.commentPending'), true); cf.reset(); } else open(id); }).catch(function (err) { msg(cf, err.message); }); };
|
||||
try { view.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (e) {}
|
||||
}).catch(function (err) { showList(true); list.innerHTML = '<p class="wm-msg err">' + esc(err.message) + '</p>'; });
|
||||
}
|
||||
if (form) form.onsubmit = function (e) { e.preventDefault(); st.q = (form.q.value || '').trim(); st.page = 1; showList(true); load(); };
|
||||
/** 주소의 #post-<ID> 로 글을 연다 (목록 링크·공유 주소·뒤로 가기) */
|
||||
function route() { var m = /^#post-([a-f0-9]+)$/.exec(location.hash || ''); if (m) open(m[1]); else { showList(true); } }
|
||||
window.addEventListener('hashchange', route); load(); route();
|
||||
};
|
||||
// ---------------- 자료실 (v1.4.0) ----------------
|
||||
var fmtSize = function (n) { n = Number(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'; };
|
||||
R.library = function (w) {
|
||||
var list = w.querySelector('.wm-library-list'), cats = w.querySelector('.wm-library-cats'), form = w.querySelector('.wm-library-search'); if (!list) return;
|
||||
var lt = w.querySelector('.wm-library-title'); if (lt && !lt.textContent.trim()) lt.textContent = AT(w, 'data-title', 'library.title');
|
||||
var fixedCat = w.getAttribute('data-category') || ''; var st = { q: '', cat: fixedCat }; var site = w.getAttribute('data-site');
|
||||
function load() {
|
||||
call(w, 'GET', '/library/files?' + (st.q ? 'q=' + encodeURIComponent(st.q) + '&' : '') + (st.cat ? 'cat=' + encodeURIComponent(st.cat) : '')).then(function (r) {
|
||||
var s = r.settings || {};
|
||||
if (cats && !fixedCat) { cats.innerHTML = (s.categories || []).length ? '<button type="button" class="wm-btn wm-ghost' + (!st.cat ? ' on' : '') + '" data-c="">' + LT('common.all') + '</button>' + s.categories.map(function (x) { return '<button type="button" class="wm-btn wm-ghost' + (st.cat === x ? ' on' : '') + '" data-c="' + esc(x) + '">' + esc(LV(x)) + '</button>'; }).join('') : ''; cats.querySelectorAll('button').forEach(function (b) { b.onclick = function () { st.cat = b.getAttribute('data-c'); load(); }; }); }
|
||||
if (!r.items.length) { list.innerHTML = '<p class="wm-empty">' + LT(st.q ? 'blog.noResults' : 'library.empty') + '</p>'; return; }
|
||||
list.innerHTML = r.items.map(function (f) { return '<div class="wm-library-item" data-id="' + f.id + '"><div class="wm-library-info"><b class="wm-library-name">' + esc(f.title) + '</b><div class="wm-meta">' + esc(f.originalName) + ' · ' + fmtSize(f.size) + ' · ' + fmtDate(f.createdAt).slice(0, 12) + (f.downloads !== undefined ? ' · ' + esc(LT('library.downloads', { n: f.downloads })) : '') + (f.category ? ' · <span class="wm-cat">' + esc(LV(f.category)) + '</span>' : '') + '</div>' + (f.description ? '<p class="wm-library-desc">' + esc(f.description) + '</p>' : '') + '</div><button type="button" class="wm-btn wm-library-dl">' + LT('library.download') + '</button></div>'; }).join('');
|
||||
list.querySelectorAll('.wm-library-dl').forEach(function (b) {
|
||||
b.onclick = function () {
|
||||
var id = b.closest('.wm-library-item').getAttribute('data-id'); var tok = getToken(site);
|
||||
if (s.downloadRole === 'member' && !tok) { msg(w, LT('library.memberOnly')); return; }
|
||||
// 링크로 내려받는다(큰 파일도 브라우저가 바로 저장). 회원 전용이면 토큰을 주소에 붙인다.
|
||||
location.href = w.getAttribute('data-api') + '/library/files/' + encodeURIComponent(id) + '/download' + (tok ? '?t=' + encodeURIComponent(tok) : '');
|
||||
};
|
||||
});
|
||||
}).catch(function (err) { list.innerHTML = '<p class="wm-msg err">' + esc(err.message) + '</p>'; });
|
||||
}
|
||||
if (form) form.onsubmit = function (e) { e.preventDefault(); st.q = (form.q.value || '').trim(); load(); };
|
||||
load();
|
||||
};
|
||||
// ---------------- 일정관리 캘린더 (v1.4.18) ----------------
|
||||
// 월간·주간·일간 보기. 날짜 정보(음력·간지·절기)와 일정 발생은 서버(/calendar/range)가 계산해 주므로 여기서는 그리기만 한다.
|
||||
// 설정 우선순위: data-view / data-lunar / data-ganji 속성(사이트가 정함) → 관리자 설정(API 응답 settings).
|
||||
var calPad = function (n) { return (n < 10 ? '0' : '') + n; };
|
||||
var calYmd = function (d) { return d.getFullYear() + '-' + calPad(d.getMonth() + 1) + '-' + calPad(d.getDate()); };
|
||||
var calParse = function (s) { var p = String(s).split('-'); return new Date(+p[0], +p[1] - 1, +p[2]); };
|
||||
var calAdd = function (d, n) { var x = new Date(d.getTime()); x.setDate(x.getDate() + n); return x; };
|
||||
var calMin = function (t) { var m = /^(\d\d):(\d\d)$/.exec(t || ''); return m ? +m[1] * 60 + +m[2] : null; };
|
||||
R.calendar = function (w) {
|
||||
var body = w.querySelector('.wm-cal-body');
|
||||
// v1.5.7: 표준 마크업(.wm-cal-title/.wm-cal-tools/.wm-cal-body)이 없는 위젯(AI 가 임의로 만든 구조·별칭 id)은 안내문을 비우고 골격을 만들어 그린다
|
||||
if (!body) { var keepTitle = w.getAttribute('data-title') || (w.querySelector('h1,h2,h3') || {}).textContent || ''; w.innerHTML = ''; w.classList.add('wm-cal'); var t0 = document.createElement('h2'); t0.className = 'wm-cal-title'; t0.textContent = keepTitle.trim(); w.appendChild(t0); var tl = document.createElement('div'); tl.className = 'wm-cal-tools'; w.appendChild(tl); body = document.createElement('div'); body.className = 'wm-cal-body'; w.appendChild(body); }
|
||||
var tools = w.querySelector('.wm-cal-tools') || (function () { var d = document.createElement('div'); d.className = 'wm-cal-tools'; w.insertBefore(d, body); return d; })();
|
||||
var tt = w.querySelector('.wm-cal-title'); if (tt && !tt.textContent.trim()) tt.textContent = AT(w, 'data-title', 'calendar.title');
|
||||
var fixedCat = w.getAttribute('data-category') || ''; var attrView = w.getAttribute('data-view') || '';
|
||||
var settings = null; var view = attrView; var cur = new Date(); cur.setHours(0, 0, 0, 0); var todayStr = calYmd(cur);
|
||||
var HOUR_PX = 44; var isKo = /^ko/i.test(PAGE_LANG);
|
||||
var flag = function (attr, key) { var a = w.getAttribute(attr); return a === '' || a == null ? !!settings[key] : a === '1' || a === 'true'; };
|
||||
var weekStart = function () { return settings && settings.weekStart === 1 ? 1 : 0; };
|
||||
/** 보기 종류에 따른 조회 기간 [시작, 끝] (월간은 6주 격자) */
|
||||
function rangeOf() {
|
||||
if (view === 'day') return [cur, cur];
|
||||
if (view === 'week') { var s = calAdd(cur, -((cur.getDay() - weekStart() + 7) % 7)); return [s, calAdd(s, 6)]; }
|
||||
var first = new Date(cur.getFullYear(), cur.getMonth(), 1); var gs = calAdd(first, -((first.getDay() - weekStart() + 7) % 7)); return [gs, calAdd(gs, 41)];
|
||||
}
|
||||
var caption = function (s, e) {
|
||||
try {
|
||||
if (view === 'month') return new Date(cur.getFullYear(), cur.getMonth(), 1).toLocaleDateString(PAGE_LANG, { year: 'numeric', month: 'long' });
|
||||
if (view === 'week') return s.toLocaleDateString(PAGE_LANG, { year: 'numeric', month: 'short', day: 'numeric' }) + ' – ' + e.toLocaleDateString(PAGE_LANG, { month: 'short', day: 'numeric' });
|
||||
return cur.toLocaleDateString(PAGE_LANG, { year: 'numeric', month: 'long', day: 'numeric', weekday: 'short' });
|
||||
} catch (e2) { return calYmd(cur); }
|
||||
};
|
||||
var wdName = function (wd) { try { return new Date(2023, 0, 1 + wd).toLocaleDateString(PAGE_LANG, { weekday: 'short' }); } catch (e) { return ['일', '월', '화', '수', '목', '금', '토'][wd]; } };
|
||||
var gText = function (g) { return g ? (isKo ? g.hangul : g.hanja) : ''; };
|
||||
/** 날짜 칸 아래 줄: 음력(음 9.4) · 일진(신유) — 설정에 따라 */
|
||||
var subLine = function (info, full) {
|
||||
if (!info) return ''; var parts = [];
|
||||
if (flag('data-lunar', 'showLunar') && info.lunar) parts.push(esc(LT('calendar.lunarPrefix') + ' ' + (info.lunar.leap ? LT('calendar.leap') : '') + info.lunar.month + '.' + info.lunar.day));
|
||||
if (flag('data-ganji', 'showGanji') && info.ganji) parts.push('<span class="wm-cal-gj" title="' + esc(isKo ? info.ganji.text : info.ganji.textHanja) + '">' + esc(full ? (isKo ? info.ganji.text : info.ganji.textHanja) : gText(info.ganji.day)) + '</span>');
|
||||
return parts.join(' · ');
|
||||
};
|
||||
var termBadge = function (info) { return settings.showTerms !== false && info && info.term ? '<span class="wm-cal-term">' + esc(isKo ? info.term : (info.termHanja || info.term)) + '</span>' : ''; };
|
||||
var evLabel = function (o) { return (o.allDay ? '' : '<b>' + esc(o.time) + '</b> ') + esc(o.title); };
|
||||
var evClass = function (o) { return 'wm-cal-ev' + (o.color ? ' wm-cal-c-' + o.color : ''); };
|
||||
function toolbar(s, e) {
|
||||
tools.innerHTML = '<button type="button" class="wm-btn wm-ghost wm-cal-prev" aria-label="' + esc(LT('calendar.prev')) + '">‹</button><button type="button" class="wm-btn wm-ghost wm-cal-today">' + esc(LT('calendar.today')) + '</button><button type="button" class="wm-btn wm-ghost wm-cal-next" aria-label="' + esc(LT('calendar.next')) + '">›</button><span class="wm-cal-caption">' + esc(caption(s, e)) + '</span>'
|
||||
+ ['month', 'week', 'day'].map(function (v) { return '<button type="button" class="wm-btn wm-ghost wm-cal-view' + (view === v ? ' on' : '') + '" data-v="' + v + '">' + esc(LT('calendar.' + v)) + '</button>'; }).join('');
|
||||
var step = function (dir) { if (view === 'month') cur = new Date(cur.getFullYear(), cur.getMonth() + dir, 1); else cur = calAdd(cur, dir * (view === 'week' ? 7 : 1)); load(); };
|
||||
tools.querySelector('.wm-cal-prev').onclick = function () { step(-1); }; tools.querySelector('.wm-cal-next').onclick = function () { step(1); };
|
||||
tools.querySelector('.wm-cal-today').onclick = function () { cur = calParse(todayStr); load(); };
|
||||
tools.querySelectorAll('.wm-cal-view').forEach(function (b) { b.onclick = function () { view = b.getAttribute('data-v'); load(); }; });
|
||||
}
|
||||
/** 일정 상세 팝업 */
|
||||
function popup(o) {
|
||||
var d = document.createElement('div'); d.className = 'wm-cal-pop';
|
||||
var when = calParse(o.start).toLocaleDateString(PAGE_LANG, { year: 'numeric', month: 'long', day: 'numeric', weekday: 'short' }) + (o.days > 1 ? ' ~ ' + calParse(o.end).toLocaleDateString(PAGE_LANG, { month: 'long', day: 'numeric', weekday: 'short' }) : '') + (o.allDay ? '' : ' ' + o.time + (o.endTime ? ' – ' + o.endTime : ''));
|
||||
var unit = o.unit === 'time' ? LT('calendar.unitTime') : o.unit === 'week' ? LT('calendar.unitWeek', { n: Math.round(o.days / 7) }) : LT('calendar.unitDay', { n: o.days });
|
||||
d.innerHTML = '<div class="wm-cal-card" role="dialog"><h3>' + esc(o.title) + '</h3><div class="wm-meta">' + esc(when) + '</div><div class="wm-meta">' + esc(unit) + (o.category ? ' · ' + esc(LV(o.category)) : '') + (o.cal === 'lunar' && o.lunarText ? ' · ' + esc(LT('calendar.lunarBased')) + ' (' + esc(LT('calendar.lunarPrefix') + ' ' + o.lunarText) + ')' : '') + '</div>' + (o.location ? '<div class="wm-meta">' + esc(LT('calendar.location')) + ': ' + esc(o.location) + '</div>' : '') + (o.body ? '<div class="wm-body">' + nl2br(o.body) + '</div>' : '') + '<p><button type="button" class="wm-btn wm-cal-close">' + esc(LT('calendar.close')) + '</button></p></div>';
|
||||
d.onclick = function (e) { if (e.target === d || e.target.classList.contains('wm-cal-close')) d.remove(); }; document.body.appendChild(d);
|
||||
}
|
||||
function bindEvents(root, byId) { root.querySelectorAll('.wm-cal-ev[data-id]').forEach(function (b) { b.onclick = function (e) { e.stopPropagation(); var o = byId[b.getAttribute('data-id')]; if (o) popup(o); }; }); }
|
||||
/** 시간 단위 일정을 겹치지 않게 열 나누기: 시작 순으로 보며 끝나지 않은 일정과 겹치면 다음 열 */
|
||||
function layout(items) {
|
||||
var cols = []; items.forEach(function (o) { o._s = calMin(o.time) || 0; o._e = Math.max(o._s + 30, calMin(o.endTime) || (o._s + 60)); var c = 0; while (cols[c] && cols[c] > o._s) c++; cols[c] = o._e; o._c = c; });
|
||||
var n = cols.length || 1; items.forEach(function (o) { o._n = n; });
|
||||
}
|
||||
function renderMonth(r, s) {
|
||||
var info = {}; r.days.forEach(function (d) { info[d.date] = d; }); var byDay = {}; var byId = {};
|
||||
r.events.forEach(function (o) { byId[o.id] = o; var d = calParse(o.start); for (var i = 0; i < o.days; i++) { var k = calYmd(calAdd(d, i)); (byDay[k] = byDay[k] || []).push(o); } });
|
||||
var html = '<div class="wm-cal-grid wm-cal-month">'; for (var i = 0; i < 7; i++) html += '<div class="wm-cal-wd">' + esc(wdName((weekStart() + i) % 7)) + '</div>';
|
||||
for (var j = 0; j < 42; j++) {
|
||||
var d = calAdd(s, j); var k = calYmd(d); var evs = byDay[k] || []; var wd = d.getDay();
|
||||
html += '<div class="wm-cal-cell' + (d.getMonth() !== cur.getMonth() ? ' other' : '') + (k === todayStr ? ' today' : '') + (wd === 0 ? ' wm-cal-sun' : wd === 6 ? ' wm-cal-sat' : '') + '" data-date="' + k + '"><div class="wm-cal-date"><span class="wm-cal-num">' + d.getDate() + '</span>' + termBadge(info[k]) + '</div><div class="wm-cal-sub">' + subLine(info[k]) + '</div><div class="wm-cal-evs">'
|
||||
+ evs.slice(0, 3).map(function (o) { return '<button type="button" class="' + evClass(o) + '" data-id="' + esc(o.id) + '">' + evLabel(o) + '</button>'; }).join('') + (evs.length > 3 ? '<button type="button" class="wm-cal-more">' + esc(LT('calendar.more', { n: evs.length - 3 })) + '</button>' : '') + '</div></div>';
|
||||
}
|
||||
body.innerHTML = html + '</div>' + (r.events.length ? '' : '<p class="wm-cal-empty">' + esc(LT('calendar.empty')) + '</p>');
|
||||
bindEvents(body, byId);
|
||||
body.querySelectorAll('.wm-cal-cell').forEach(function (c) { c.onclick = function () { cur = calParse(c.getAttribute('data-date')); view = 'day'; load(); }; });
|
||||
}
|
||||
function renderWeek(r, s, e) {
|
||||
var days = []; for (var d = s; d <= e; d = calAdd(d, 1)) days.push(d); var n = days.length; var info = {}; r.days.forEach(function (x) { info[x.date] = x; }); var byId = {}; r.events.forEach(function (o) { byId[o.id] = o; });
|
||||
var allOf = function (k) { return r.events.filter(function (o) { return o.allDay && o.start <= k && o.end >= k; }); };
|
||||
var timedOf = function (k) { return r.events.filter(function (o) { return !o.allDay && o.start === k; }); };
|
||||
var h0 = settings.dayStart || 0, h1 = settings.dayEnd || 24;
|
||||
var html = '<div class="wm-cal-week' + (n === 1 ? ' one' : '') + '" style="--wm-cal-cols:' + n + '">';
|
||||
if (n === 1) { var di = info[calYmd(cur)]; if (di) html += '<div class="wm-cal-info">' + subLine(di, true) + (di.term ? ' · ' + esc(LT('calendar.termLabel')) + ': ' + esc(isKo ? di.term : di.termHanja || di.term) : '') + '</div>'; }
|
||||
html += '<div class="wm-cal-row wm-cal-head"><div></div>' + days.map(function (d) { var k = calYmd(d); var wd = d.getDay(); return '<div class="' + (k === todayStr ? 'today' : '') + (wd === 0 ? ' wm-cal-sun' : wd === 6 ? ' wm-cal-sat' : '') + '"><span class="wm-cal-num">' + esc(wdName(wd)) + ' ' + d.getDate() + '</span> ' + termBadge(info[k]) + (n > 1 ? '<div class="wm-cal-sub">' + subLine(info[k]) + '</div>' : '') + '</div>'; }).join('') + '</div>';
|
||||
html += '<div class="wm-cal-row wm-cal-allday"><div class="wm-cal-sub">' + esc(LT('calendar.allDay')) + '</div>' + days.map(function (d) { return '<div>' + allOf(calYmd(d)).map(function (o) { return '<button type="button" class="' + evClass(o) + '" data-id="' + esc(o.id) + '">' + evLabel(o) + '</button>'; }).join('') + '</div>'; }).join('') + '</div>';
|
||||
html += '<div class="wm-cal-row wm-cal-timegrid"><div class="wm-cal-hours">'; for (var h = h0; h < h1; h++) html += '<div class="wm-cal-hour">' + calPad(h) + ':00</div>'; html += '</div>';
|
||||
days.forEach(function (d) {
|
||||
var k = calYmd(d); var items = timedOf(k); layout(items); html += '<div class="wm-cal-col" data-date="' + k + '">'; for (var h = h0; h < h1; h++) html += '<div class="wm-cal-hour"></div>';
|
||||
items.forEach(function (o) { var top = Math.max(0, (o._s - h0 * 60) / 60 * HOUR_PX); var bottom = Math.min((h1 - h0) * HOUR_PX, (o._e - h0 * 60) / 60 * HOUR_PX); var ht = Math.max(22, bottom - top); var wpct = 100 / o._n; html += '<button type="button" class="' + evClass(o) + ' wm-cal-tev" data-id="' + esc(o.id) + '" style="top:' + top + 'px;height:' + ht + 'px;left:calc(' + (o._c * wpct) + '% + 2px);width:calc(' + wpct + '% - 4px)">' + evLabel(o) + '</button>'; });
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div></div>';
|
||||
if (n === 1) { var list = r.events.filter(function (o) { return o.start <= calYmd(cur) && o.end >= calYmd(cur); }); html += '<div class="wm-cal-list">' + (list.length ? list.map(function (o) { return '<button type="button" class="' + evClass(o) + '" data-id="' + esc(o.id) + '">' + evLabel(o) + (o.location ? ' <small>' + esc(o.location) + '</small>' : '') + '</button>'; }).join('') : '<p class="wm-cal-empty">' + esc(LT('calendar.empty')) + '</p>') + '</div>'; }
|
||||
body.innerHTML = html; bindEvents(body, byId);
|
||||
}
|
||||
function load() {
|
||||
if (!view) view = (settings && settings.defaultView) || 'month';
|
||||
var se = rangeOf(); toolbar(se[0], se[1]); body.innerHTML = '<p class="wm-cal-empty">' + esc(LT('common.loading')) + '</p>';
|
||||
call(w, 'GET', '/calendar/range?from=' + calYmd(se[0]) + '&to=' + calYmd(se[1]) + (fixedCat ? '&cat=' + encodeURIComponent(fixedCat) : '')).then(function (r) {
|
||||
settings = r.settings || settings || {}; if (view === 'month') renderMonth(r, se[0]); else renderWeek(r, se[0], se[1]);
|
||||
}).catch(function (err) { body.innerHTML = '<p class="wm-msg err">' + esc(err.message || LT('calendar.loadFailed')) + '</p>'; });
|
||||
}
|
||||
// 설정을 먼저 받아 기본 보기·주 시작 요일을 알고 나서 그린다
|
||||
call(w, 'GET', '/calendar/settings').then(function (s) { settings = s || {}; load(); }).catch(function () { settings = {}; load(); });
|
||||
};
|
||||
// ---------------- FAQ ----------------
|
||||
R.faq = function (w) {
|
||||
var list = w.querySelector('.wm-faq-list'); if (!list) return; var catsBox = w.querySelector('.wm-faq-cats') || document.createElement('div'); var ft = w.querySelector('.wm-faq-title'); if (ft && !ft.textContent.trim()) ft.textContent = AT(w, 'data-title', 'faq.title'); var cat = w.getAttribute('data-category') || '';
|
||||
call(w, 'GET', '/faq/list').then(function (r) { var cats = []; r.items.forEach(function (i) { if (i.category && cats.indexOf(i.category) < 0) cats.push(i.category); }); function draw() { var items = cat ? r.items.filter(function (i) { return i.category === cat; }) : r.items; list.innerHTML = items.length ? items.map(function (i) { return '<details><summary>' + esc(i.q) + '</summary><div class="wm-faq-a">' + nl2br(i.a) + '</div></details>'; }).join('') : '<p class="wm-empty">' + LT('faq.empty') + '</p>'; } if (!w.getAttribute('data-category') && cats.length) { catsBox.className = 'wm-faq-cats wm-cats'; catsBox.innerHTML = '<button type="button" class="wm-btn wm-ghost on" data-c="">' + LT('common.all') + '</button>' + cats.map(function (c) { return '<button type="button" class="wm-btn wm-ghost" data-c="' + esc(c) + '">' + esc(LV(c)) + '</button>'; }).join(''); catsBox.querySelectorAll('button').forEach(function (b) { b.onclick = function () { cat = b.getAttribute('data-c'); catsBox.querySelectorAll('button').forEach(function (x) { x.classList.toggle('on', x === b); }); draw(); }; }); } draw(); }).catch(function (err) { list.innerHTML = '<p class="wm-msg err">' + esc(err.message) + '</p>'; });
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.5.7: 모듈 id 별칭. AI 가 표준 코드 없이 비슷한 이름(schedule·events·일정)으로 위젯을 만든 페이지도 일정관리 런타임이 그려 준다
|
||||
* (2026-10-14 운영 사이트에서 data-wm-module="schedule" 이 빈 안내문만 보이던 문제). 표준 id 가 우선이다.
|
||||
*/
|
||||
var ALIAS = { schedule: 'calendar', schedules: 'calendar', events: 'calendar', event: 'calendar', agenda: 'calendar', '일정': 'calendar', '일정관리': 'calendar', '캘린더': 'calendar', bbs: 'board', forum: 'board', photos: 'gallery', photo: 'gallery', contact: 'inquiry', 'contact-form': 'inquiry', review: 'reviews', faqs: 'faq', news: 'blog', posts: 'blog', downloads: 'library', files: 'library', reservation: 'booking', member: 'members', login: 'members' };
|
||||
function boot() { document.querySelectorAll('[data-wm-module]').forEach(function (w) { if (w.__wm) return; w.__wm = 1; var id = String(w.getAttribute('data-wm-module') || '').trim().toLowerCase(); var fn = R[id] || R[ALIAS[id]]; if (fn) { try { fn(w); } catch (e) { console.error('wm module error', e); } } }); }
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
|
||||
window.wmModules = { boot: boot, call: call };
|
||||
})();
|
||||
@@ -0,0 +1,49 @@
|
||||
/* 웹메이커 접속 통계 비컨 (v1.0.2)
|
||||
* 생성·배포되는 모든 HTML 에 <script src=".../wm-stats.js" data-wm-stats data-site="<사이트ID>" async> 로 삽입된다.
|
||||
* v1.4.17: 빌더 미리보기 안에서 열렸을 때는 부모 창(빌더)에 "지금 어느 페이지인지" 를 postMessage 로 알린다 — 아래 notifyParent.
|
||||
* 페이지를 열 때 한 번, 다음 정보를 서비스 서버의 POST /api/stats/beacon 으로 보낸다.
|
||||
* - path/ref/host : 어떤 페이지를 어디서 왔는지 (리퍼러는 호스트만 집계됨)
|
||||
* - w/h/lang : 기기 구간(모바일/태블릿/PC) 과 언어 집계용
|
||||
* - transfer : Performance API 의 transferSize 합계 = 이 페이지 열람에 실제로 전송된 바이트(추정 아웃바운드)
|
||||
* (교차 출처 자원은 Timing-Allow-Origin 이 없으면 0 으로 잡힌다)
|
||||
* 쿠키·로컬스토리지를 쓰지 않고, 서버는 IP 를 저장하지 않고 일자별 해시로만 방문자를 센다.
|
||||
* 뒤로가기 캐시(bfcache) 복원이나 같은 페이지 재방문도 한 번의 열람으로 센다.
|
||||
*/
|
||||
(function () {
|
||||
try {
|
||||
var me = document.currentScript || (function () { var s = document.querySelectorAll('script[data-wm-stats]'); return s[s.length - 1]; })();
|
||||
if (!me || !me.getAttribute('data-site')) return;
|
||||
var site = me.getAttribute('data-site');
|
||||
var base = (me.getAttribute('src') || '').replace(/\/wm-stats\.js(\?.*)?$/, '');
|
||||
if (!/^https?:\/\//.test(base)) return;
|
||||
/* v1.4.17 빌더 미리보기 페이지 동기화: 미리보기 iframe 은 sandbox(allow-same-origin 없음)라 빌더가 iframe 의 주소를 읽을 수 없다.
|
||||
* 그래서 페이지 쪽에서 부모 창에 {type:'wm-page', site, path, title} 을 보낸다(대상 '*' — 이 페이지의 출처는 opaque 라 지정할 수 없고,
|
||||
* 받는 쪽(public/app.js)이 보낸 창이 자기 미리보기 iframe 인지·경로가 그 사이트인지 확인한다). 빌더 밖(운영 주소·다른 사이트에 삽입)에서는
|
||||
* 부모가 없거나 무시하므로 영향이 없다. bfcache 복원(pageshow)에도 다시 알린다. */
|
||||
function notifyParent() {
|
||||
try { if (window.parent && window.parent !== window) window.parent.postMessage({ type: 'wm-page', site: site, path: location.pathname, title: document.title || '' }, '*'); } catch (e) {}
|
||||
}
|
||||
notifyParent(); window.addEventListener('pageshow', function (ev) { if (ev.persisted) notifyParent(); });
|
||||
var sent = false;
|
||||
function transferBytes() {
|
||||
try {
|
||||
var total = 0; var nav = performance.getEntriesByType('navigation')[0];
|
||||
if (nav && nav.transferSize) total += nav.transferSize;
|
||||
performance.getEntriesByType('resource').forEach(function (r) { if (r.transferSize) total += r.transferSize; });
|
||||
return total;
|
||||
} catch (e) { return 0; }
|
||||
}
|
||||
function send() {
|
||||
if (sent) return; sent = true;
|
||||
var body = JSON.stringify({ site: site, path: location.pathname, ref: document.referrer || '', host: location.hostname, w: window.innerWidth || screen.width || 0, h: window.innerHeight || screen.height || 0, lang: navigator.language || '', transfer: transferBytes(), t: Date.now() });
|
||||
var url = base + '/api/stats/beacon';
|
||||
// sendBeacon 은 페이지를 떠나는 중에도 전송이 보장된다. 지원하지 않으면 keepalive fetch 로 대체.
|
||||
if (navigator.sendBeacon) { try { if (navigator.sendBeacon(url, new Blob([body], { type: 'text/plain' }))) return; } catch (e) {} }
|
||||
try { fetch(url, { method: 'POST', body: body, headers: { 'Content-Type': 'text/plain' }, keepalive: true, mode: 'cors', credentials: 'omit' }).catch(function () {}); } catch (e) {}
|
||||
}
|
||||
// 자원 로딩이 끝난 뒤(전송량 계산을 위해) 보내되, 그 전에 떠나면 그 시점 값으로 보낸다.
|
||||
if (document.readyState === 'complete') setTimeout(send, 800); else window.addEventListener('load', function () { setTimeout(send, 800); });
|
||||
window.addEventListener('pagehide', send);
|
||||
document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'hidden') send(); });
|
||||
} catch (e) { /* 통계 실패는 사이트 동작에 영향을 주지 않는다 */ }
|
||||
})();
|
||||
Reference in New Issue
Block a user