Public Source Viewer
비나래아카이브 개발자 포털
실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.
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