Public Source Viewer

비나래아카이브 개발자 포털

실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.

Redacted View
view/hinana/voicepeak.ejs
공개 가능
1 <!DOCTYPE html>
2 <html lang="ko">
3 <head>
4 <meta charset="utf-8" />
5 <meta name="color-scheme" content="light dark">
6 <meta name="viewport" content="width=device-width, initial-scale=1">
7 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
8 <title>Voicepeak — 비나래아카이브</title>
9 <link rel="stylesheet" href="/vendors/bootstrap/css/bootstrap.min.css" />
10 <script src="/vendors/bootstrap/js/bootstrap.min.js"></script>
11 <link rel="stylesheet" href="/css/hinana.css" type="text/css">
12 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons/font/bootstrap-icons.css">
13 <style>
14 :root {
15 --font-family: 'Noto Sans KR', system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
16 --bg-main: #fbfcff;
17 --bg-panel: #ffffff;
18 --bg-soft: #eef6f2;
19 --text-primary: #111827;
20 --text-secondary: #526171;
21 --accent-color: #0ea5e9;
22 --accent-green: #10b981;
23 --danger-color: #dc2626;
24 --border-color: #dbe5ed;
25 --shadow-line: 0 1px 0 rgba(15,23,42,0.04), 0 18px 50px rgba(15,23,42,0.08);
26 }
27 body.dark-mode {
28 --bg-main: #050505;
29 --bg-panel: #101216;
30 --bg-soft: #171c22;
31 --text-primary: #f2f7fb;
32 --text-secondary: #95a3b2;
33 --accent-color: #38bdf8;
34 --accent-green: #34d399;
35 --danger-color: #fb7185;
36 --border-color: #252b33;
37 --shadow-line: 0 1px 0 rgba(255,255,255,0.04), 0 22px 60px rgba(0,0,0,0.28);
38 }
39 * { box-sizing: border-box; }
40 html, body {
41 min-height: 100%; margin: 0; background: var(--bg-main); color: var(--text-primary);
42 font-family: var(--font-family);
43 }
44 a { color: inherit; text-decoration: none; }
45 .global-header {
46 height: 62px; padding: 0 22px; display: flex; align-items: center; justify-content: space-between; gap: 18px;
47 border-bottom: 1px solid var(--border-color); background: rgba(251,252,255,0.86); backdrop-filter: blur(18px);
48 position: sticky; top: 0; z-index: 20;
49 }
50 body.dark-mode .global-header { background: rgba(5,5,5,0.86); }
51 .header-brand { display: flex; align-items: center; min-width: 0; }
52 .header-logo { height: 30px; width: auto; object-fit: contain; mix-blend-mode: multiply; }
53 body.dark-mode .header-logo { mix-blend-mode: screen; }
54 .header-nav, .header-controls { display: flex; align-items: center; gap: 16px; }
55 .nav-link { color: var(--text-secondary); font-size: 0.86rem; font-weight: 850; }
56 .nav-link:hover, .nav-link.active { color: var(--accent-color); }
57 .nav-divider { color: var(--border-color); }
58 .icon-btn {
59 width: 34px; height: 34px; display: inline-flex; align-items: center; justify-content: center;
60 border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-panel); color: var(--text-secondary);
61 }
62 .page-shell { width: min(1120px, calc(100% - 36px)); margin: 0 auto; padding: 34px 0 48px; }
63 .page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin-bottom: 18px; }
64 .title-block h1 { margin: 0 0 6px; font-size: clamp(1.55rem, 4vw, 2.35rem); font-weight: 950; letter-spacing: 0; }
65 .title-block p { margin: 0; color: var(--text-secondary); font-size: 0.9rem; }
66 .narrator-chip {
67 display: inline-flex; align-items: center; gap: 8px; min-height: 34px; padding: 0 12px;
68 border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-panel); color: var(--text-secondary);
69 font-weight: 850; font-size: 0.78rem; white-space: nowrap;
70 }
71 .tool-grid { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 16px; align-items: start; }
72 .panel {
73 border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-panel);
74 box-shadow: var(--shadow-line); overflow: hidden;
75 }
76 .input-panel { padding: 18px; }
77 .panel-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
78 .panel-label { color: var(--text-secondary); font-size: 0.76rem; font-weight: 900; text-transform: uppercase; letter-spacing: 0.08em; }
79 .count-text { color: var(--text-secondary); font-size: 0.78rem; font-weight: 800; }
80 .title-input, textarea {
81 width: 100%; border: 1px solid var(--border-color); border-radius: 8px;
82 background: var(--bg-soft); color: var(--text-primary); padding: 12px 14px; font: inherit; outline: none;
83 }
84 .title-input { min-height: 42px; margin-bottom: 10px; font-weight: 850; }
85 textarea { min-height: 280px; resize: vertical; line-height: 1.75; }
86 .title-input:focus, textarea:focus { border-color: var(--accent-color); box-shadow: 0 0 0 3px rgba(14,165,233,0.14); }
87 .toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 14px; }
88 .status-line { color: var(--text-secondary); font-size: 0.82rem; min-height: 1.4em; }
89 .status-line.error { color: var(--danger-color); }
90 .primary-btn, .ghost-btn, .danger-btn {
91 min-height: 40px; display: inline-flex; align-items: center; justify-content: center; gap: 8px;
92 border-radius: 8px; padding: 0 15px; font-weight: 900; font-size: 0.86rem; cursor: pointer;
93 border: 1px solid transparent; transition: opacity 0.16s, transform 0.16s, border-color 0.16s;
94 }
95 .primary-btn { background: var(--accent-color); color: #fff; }
96 .ghost-btn { background: var(--bg-panel); border-color: var(--border-color); color: var(--text-secondary); }
97 .danger-btn { background: transparent; border-color: rgba(220,38,38,0.35); color: var(--danger-color); }
98 .primary-btn:hover, .ghost-btn:hover, .danger-btn:hover { transform: translateY(-1px); }
99 .primary-btn:disabled { opacity: 0.48; cursor: wait; transform: none; }
100 .drawer-panel { padding: 0; }
101 .drawer-head {
102 display: flex; align-items: center; justify-content: space-between; gap: 10px;
103 padding: 14px 14px; border-bottom: 1px solid var(--border-color);
104 }
105 .drawer-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
106 .drawer-title { font-size: 0.95rem; font-weight: 950; margin: 0; }
107 .archive-tools {
108 display: grid; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--border-color);
109 background: var(--bg-soft);
110 }
111 .archive-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
112 .archive-input {
113 width: 100%; min-height: 34px; border: 1px solid var(--border-color); border-radius: 8px;
114 background: var(--bg-panel); color: var(--text-primary); padding: 6px 8px; font: inherit; font-size: 0.78rem;
115 }
116 .archive-file { font-size: 0.76rem; color: var(--text-secondary); }
117 .archive-actions { display: flex; gap: 8px; flex-wrap: wrap; }
118 .sync-tools {
119 display: grid; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--border-color);
120 }
121 .sync-actions { display: flex; gap: 8px; flex-wrap: wrap; }
122 .day-drawer { border-bottom: 1px solid var(--border-color); }
123 .day-drawer:last-child { border-bottom: none; }
124 .day-toggle {
125 width: 100%; border: none; background: transparent; color: var(--text-primary);
126 display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 12px 14px;
127 font: inherit; font-weight: 950; text-align: left;
128 }
129 .day-meta { color: var(--text-secondary); font-size: 0.74rem; font-weight: 850; white-space: nowrap; }
130 .voice-list { display: none; padding: 0 10px 12px; }
131 .day-drawer.open .voice-list { display: grid; gap: 8px; }
132 .voice-item {
133 border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-soft);
134 padding: 10px; display: grid; gap: 8px;
135 }
136 .voice-item-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
137 .voice-title { font-weight: 950; overflow-wrap: anywhere; }
138 .voice-sub { color: var(--text-secondary); font-size: 0.74rem; font-weight: 800; margin-top: 2px; }
139 .voice-progress { display: grid; gap: 4px; margin-top: 6px; }
140 .progress-track { height: 6px; border-radius: 999px; background: var(--border-color); overflow: hidden; }
141 .progress-bar { height: 100%; width: 0%; background: var(--accent-color); transition: width 0.2s ease; }
142 .progress-label { color: var(--text-secondary); font-size: 0.72rem; font-weight: 800; }
143 .voice-actions { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
144 .mini-btn {
145 min-height: 30px; padding: 0 9px; border: 1px solid var(--border-color); border-radius: 8px;
146 background: var(--bg-panel); color: var(--text-secondary); font-size: 0.74rem; font-weight: 900;
147 display: inline-flex; align-items: center; gap: 5px;
148 }
149 .mini-btn.danger { border-color: rgba(220,38,38,0.35); color: var(--danger-color); }
150 .empty-state { padding: 18px 14px; color: var(--text-secondary); font-size: 0.85rem; }
151 .result-panel { margin-top: 16px; display: none; }
152 .result-panel.active { display: block; }
153 .result-head {
154 display: flex; align-items: center; justify-content: space-between; gap: 12px;
155 padding: 14px 16px; border-bottom: 1px solid var(--border-color);
156 }
157 .result-title { font-weight: 950; }
158 .full-player { padding: 16px; border-bottom: 1px solid var(--border-color); display: grid; gap: 10px; }
159 audio { width: 100%; height: 40px; }
160 .sentence-list { display: grid; gap: 0; }
161 .sentence-item {
162 display: grid; grid-template-columns: 46px minmax(0, 1fr); gap: 12px;
163 padding: 14px 16px; border-bottom: 1px solid var(--border-color);
164 }
165 .sentence-item:last-child { border-bottom: none; }
166 .sentence-index {
167 width: 34px; height: 34px; display: inline-flex; align-items: center; justify-content: center;
168 border-radius: 8px; background: rgba(14,165,233,0.12); color: var(--accent-color); font-weight: 950; font-size: 0.8rem;
169 }
170 .sentence-text { margin-bottom: 10px; line-height: 1.65; overflow-wrap: anywhere; }
171 .site-footer {
172 width: min(1120px, calc(100% - 36px)); margin: 0 auto 38px; padding-top: 28px;
173 border-top: 1px solid var(--border-color); color: var(--text-secondary);
174 display: flex; align-items: center; justify-content: space-between; gap: 20px; font-size: 0.78rem;
175 }
176 .footer-brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
177 .footer-logo { width: 44px; height: 44px; object-fit: contain; opacity: 0.76; mix-blend-mode: multiply; }
178 body.dark-mode .footer-logo { mix-blend-mode: screen; opacity: 0.88; }
179 .footer-title { color: var(--text-primary); font-weight: 900; font-size: 0.9rem; }
180 .footer-links { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }
181 .footer-links a { color: var(--text-secondary); font-weight: 800; }
182 .footer-links a:hover { color: var(--accent-color); }
183 .footer-copy { margin-top: 4px; text-align: right; font-size: 0.72rem; }
184 @media (max-width: 980px) {
185 .global-header { flex-wrap: wrap; height: auto; min-height: 62px; padding: 10px 16px; }
186 .header-nav { width: 100%; order: 3; justify-content: center; gap: 16px; padding-top: 10px; border-top: 1px solid var(--border-color); overflow-x: auto; }
187 .header-brand { flex: 1; }
188 .tool-grid { grid-template-columns: 1fr; }
189 }
190 @media (max-width: 620px) {
191 .page-shell, .site-footer { width: min(100% - 24px, 1120px); }
192 .page-head, .toolbar, .result-head { align-items: stretch; flex-direction: column; }
193 .primary-btn, .ghost-btn, .danger-btn { width: 100%; }
194 .sentence-item { grid-template-columns: 1fr; }
195 .site-footer { flex-direction: column; align-items: flex-start; }
196 .footer-links { justify-content: flex-start; }
197 .footer-copy { text-align: left; }
198 }
199 </style>
200 </head>
201 <body class="<%= theme === 'dark' ? 'dark-mode' : '' %>">
202 <header class="global-header">
203 <div class="header-brand">
204 <a href="/hinana/index">
205 <img src="/image/<%= theme === 'dark' ? 'archive1.png' : 'archive.png' %>" alt="비나래 아카이브" class="header-logo">
206 </a>
207 </div>
208 <nav class="header-nav">
209 <a href="/hinana/index" class="nav-link">Archive</a>
210 <a href="/hinana/info" class="nav-link">Info</a>
211 <a href="/hinana/blog" class="nav-link">Blog</a>
212 <a href="/hinana/lounge" class="nav-link">Lounge</a>
213 <span class="nav-divider">|</span>
214 <a href="/logout?redirect=/hinana/info" class="nav-link text-danger fw-bold">Logout</a>
215 </nav>
216 <div class="header-controls">
217 <a href="/hinana/gallery#brand-assets" class="nav-link" style="font-size:0.9rem;">사이트 맵</a>
218 <form action="/toggle-theme" method="POST" class="d-inline">
219 <button type="submit" class="icon-btn" title="테마 변경">
220 <i class="bi <%= theme === 'dark' ? 'bi-moon-stars-fill':'bi-sun-fill' %>"></i>
221 </button>
222 </form>
223 </div>
224 </header>
225
226 <main class="page-shell">
227 <div class="page-head">
228 <div class="title-block">
229 <h1>Voicepeak 음성 서랍</h1>
230 <p>일본어 원문을 Koharu Rikka 음성으로 만들고, 생성 기록을 일별로 정리합니다.</p>
231 </div>
232 <div class="narrator-chip"><i class="bi bi-soundwave"></i> Koharu Rikka</div>
233 </div>
234
235 <div class="tool-grid">
236 <div>
237 <section class="panel input-panel">
238 <div class="panel-top">
239 <div class="panel-label">Japanese Source</div>
240 <div class="count-text"><span id="charCount">0</span> / 12000</div>
241 </div>
242 <input id="voiceTitle" class="title-input" maxlength="80" placeholder="제목">
243 <textarea id="sourceText" maxlength="12000" spellcheck="false" placeholder="日本語の原文をここに貼り付けます。"></textarea>
244 <div class="toolbar">
245 <div id="statusLine" class="status-line"></div>
246 <button id="generateBtn" class="primary-btn" type="button">
247 <i class="bi bi-mic-fill"></i>
248 생성
249 </button>
250 </div>
251 </section>
252
253 <section id="resultPanel" class="panel result-panel" aria-live="polite">
254 <div class="result-head">
255 <div>
256 <div class="panel-label">Result</div>
257 <div id="resultTitle" class="result-title">대기 중</div>
258 </div>
259 <a id="downloadLink" class="ghost-btn" href="#" download style="display:none;">
260 <i class="bi bi-download"></i>
261 MP3 다운로드
262 </a>
263 </div>
264 <div id="fullPlayer" class="full-player" style="display:none;">
265 <audio id="fullAudio" controls preload="metadata"></audio>
266 </div>
267 <div id="sentenceList" class="sentence-list">
268 <div class="empty-state">생성 결과가 여기에 표시됩니다.</div>
269 </div>
270 </section>
271 </div>
272
273 <aside class="panel drawer-panel">
274 <div class="drawer-head">
275 <div>
276 <div class="panel-label">Drawer</div>
277 <h2 class="drawer-title">생성한 음성</h2>
278 </div>
279 <div class="drawer-actions">
280 <button id="processWaitingBtn" class="ghost-btn" type="button">
281 <i class="bi bi-arrow-repeat"></i>
282 대기 처리
283 </button>
284 <button id="deleteAllBtn" class="danger-btn" type="button">
285 <i class="bi bi-trash3"></i>
286 전체 삭제
287 </button>
288 </div>
289 </div>
290 <div class="archive-tools">
291 <div class="archive-row">
292 <input id="exportFrom" class="archive-input" type="date" aria-label="내보내기 시작일">
293 <input id="exportTo" class="archive-input" type="date" aria-label="내보내기 종료일">
294 </div>
295 <div class="archive-actions">
296 <button id="exportBtn" class="ghost-btn" type="button">
297 <i class="bi bi-box-arrow-up"></i>
298 내보내기
299 </button>
300 <button id="exportAllBtn" class="ghost-btn" type="button">
301 <i class="bi bi-archive"></i>
302 전체
303 </button>
304 </div>
305 <input id="importArchive" class="archive-file" type="file" accept=".gz,.tgz,.tar.gz,application/gzip">
306 <button id="importBtn" class="ghost-btn" type="button">
307 <i class="bi bi-box-arrow-in-down"></i>
308 가져오기
309 </button>
310 </div>
311 <div class="sync-tools">
312 <div class="panel-label">Live Sync</div>
313 <input id="remoteUrl" class="archive-input" type="url" placeholder="https://hinana.moe">
314 <div class="sync-actions">
315 <button id="pullLiveBtn" class="ghost-btn" type="button">
316 <i class="bi bi-cloud-arrow-down"></i>
317 라이브에서 가져오기
318 </button>
319 <button id="pushLiveBtn" class="ghost-btn" type="button">
320 <i class="bi bi-cloud-arrow-up"></i>
321 라이브로 보내기
322 </button>
323 </div>
324 </div>
325 <div id="drawerList">
326 <div class="empty-state">저장된 음성이 없습니다.</div>
327 </div>
328 </aside>
329 </div>
330 </main>
331
332 <footer class="site-footer">
333 <div class="footer-brand">
334 <img src="/image/sign.png" alt="비나래" class="footer-logo">
335 <div>
336 <div class="footer-title">비나래 아카이브</div>
337 <div>Archive My Memory</div>
338 </div>
339 </div>
340 <div>
341 <div class="footer-links">
342 <a href="/hinana/index">Archive</a>
343 <a href="/hinana/info">Info</a>
344 <a href="/hinana/blog">Blog</a>
345 <a href="/hinana/lounge">Lounge</a>
346 <a href="/hinana/developer">Developer</a>
347 <a href="https://x.com/NoctchillHinana" target="_blank" rel="noreferrer">X</a>
348 </div>
349 <div class="footer-copy">ⓒ 2024~2026. 비나래 | hinana.moe</div>
350 </div>
351 </footer>
352
353 <script>
354 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
355 var voiceTitle = document.getElementById('voiceTitle');
356 var sourceText = document.getElementById('sourceText');
357 var charCount = document.getElementById('charCount');
358 var generateBtn = document.getElementById('generateBtn');
359 var deleteAllBtn = document.getElementById('deleteAllBtn');
360 var processWaitingBtn = document.getElementById('processWaitingBtn');
361 var exportBtn = document.getElementById('exportBtn');
362 var exportAllBtn = document.getElementById('exportAllBtn');
363 var exportFrom = document.getElementById('exportFrom');
364 var exportTo = document.getElementById('exportTo');
365 var importArchive = document.getElementById('importArchive');
366 var importBtn = document.getElementById('importBtn');
367 var remoteUrl = document.getElementById('remoteUrl');
368 var pullLiveBtn = document.getElementById('pullLiveBtn');
369 var pushLiveBtn = document.getElementById('pushLiveBtn');
370 var statusLine = document.getElementById('statusLine');
371 var resultPanel = document.getElementById('resultPanel');
372 var resultTitle = document.getElementById('resultTitle');
373 var sentenceList = document.getElementById('sentenceList');
374 var fullPlayer = document.getElementById('fullPlayer');
375 var fullAudio = document.getElementById('fullAudio');
376 var downloadLink = document.getElementById('downloadLink');
377 var drawerList = document.getElementById('drawerList');
378 var pollTimer = null;
379 var drawerPollTimer = null;
380 var activeJobId = null;
381 var weekdayFormatter = new Intl.DateTimeFormat('ko-KR', { weekday: 'short' });
382 var dayFormatter = new Intl.DateTimeFormat('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit' });
383 var timeFormatter = new Intl.DateTimeFormat('ko-KR', { hour: '2-digit', minute: '2-digit' });
384
385 function setStatus(message, isError) {
386 statusLine.textContent = message || '';
387 statusLine.classList.toggle('error', Boolean(isError));
388 }
389
390 function loadSyncSettings() {
391 remoteUrl.value = localStorage.getItem('voicepeakRemoteUrl') || '';
392 }
393
394 function saveSyncSettings() {
395 localStorage.setItem('voicepeakRemoteUrl', remoteUrl.value.trim());
396 }
397
398 function formatDay(timestamp) {
399 var date = new Date(timestamp);
400 return dayFormatter.format(date) + ' ' + weekdayFormatter.format(date);
401 }
402
403 function statusText(status) {
404 if (status === 'done') return '완료';
405 if (status === 'processing') return '생성 중';
406 if (status === 'pending') return '대기';
407 if (status === 'waiting') return '도구 대기';
408 return '오류';
409 }
410
411 function progressPercent(job) {
412 if (!job.progress || !job.progress.total) return job.status === 'done' ? 100 : 0;
413 return Math.max(0, Math.min(100, Math.round((job.progress.current / job.progress.total) * 100)));
414 }
415
416 function progressLabel(job) {
417 if (!job.progress) return '';
418 return job.progress.message + ' · ' + job.progress.current + '/' + job.progress.total;
419 }
420
421 function renderSentences(job) {
422 sentenceList.innerHTML = '';
423 job.sentences.forEach(function(sentence) {
424 var item = document.createElement('div');
425 item.className = 'sentence-item';
426
427 var index = document.createElement('div');
428 index.className = 'sentence-index';
429 index.textContent = String(sentence.index + 1).padStart(2, '0');
430
431 var body = document.createElement('div');
432 var text = document.createElement('div');
433 text.className = 'sentence-text';
434 text.textContent = sentence.text;
435 body.appendChild(text);
436
437 if (sentence.audioUrl) {
438 var audio = document.createElement('audio');
439 audio.controls = true;
440 audio.preload = 'metadata';
441 audio.src = sentence.audioUrl;
442 body.appendChild(audio);
443 }
444
445 item.appendChild(index);
446 item.appendChild(body);
447 sentenceList.appendChild(item);
448 });
449 }
450
451 function renderJob(job) {
452 resultPanel.classList.add('active');
453 activeJobId = job.id;
454 renderSentences(job);
455
456 if (job.status === 'pending' || job.status === 'processing') {
457 resultTitle.textContent = (job.title || '무제 음성') + ' · 생성 중 (' + progressPercent(job) + '%)';
458 setStatus(progressLabel(job) || 'Voicepeak 처리 중입니다.');
459 return;
460 }
461
462 if (job.status === 'waiting') {
463 resultTitle.textContent = (job.title || '무제 음성') + ' · 도구 대기';
464 setStatus(job.error || 'Voicepeak가 있는 로컬 환경에서 자동 처리됩니다.');
465 generateBtn.disabled = false;
466 loadDrawer();
467 return;
468 }
469
470 if (job.status === 'done') {
471 resultTitle.textContent = (job.title || '무제 음성') + ' · ' + job.sentences.length + '문장';
472 setStatus('완료되었습니다.');
473 fullPlayer.style.display = 'grid';
474 fullAudio.src = job.fullAudioUrl;
475 downloadLink.href = job.downloadUrl || job.fullAudioUrl;
476 downloadLink.style.display = 'inline-flex';
477 generateBtn.disabled = false;
478 loadDrawer();
479 return;
480 }
481
482 resultTitle.textContent = (job.title || '무제 음성') + ' · 생성 실패';
483 setStatus(job.error || '음성 생성에 실패했습니다.', true);
484 generateBtn.disabled = false;
485 loadDrawer();
486 }
487
488 async function pollJob(jobId) {
489 try {
490 var response = await fetch('/hinana/voicepeak/jobs/' + encodeURIComponent(jobId), {
491 headers: { 'Accept': 'application/json' }
492 });
493 var payload = await response.json();
494 if (!response.ok || !payload.success) throw new Error(payload.message || '작업 상태를 확인할 수 없습니다.');
495
496 renderJob(payload.job);
497 if (payload.job.status === 'pending' || payload.job.status === 'processing') {
498 pollTimer = setTimeout(function() { pollJob(jobId); }, 1500);
499 }
500 } catch (error) {
501 setStatus(error.message || '작업 상태를 확인할 수 없습니다.', true);
502 generateBtn.disabled = false;
503 }
504 }
505
506 async function openJob(jobId) {
507 var response = await fetch('/hinana/voicepeak/jobs/' + encodeURIComponent(jobId), {
508 headers: { 'Accept': 'application/json' }
509 });
510 var payload = await response.json();
511 if (!response.ok || !payload.success) throw new Error(payload.message || '음성을 불러올 수 없습니다.');
512 renderJob(payload.job);
513 window.scrollTo({ top: 0, behavior: 'smooth' });
514 }
515
516 async function deleteJob(jobId) {
517 if (!window.confirm('이 음성을 삭제할까요?')) return;
518 var response = await fetch('/hinana/voicepeak/jobs/' + encodeURIComponent(jobId), {
519 method: 'DELETE',
520 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
521 });
522 var payload = await response.json();
523 if (!response.ok || !payload.success) throw new Error(payload.message || '삭제할 수 없습니다.');
524 setStatus('삭제했습니다.');
525 loadDrawer();
526 }
527
528 async function loadDrawer() {
529 try {
530 var response = await fetch('/hinana/voicepeak/jobs', { headers: { 'Accept': 'application/json' } });
531 var payload = await response.json();
532 if (!response.ok || !payload.success) throw new Error(payload.message || '목록을 불러올 수 없습니다.');
533
534 var jobs = payload.jobs || [];
535 if (!jobs.length) {
536 drawerList.innerHTML = '<div class="empty-state">저장된 음성이 없습니다.</div>';
537 scheduleDrawerPoll(false);
538 return;
539 }
540
541 var groups = {};
542 jobs.forEach(function(job) {
543 var key = formatDay(job.createdAt);
544 if (!groups[key]) groups[key] = [];
545 groups[key].push(job);
546 });
547
548 drawerList.innerHTML = '';
549 Object.keys(groups).forEach(function(day, index) {
550 var drawer = document.createElement('div');
551 drawer.className = 'day-drawer' + (index === 0 ? ' open' : '');
552
553 var toggle = document.createElement('button');
554 toggle.type = 'button';
555 toggle.className = 'day-toggle';
556 toggle.innerHTML = '<span>' + day + '</span><span class="day-meta">' + groups[day].length + '개</span>';
557 toggle.addEventListener('click', function() { drawer.classList.toggle('open'); });
558
559 var list = document.createElement('div');
560 list.className = 'voice-list';
561
562 groups[day].forEach(function(job) {
563 var item = document.createElement('div');
564 item.className = 'voice-item';
565
566 var head = document.createElement('div');
567 head.className = 'voice-item-head';
568
569 var main = document.createElement('div');
570 var title = document.createElement('div');
571 title.className = 'voice-title';
572 title.textContent = job.title || '무제 음성';
573 var sub = document.createElement('div');
574 sub.className = 'voice-sub';
575 sub.textContent = timeFormatter.format(new Date(job.createdAt)) + ' · ' + statusText(job.status) + ' · ' + job.sentenceCount + '문장';
576 main.appendChild(title);
577 main.appendChild(sub);
578
579 if (job.status === 'processing' || job.status === 'pending') {
580 var progress = document.createElement('div');
581 progress.className = 'voice-progress';
582 var track = document.createElement('div');
583 track.className = 'progress-track';
584 var bar = document.createElement('div');
585 bar.className = 'progress-bar';
586 bar.style.width = progressPercent(job) + '%';
587 var label = document.createElement('div');
588 label.className = 'progress-label';
589 label.textContent = progressLabel(job) || '처리 중';
590 track.appendChild(bar);
591 progress.appendChild(track);
592 progress.appendChild(label);
593 main.appendChild(progress);
594 }
595
596 var actions = document.createElement('div');
597 actions.className = 'voice-actions';
598
599 var openBtn = document.createElement('button');
600 openBtn.type = 'button';
601 openBtn.className = 'mini-btn';
602 openBtn.innerHTML = '<i class="bi bi-play-fill"></i> 열기';
603 openBtn.addEventListener('click', function() { openJob(job.id).catch(function(error) { setStatus(error.message, true); }); });
604 actions.appendChild(openBtn);
605
606 if (job.downloadUrl) {
607 var download = document.createElement('a');
608 download.className = 'mini-btn';
609 download.href = job.downloadUrl;
610 download.innerHTML = '<i class="bi bi-download"></i> MP3';
611 actions.appendChild(download);
612 }
613
614 var delBtn = document.createElement('button');
615 delBtn.type = 'button';
616 delBtn.className = 'mini-btn danger';
617 delBtn.innerHTML = '<i class="bi bi-trash3"></i> 삭제';
618 delBtn.addEventListener('click', function() { deleteJob(job.id).catch(function(error) { setStatus(error.message, true); }); });
619 actions.appendChild(delBtn);
620
621 head.appendChild(main);
622 item.appendChild(head);
623 item.appendChild(actions);
624 list.appendChild(item);
625 });
626
627 drawer.appendChild(toggle);
628 drawer.appendChild(list);
629 drawerList.appendChild(drawer);
630 });
631
632 var hasActiveJobs = jobs.some(function(job) {
633 return job.status === 'processing' || job.status === 'pending';
634 });
635 scheduleDrawerPoll(hasActiveJobs);
636 } catch (error) {
637 drawerList.innerHTML = '<div class="empty-state">목록을 불러오지 못했습니다.</div>';
638 scheduleDrawerPoll(false);
639 }
640 }
641
642 function scheduleDrawerPoll(shouldPoll) {
643 if (drawerPollTimer) {
644 clearTimeout(drawerPollTimer);
645 drawerPollTimer = null;
646 }
647 if (!shouldPoll) return;
648 drawerPollTimer = setTimeout(async function() {
649 await loadDrawer();
650 if (activeJobId) {
651 try {
652 var response = await fetch('/hinana/voicepeak/jobs/' + encodeURIComponent(activeJobId), {
653 headers: { 'Accept': 'application/json' }
654 });
655 var payload = await response.json();
656 if (response.ok && payload.success) renderJob(payload.job);
657 } catch (error) {}
658 }
659 }, 1500);
660 }
661
662 sourceText.addEventListener('input', function() {
663 charCount.textContent = String(sourceText.value.length);
664 });
665
666 generateBtn.addEventListener('click', async function() {
667 var text = sourceText.value.trim();
668 if (!text) {
669 setStatus('원문을 입력해 주세요.', true);
670 return;
671 }
672
673 if (pollTimer) clearTimeout(pollTimer);
674 generateBtn.disabled = true;
675 downloadLink.style.display = 'none';
676 fullPlayer.style.display = 'none';
677 fullAudio.removeAttribute('src');
678 setStatus('작업을 시작합니다.');
679 resultPanel.classList.add('active');
680 resultTitle.textContent = '대기 중';
681 sentenceList.innerHTML = '<div class="empty-state">문장을 분리하고 있습니다.</div>';
682
683 try {
684 var response = await fetch('/hinana/voicepeak/jobs', {
685 method: 'POST',
686 headers: {
687 'Content-Type': 'application/json',
688 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
689 },
690 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
691 });
692 var payload = await response.json();
693 if (!response.ok || !payload.success) throw new Error(payload.message || payload.error || '작업을 시작할 수 없습니다.');
694
695 renderJob(payload.job);
696 loadDrawer();
697 pollJob(payload.job.id);
698 } catch (error) {
699 setStatus(error.message || '작업을 시작할 수 없습니다.', true);
700 generateBtn.disabled = false;
701 }
702 });
703
704 deleteAllBtn.addEventListener('click', async function() {
705 if (!window.confirm('저장된 모든 음성을 삭제할까요?')) return;
706 var response = await fetch('/hinana/voicepeak/jobs', {
707 method: 'DELETE',
708 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
709 });
710 var payload = await response.json();
711 if (!response.ok || !payload.success) {
712 setStatus(payload.message || '전체 삭제에 실패했습니다.', true);
713 return;
714 }
715 setStatus('모든 음성을 삭제했습니다.');
716 fullPlayer.style.display = 'none';
717 downloadLink.style.display = 'none';
718 sentenceList.innerHTML = '<div class="empty-state">생성 결과가 여기에 표시됩니다.</div>';
719 resultPanel.classList.remove('active');
720 loadDrawer();
721 });
722
723 processWaitingBtn.addEventListener('click', async function() {
724 try {
725 processWaitingBtn.disabled = true;
726 var response = await fetch('/hinana/voicepeak/jobs/process-waiting', {
727 method: 'POST',
728 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
729 });
730 var payload = await response.json();
731 if (!response.ok || !payload.success) {
732 setStatus(payload.message || '대기 작업을 처리할 수 없습니다.', true);
733 return;
734 }
735 setStatus(payload.queuedCount ? payload.queuedCount + '개 작업을 순차 처리 큐에 넣었습니다.' : '처리할 대기/오류 작업이 없거나 Voicepeak를 찾지 못했습니다.');
736 loadDrawer();
737 scheduleDrawerPoll(Boolean(payload.queuedCount));
738 } finally {
739 processWaitingBtn.disabled = false;
740 }
741 });
742
743 function buildExportUrl(includeRange) {
744 var params = new URLSearchParams();
745 if (includeRange) {
746 if (exportFrom.value) params.set('from', exportFrom.value);
747 if (exportTo.value) params.set('to', exportTo.value);
748 }
749 var query = params.toString();
750 return '/hinana/voicepeak/archive/export' + (query ? '?' + query : '');
751 }
752
753 exportBtn.addEventListener('click', function() {
754 window.location.href = buildExportUrl(true);
755 });
756
757 exportAllBtn.addEventListener('click', function() {
758 window.location.href = buildExportUrl(false);
759 });
760
761 importBtn.addEventListener('click', async function() {
762 if (!importArchive.files || !importArchive.files[0]) {
763 setStatus('가져올 아카이브 파일을 선택해 주세요.', true);
764 return;
765 }
766
767 var form = new FormData();
768 form.append('archive', importArchive.files[0]);
769
770 try {
771 importBtn.disabled = true;
772 setStatus('아카이브를 가져오는 중입니다.');
773 var response = await fetch('/hinana/voicepeak/archive/import', {
774 method: 'POST',
775 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
776 body: form
777 });
778 var payload = await response.json();
779 if (!response.ok || !payload.success) throw new Error(payload.message || '가져오기에 실패했습니다.');
780 setStatus(payload.importedCount + '개 작업을 가져왔습니다.');
781 importArchive.value = '';
782 loadDrawer();
783 } catch (error) {
784 setStatus(error.message || '가져오기에 실패했습니다.', true);
785 } finally {
786 importBtn.disabled = false;
787 }
788 });
789
790 async function runLiveSync(kind) {
791 saveSyncSettings();
792 var url = remoteUrl.value.trim();
793 if (!url) {
794 setStatus('라이브 URL을 입력해 주세요.', true);
795 return;
796 }
797
798 var button = kind === 'pull' ? pullLiveBtn : pushLiveBtn;
799 var endpoint = kind === 'pull' ? '/hinana/voicepeak/sync/pull' : '/hinana/voicepeak/sync/push';
800 var loadingText = kind === 'pull' ? '라이브 서버에서 가져오는 중입니다.' : '라이브 서버로 보내는 중입니다.';
801
802 try {
803 button.disabled = true;
804 setStatus(loadingText);
805 var response = await fetch(endpoint, {
806 method: 'POST',
807 headers: {
808 'Content-Type': 'application/json',
809 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
810 },
811 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
812 remoteUrl: url,
813 from: exportFrom.value,
814 to: exportTo.value
815 })
816 });
817 var payload = await response.json();
818 if (!response.ok || !payload.success) throw new Error(payload.message || '동기화에 실패했습니다.');
819
820 if (kind === 'pull') {
821 setStatus(payload.importedCount + '개 작업을 라이브에서 가져왔습니다.');
822 } else {
823 setStatus(payload.exportedCount + '개 작업을 라이브로 보냈습니다.');
824 }
825 loadDrawer();
826 } catch (error) {
827 setStatus(error.message || '동기화에 실패했습니다.', true);
828 } finally {
829 button.disabled = false;
830 }
831 }
832
833 pullLiveBtn.addEventListener('click', function() { runLiveSync('pull'); });
834 pushLiveBtn.addEventListener('click', function() { runLiveSync('push'); });
835
836 loadSyncSettings();
837 loadDrawer();
838 </script>
839 </body>
840 </html>
841