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: #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