sns-auto-builder 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.html ADDED
@@ -0,0 +1,1350 @@
1
+ <!DOCTYPE html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>카피 빌더</title>
7
+ <style>
8
+ @import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/variable/pretendardvariable.css');
9
+ @import url('https://fonts.googleapis.com/css2?family=Jua&display=swap');
10
+
11
+ /* 색은 전부 크림(--paper) 기준 4.5:1 이상 실측해서 고른 값. 임의로 밝히지 말 것. */
12
+ :root {
13
+ --paper:#FFF9F2; --card:#FFFDFA; --field:#FDF1E4;
14
+ --ink:#2B2118; /* 15.06:1 */
15
+ --ink-2:#71614E; /* 5.70:1 */
16
+ --accent:#B4471F; /* 5.20:1 — 크림 위 글자, 그리고 채움 위 크림 글자 양방향 통과 */
17
+ --accent-soft:#FAE9DE; /* 이 위의 --accent 글자가 4.60:1 — 더 진하게 하면 미달 */
18
+ --rule:rgba(43,33,24,.12);
19
+ --shadow:0 1px 2px rgba(43,33,24,.05), 0 6px 16px rgba(43,33,24,.05);
20
+ --shadow-up:0 3px 6px rgba(43,33,24,.06), 0 14px 30px rgba(43,33,24,.09);
21
+ --display:'Jua',system-ui,sans-serif;
22
+ --sans:'Pretendard Variable',-apple-system,sans-serif;
23
+ --ease:cubic-bezier(.16,1,.3,1);
24
+ }
25
+ * { margin:0; padding:0; box-sizing:border-box; }
26
+ html { -webkit-text-size-adjust:100%; }
27
+ body {
28
+ background:var(--paper); color:var(--ink); font-family:var(--sans);
29
+ font-size:16px; line-height:1.65; min-height:100vh;
30
+ display:flex; justify-content:center; padding:clamp(40px,7vw,84px) 24px 96px;
31
+ }
32
+ main { width:100%; max-width:660px; }
33
+
34
+ /* ── 타이포 ─────────────────────────────────── */
35
+ /* Jua 는 굵기가 하나뿐이라 위계는 크기와 서체 대비로 만든다. */
36
+ h1 { font-family:var(--display); font-weight:400; letter-spacing:-.015em; line-height:1.3;
37
+ font-size:clamp(29px,5.2vw,42px); text-wrap:balance; }
38
+ h2 { font-family:var(--display); font-weight:400; font-size:20px; letter-spacing:-.01em; }
39
+ p { max-width:62ch; }
40
+ .lede { color:var(--ink-2); margin-top:12px; font-size:16.5px; }
41
+ .fine { color:var(--ink-2); font-size:13.5px; }
42
+
43
+ /* 표제 아래 짧은 밑줄 — 한 번만 그려지는 모션 */
44
+ .masthead { margin-bottom:30px; }
45
+ .masthead::after {
46
+ content:''; display:block; width:44px; height:6px; border-radius:99px; background:var(--accent);
47
+ margin-top:18px; transform-origin:left; animation:draw .7s var(--ease) both;
48
+ }
49
+ @keyframes draw { from { transform:scaleX(0); } to { transform:scaleX(1); } }
50
+
51
+ /* ── 모드 선택 카드 ─────────────────────────── */
52
+ .toc { display:grid; gap:14px; }
53
+ .entry {
54
+ display:grid; grid-template-columns:1fr auto; align-items:center; gap:8px 20px;
55
+ width:100%; text-align:left; cursor:pointer; font:inherit; color:inherit;
56
+ padding:24px 26px; border-radius:20px;
57
+ background:var(--card); border:1px solid var(--rule); box-shadow:var(--shadow);
58
+ transition:transform .35s var(--ease), box-shadow .35s var(--ease), border-color .2s;
59
+ }
60
+ .entry:hover, .entry:focus-visible {
61
+ transform:translateY(-3px); box-shadow:var(--shadow-up); border-color:rgba(180,71,31,.4);
62
+ }
63
+ .entry:focus-visible { outline:2px solid var(--accent); outline-offset:3px; }
64
+ .entry .desc { grid-column:1; color:var(--ink-2); font-size:14.5px; margin-top:4px; max-width:44ch; }
65
+ .entry .spec {
66
+ color:var(--accent); background:var(--accent-soft); font-size:13px; font-weight:700;
67
+ white-space:nowrap; padding:5px 12px; border-radius:99px; font-variant-numeric:tabular-nums;
68
+ }
69
+
70
+ /* ── 입력 ──────────────────────────────────── */
71
+ label { display:block; font-size:14px; font-weight:600; margin-bottom:10px; }
72
+ textarea {
73
+ width:100%; min-height:172px; padding:18px 20px; resize:vertical;
74
+ background:var(--field); border:1px solid var(--rule); border-radius:18px;
75
+ color:var(--ink); font:inherit; font-size:16.5px; line-height:1.7;
76
+ }
77
+ textarea::placeholder { color:#756550; } /* --field 대비 5.06:1 */
78
+ textarea:focus-visible { outline:2px solid var(--accent); outline-offset:2px; border-color:transparent; }
79
+
80
+ /* ── 참고 자료 드롭존 ──────────────────────── */
81
+ #refLabel { margin-top:26px; }
82
+ #refLabel .fine { font-weight:400; }
83
+ .drop {
84
+ border:1.5px dashed var(--rule); border-radius:18px; background:var(--field);
85
+ padding:26px 20px; text-align:center; cursor:pointer;
86
+ transition:border-color .2s, background-color .2s;
87
+ }
88
+ .drop:hover, .drop.over { border-color:var(--accent); background:#FDEBDD; }
89
+ .drop:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
90
+ .drop p { margin:0 auto; max-width:none; }
91
+ kbd { font:inherit; font-size:.92em; background:rgba(43,33,24,.07); border-radius:5px; padding:1px 6px; }
92
+
93
+ .thumbs { display:flex; flex-wrap:wrap; gap:10px; margin-top:12px; }
94
+ .thumb { position:relative; width:76px; height:76px; border-radius:12px; overflow:hidden;
95
+ border:1px solid var(--rule); background:var(--card); }
96
+ .thumb img { width:100%; height:100%; object-fit:cover; display:block; }
97
+ .thumb button {
98
+ position:absolute; top:3px; right:3px; width:22px; height:22px; border-radius:99px; cursor:pointer;
99
+ border:0; background:rgba(43,33,24,.72); color:#fff; font:inherit; font-size:14px; line-height:1;
100
+ display:grid; place-items:center; padding:0;
101
+ }
102
+ .thumb button:hover { background:var(--accent); }
103
+ .thumb button:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
104
+
105
+ .bar { display:flex; flex-wrap:wrap; gap:10px; align-items:center; margin-top:22px; }
106
+ button.go, button.quiet {
107
+ font:inherit; font-size:15px; cursor:pointer; border-radius:99px; padding:13px 26px;
108
+ transition:background-color .18s, color .18s, opacity .18s, transform .18s var(--ease);
109
+ }
110
+ button.go { background:var(--accent); color:var(--paper); border:1px solid var(--accent); font-weight:700; }
111
+ button.go:hover:not(:disabled) { background:#9C3B18; transform:translateY(-1px); }
112
+ button.go:disabled { opacity:.45; cursor:not-allowed; }
113
+ button.quiet, a.dl { background:none; border:1px solid var(--rule); color:var(--ink-2); }
114
+ a.dl { font:inherit; font-size:15px; border-radius:99px; padding:13px 26px; text-decoration:none; display:inline-block; }
115
+ a.dl:hover { color:var(--accent); border-color:var(--accent); }
116
+ button.quiet:hover { color:var(--accent); border-color:var(--accent); }
117
+ button.go:focus-visible, button.quiet:focus-visible, .copy:focus-visible { outline:2px solid var(--accent); outline-offset:3px; }
118
+
119
+ /* ── 스트리밍 ──────────────────────────────── */
120
+ .live {
121
+ margin-top:28px; padding:24px 26px; background:var(--card); border:1px solid var(--rule);
122
+ border-radius:20px; box-shadow:var(--shadow);
123
+ white-space:pre-wrap; word-break:break-word; font-size:15.5px; line-height:1.78;
124
+ max-height:56vh; overflow-y:auto;
125
+ }
126
+ .live::after {
127
+ content:''; display:inline-block; width:8px; height:8px; border-radius:99px; background:var(--accent);
128
+ vertical-align:.1em; margin-left:5px; animation:blink 1.05s steps(2,start) infinite;
129
+ }
130
+ @keyframes blink { 50% { opacity:.15; } }
131
+ @media (prefers-reduced-motion:reduce) { *,*::after { animation:none !important; transition:none !important; } }
132
+
133
+ /* ── 진행 단계 ─────────────────────────────── */
134
+ .steps { list-style:none; margin-top:26px; display:grid; gap:2px; }
135
+ .steps li { display:flex; align-items:center; gap:12px; padding:9px 4px; font-size:15px; color:var(--ink-2); }
136
+ .steps li .dot {
137
+ width:18px; height:18px; border-radius:99px; flex:none; display:grid; place-items:center;
138
+ border:1.5px solid var(--rule); font-size:11px; line-height:1; color:var(--paper);
139
+ }
140
+ .steps li[data-status="run"] { color:var(--ink); font-weight:600; }
141
+ .steps li[data-status="run"] .dot { border-color:var(--accent); background:var(--accent-soft); animation:pulse 1.4s ease-in-out infinite; }
142
+ .steps li[data-status="done"] .dot { border-color:var(--accent); background:var(--accent); }
143
+ .steps li[data-status="done"] .dot::after { content:'✓'; }
144
+ @keyframes pulse { 50% { opacity:.45; } }
145
+
146
+ /* ── 브랜드 설정 폼 ────────────────────────── */
147
+ .bgroup { margin-bottom:24px; }
148
+ .bgroup > h2 { font-size:17px; margin-bottom:4px; }
149
+ .bgroup > .fine { display:block; margin-bottom:12px; }
150
+ .bfields { display:grid; gap:12px; }
151
+ .bfield label { margin-bottom:5px; font-weight:500; }
152
+ .bfield label .req { color:var(--accent); font-weight:700; }
153
+ .bfield input[type=text], .bfield select, .bfield textarea {
154
+ width:100%; padding:11px 13px; font:inherit; font-size:15px; color:var(--ink);
155
+ background:var(--field); border:1px solid var(--rule); border-radius:11px;
156
+ }
157
+ .bfield textarea { min-height:78px; line-height:1.6; resize:vertical; }
158
+ .bfield input:focus-visible, .bfield select:focus-visible, .bfield textarea:focus-visible {
159
+ outline:2px solid var(--accent); outline-offset:2px; border-color:transparent;
160
+ }
161
+ .bfield .hint { display:block; margin-top:5px; }
162
+ .bfield.color { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
163
+ .bfield.color label { flex:1 0 100%; margin:0 0 5px; }
164
+ .bfield.color input[type=color] { width:44px; height:36px; padding:2px; border:1px solid var(--rule); border-radius:9px; background:var(--field); cursor:pointer; }
165
+ .bfield.color input[type=text] { flex:1; min-width:120px; }
166
+
167
+ /* ── 로고 ──────────────────────────────────── */
168
+ #logoBox { margin-top:26px; }
169
+ .logo-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
170
+ .logo-prev {
171
+ width:96px; height:52px; display:grid; place-items:center; padding:6px;
172
+ border:1px solid var(--rule); border-radius:11px; background:var(--field);
173
+ }
174
+ .logo-prev img { max-width:100%; max-height:100%; }
175
+
176
+ /* ── 지난 결과물 ───────────────────────────── */
177
+ .outs { display:grid; gap:12px; }
178
+ .out-row {
179
+ display:flex; align-items:center; gap:14px; padding:12px 14px;
180
+ background:var(--card); border:1px solid var(--rule); border-radius:16px; box-shadow:var(--shadow);
181
+ }
182
+ .out-row .open {
183
+ display:flex; align-items:center; gap:12px; flex:1; min-width:0;
184
+ background:none; border:0; padding:0; font:inherit; color:inherit; cursor:pointer; text-align:left;
185
+ }
186
+ .out-row .open:hover .t { color:var(--accent); }
187
+ .out-row img { width:46px; height:58px; object-fit:cover; border-radius:8px; border:1px solid var(--rule); flex:none; }
188
+ .out-row .t { font-weight:600; font-size:15px; line-height:1.4;
189
+ overflow:hidden; text-overflow:ellipsis; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; }
190
+ .out-row .n { font-size:13px; color:var(--ink-2); margin-top:2px; }
191
+ .out-row .acts { display:flex; gap:6px; flex:none; }
192
+ .out-row .acts a, .out-row .acts button {
193
+ font:inherit; font-size:12px; padding:6px 10px; cursor:pointer; text-decoration:none;
194
+ border:1px solid var(--rule); border-radius:7px; background:transparent; color:var(--ink-2);
195
+ }
196
+ .out-row .acts a:hover, .out-row .acts button:hover { color:var(--accent); border-color:var(--accent); }
197
+ .outs .empty { color:var(--ink-2); font-size:14.5px; padding:8px 2px; }
198
+
199
+ /* ── 참고자료 템플릿 ───────────────────────── */
200
+ #tplBox { margin-top:26px; }
201
+ .tpls { display:grid; gap:10px; }
202
+ .tpl {
203
+ display:flex; align-items:center; gap:12px; padding:10px 12px;
204
+ border:1px solid var(--rule); border-radius:14px; background:var(--card);
205
+ }
206
+ .tpl.on { border-color:var(--accent); background:var(--accent-soft); }
207
+ .tpl .pick { display:flex; gap:6px; flex:1; cursor:pointer; background:none; border:0; padding:0; font:inherit; }
208
+ .tpl .pick img { width:44px; height:55px; object-fit:cover; border-radius:7px; border:1px solid var(--rule); }
209
+ .tpl .meta { font-size:13px; color:var(--ink-2); align-self:center; margin-left:4px; }
210
+ .tpl .del {
211
+ font:inherit; font-size:12px; padding:5px 9px; cursor:pointer; flex:none;
212
+ border:1px solid var(--rule); border-radius:6px; background:transparent; color:var(--ink-2);
213
+ }
214
+ .tpl .del:hover { color:var(--accent); border-color:var(--accent); }
215
+
216
+ /* ── 시나리오 확인 ─────────────────────────── */
217
+ .scenario { list-style:none; display:grid; gap:8px; counter-reset:s; }
218
+ .scenario li {
219
+ counter-increment:s; display:grid; gap:3px;
220
+ padding:14px 18px 14px 46px; position:relative;
221
+ background:var(--card); border:1px solid var(--rule); border-radius:14px;
222
+ }
223
+ .scenario li::before {
224
+ content:counter(s,decimal-leading-zero); position:absolute; left:16px; top:14px;
225
+ color:var(--accent); font-size:13px; font-weight:700; font-variant-numeric:tabular-nums;
226
+ }
227
+ .scenario strong { font-weight:600; font-size:15.5px; line-height:1.5; }
228
+
229
+ /* ── 인스타 목업 미리보기 ──────────────────── */
230
+ .ig {
231
+ max-width:392px; margin:0 auto 30px; background:var(--card);
232
+ border:1px solid var(--rule); border-radius:16px; overflow:hidden; box-shadow:var(--shadow);
233
+ }
234
+ .ig-head { display:flex; align-items:center; gap:9px; padding:11px 13px; }
235
+ .ig-ava {
236
+ width:29px; height:29px; border-radius:99px; flex:none;
237
+ background:linear-gradient(135deg,var(--accent),#E8A23C);
238
+ }
239
+ .ig-name { font-size:13.5px; font-weight:700; }
240
+ .ig-more { margin-left:auto; color:var(--ink-2); letter-spacing:1px; }
241
+
242
+ .ig-media { position:relative; aspect-ratio:4/5; background:#000; }
243
+ /* scroll-snap 하나로 터치·트랙패드 스와이프가 다 된다. 스와이퍼 라이브러리 불필요. */
244
+ .ig-track {
245
+ display:flex; height:100%; overflow-x:auto; scroll-snap-type:x mandatory;
246
+ scrollbar-width:none; -ms-overflow-style:none; scroll-behavior:smooth;
247
+ }
248
+ .ig-track::-webkit-scrollbar { display:none; }
249
+ .ig-track img {
250
+ flex:0 0 100%; width:100%; height:100%; object-fit:cover;
251
+ scroll-snap-align:center; scroll-snap-stop:always; display:block;
252
+ }
253
+ .ig-track:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; }
254
+
255
+ .ig-nav {
256
+ position:absolute; top:50%; transform:translateY(-50%); z-index:2;
257
+ width:30px; height:30px; border-radius:99px; border:0; cursor:pointer;
258
+ background:rgba(255,255,255,.9); color:#2B2118; font-size:19px; line-height:1;
259
+ display:grid; place-items:center; padding:0 0 2px; box-shadow:0 1px 4px rgba(0,0,0,.25);
260
+ opacity:0; transition:opacity .18s;
261
+ }
262
+ .ig:hover .ig-nav, .ig-nav:focus-visible { opacity:1; }
263
+ .ig-nav.prev { left:9px; } .ig-nav.next { right:9px; }
264
+ .ig-nav[disabled] { display:none; }
265
+
266
+ .ig-count {
267
+ position:absolute; top:11px; right:11px; z-index:2;
268
+ background:rgba(0,0,0,.6); color:#fff; font-size:12px; font-weight:600;
269
+ padding:3px 9px; border-radius:99px; font-variant-numeric:tabular-nums;
270
+ }
271
+ .ig-pips { display:flex; justify-content:center; gap:5px; padding:10px 0 2px; }
272
+ .ig-pips i { width:6px; height:6px; border-radius:99px; background:var(--rule); transition:background-color .2s; }
273
+ .ig-pips i.on { background:var(--accent); }
274
+
275
+ .ig-acts { display:flex; align-items:center; gap:13px; padding:6px 13px 2px; font-size:17px; }
276
+ .ig-acts b { flex:1; }
277
+ .ig-cap { padding:2px 13px 14px; font-size:13.5px; color:var(--ink-2); line-height:1.55; }
278
+
279
+ /* 모션 최소화 설정이면 스냅 애니메이션도 끈다 */
280
+ @media (prefers-reduced-motion:reduce) { .ig-track { scroll-behavior:auto; } }
281
+
282
+ /* ── 결과: 슬라이드 ────────────────────────── */
283
+ .slides { display:grid; grid-template-columns:repeat(auto-fill,minmax(150px,1fr)); gap:12px; margin-bottom:28px; }
284
+ .slides a {
285
+ display:block; border-radius:14px; overflow:hidden; border:1px solid var(--rule);
286
+ box-shadow:var(--shadow); background:var(--card); transition:transform .3s var(--ease), box-shadow .3s var(--ease);
287
+ }
288
+ .slides a:hover { transform:translateY(-3px); box-shadow:var(--shadow-up); }
289
+ .slides a:focus-visible { outline:2px solid var(--accent); outline-offset:3px; }
290
+ .slides img { width:100%; aspect-ratio:4/5; object-fit:cover; display:block; }
291
+ .slides .cell { display:flex; flex-direction:column; gap:6px; }
292
+ .slides .cell.busy img { opacity:.4; filter:grayscale(1); }
293
+ .regen {
294
+ font:inherit; font-size:12px; padding:5px 8px; cursor:pointer;
295
+ border:1px solid var(--rule); border-radius:6px; background:transparent; color:inherit;
296
+ }
297
+ .regen:hover:not(:disabled) { border-color:var(--accent); color:var(--accent); }
298
+ .regen:disabled { opacity:.5; cursor:progress; }
299
+ #outPath { margin-bottom:24px; word-break:break-all; }
300
+
301
+ /* ── 결과: 장별 카드 ───────────────────────── */
302
+ #blocks { display:grid; gap:12px; }
303
+ .block {
304
+ background:var(--card); border:1px solid var(--rule); border-radius:20px;
305
+ box-shadow:var(--shadow); padding:20px 24px 22px;
306
+ }
307
+ .block header { display:flex; justify-content:space-between; align-items:center; gap:16px; margin-bottom:10px; }
308
+ .block h2 { font-size:17px; color:var(--accent); }
309
+ .block .text { white-space:pre-wrap; word-break:break-word; font-size:15.5px; line-height:1.8; max-width:62ch; }
310
+ .copy {
311
+ font:inherit; font-size:12.5px; font-weight:700; cursor:pointer;
312
+ background:none; border:1px solid var(--rule); color:var(--ink-2);
313
+ padding:5px 14px; border-radius:99px; opacity:0; transition:opacity .18s, color .18s, border-color .18s;
314
+ }
315
+ .block:hover .copy, .copy:focus-visible { opacity:1; }
316
+ .copy:hover { color:var(--accent); border-color:var(--accent); }
317
+ @media (hover:none) { .copy { opacity:1; } }
318
+
319
+ .notice { margin-top:18px; font-size:14.5px; }
320
+ .notice.error { color:var(--accent); font-weight:700; }
321
+ [hidden] { display:none !important; }
322
+ .screen { animation:rise .5s var(--ease) both; }
323
+ @keyframes rise { from { opacity:0; transform:translateY(10px); } to { opacity:1; transform:none; } }
324
+ @media (max-width:560px) {
325
+ .entry { grid-template-columns:1fr; align-items:start; }
326
+ .entry .spec { grid-column:1; justify-self:start; }
327
+ }
328
+ </style>
329
+ </head>
330
+ <body>
331
+ <main>
332
+
333
+ <!-- 1. 모드 선택 -->
334
+ <section id="pick" class="screen">
335
+ <div class="masthead">
336
+ <h1>무엇을 쓸까요</h1>
337
+ <p class="lede">주제 하나만 주면 형식에 맞춰 카피를 씁니다.</p>
338
+ </div>
339
+ <div class="toc">
340
+ <button class="entry" data-mode="cardnews">
341
+ <h2>카드뉴스</h2>
342
+ <span class="spec">9장</span>
343
+ <span class="desc">인스타 캐러셀. 커버 한 장, 본문 일곱 장, 아웃트로 한 장에 인스타 캡션까지.</span>
344
+ </button>
345
+ <button class="entry" data-mode="thread">
346
+ <h2>쓰레드</h2>
347
+ <span class="spec">3~5글</span>
348
+ <span class="desc">Threads 게시글. 첫 줄에서 끊기는 훅과 이어지는 글, 마지막에만 CTA.</span>
349
+ </button>
350
+ </div>
351
+ <div class="bar">
352
+ <button class="quiet" id="brandOpen">⚙ 브랜드 컨셉 설정</button>
353
+ <span class="fine" id="brandNow"></span>
354
+ </div>
355
+ </section>
356
+
357
+ <!-- 1.2 브랜드 설정 — 여기 값이 모든 카피·디자인의 기준이 된다 -->
358
+ <section id="brand" class="screen" hidden>
359
+ <div class="masthead">
360
+ <h1>브랜드 컨셉</h1>
361
+ <p class="lede">브랜드명만 필수입니다. <strong>나머지는 비워두면 아예 언급되지 않습니다</strong> — 모르는 걸 지어내지 않도록.</p>
362
+ </div>
363
+ <div id="brandForm"></div>
364
+ <p class="notice error" id="brandErr" hidden role="alert"></p>
365
+ <div class="bar">
366
+ <button class="go" id="brandSave">저장</button>
367
+ <button class="quiet" id="brandBack">돌아가기</button>
368
+ <span class="fine" id="brandSaved"></span>
369
+ </div>
370
+ </section>
371
+
372
+ <!-- 1.5 엔진 선택 (카드뉴스만) -->
373
+ <section id="engine" class="screen" hidden>
374
+ <div class="masthead">
375
+ <h1>어떻게 만들까요</h1>
376
+ <p class="lede">글자를 어디서 그릴지에 따라 한글 정확도와 비용이 갈립니다.</p>
377
+ </div>
378
+ <div class="toc">
379
+ <button class="entry" data-engine="html">
380
+ <h2>HTML만</h2>
381
+ <span class="spec">0원</span>
382
+ <span class="desc">글자를 HTML로 그려 캡처합니다. 한글 100% 정확하고 한 글자만 고쳐 다시 뽑을 수 있습니다. 키가 필요 없습니다.</span>
383
+ </button>
384
+ <button class="entry" data-engine="both">
385
+ <h2>이미지 + HTML</h2>
386
+ <span class="spec">ChatGPT 구독</span>
387
+ <span class="desc">브라우저로 ChatGPT를 열어 배경만 뽑고, 한글은 그 위에 HTML로 얹습니다. 분위기는 살면서 한글은 그대로 100%입니다. 키가 필요 없습니다.</span>
388
+ </button>
389
+ <button class="entry" data-engine="image">
390
+ <h2>이미지만</h2>
391
+ <span class="spec">ChatGPT 구독</span>
392
+ <span class="desc">브라우저로 ChatGPT를 열어 9장을 한 장씩 그립니다. 한글이 이미지 안에 들어가 매번 조금씩 달라지고, 육안 검수가 필요합니다.</span>
393
+ </button>
394
+ </div>
395
+ <div class="bar">
396
+ <button class="quiet" id="engineBack">모드 바꾸기</button>
397
+ <button class="quiet" id="outsOpen">📁 결과물 보기</button>
398
+ </div>
399
+ </section>
400
+
401
+ <!-- 1.7 지난 결과물 -->
402
+ <section id="outs" class="screen" hidden>
403
+ <div class="masthead">
404
+ <h1>결과물</h1>
405
+ <p class="lede" id="outsNote">지금까지 만든 카드뉴스입니다.</p>
406
+ </div>
407
+ <div class="outs" id="outsList"></div>
408
+ <div class="bar"><button class="quiet" id="outsBack">돌아가기</button></div>
409
+ </section>
410
+
411
+ <!-- 2. 주제 입력 -->
412
+ <section id="form" class="screen" hidden>
413
+ <div class="masthead">
414
+ <h1 id="formTitle"></h1>
415
+ <p class="lede">한 줄이어도 되고, 참고할 자료를 통째로 붙여넣어도 됩니다.</p>
416
+ </div>
417
+ <label for="topic">주제</label>
418
+ <textarea id="topic" placeholder="예) 클로드 코드로 개발 속도 올린 방법 일곱 가지"></textarea>
419
+
420
+ <div id="countRow" hidden>
421
+ <label for="count">장수 <span class="fine">고르지 않으면 9장</span></label>
422
+ <select id="count">
423
+ <option value="">9장 (기본)</option>
424
+ <option value="4">4장</option>
425
+ <option value="5">5장</option>
426
+ <option value="6">6장</option>
427
+ <option value="7">7장</option>
428
+ <option value="8">8장</option>
429
+ <option value="10">10장</option>
430
+ <option value="12">12장</option>
431
+ </select>
432
+ </div>
433
+
434
+ <div id="tplBox" hidden>
435
+ <label>템플릿 <span class="fine">전에 쓴 참고 자료 · 누르면 이번에도 씁니다</span></label>
436
+ <div class="tpls" id="tpls"></div>
437
+ </div>
438
+
439
+ <div id="logoBox">
440
+ <label for="pick-logo">로고 <span class="fine">선택 · SVG · 슬라이드에 실제로 얹힙니다</span></label>
441
+ <div class="logo-row">
442
+ <div class="logo-prev" id="logoPrev" aria-live="polite"><span class="fine">없음</span></div>
443
+ <button type="button" class="quiet" id="logoPick">SVG 올리기</button>
444
+ <button type="button" class="quiet" id="logoDel" hidden>삭제</button>
445
+ <input type="file" id="pick-logo" accept=".svg,image/svg+xml" hidden>
446
+ </div>
447
+ </div>
448
+
449
+ <label for="pick-file" id="refLabel">참고 자료 <span class="fine">선택 · 최대 6장</span></label>
450
+ <div class="drop" id="drop" tabindex="0" role="button"
451
+ aria-describedby="refHint" aria-label="참고 이미지 추가. 끌어다 놓거나 붙여넣거나 눌러서 고르세요">
452
+ <p class="fine" id="refHint">끌어다 놓거나, <kbd>⌘/Ctrl + V</kbd>로 붙여넣거나, 눌러서 고르세요</p>
453
+ <input type="file" id="pick-file" accept="image/png,image/jpeg,image/webp,image/gif" multiple hidden>
454
+ </div>
455
+ <div class="thumbs" id="thumbs"></div>
456
+
457
+ <div class="bar">
458
+ <button class="go" id="go">쓰기 시작</button>
459
+ <button class="quiet" id="back">모드 바꾸기</button>
460
+ <span class="fine">⌘/Ctrl + Enter</span>
461
+ </div>
462
+ <p class="notice error" id="err" hidden role="alert"></p>
463
+ </section>
464
+
465
+ <!-- 3. 쓰는 중 -->
466
+ <section id="run" class="screen" hidden>
467
+ <div class="masthead">
468
+ <h1>쓰는 중</h1>
469
+ <p class="lede" id="runNote" aria-live="polite">첫 문장을 고르고 있습니다</p>
470
+ </div>
471
+ <ol class="steps" id="steps" hidden aria-label="진행 단계"></ol>
472
+ <div class="live" id="live" aria-live="polite" aria-busy="true"></div>
473
+ <div class="bar"><button class="quiet" id="stop">중단</button></div>
474
+ </section>
475
+
476
+ <!-- 3.5 시나리오 확인 — 이미지에 시간·쿼터를 쓰기 전에 사람이 승인한다 -->
477
+ <section id="plan" class="screen" hidden>
478
+ <div class="masthead">
479
+ <h1>이렇게 만들까요?</h1>
480
+ <p class="lede" id="planNote"></p>
481
+ </div>
482
+ <ol class="scenario" id="scenario"></ol>
483
+ <div class="bar">
484
+ <button class="go" id="approve">이대로 만들기</button>
485
+ <button class="quiet" id="redo">장수 바꿔 다시</button>
486
+ <button class="quiet" id="planCancel">처음으로</button>
487
+ </div>
488
+ </section>
489
+
490
+ <!-- 4. 결과 -->
491
+ <section id="out" class="screen" hidden>
492
+ <div class="masthead">
493
+ <h1 id="outTitle"></h1>
494
+ <p class="lede" id="outNote"></p>
495
+ </div>
496
+ <!-- 인스타 목업 미리보기. 피드에서 어떻게 보이는지 넘겨보며 확인한다. -->
497
+ <div class="ig" id="ig" hidden>
498
+ <div class="ig-head">
499
+ <span class="ig-ava" aria-hidden="true"></span>
500
+ <span class="ig-name">ohjic</span>
501
+ <span class="ig-more" aria-hidden="true">···</span>
502
+ </div>
503
+ <div class="ig-media">
504
+ <div class="ig-track" id="igTrack" tabindex="0" aria-label="슬라이드 미리보기 — 좌우로 넘기세요"></div>
505
+ <button class="ig-nav prev" id="igPrev" type="button" aria-label="이전 슬라이드">‹</button>
506
+ <button class="ig-nav next" id="igNext" type="button" aria-label="다음 슬라이드">›</button>
507
+ <span class="ig-count" id="igCount">1/1</span>
508
+ </div>
509
+ <div class="ig-pips" id="igPips"></div>
510
+ <div class="ig-acts" aria-hidden="true">
511
+ <span>♡</span><span>💬</span><span>↗</span><b></b><span>🔖</span>
512
+ </div>
513
+ <p class="ig-cap" id="igCap"></p>
514
+ </div>
515
+
516
+ <div class="slides" id="slides" hidden></div>
517
+ <p class="fine" id="outPath" hidden></p>
518
+ <details id="reviewBox" hidden>
519
+ <summary id="reviewTitle">품질 리뷰 채점 중…</summary>
520
+ <div id="reviewBody"></div>
521
+ </details>
522
+ <div id="blocks"></div>
523
+ <div class="bar">
524
+ <button class="go" id="copyAll">전체 복사</button>
525
+ <a class="quiet dl" id="zipAll" hidden download>한번에 받기</a>
526
+ <button class="quiet" id="again">다시 쓰기</button>
527
+ <button class="quiet" id="reset">처음으로</button>
528
+ </div>
529
+ </section>
530
+
531
+ </main>
532
+
533
+ <script>
534
+ const $ = (id) => document.getElementById(id);
535
+ const MODES = {
536
+ cardnews: { title: '카드뉴스', form: '어떤 주제로 아홉 장을 채울까요' },
537
+ thread: { title: '쓰레드', form: '어떤 이야기를 풀까요' },
538
+ };
539
+ const ENGINES = {
540
+ html: { label: 'HTML만', steps: ['research', 'html', 'capture', 'check'] },
541
+ both: { label: '이미지 + HTML', steps: ['research', 'bg', 'html', 'capture', 'check'] },
542
+ image: { label: '이미지만', steps: ['research', 'prompt', 'image', 'check'] },
543
+ };
544
+ const STEP_LABEL = {
545
+ research: '리서치', bg: '배경 이미지', html: 'HTML 작성', prompt: '이미지 프롬프트',
546
+ image: '이미지 생성', capture: 'PNG 캡처', check: '품질 검사',
547
+ };
548
+ let mode = null, engine = null, raw = '', clean = '', ctrl = null, result = null, plan = null;
549
+
550
+ const show = (id) => ['pick', 'brand', 'engine', 'outs', 'form', 'run', 'plan', 'out'].forEach((s) => {
551
+ const el = $(s), on = s === id;
552
+ if (on && el.hidden) { el.hidden = false; el.style.animation = 'none'; el.offsetHeight; el.style.animation = ''; }
553
+ else el.hidden = !on;
554
+ });
555
+
556
+ function toForm() {
557
+ $('formTitle').textContent = MODES[mode].form;
558
+ show('form');
559
+ $('topic').focus();
560
+ }
561
+
562
+ document.querySelectorAll('[data-mode]').forEach((b) => b.onclick = () => {
563
+ mode = b.dataset.mode;
564
+ engine = null;
565
+ // 쓰레드는 글만 뽑으므로 엔진 선택이 없다.
566
+ $('countRow').hidden = mode !== 'cardnews';
567
+ if (mode === 'cardnews') show('engine'); else toForm();
568
+ });
569
+ document.querySelectorAll('[data-engine]').forEach((b) => b.onclick = () => {
570
+ engine = b.dataset.engine;
571
+ toForm();
572
+ });
573
+ $('engineBack').onclick = () => show('pick');
574
+
575
+ function renderSteps(active, status) {
576
+ const host = $('steps');
577
+ if (!engine) { host.hidden = true; return; }
578
+ host.hidden = false;
579
+ if (!host.children.length) {
580
+ for (const id of ENGINES[engine].steps) {
581
+ const li = document.createElement('li');
582
+ li.dataset.step = id;
583
+ const dot = document.createElement('span');
584
+ dot.className = 'dot';
585
+ const t = document.createElement('span');
586
+ t.textContent = STEP_LABEL[id] || id;
587
+ li.append(dot, t);
588
+ host.append(li);
589
+ }
590
+ }
591
+ if (!active) return;
592
+ const li = host.querySelector(`[data-step="${active}"]`);
593
+ if (li) li.dataset.status = status === 'done' ? 'done' : 'run';
594
+ }
595
+
596
+ // 프롬프트 출력 형식이 "## 01 · Cover" 라 ## 기준으로 장을 나눈다.
597
+ function parseBlocks(text) {
598
+ const blocks = text.split(/^##\s+/m).slice(1).map((chunk) => {
599
+ const nl = chunk.indexOf('\n');
600
+ const title = (nl < 0 ? chunk : chunk.slice(0, nl)).trim();
601
+ const body = (nl < 0 ? '' : chunk.slice(nl + 1)).replace(/^\s*-{3,}\s*$/gm, '').trim();
602
+ return { title, body };
603
+ }).filter((b) => b.title && b.body);
604
+ return blocks.length >= 2 ? blocks : null; // 형식이 깨지면 통짜로 보여준다
605
+ }
606
+
607
+ function render(text) {
608
+ const blocks = parseBlocks(text);
609
+ const host = $('blocks');
610
+ host.textContent = '';
611
+ $('outNote').textContent = blocks ? `${blocks.length}개 조각 · 각 조각 위에 커서를 올리면 따로 복사할 수 있습니다`
612
+ : '형식이 예상과 달라 통째로 보여줍니다';
613
+ for (const b of (blocks || [{ title: MODES[mode].title, body: text }])) {
614
+ const el = document.createElement('article');
615
+ el.className = 'block';
616
+ const h = document.createElement('header');
617
+ const t = document.createElement('h2');
618
+ t.textContent = b.title;
619
+ const btn = document.createElement('button');
620
+ btn.className = 'copy';
621
+ btn.type = 'button';
622
+ btn.textContent = '복사';
623
+ btn.setAttribute('aria-label', `${b.title} 복사`);
624
+ btn.onclick = () => copy(b.body, btn);
625
+ h.append(t, btn);
626
+ const body = document.createElement('div');
627
+ body.className = 'text';
628
+ body.textContent = b.body;
629
+ el.append(h, body);
630
+ host.append(el);
631
+ }
632
+ }
633
+
634
+ // 인스타 피드에서 어떻게 보이는지 넘겨보며 확인한다.
635
+ // 스와이프는 CSS scroll-snap 이 처리하고, 여기서는 화살표·점·번호만 동기화한다.
636
+ function renderIg({ dir, slides }) {
637
+ const track = $('igTrack');
638
+ const pips = $('igPips');
639
+ track.textContent = '';
640
+ pips.textContent = '';
641
+ $('ig').hidden = !slides.length;
642
+ if (!slides.length) return;
643
+
644
+ slides.forEach((f, i) => {
645
+ const img = document.createElement('img');
646
+ img.src = `/out/${dir.replace(/^output\//, '')}/${f}`;
647
+ img.alt = `${i + 1}번째 슬라이드 미리보기`;
648
+ img.loading = i < 2 ? 'eager' : 'lazy';
649
+ track.append(img);
650
+ pips.append(document.createElement('i'));
651
+ });
652
+
653
+ // 화면이 아직 hidden 이면 clientWidth 가 0 이라 나누면 NaN 이 된다. 그때는 첫 장으로 본다.
654
+ const at = () => (track.clientWidth ? Math.round(track.scrollLeft / track.clientWidth) : 0);
655
+ const sync = () => {
656
+ const i = Math.min(slides.length - 1, Math.max(0, at()));
657
+ $('igCount').textContent = `${i + 1}/${slides.length}`;
658
+ [...pips.children].forEach((p, n) => p.classList.toggle('on', n === i));
659
+ $('igPrev').disabled = i === 0;
660
+ $('igNext').disabled = i === slides.length - 1;
661
+ };
662
+ const go = (d) => track.scrollBy({ left: d * track.clientWidth, behavior: 'smooth' });
663
+
664
+ track.onscroll = sync;
665
+ $('igPrev').onclick = () => go(-1);
666
+ $('igNext').onclick = () => go(1);
667
+ // 키보드로도 넘길 수 있어야 한다 (트랙에 포커스가 있을 때)
668
+ track.onkeydown = (e) => {
669
+ if (e.key === 'ArrowRight') { e.preventDefault(); go(1); }
670
+ if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); }
671
+ };
672
+ track.scrollLeft = 0;
673
+ sync();
674
+ }
675
+
676
+ function renderSlides({ dir, slides, canRegen }) {
677
+ const host = $('slides');
678
+ renderIg({ dir, slides });
679
+ host.textContent = '';
680
+ host.hidden = false;
681
+ slides.forEach((f, i) => {
682
+ const cell = document.createElement('div');
683
+ cell.className = 'cell';
684
+ const a = document.createElement('a');
685
+ a.href = `/out/${dir.replace(/^output\//, '')}/${f}`;
686
+ a.download = f;
687
+ a.title = `${f} 내려받기`;
688
+ const img = document.createElement('img');
689
+ img.src = a.href;
690
+ img.alt = `${i + 1}번째 슬라이드`;
691
+ img.loading = 'lazy';
692
+ a.append(img);
693
+ cell.append(a);
694
+ // 이미지 트랙은 한글이 이미지 안에 들어가 오타가 난다. 그 장만 갈아끼울 수 있어야 한다.
695
+ if (canRegen) cell.append(regenButton({ dir, f, img, cell }));
696
+ host.append(cell);
697
+ });
698
+ $('outPath').textContent = `${slides.length}장 · ${dir}/ — 슬라이드를 누르면 내려받습니다`
699
+ + (canRegen ? ' · 오타가 있으면 “다시 만들기”' : '');
700
+ $('outPath').hidden = false;
701
+ // 한 장씩 누르면 브라우저가 연속 다운로드를 막는다. zip 하나로 묶어 받는다.
702
+ const zip = $('zipAll');
703
+ zip.href = `/zip/${dir.replace(/^output\//, '')}`;
704
+ zip.textContent = `한번에 받기 (${slides.length}장)`;
705
+ zip.hidden = false;
706
+ }
707
+
708
+ function regenButton({ dir, f, img, cell }) {
709
+ const btn = document.createElement('button');
710
+ btn.type = 'button';
711
+ btn.className = 'regen';
712
+ btn.textContent = '다시 만들기';
713
+ btn.onclick = async () => {
714
+ const n = Number((f.match(/(\d+)/) || [])[1]);
715
+ if (!n) return;
716
+ btn.disabled = true;
717
+ cell.classList.add('busy');
718
+ btn.textContent = '만드는 중…';
719
+ try {
720
+ const res = await fetch('/regen', {
721
+ method: 'POST',
722
+ headers: { 'content-type': 'application/json' },
723
+ body: JSON.stringify({ dir, n }),
724
+ });
725
+ // ndjson 을 끝까지 읽어야 실제 완료/실패를 안다. 마지막 줄만 판단에 쓴다.
726
+ const reader = res.body.getReader();
727
+ const dec = new TextDecoder();
728
+ let buf = '', last = null;
729
+ for (;;) {
730
+ const { value, done } = await reader.read();
731
+ if (done) break;
732
+ buf += dec.decode(value, { stream: true });
733
+ const lines = buf.split('\n');
734
+ buf = lines.pop();
735
+ for (const l of lines) { try { last = JSON.parse(l); } catch {} }
736
+ }
737
+ if (last?.error) throw new Error(last.error);
738
+ // 파일명이 같아 브라우저가 캐시를 쓴다. 쿼리로 깨야 새 이미지가 보인다.
739
+ const bust = `?t=${Date.now()}`;
740
+ img.src = img.src.split('?')[0] + bust;
741
+ // 목업 미리보기에도 같은 장이 걸려 있다. 같이 갈아주지 않으면 옛 이미지가 남는다.
742
+ const mock = $('igTrack').children[n - 1];
743
+ if (mock) mock.src = mock.src.split('?')[0] + bust;
744
+ btn.textContent = '다시 만들기';
745
+ } catch (e) {
746
+ btn.textContent = '실패 · 다시';
747
+ alert(`재생성 실패: ${e.message}`);
748
+ } finally {
749
+ btn.disabled = false;
750
+ cell.classList.remove('busy');
751
+ }
752
+ };
753
+ return btn;
754
+ }
755
+
756
+ async function copy(text, btn) {
757
+ const before = btn.textContent;
758
+ try {
759
+ await navigator.clipboard.writeText(text);
760
+ btn.textContent = '복사됨';
761
+ } catch {
762
+ btn.textContent = '복사 실패';
763
+ }
764
+ setTimeout(() => (btn.textContent = before), 1400);
765
+ }
766
+
767
+ // ── 참고 자료 ────────────────────────────────
768
+ const MAX_REFS = 6;
769
+ const OK_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];
770
+ let refs = []; // [{ name, dataUrl }]
771
+
772
+ const readFile = (f) => new Promise((res, rej) => {
773
+ const r = new FileReader();
774
+ r.onload = () => res(r.result);
775
+ r.onerror = () => rej(new Error(`${f.name} 를 읽지 못했습니다`));
776
+ r.readAsDataURL(f);
777
+ });
778
+
779
+ async function addFiles(list) {
780
+ const files = [...list].filter((f) => f && OK_TYPES.includes(f.type));
781
+ if (!files.length) return showErr('png · jpg · webp · gif 만 넣을 수 있습니다.');
782
+ const room = MAX_REFS - refs.length;
783
+ if (room <= 0) return showErr(`참고 자료는 ${MAX_REFS}장까지입니다.`);
784
+ for (const f of files.slice(0, room)) {
785
+ refSet = null; // 새로 올리면 템플릿 선택은 푼다 — 둘을 섞지 않는다
786
+ try { refs.push({ name: f.name || '붙여넣은 이미지', dataUrl: await readFile(f) }); }
787
+ catch (e) { showErr(e.message); }
788
+ }
789
+ if (files.length > room) showErr(`${MAX_REFS}장까지만 담았습니다.`);
790
+ renderThumbs();
791
+ }
792
+
793
+ function renderThumbs() {
794
+ const host = $('thumbs');
795
+ loadTemplates(); // 업로드/삭제로 refSet 이 풀렸을 수 있어 템플릿 강조를 다시 맞춘다
796
+ host.textContent = '';
797
+ refs.forEach((r, i) => {
798
+ const el = document.createElement('div');
799
+ el.className = 'thumb';
800
+ const img = document.createElement('img');
801
+ img.src = r.dataUrl;
802
+ img.alt = r.name;
803
+ const del = document.createElement('button');
804
+ del.type = 'button';
805
+ del.textContent = '×';
806
+ del.setAttribute('aria-label', `${r.name} 빼기`);
807
+ del.onclick = () => { refs.splice(i, 1); renderThumbs(); };
808
+ el.append(img, del);
809
+ host.append(el);
810
+ });
811
+ $('refHint').textContent = refs.length
812
+ ? `${refs.length}장 담김 · 더 넣으려면 끌어다 놓거나 붙여넣으세요`
813
+ : '끌어다 놓거나, ⌘/Ctrl + V로 붙여넣거나, 눌러서 고르세요';
814
+ }
815
+
816
+ const showErr = (msg) => { $('err').textContent = msg; $('err').hidden = false; };
817
+
818
+ $('drop').onclick = () => $('pick-file').click();
819
+ $('drop').onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); $('pick-file').click(); } };
820
+ $('pick-file').onchange = (e) => { addFiles(e.target.files); e.target.value = ''; };
821
+ ['dragenter', 'dragover'].forEach((t) => $('drop').addEventListener(t, (e) => {
822
+ e.preventDefault(); $('drop').classList.add('over');
823
+ }));
824
+ ['dragleave', 'drop'].forEach((t) => $('drop').addEventListener(t, () => $('drop').classList.remove('over')));
825
+ $('drop').addEventListener('drop', (e) => { e.preventDefault(); addFiles(e.dataTransfer.files); });
826
+ // 폼 화면에서 어디에 커서가 있든 붙여넣기를 받는다.
827
+ document.addEventListener('paste', (e) => {
828
+ if ($('form').hidden) return;
829
+ const imgs = [...(e.clipboardData?.files || [])].filter((f) => OK_TYPES.includes(f.type));
830
+ if (imgs.length) { e.preventDefault(); addFiles(imgs); }
831
+ });
832
+
833
+ async function generate() {
834
+ const topic = $('topic').value.trim();
835
+ if (!topic) { $('err').textContent = '주제를 한 줄이라도 적어주세요.'; $('err').hidden = false; $('topic').focus(); return; }
836
+ $('err').hidden = true;
837
+ raw = '';
838
+ result = null;
839
+ $('live').textContent = '';
840
+ $('steps').textContent = '';
841
+ renderSteps();
842
+ show('run');
843
+
844
+ const t0 = Date.now();
845
+ const tick = setInterval(() => {
846
+ const s = Math.round((Date.now() - t0) / 1000);
847
+ $('runNote').textContent = raw ? `${raw.length}자 · ${s}초` : `첫 문장을 고르고 있습니다 · ${s}초`;
848
+ }, 500);
849
+
850
+ try {
851
+ await stream('/gen', {
852
+ mode, engine, topic,
853
+ count: $('count').value || undefined,
854
+ refSet, // 템플릿을 골랐으면 업로드 대신 그 경로를 쓴다
855
+ refs: refs.map((r) => r.dataUrl),
856
+ today: new Date().toISOString().slice(0, 10), // 폴더명용 날짜는 브라우저 기준
857
+ });
858
+
859
+ // 카드뉴스는 리서치까지만 돌았다. 시나리오를 보여주고 승인받아야 이미지를 뽑는다.
860
+ if (engine) {
861
+ if (!plan?.slides?.length) throw new Error('시나리오가 만들어지지 않았습니다. 아래 로그를 확인하세요.');
862
+ clearInterval(tick);
863
+ return renderPlan(plan);
864
+ }
865
+
866
+ $('outTitle').textContent = MODES[mode].title;
867
+
868
+ if (!raw.trim()) throw new Error('아무것도 생성되지 않았습니다');
869
+ // 프롬프트의 출력 예시가 펜스라 답변 전체를 ``` 로 감싸 오는 경우가 있어 벗겨낸다.
870
+ clean = raw.trim().replace(/^```[a-z]*\n([\s\S]*)\n```$/, '$1').trim();
871
+ $('slides').hidden = true;
872
+ $('outPath').hidden = true;
873
+ $('ig').hidden = true; // 쓰레드는 이미지가 없다
874
+ $('zipAll').hidden = true;
875
+ $('reviewBox').hidden = true;
876
+ render(clean);
877
+ show('out');
878
+ } catch (e) {
879
+ if (e.name === 'AbortError') { show('form'); return; }
880
+ $('err').textContent = e.message;
881
+ $('err').hidden = false;
882
+ show('form');
883
+ } finally {
884
+ clearInterval(tick);
885
+ ctrl = null;
886
+ }
887
+ }
888
+
889
+ // ndjson 스트림 하나를 끝까지 읽는다. /gen 과 /build 가 같은 형식을 쓴다.
890
+ async function stream(url, payload) {
891
+ ctrl = new AbortController();
892
+ const res = await fetch(url, {
893
+ method: 'POST',
894
+ headers: { 'content-type': 'application/json' },
895
+ body: JSON.stringify(payload),
896
+ signal: ctrl.signal,
897
+ });
898
+ if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || '요청이 거부됐습니다');
899
+ const reader = res.body.getReader();
900
+ const dec = new TextDecoder();
901
+ let buf = '';
902
+ for (;;) {
903
+ const { value, done } = await reader.read();
904
+ if (done) break;
905
+ buf += dec.decode(value, { stream: true });
906
+ const lines = buf.split('\n');
907
+ buf = lines.pop();
908
+ for (const line of lines) {
909
+ if (!line.trim()) continue;
910
+ const msg = JSON.parse(line);
911
+ if (msg.error) throw new Error(msg.error);
912
+ if (msg.step) renderSteps(msg.step, msg.status);
913
+ if (msg.result) result = msg.result;
914
+ if (msg.brief) plan = msg.brief;
915
+ if (msg.t) {
916
+ raw += msg.t;
917
+ $('live').textContent = raw;
918
+ $('live').scrollTop = $('live').scrollHeight;
919
+ }
920
+ }
921
+ }
922
+ }
923
+
924
+ // 승인 전에 시나리오를 보여준다. 장수가 마음에 안 들면 여기서 되돌린다.
925
+ function renderPlan(p) {
926
+ const host = $('scenario');
927
+ host.textContent = '';
928
+ p.slides.forEach((s) => {
929
+ const li = document.createElement('li');
930
+ const h = document.createElement('strong');
931
+ h.textContent = (s.headline || s.role || '').replace(/\n/g, ' ');
932
+ li.append(h);
933
+ if (s.sub) {
934
+ const sub = document.createElement('span');
935
+ sub.className = 'fine';
936
+ sub.textContent = String(s.sub).replace(/\n/g, ' ');
937
+ li.append(sub);
938
+ }
939
+ host.append(li);
940
+ });
941
+ $('planNote').textContent = `${p.topic} · ${p.slides.length}장 · ${ENGINES[engine].label}`;
942
+ show('plan');
943
+ }
944
+
945
+ async function build() {
946
+ if (!plan?.dir) return;
947
+ raw = '';
948
+ result = null;
949
+ $('live').textContent = '';
950
+ $('steps').textContent = '';
951
+ renderSteps();
952
+ show('run');
953
+ const t0 = Date.now();
954
+ const tick = setInterval(() => {
955
+ $('runNote').textContent = `만드는 중 · ${Math.round((Date.now() - t0) / 1000)}초`;
956
+ }, 500);
957
+ try {
958
+ await stream('/build', { dir: plan.dir });
959
+ if (!result?.slides?.length) throw new Error('슬라이드가 만들어지지 않았습니다. 아래 로그를 확인하세요.');
960
+ $('outTitle').textContent = `${MODES[mode].title} · ${ENGINES[engine].label}`;
961
+ renderSlides(result);
962
+ clean = result.caption || '';
963
+ // 목업 캡션은 제목줄·해시태그를 뺀 본문 두 줄만 (피드에서 접히는 만큼)
964
+ $('igCap').textContent = clean.replace(/^#.*$/gm, '').trim().split('\n')
965
+ .filter((l) => l.trim()).slice(0, 2).join(' ');
966
+ render(clean || raw.trim());
967
+ show('out');
968
+ runReview(plan.dir); // 결과를 먼저 띄우고 채점은 뒤에서 — 기다리게 하지 않는다
969
+ } catch (e) {
970
+ if (e.name === 'AbortError') { renderPlan(plan); return; }
971
+ $('err').textContent = e.message;
972
+ $('err').hidden = false;
973
+ show('form');
974
+ } finally {
975
+ clearInterval(tick);
976
+ ctrl = null;
977
+ }
978
+ }
979
+
980
+ // 결과 화면을 띄운 뒤 백그라운드로 채점한다. 실패해도 결과물에는 영향이 없다.
981
+ async function runReview(dir) {
982
+ const box = $('reviewBox');
983
+ box.hidden = false;
984
+ box.open = false;
985
+ $('reviewTitle').textContent = '품질 리뷰 채점 중… (몇 분 걸립니다)';
986
+ $('reviewBody').textContent = '';
987
+ try {
988
+ const res = await fetch('/review', {
989
+ method: 'POST',
990
+ headers: { 'content-type': 'application/json' },
991
+ body: JSON.stringify({ dir }),
992
+ });
993
+ if (!res.ok) throw new Error('리뷰 요청이 거부됐습니다');
994
+ const reader = res.body.getReader();
995
+ const dec = new TextDecoder();
996
+ let buf = '', md = null, err = null;
997
+ for (;;) {
998
+ const { value, done } = await reader.read();
999
+ if (done) break;
1000
+ buf += dec.decode(value, { stream: true });
1001
+ const lines = buf.split('\n');
1002
+ buf = lines.pop();
1003
+ for (const l of lines) {
1004
+ if (!l.trim()) continue;
1005
+ let m; try { m = JSON.parse(l); } catch { continue; }
1006
+ if (m.review) md = m.review;
1007
+ if (m.error) err = m.error;
1008
+ }
1009
+ }
1010
+ if (!md) throw new Error(err || '리뷰가 만들어지지 않았습니다');
1011
+ const score = (md.match(/총점[:\s*]*(\d+)\s*\/\s*100/) || [])[1];
1012
+ $('reviewTitle').textContent = score ? `품질 리뷰 — ${score}/100 (눌러서 보기)` : '품질 리뷰 (눌러서 보기)';
1013
+ $('reviewBody').textContent = md;
1014
+ } catch (e) {
1015
+ $('reviewTitle').textContent = `품질 리뷰 실패 — ${e.message}`;
1016
+ }
1017
+ }
1018
+
1019
+ // ── 참고자료 템플릿 ──────────────────────────
1020
+ // 전에 올린 세트를 골라 쓰거나 지운다. 고르면 이번 실행은 업로드 없이 그 경로를 그대로 쓴다.
1021
+ let refSet = null;
1022
+
1023
+ async function loadTemplates() {
1024
+ try {
1025
+ const { sets } = await (await fetch('/refs')).json();
1026
+ renderTemplates(sets);
1027
+ } catch { $('tplBox').hidden = true; }
1028
+ }
1029
+
1030
+ function renderTemplates(sets) {
1031
+ const host = $('tpls');
1032
+ host.textContent = '';
1033
+ $('tplBox').hidden = !sets.length;
1034
+ if (!sets.length) { refSet = null; return; }
1035
+ sets.forEach((s) => {
1036
+ const row = document.createElement('div');
1037
+ row.className = 'tpl' + (refSet === s.id ? ' on' : '');
1038
+
1039
+ const pick = document.createElement('button');
1040
+ pick.type = 'button';
1041
+ pick.className = 'pick';
1042
+ pick.title = '이 참고 자료를 이번에도 쓰기';
1043
+ s.files.slice(0, 4).forEach((f) => {
1044
+ const img = document.createElement('img');
1045
+ img.src = `/out/${f.replace(/^output\//, '')}`;
1046
+ img.alt = '';
1047
+ img.loading = 'lazy';
1048
+ pick.append(img);
1049
+ });
1050
+ const meta = document.createElement('span');
1051
+ meta.className = 'meta';
1052
+ meta.textContent = `${s.files.length}장${refSet === s.id ? ' · 사용 중' : ''}`;
1053
+ pick.append(meta);
1054
+ pick.onclick = () => {
1055
+ refSet = refSet === s.id ? null : s.id; // 다시 누르면 해제
1056
+ if (refSet) { refs = []; renderThumbs(); } // 템플릿과 새 업로드는 섞지 않는다
1057
+ renderTemplates(sets);
1058
+ };
1059
+
1060
+ const del = document.createElement('button');
1061
+ del.type = 'button';
1062
+ del.className = 'del';
1063
+ del.textContent = '삭제';
1064
+ del.onclick = async () => {
1065
+ if (!confirm(`참고 자료 ${s.files.length}장을 지웁니다. 되돌릴 수 없습니다.`)) return;
1066
+ const res = await fetch('/refs/delete', {
1067
+ method: 'POST',
1068
+ headers: { 'content-type': 'application/json' },
1069
+ body: JSON.stringify({ id: s.id }),
1070
+ });
1071
+ const out = await res.json().catch(() => ({}));
1072
+ if (!res.ok) return alert(out.error || '삭제하지 못했습니다');
1073
+ if (refSet === s.id) refSet = null;
1074
+ renderTemplates(out.sets || []);
1075
+ };
1076
+
1077
+ row.append(pick, del);
1078
+ host.append(row);
1079
+ });
1080
+ }
1081
+
1082
+ loadTemplates();
1083
+
1084
+ // ── 브랜드 설정 ──────────────────────────────
1085
+ // 폼은 서버의 brand.schema.json 으로 그린다. 항목을 늘려도 여기는 안 고쳐도 된다.
1086
+ let brandVals = {};
1087
+
1088
+ async function loadBrand() {
1089
+ try {
1090
+ const { schema, values, logo } = await (await fetch('/brand')).json();
1091
+ brandVals = values || {};
1092
+ buildBrandForm(schema, brandVals);
1093
+ $('brandNow').textContent = brandVals.name ? `현재: ${brandVals.name}` : '아직 설정 안 됨';
1094
+ setLogo(logo);
1095
+ } catch { /* 서버가 아직 준비 전이면 다음 진입 때 다시 시도한다 */ }
1096
+ }
1097
+
1098
+ function buildBrandForm(schema, values) {
1099
+ const host = $('brandForm');
1100
+ host.textContent = '';
1101
+ for (const g of schema.groups) {
1102
+ const box = document.createElement('section');
1103
+ box.className = 'bgroup';
1104
+ const h = document.createElement('h2');
1105
+ h.textContent = g.title;
1106
+ box.append(h);
1107
+ if (g.hint) {
1108
+ const hint = document.createElement('span');
1109
+ hint.className = 'fine';
1110
+ hint.textContent = g.hint;
1111
+ box.append(hint);
1112
+ }
1113
+ const fields = document.createElement('div');
1114
+ fields.className = 'bfields';
1115
+ for (const f of g.fields) fields.append(brandField(f, values[f.key] ?? ''));
1116
+ box.append(fields);
1117
+ host.append(box);
1118
+ }
1119
+ }
1120
+
1121
+ function brandField(f, val) {
1122
+ const wrap = document.createElement('div');
1123
+ wrap.className = 'bfield' + (f.type === 'color' ? ' color' : '');
1124
+ const id = `bf-${f.key}`;
1125
+ const label = document.createElement('label');
1126
+ label.htmlFor = id;
1127
+ label.textContent = f.label;
1128
+ if (f.required) {
1129
+ const req = document.createElement('span');
1130
+ req.className = 'req';
1131
+ req.textContent = ' *';
1132
+ label.append(req);
1133
+ }
1134
+ wrap.append(label);
1135
+
1136
+ let input;
1137
+ if (f.type === 'textarea') {
1138
+ input = document.createElement('textarea');
1139
+ } else if (f.type === 'select') {
1140
+ input = document.createElement('select');
1141
+ // 선택 안 함도 허용 — 빈칸이면 md 에서 통째로 빠진다
1142
+ [''].concat(f.options || []).forEach((o) => {
1143
+ const op = document.createElement('option');
1144
+ op.value = o;
1145
+ op.textContent = o || '— 선택 안 함 —';
1146
+ input.append(op);
1147
+ });
1148
+ } else {
1149
+ input = document.createElement('input');
1150
+ input.type = 'text';
1151
+ }
1152
+ input.id = id;
1153
+ input.dataset.key = f.key;
1154
+ input.value = val;
1155
+ if (f.placeholder && f.type !== 'select') input.placeholder = f.placeholder;
1156
+
1157
+ if (f.type === 'color') {
1158
+ // 색은 색상표와 직접 입력을 같이 준다. 브랜드 HEX 를 아는 사람은 붙여넣는 게 빠르다.
1159
+ const swatch = document.createElement('input');
1160
+ swatch.type = 'color';
1161
+ swatch.value = /^#[0-9a-f]{6}$/i.test(val) ? val : (f.placeholder || '#000000');
1162
+ swatch.oninput = () => { input.value = swatch.value; };
1163
+ input.oninput = () => { if (/^#[0-9a-f]{6}$/i.test(input.value)) swatch.value = input.value; };
1164
+ wrap.append(swatch);
1165
+ }
1166
+ wrap.append(input);
1167
+
1168
+ if (f.hint) {
1169
+ const hint = document.createElement('span');
1170
+ hint.className = 'fine hint';
1171
+ hint.textContent = f.hint;
1172
+ wrap.append(hint);
1173
+ }
1174
+ return wrap;
1175
+ }
1176
+
1177
+ $('brandOpen').onclick = () => { $('brandErr').hidden = true; $('brandSaved').textContent = ''; show('brand'); };
1178
+ $('brandBack').onclick = () => show('pick');
1179
+ $('brandSave').onclick = async () => {
1180
+ const values = {};
1181
+ $('brandForm').querySelectorAll('[data-key]').forEach((el) => { values[el.dataset.key] = el.value.trim(); });
1182
+ if (!values.name) {
1183
+ $('brandErr').textContent = '브랜드명은 있어야 합니다. 나머지는 비워도 됩니다.';
1184
+ $('brandErr').hidden = false;
1185
+ return;
1186
+ }
1187
+ $('brandErr').hidden = true;
1188
+ try {
1189
+ const res = await fetch('/brand', {
1190
+ method: 'POST',
1191
+ headers: { 'content-type': 'application/json' },
1192
+ body: JSON.stringify({ values }),
1193
+ });
1194
+ const out = await res.json().catch(() => ({}));
1195
+ if (!res.ok) throw new Error(out.error || '저장하지 못했습니다');
1196
+ brandVals = out.values;
1197
+ $('brandNow').textContent = `현재: ${brandVals.name}`;
1198
+ $('brandSaved').textContent = '저장했습니다';
1199
+ setTimeout(() => { $('brandSaved').textContent = ''; }, 2500);
1200
+ } catch (e) {
1201
+ $('brandErr').textContent = e.message;
1202
+ $('brandErr').hidden = false;
1203
+ }
1204
+ };
1205
+
1206
+ // ── 로고 ─────────────────────────────────────
1207
+ function setLogo(on) {
1208
+ const prev = $('logoPrev');
1209
+ prev.textContent = '';
1210
+ $('logoDel').hidden = !on;
1211
+ if (on) {
1212
+ const img = document.createElement('img');
1213
+ img.src = `/logo.svg?t=${Date.now()}`; // 교체 직후에도 옛 파일이 안 남게
1214
+ img.alt = '로고';
1215
+ prev.append(img);
1216
+ } else {
1217
+ const s = document.createElement('span');
1218
+ s.className = 'fine';
1219
+ s.textContent = '없음';
1220
+ prev.append(s);
1221
+ }
1222
+ }
1223
+
1224
+ async function sendLogo(dataUrl) {
1225
+ const res = await fetch('/logo', {
1226
+ method: 'POST',
1227
+ headers: { 'content-type': 'application/json' },
1228
+ body: JSON.stringify({ dataUrl }),
1229
+ });
1230
+ const out = await res.json().catch(() => ({}));
1231
+ if (!res.ok) { alert(out.error || '로고를 저장하지 못했습니다'); return; }
1232
+ setLogo(out.logo);
1233
+ }
1234
+
1235
+ $('logoPick').onclick = () => $('pick-logo').click();
1236
+ $('pick-logo').onchange = async (e) => {
1237
+ const f = e.target.files?.[0];
1238
+ e.target.value = '';
1239
+ if (!f) return;
1240
+ if (!/\.svg$/i.test(f.name) && f.type !== 'image/svg+xml') return alert('SVG 파일만 됩니다');
1241
+ await sendLogo(await readFile(f));
1242
+ };
1243
+ $('logoDel').onclick = async () => { if (confirm('로고를 지웁니다.')) await sendLogo(null); };
1244
+
1245
+ // ── 지난 결과물 ──────────────────────────────
1246
+ // 결과 화면(목업·그리드·다시 만들기·한번에 받기)을 그대로 재사용한다.
1247
+ async function loadOutputs() {
1248
+ const host = $('outsList');
1249
+ host.textContent = '';
1250
+ let items = [];
1251
+ try { ({ items } = await (await fetch('/outputs')).json()); } catch { /* 아래 빈 상태로 */ }
1252
+
1253
+ $('outsNote').textContent = items.length
1254
+ ? `지금까지 만든 카드뉴스 ${items.length}건입니다. 눌러서 보고, 받고, 지울 수 있습니다.`
1255
+ : '아직 만든 결과물이 없습니다.';
1256
+ if (!items.length) {
1257
+ const p = document.createElement('p');
1258
+ p.className = 'empty';
1259
+ p.textContent = '카드뉴스를 하나 만들면 여기에 쌓입니다.';
1260
+ host.append(p);
1261
+ return;
1262
+ }
1263
+
1264
+ for (const it of items) {
1265
+ const row = document.createElement('div');
1266
+ row.className = 'out-row';
1267
+
1268
+ const open = document.createElement('button');
1269
+ open.type = 'button';
1270
+ open.className = 'open';
1271
+ open.title = '열어서 보기';
1272
+ const img = document.createElement('img');
1273
+ img.src = `/out/${it.dir.replace(/^output\//, '')}/${it.slides[0]}`;
1274
+ img.alt = '';
1275
+ img.loading = 'lazy';
1276
+ const box = document.createElement('span');
1277
+ const t = document.createElement('span');
1278
+ t.className = 't';
1279
+ t.textContent = it.title;
1280
+ const n = document.createElement('span');
1281
+ n.className = 'n';
1282
+ n.textContent = `${it.slides.length}장`;
1283
+ box.append(t, n);
1284
+ open.append(img, box);
1285
+ open.onclick = () => openOutput(it);
1286
+
1287
+ const acts = document.createElement('span');
1288
+ acts.className = 'acts';
1289
+ const zip = document.createElement('a');
1290
+ zip.textContent = '받기';
1291
+ zip.href = `/zip/${it.dir.replace(/^output\//, '')}`;
1292
+ zip.setAttribute('download', '');
1293
+ const del = document.createElement('button');
1294
+ del.type = 'button';
1295
+ del.textContent = '삭제';
1296
+ del.onclick = async () => {
1297
+ if (!confirm(`“${it.title}” ${it.slides.length}장을 지웁니다. 되돌릴 수 없습니다.`)) return;
1298
+ const res = await fetch('/outputs/delete', {
1299
+ method: 'POST',
1300
+ headers: { 'content-type': 'application/json' },
1301
+ body: JSON.stringify({ dir: it.dir }),
1302
+ });
1303
+ const out = await res.json().catch(() => ({}));
1304
+ if (!res.ok) return alert(out.error || '삭제하지 못했습니다');
1305
+ loadOutputs();
1306
+ };
1307
+ acts.append(zip, del);
1308
+
1309
+ row.append(open, acts);
1310
+ host.append(row);
1311
+ }
1312
+ }
1313
+
1314
+ // 지난 결과물을 결과 화면에 띄운다. 캡션은 파일에서 직접 읽는다.
1315
+ async function openOutput(it) {
1316
+ result = { dir: it.dir, slides: it.slides, canRegen: it.canRegen };
1317
+ plan = { dir: it.dir };
1318
+ $('outTitle').textContent = it.title;
1319
+ renderSlides(result);
1320
+ clean = '';
1321
+ try {
1322
+ const r = await fetch(`/out/${it.dir.replace(/^output\//, '')}/caption.md`);
1323
+ if (r.ok) clean = await r.text();
1324
+ } catch { /* 캡션이 없을 수도 있다 */ }
1325
+ $('igCap').textContent = clean.replace(/^#.*$/gm, '').trim().split('\n')
1326
+ .filter((l) => l.trim()).slice(0, 2).join(' ');
1327
+ render(clean || '(캡션 없음)');
1328
+ $('reviewBox').hidden = true;
1329
+ show('out');
1330
+ }
1331
+
1332
+ $('outsOpen').onclick = () => { loadOutputs(); show('outs'); };
1333
+ $('outsBack').onclick = () => show('engine');
1334
+
1335
+ loadBrand();
1336
+
1337
+ $('go').onclick = generate;
1338
+ $('approve').onclick = build;
1339
+ $('redo').onclick = () => show('form'); // 장수를 바꿔 리서치부터 다시
1340
+ $('planCancel').onclick = () => show('pick');
1341
+ $('back').onclick = () => show('pick');
1342
+ $('again').onclick = () => show('form');
1343
+ $('back').onclick = () => show(mode === 'cardnews' ? 'engine' : 'pick');
1344
+ $('reset').onclick = () => show('pick');
1345
+ $('stop').onclick = () => ctrl?.abort();
1346
+ $('copyAll').onclick = (e) => copy(clean, e.currentTarget);
1347
+ $('topic').onkeydown = (e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) generate(); };
1348
+ </script>
1349
+ </body>
1350
+ </html>