본문으로 건너뛰기
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: var(--ui-on-accent, #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 <link rel="stylesheet" href="/css/site-ui.css?v=3">
201 <link rel="stylesheet" href="/css/site-app.css?v=1">
202 </head>
203 <body data-ui-page="voicepeak" class="ui-shell <%= theme === 'dark' ? 'dark-mode' : '' %>">
204 <%- include('partials/site-header', { uiPage: 'voicepeak' }) %>
205
206 <main id="ui-content" tabindex="-1" class="page-shell">
207 <div class="page-head">
208 <div class="title-block">
209 <h1>Voicepeak 음성 서랍</h1>
210 <p>일본어 원문을 Koharu Rikka 음성으로 만들고, 생성 기록을 일별로 정리합니다.</p>
211 </div>
212 <div class="narrator-chip"><i class="bi bi-soundwave"></i> Koharu Rikka</div>
213 </div>
214
215 <div class="tool-grid">
216 <div>
217 <section class="panel input-panel">
218 <div class="panel-top">
219 <div class="panel-label">Japanese Source</div>
220 <div class="count-text"><span id="charCount">0</span> / 12000</div>
221 </div>
222 <input id="voiceTitle" class="title-input" maxlength="80" placeholder="제목">
223 <textarea id="sourceText" maxlength="12000" spellcheck="false" placeholder="日本語の原文をここに貼り付けます。"></textarea>
224 <div class="toolbar">
225 <div id="statusLine" class="status-line"></div>
226 <button id="generateBtn" class="primary-btn" type="button">
227 <i class="bi bi-mic-fill"></i>
228 생성
229 </button>
230 </div>
231 </section>
232
233 <section id="resultPanel" class="panel result-panel" aria-live="polite">
234 <div class="result-head">
235 <div>
236 <div class="panel-label">Result</div>
237 <div id="resultTitle" class="result-title">대기 중</div>
238 </div>
239 <a id="downloadLink" class="ghost-btn" href="#" download style="display:none;">
240 <i class="bi bi-download"></i>
241 MP3 다운로드
242 </a>
243 </div>
244 <div id="fullPlayer" class="full-player" style="display:none;">
245 <audio id="fullAudio" controls preload="metadata"></audio>
246 </div>
247 <div id="sentenceList" class="sentence-list">
248 <div class="empty-state">생성 결과가 여기에 표시됩니다.</div>
249 </div>
250 </section>
251 </div>
252
253 <aside class="panel drawer-panel">
254 <div class="drawer-head">
255 <div>
256 <div class="panel-label">Drawer</div>
257 <h2 class="drawer-title">생성한 음성</h2>
258 </div>
259 <div class="drawer-actions">
260 <button id="processWaitingBtn" class="ghost-btn" type="button">
261 <i class="bi bi-arrow-repeat"></i>
262 대기 처리
263 </button>
264 <button id="deleteAllBtn" class="danger-btn" type="button">
265 <i class="bi bi-trash3"></i>
266 전체 삭제
267 </button>
268 </div>
269 </div>
270 <div class="archive-tools">
271 <div class="archive-row">
272 <input id="exportFrom" class="archive-input" type="date" aria-label="내보내기 시작일">
273 <input id="exportTo" class="archive-input" type="date" aria-label="내보내기 종료일">
274 </div>
275 <div class="archive-actions">
276 <button id="exportBtn" class="ghost-btn" type="button">
277 <i class="bi bi-box-arrow-up"></i>
278 내보내기
279 </button>
280 <button id="exportAllBtn" class="ghost-btn" type="button">
281 <i class="bi bi-archive"></i>
282 전체
283 </button>
284 </div>
285 <input id="importArchive" class="archive-file" type="file" accept=".gz,.tgz,.tar.gz,application/gzip">
286 <button id="importBtn" class="ghost-btn" type="button">
287 <i class="bi bi-box-arrow-in-down"></i>
288 가져오기
289 </button>
290 </div>
291 <div class="sync-tools">
292 <div class="panel-label">Live Sync</div>
293 <input id="remoteUrl" class="archive-input" type="url" placeholder="https://hinana.moe">
294 <div class="sync-actions">
295 <button id="pullLiveBtn" class="ghost-btn" type="button">
296 <i class="bi bi-cloud-arrow-down"></i>
297 라이브에서 가져오기
298 </button>
299 <button id="pushLiveBtn" class="ghost-btn" type="button">
300 <i class="bi bi-cloud-arrow-up"></i>
301 라이브로 보내기
302 </button>
303 </div>
304 </div>
305 <div id="drawerList">
306 <div class="empty-state">저장된 음성이 없습니다.</div>
307 </div>
308 </aside>
309 </div>
310 </main>
311
312 <footer class="site-footer">
313 <div class="footer-brand">
314 <img src="/image/sign.png" alt="비나래" class="footer-logo">
315 <div>
316 <div class="footer-title">비나래 아카이브</div>
317 <div>Archive My Memory</div>
318 </div>
319 </div>
320 <div>
321 <div class="footer-links">
322 <a href="/hinana/index">Archive</a>
323 <a href="/hinana/info">Info</a>
324 <a href="/hinana/blog">Blog</a>
325 <a href="/hinana/lounge">Lounge</a>
326 <a href="/hinana/developer">Developer</a>
327 <a href="https://x.com/NoctchillHinana" target="_blank" rel="noreferrer">X</a>
328 </div>
329 <div class="footer-copy">ⓒ 2024~2026. 비나래 | hinana.moe</div>
330 </div>
331 </footer>
332
333 <script>
334 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
335 var voiceTitle = document.getElementById('voiceTitle');
336 var sourceText = document.getElementById('sourceText');
337 var charCount = document.getElementById('charCount');
338 var generateBtn = document.getElementById('generateBtn');
339 var deleteAllBtn = document.getElementById('deleteAllBtn');
340 var processWaitingBtn = document.getElementById('processWaitingBtn');
341 var exportBtn = document.getElementById('exportBtn');
342 var exportAllBtn = document.getElementById('exportAllBtn');
343 var exportFrom = document.getElementById('exportFrom');
344 var exportTo = document.getElementById('exportTo');
345 var importArchive = document.getElementById('importArchive');
346 var importBtn = document.getElementById('importBtn');
347 var remoteUrl = document.getElementById('remoteUrl');
348 var pullLiveBtn = document.getElementById('pullLiveBtn');
349 var pushLiveBtn = document.getElementById('pushLiveBtn');
350 var statusLine = document.getElementById('statusLine');
351 var resultPanel = document.getElementById('resultPanel');
352 var resultTitle = document.getElementById('resultTitle');
353 var sentenceList = document.getElementById('sentenceList');
354 var fullPlayer = document.getElementById('fullPlayer');
355 var fullAudio = document.getElementById('fullAudio');
356 var downloadLink = document.getElementById('downloadLink');
357 var drawerList = document.getElementById('drawerList');
358 var pollTimer = null;
359 var drawerPollTimer = null;
360 var activeJobId = null;
361 var weekdayFormatter = new Intl.DateTimeFormat('ko-KR', { weekday: 'short' });
362 var dayFormatter = new Intl.DateTimeFormat('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit' });
363 var timeFormatter = new Intl.DateTimeFormat('ko-KR', { hour: '2-digit', minute: '2-digit' });
364
365 function setStatus(message, isError) {
366 statusLine.textContent = message || '';
367 statusLine.classList.toggle('error', Boolean(isError));
368 }
369
370 function loadSyncSettings() {
371 remoteUrl.value = localStorage.getItem('voicepeakRemoteUrl') || '';
372 }
373
374 function saveSyncSettings() {
375 localStorage.setItem('voicepeakRemoteUrl', remoteUrl.value.trim());
376 }
377
378 function formatDay(timestamp) {
379 var date = new Date(timestamp);
380 return dayFormatter.format(date) + ' ' + weekdayFormatter.format(date);
381 }
382
383 function statusText(status) {
384 if (status === 'done') return '완료';
385 if (status === 'processing') return '생성 중';
386 if (status === 'pending') return '대기';
387 if (status === 'waiting') return '도구 대기';
388 return '오류';
389 }
390
391 function progressPercent(job) {
392 if (!job.progress || !job.progress.total) return job.status === 'done' ? 100 : 0;
393 return Math.max(0, Math.min(100, Math.round((job.progress.current / job.progress.total) * 100)));
394 }
395
396 function progressLabel(job) {
397 if (!job.progress) return '';
398 return job.progress.message + ' · ' + job.progress.current + '/' + job.progress.total;
399 }
400
401 function renderSentences(job) {
402 sentenceList.innerHTML = '';
403 job.sentences.forEach(function(sentence) {
404 var item = document.createElement('div');
405 item.className = 'sentence-item';
406
407 var index = document.createElement('div');
408 index.className = 'sentence-index';
409 index.textContent = String(sentence.index + 1).padStart(2, '0');
410
411 var body = document.createElement('div');
412 var text = document.createElement('div');
413 text.className = 'sentence-text';
414 text.textContent = sentence.text;
415 body.appendChild(text);
416
417 if (sentence.audioUrl) {
418 var audio = document.createElement('audio');
419 audio.controls = true;
420 audio.preload = 'metadata';
421 audio.src = sentence.audioUrl;
422 body.appendChild(audio);
423 }
424
425 item.appendChild(index);
426 item.appendChild(body);
427 sentenceList.appendChild(item);
428 });
429 }
430
431 function renderJob(job) {
432 resultPanel.classList.add('active');
433 activeJobId = job.id;
434 renderSentences(job);
435
436 if (job.status === 'pending' || job.status === 'processing') {
437 resultTitle.textContent = (job.title || '무제 음성') + ' · 생성 중 (' + progressPercent(job) + '%)';
438 setStatus(progressLabel(job) || 'Voicepeak 처리 중입니다.');
439 return;
440 }
441
442 if (job.status === 'waiting') {
443 resultTitle.textContent = (job.title || '무제 음성') + ' · 도구 대기';
444 setStatus(job.error || 'Voicepeak가 있는 로컬 환경에서 자동 처리됩니다.');
445 generateBtn.disabled = false;
446 loadDrawer();
447 return;
448 }
449
450 if (job.status === 'done') {
451 resultTitle.textContent = (job.title || '무제 음성') + ' · ' + job.sentences.length + '문장';
452 setStatus('완료되었습니다.');
453 fullPlayer.style.display = 'grid';
454 fullAudio.src = job.fullAudioUrl;
455 downloadLink.href = job.downloadUrl || job.fullAudioUrl;
456 downloadLink.style.display = 'inline-flex';
457 generateBtn.disabled = false;
458 loadDrawer();
459 return;
460 }
461
462 resultTitle.textContent = (job.title || '무제 음성') + ' · 생성 실패';
463 setStatus(job.error || '음성 생성에 실패했습니다.', true);
464 generateBtn.disabled = false;
465 loadDrawer();
466 }
467
468 async function pollJob(jobId) {
469 try {
470 var response = await fetch('/hinana/voicepeak/jobs/' + encodeURIComponent(jobId), {
471 headers: { 'Accept': 'application/json' }
472 });
473 var payload = await response.json();
474 if (!response.ok || !payload.success) throw new Error(payload.message || '작업 상태를 확인할 수 없습니다.');
475
476 renderJob(payload.job);
477 if (payload.job.status === 'pending' || payload.job.status === 'processing') {
478 pollTimer = setTimeout(function() { pollJob(jobId); }, 1500);
479 }
480 } catch (error) {
481 setStatus(error.message || '작업 상태를 확인할 수 없습니다.', true);
482 generateBtn.disabled = false;
483 }
484 }
485
486 async function openJob(jobId) {
487 var response = await fetch('/hinana/voicepeak/jobs/' + encodeURIComponent(jobId), {
488 headers: { 'Accept': 'application/json' }
489 });
490 var payload = await response.json();
491 if (!response.ok || !payload.success) throw new Error(payload.message || '음성을 불러올 수 없습니다.');
492 renderJob(payload.job);
493 window.scrollTo({ top: 0, behavior: 'smooth' });
494 }
495
496 async function deleteJob(jobId) {
497 if (!window.confirm('이 음성을 삭제할까요?')) return;
498 var response = await fetch('/hinana/voicepeak/jobs/' + encodeURIComponent(jobId), {
499 method: 'DELETE',
500 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
501 });
502 var payload = await response.json();
503 if (!response.ok || !payload.success) throw new Error(payload.message || '삭제할 수 없습니다.');
504 setStatus('삭제했습니다.');
505 loadDrawer();
506 }
507
508 async function loadDrawer() {
509 try {
510 var response = await fetch('/hinana/voicepeak/jobs', { headers: { 'Accept': 'application/json' } });
511 var payload = await response.json();
512 if (!response.ok || !payload.success) throw new Error(payload.message || '목록을 불러올 수 없습니다.');
513
514 var jobs = payload.jobs || [];
515 if (!jobs.length) {
516 drawerList.innerHTML = '<div class="empty-state">저장된 음성이 없습니다.</div>';
517 scheduleDrawerPoll(false);
518 return;
519 }
520
521 var groups = {};
522 jobs.forEach(function(job) {
523 var key = formatDay(job.createdAt);
524 if (!groups[key]) groups[key] = [];
525 groups[key].push(job);
526 });
527
528 drawerList.innerHTML = '';
529 Object.keys(groups).forEach(function(day, index) {
530 var drawer = document.createElement('div');
531 drawer.className = 'day-drawer' + (index === 0 ? ' open' : '');
532
533 var toggle = document.createElement('button');
534 toggle.type = 'button';
535 toggle.className = 'day-toggle';
536 toggle.innerHTML = '<span>' + day + '</span><span class="day-meta">' + groups[day].length + '개</span>';
537 toggle.addEventListener('click', function() { drawer.classList.toggle('open'); });
538
539 var list = document.createElement('div');
540 list.className = 'voice-list';
541
542 groups[day].forEach(function(job) {
543 var item = document.createElement('div');
544 item.className = 'voice-item';
545
546 var head = document.createElement('div');
547 head.className = 'voice-item-head';
548
549 var main = document.createElement('div');
550 var title = document.createElement('div');
551 title.className = 'voice-title';
552 title.textContent = job.title || '무제 음성';
553 var sub = document.createElement('div');
554 sub.className = 'voice-sub';
555 sub.textContent = timeFormatter.format(new Date(job.createdAt)) + ' · ' + statusText(job.status) + ' · ' + job.sentenceCount + '문장';
556 main.appendChild(title);
557 main.appendChild(sub);
558
559 if (job.status === 'processing' || job.status === 'pending') {
560 var progress = document.createElement('div');
561 progress.className = 'voice-progress';
562 var track = document.createElement('div');
563 track.className = 'progress-track';
564 var bar = document.createElement('div');
565 bar.className = 'progress-bar';
566 bar.style.width = progressPercent(job) + '%';
567 var label = document.createElement('div');
568 label.className = 'progress-label';
569 label.textContent = progressLabel(job) || '처리 중';
570 track.appendChild(bar);
571 progress.appendChild(track);
572 progress.appendChild(label);
573 main.appendChild(progress);
574 }
575
576 var actions = document.createElement('div');
577 actions.className = 'voice-actions';
578
579 var openBtn = document.createElement('button');
580 openBtn.type = 'button';
581 openBtn.className = 'mini-btn';
582 openBtn.innerHTML = '<i class="bi bi-play-fill"></i> 열기';
583 openBtn.addEventListener('click', function() { openJob(job.id).catch(function(error) { setStatus(error.message, true); }); });
584 actions.appendChild(openBtn);
585
586 if (job.downloadUrl) {
587 var download = document.createElement('a');
588 download.className = 'mini-btn';
589 download.href = job.downloadUrl;
590 download.innerHTML = '<i class="bi bi-download"></i> MP3';
591 actions.appendChild(download);
592 }
593
594 var delBtn = document.createElement('button');
595 delBtn.type = 'button';
596 delBtn.className = 'mini-btn danger';
597 delBtn.innerHTML = '<i class="bi bi-trash3"></i> 삭제';
598 delBtn.addEventListener('click', function() { deleteJob(job.id).catch(function(error) { setStatus(error.message, true); }); });
599 actions.appendChild(delBtn);
600
601 head.appendChild(main);
602 item.appendChild(head);
603 item.appendChild(actions);
604 list.appendChild(item);
605 });
606
607 drawer.appendChild(toggle);
608 drawer.appendChild(list);
609 drawerList.appendChild(drawer);
610 });
611
612 var hasActiveJobs = jobs.some(function(job) {
613 return job.status === 'processing' || job.status === 'pending';
614 });
615 scheduleDrawerPoll(hasActiveJobs);
616 } catch (error) {
617 drawerList.innerHTML = '<div class="empty-state">목록을 불러오지 못했습니다.</div>';
618 scheduleDrawerPoll(false);
619 }
620 }
621
622 function scheduleDrawerPoll(shouldPoll) {
623 if (drawerPollTimer) {
624 clearTimeout(drawerPollTimer);
625 drawerPollTimer = null;
626 }
627 if (!shouldPoll) return;
628 drawerPollTimer = setTimeout(async function() {
629 await loadDrawer();
630 if (activeJobId) {
631 try {
632 var response = await fetch('/hinana/voicepeak/jobs/' + encodeURIComponent(activeJobId), {
633 headers: { 'Accept': 'application/json' }
634 });
635 var payload = await response.json();
636 if (response.ok && payload.success) renderJob(payload.job);
637 } catch (error) {}
638 }
639 }, 1500);
640 }
641
642 sourceText.addEventListener('input', function() {
643 charCount.textContent = String(sourceText.value.length);
644 });
645
646 generateBtn.addEventListener('click', async function() {
647 var text = sourceText.value.trim();
648 if (!text) {
649 setStatus('원문을 입력해 주세요.', true);
650 return;
651 }
652
653 if (pollTimer) clearTimeout(pollTimer);
654 generateBtn.disabled = true;
655 downloadLink.style.display = 'none';
656 fullPlayer.style.display = 'none';
657 fullAudio.removeAttribute('src');
658 setStatus('작업을 시작합니다.');
659 resultPanel.classList.add('active');
660 resultTitle.textContent = '대기 중';
661 sentenceList.innerHTML = '<div class="empty-state">문장을 분리하고 있습니다.</div>';
662
663 try {
664 var response = await fetch('/hinana/voicepeak/jobs', {
665 method: 'POST',
666 headers: {
667 'Content-Type': 'application/json',
668 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
669 },
670 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
671 });
672 var payload = await response.json();
673 if (!response.ok || !payload.success) throw new Error(payload.message || payload.error || '작업을 시작할 수 없습니다.');
674
675 renderJob(payload.job);
676 loadDrawer();
677 pollJob(payload.job.id);
678 } catch (error) {
679 setStatus(error.message || '작업을 시작할 수 없습니다.', true);
680 generateBtn.disabled = false;
681 }
682 });
683
684 deleteAllBtn.addEventListener('click', async function() {
685 if (!window.confirm('저장된 모든 음성을 삭제할까요?')) return;
686 var response = await fetch('/hinana/voicepeak/jobs', {
687 method: 'DELETE',
688 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
689 });
690 var payload = await response.json();
691 if (!response.ok || !payload.success) {
692 setStatus(payload.message || '전체 삭제에 실패했습니다.', true);
693 return;
694 }
695 setStatus('모든 음성을 삭제했습니다.');
696 fullPlayer.style.display = 'none';
697 downloadLink.style.display = 'none';
698 sentenceList.innerHTML = '<div class="empty-state">생성 결과가 여기에 표시됩니다.</div>';
699 resultPanel.classList.remove('active');
700 loadDrawer();
701 });
702
703 processWaitingBtn.addEventListener('click', async function() {
704 try {
705 processWaitingBtn.disabled = true;
706 var response = await fetch('/hinana/voicepeak/jobs/process-waiting', {
707 method: 'POST',
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(payload.queuedCount ? payload.queuedCount + '개 작업을 순차 처리 큐에 넣었습니다.' : '처리할 대기/오류 작업이 없거나 Voicepeak를 찾지 못했습니다.');
716 loadDrawer();
717 scheduleDrawerPoll(Boolean(payload.queuedCount));
718 } finally {
719 processWaitingBtn.disabled = false;
720 }
721 });
722
723 function buildExportUrl(includeRange) {
724 var params = new URLSearchParams();
725 if (includeRange) {
726 if (exportFrom.value) params.set('from', exportFrom.value);
727 if (exportTo.value) params.set('to', exportTo.value);
728 }
729 var query = params.toString();
730 return '/hinana/voicepeak/archive/export' + (query ? '?' + query : '');
731 }
732
733 exportBtn.addEventListener('click', function() {
734 window.location.href = buildExportUrl(true);
735 });
736
737 exportAllBtn.addEventListener('click', function() {
738 window.location.href = buildExportUrl(false);
739 });
740
741 importBtn.addEventListener('click', async function() {
742 if (!importArchive.files || !importArchive.files[0]) {
743 setStatus('가져올 아카이브 파일을 선택해 주세요.', true);
744 return;
745 }
746
747 var form = new FormData();
748 form.append('archive', importArchive.files[0]);
749
750 try {
751 importBtn.disabled = true;
752 setStatus('아카이브를 가져오는 중입니다.');
753 var response = await fetch('/hinana/voicepeak/archive/import', {
754 method: 'POST',
755 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
756 body: form
757 });
758 var payload = await response.json();
759 if (!response.ok || !payload.success) throw new Error(payload.message || '가져오기에 실패했습니다.');
760 setStatus(payload.importedCount + '개 작업을 가져왔습니다.');
761 importArchive.value = '';
762 loadDrawer();
763 } catch (error) {
764 setStatus(error.message || '가져오기에 실패했습니다.', true);
765 } finally {
766 importBtn.disabled = false;
767 }
768 });
769
770 async function runLiveSync(kind) {
771 saveSyncSettings();
772 var url = remoteUrl.value.trim();
773 if (!url) {
774 setStatus('라이브 URL을 입력해 주세요.', true);
775 return;
776 }
777
778 var button = kind === 'pull' ? pullLiveBtn : pushLiveBtn;
779 var endpoint = kind === 'pull' ? '/hinana/voicepeak/sync/pull' : '/hinana/voicepeak/sync/push';
780 var loadingText = kind === 'pull' ? '라이브 서버에서 가져오는 중입니다.' : '라이브 서버로 보내는 중입니다.';
781
782 try {
783 button.disabled = true;
784 setStatus(loadingText);
785 var response = await fetch(endpoint, {
786 method: 'POST',
787 headers: {
788 'Content-Type': 'application/json',
789 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
790 },
791 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
792 remoteUrl: url,
793 from: exportFrom.value,
794 to: exportTo.value
795 })
796 });
797 var payload = await response.json();
798 if (!response.ok || !payload.success) throw new Error(payload.message || '동기화에 실패했습니다.');
799
800 if (kind === 'pull') {
801 setStatus(payload.importedCount + '개 작업을 라이브에서 가져왔습니다.');
802 } else {
803 setStatus(payload.exportedCount + '개 작업을 라이브로 보냈습니다.');
804 }
805 loadDrawer();
806 } catch (error) {
807 setStatus(error.message || '동기화에 실패했습니다.', true);
808 } finally {
809 button.disabled = false;
810 }
811 }
812
813 pullLiveBtn.addEventListener('click', function() { runLiveSync('pull'); });
814 pushLiveBtn.addEventListener('click', function() { runLiveSync('push'); });
815
816 loadSyncSettings();
817 loadDrawer();
818 </script>
819 </body>
820 </html>
821