Public Source Viewer
비나래아카이브 개발자 포털
실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.
view/hinana/japaneseLounge.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
<meta property="og:title" content="일본어 라운지 — 비나래 라운지" />
9
<title>일본어 라운지 — 비나래 라운지</title>
10
<link rel="stylesheet" href="/vendors/bootstrap/css/bootstrap.min.css" />
11
<script src="/vendors/bootstrap/js/bootstrap.min.js"></script>
12
<link rel="stylesheet" href="/css/lounge-modern.css">
13
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons/font/bootstrap-icons.css">
14
<style>
15
:root {
16
--jp-bg: #f8fafc;
17
--jp-panel: #ffffff;
18
--jp-ink: #111827;
19
--jp-muted: #64748b;
20
--jp-line: #dbe5ed;
21
--jp-soft: #eef6f2;
22
--jp-accent: #0f766e;
23
--jp-accent-2: #d97706;
24
--jp-danger: #dc2626;
25
--jp-shadow: 0 16px 44px rgba(15, 23, 42, 0.08);
26
}
27
body.dark-mode {
28
--jp-bg: #07090c;
29
--jp-panel: #11151b;
30
--jp-ink: #f1f5f9;
31
--jp-muted: #94a3b8;
32
--jp-line: #28313c;
33
--jp-soft: #171d24;
34
--jp-accent: #2dd4bf;
35
--jp-accent-2: #fbbf24;
36
--jp-danger: #fb7185;
37
--jp-shadow: 0 20px 56px rgba(0, 0, 0, 0.3);
38
}
39
* { box-sizing: border-box; }
40
body {
41
margin: 0;
42
padding-top: 78px;
43
background: var(--jp-bg);
44
color: var(--jp-ink);
45
font-family: 'Noto Sans KR', system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
46
}
47
a { color: inherit; text-decoration: none; }
48
.global-header {
49
position: fixed;
50
top: 0;
51
left: 0;
52
right: 0;
53
z-index: 50;
54
min-height: 62px;
55
padding: 0 22px;
56
display: flex;
57
align-items: center;
58
justify-content: space-between;
59
gap: 18px;
60
border-bottom: 1px solid var(--jp-line);
61
background: color-mix(in srgb, var(--jp-bg) 88%, transparent);
62
backdrop-filter: blur(18px);
63
}
64
.header-logo { height: 32px; width: auto; object-fit: contain; mix-blend-mode: multiply; }
65
body.dark-mode .header-logo { mix-blend-mode: screen; }
66
.header-nav, .header-controls { display: flex; align-items: center; gap: 14px; }
67
.nav-link { color: var(--jp-muted); font-size: 0.86rem; font-weight: 850; }
68
.nav-link:hover, .nav-link.active { color: var(--jp-accent); }
69
.map-link { color: var(--jp-muted); font-size: 0.82rem; font-weight: 850; }
70
.icon-btn {
71
width: 34px;
72
height: 34px;
73
border: 1px solid var(--jp-line);
74
border-radius: 8px;
75
background: var(--jp-panel);
76
color: var(--jp-muted);
77
display: inline-flex;
78
align-items: center;
79
justify-content: center;
80
}
81
.page {
82
width: min(1180px, calc(100% - 36px));
83
margin: 0 auto;
84
padding: 34px 0 52px;
85
}
86
.page-head {
87
display: flex;
88
align-items: flex-end;
89
justify-content: space-between;
90
gap: 18px;
91
margin-bottom: 18px;
92
}
93
.eyebrow {
94
color: var(--jp-accent-2);
95
font-size: 0.72rem;
96
font-weight: 950;
97
letter-spacing: 0.16em;
98
text-transform: uppercase;
99
margin-bottom: 10px;
100
}
101
h1 {
102
margin: 0;
103
font-size: clamp(2rem, 5vw, 3.7rem);
104
line-height: 1;
105
font-weight: 950;
106
letter-spacing: 0;
107
}
108
.head-copy {
109
max-width: 640px;
110
margin: 12px 0 0;
111
color: var(--jp-muted);
112
line-height: 1.7;
113
font-size: 0.94rem;
114
}
115
.head-chip {
116
display: inline-flex;
117
align-items: center;
118
gap: 8px;
119
min-height: 36px;
120
padding: 0 12px;
121
border: 1px solid var(--jp-line);
122
border-radius: 8px;
123
background: var(--jp-panel);
124
color: var(--jp-muted);
125
font-size: 0.78rem;
126
font-weight: 900;
127
white-space: nowrap;
128
}
129
.layout {
130
display: grid;
131
grid-template-columns: 300px minmax(0, 1fr);
132
gap: 16px;
133
align-items: start;
134
}
135
.panel {
136
border: 1px solid var(--jp-line);
137
border-radius: 8px;
138
background: var(--jp-panel);
139
box-shadow: var(--jp-shadow);
140
overflow: hidden;
141
}
142
.drawer { padding: 16px; }
143
.panel-label {
144
color: var(--jp-muted);
145
font-size: 0.72rem;
146
font-weight: 950;
147
letter-spacing: 0.12em;
148
text-transform: uppercase;
149
margin-bottom: 12px;
150
}
151
.entry-list { display: grid; gap: 8px; }
152
.entry-link {
153
display: block;
154
padding: 12px;
155
border: 1px solid var(--jp-line);
156
border-radius: 8px;
157
background: var(--jp-soft);
158
}
159
.entry-link.active { border-color: var(--jp-accent); background: color-mix(in srgb, var(--jp-accent) 11%, var(--jp-panel)); }
160
.entry-title { display: block; font-size: 0.94rem; font-weight: 950; overflow-wrap: anywhere; }
161
.entry-meta { display: block; margin-top: 4px; color: var(--jp-muted); font-size: 0.76rem; font-weight: 750; }
162
.entry-file {
163
display: block;
164
margin-top: 6px;
165
color: var(--jp-accent);
166
font-size: 0.74rem;
167
font-weight: 850;
168
overflow-wrap: anywhere;
169
}
170
.drawer-pagination {
171
display: flex;
172
align-items: center;
173
justify-content: center;
174
gap: 6px;
175
flex-wrap: wrap;
176
margin-top: 12px;
177
}
178
.drawer-page-link {
179
min-width: 34px;
180
height: 34px;
181
display: inline-flex;
182
align-items: center;
183
justify-content: center;
184
border: 1px solid var(--jp-line);
185
border-radius: 8px;
186
background: var(--jp-panel);
187
color: var(--jp-muted);
188
font-size: 0.78rem;
189
font-weight: 950;
190
}
191
.drawer-page-link.active {
192
border-color: var(--jp-accent);
193
background: var(--jp-accent);
194
color: #fff;
195
}
196
.drawer-count {
197
margin-top: 10px;
198
color: var(--jp-muted);
199
font-size: 0.74rem;
200
font-weight: 800;
201
text-align: center;
202
}
203
.upload-form {
204
display: grid;
205
gap: 10px;
206
padding-top: 16px;
207
margin-top: 16px;
208
border-top: 1px solid var(--jp-line);
209
}
210
.form-field { display: grid; gap: 6px; }
211
.form-field label {
212
color: var(--jp-muted);
213
font-size: 0.76rem;
214
font-weight: 900;
215
}
216
.field-actions {
217
display: flex;
218
align-items: center;
219
gap: 6px;
220
flex-wrap: wrap;
221
margin-top: 6px;
222
}
223
.field-actions .action-btn {
224
min-height: 32px;
225
padding: 0 10px;
226
font-size: 0.76rem;
227
}
228
input[type="text"], input[type="file"], textarea {
229
width: 100%;
230
border: 1px solid var(--jp-line);
231
border-radius: 8px;
232
background: var(--jp-bg);
233
color: var(--jp-ink);
234
font: inherit;
235
font-size: 0.86rem;
236
}
237
input[type="text"] { min-height: 38px; padding: 0 10px; }
238
input[type="file"] { padding: 8px; }
239
textarea {
240
min-height: 116px;
241
resize: vertical;
242
padding: 10px;
243
line-height: 1.55;
244
}
245
.line-edit {
246
min-height: 84px;
247
font-size: 0.9rem;
248
}
249
.split-modal {
250
position: fixed;
251
inset: 0;
252
z-index: 100;
253
display: none;
254
align-items: center;
255
justify-content: center;
256
padding: 20px;
257
background: rgba(15, 23, 42, 0.46);
258
backdrop-filter: blur(10px);
259
}
260
.split-modal.is-open { display: flex; }
261
.split-dialog {
262
width: min(760px, 100%);
263
max-height: min(760px, calc(100vh - 40px));
264
display: grid;
265
grid-template-rows: auto minmax(0, 1fr) auto;
266
border: 1px solid var(--jp-line);
267
border-radius: 8px;
268
background: var(--jp-panel);
269
color: var(--jp-ink);
270
box-shadow: 0 28px 80px rgba(15, 23, 42, 0.26);
271
overflow: hidden;
272
}
273
.split-dialog-head, .split-dialog-foot {
274
display: flex;
275
align-items: center;
276
justify-content: space-between;
277
gap: 12px;
278
padding: 14px;
279
border-bottom: 1px solid var(--jp-line);
280
}
281
.split-dialog-foot { border-top: 1px solid var(--jp-line); border-bottom: 0; justify-content: flex-end; }
282
.split-dialog-title { margin: 0; font-size: 1rem; font-weight: 950; letter-spacing: 0; }
283
.split-dialog-meta { color: var(--jp-muted); font-size: 0.78rem; font-weight: 850; }
284
.split-dialog-body { padding: 14px; overflow: auto; }
285
.split-dialog textarea {
286
min-height: 430px;
287
resize: vertical;
288
line-height: 1.7;
289
}
290
.split-editor {
291
display: grid;
292
grid-template-columns: repeat(2, minmax(0, 1fr));
293
gap: 10px;
294
}
295
.split-editor textarea {
296
min-height: 220px;
297
font-size: 0.84rem;
298
line-height: 1.65;
299
}
300
.meta-editor {
301
display: grid;
302
grid-template-columns: minmax(0, 1fr) auto;
303
gap: 8px;
304
align-items: end;
305
}
306
.action-btn {
307
min-height: 38px;
308
border: 1px solid var(--jp-line);
309
border-radius: 8px;
310
background: var(--jp-panel);
311
color: var(--jp-ink);
312
display: inline-flex;
313
align-items: center;
314
justify-content: center;
315
gap: 8px;
316
padding: 0 12px;
317
font-size: 0.84rem;
318
font-weight: 900;
319
}
320
.action-btn.primary { background: var(--jp-accent); border-color: var(--jp-accent); color: #fff; }
321
.action-btn.danger { color: var(--jp-danger); }
322
.viewer-head {
323
padding: 18px;
324
border-bottom: 1px solid var(--jp-line);
325
display: grid;
326
gap: 14px;
327
}
328
.viewer-title-row {
329
display: flex;
330
align-items: flex-start;
331
justify-content: space-between;
332
gap: 12px;
333
}
334
.viewer-title { margin: 0; font-size: clamp(1.35rem, 3vw, 2.1rem); font-weight: 950; letter-spacing: 0; }
335
.viewer-meta { color: var(--jp-muted); font-size: 0.8rem; font-weight: 800; margin-top: 5px; }
336
audio { width: 100%; height: 42px; }
337
.sentence-list { display: grid; gap: 0; }
338
.sentence-row {
339
display: grid;
340
grid-template-columns: 44px minmax(0, 1fr) minmax(0, 1fr) 140px;
341
gap: 12px;
342
align-items: start;
343
padding: 16px 18px;
344
border-bottom: 1px solid var(--jp-line);
345
transition: background 0.18s ease, box-shadow 0.18s ease;
346
}
347
.sentence-row:last-child { border-bottom: 0; }
348
.sentence-row.is-speaking {
349
background: color-mix(in srgb, var(--jp-accent-2) 14%, var(--jp-panel));
350
box-shadow: inset 4px 0 0 var(--jp-accent-2);
351
}
352
.sentence-row.is-speaking .line-no {
353
background: var(--jp-accent-2);
354
color: #fff;
355
}
356
.sentence-row.is-speaking .jp-text {
357
color: var(--jp-accent);
358
}
359
.sentence-row.is-speaking .ko-text {
360
color: var(--jp-ink);
361
font-weight: 850;
362
}
363
.sentence-row.is-selected {
364
outline: 2px solid color-mix(in srgb, var(--jp-accent) 38%, transparent);
365
outline-offset: -2px;
366
}
367
.line-no {
368
width: 34px;
369
height: 34px;
370
border-radius: 8px;
371
display: inline-flex;
372
align-items: center;
373
justify-content: center;
374
background: var(--jp-soft);
375
color: var(--jp-muted);
376
font-weight: 950;
377
font-size: 0.78rem;
378
}
379
.line-block { min-width: 0; }
380
.line-label {
381
color: var(--jp-muted);
382
font-size: 0.68rem;
383
font-weight: 950;
384
letter-spacing: 0.1em;
385
text-transform: uppercase;
386
margin-bottom: 7px;
387
}
388
.jp-text, .ko-text {
389
margin: 0;
390
line-height: 1.72;
391
overflow-wrap: anywhere;
392
}
393
.jp-text { font-size: 1.02rem; font-weight: 850; }
394
.ko-text { color: var(--jp-muted); font-size: 0.94rem; }
395
.row-tools {
396
display: grid;
397
gap: 8px;
398
justify-items: stretch;
399
}
400
.empty {
401
padding: 42px 18px;
402
text-align: center;
403
color: var(--jp-muted);
404
}
405
.site-footer {
406
width: min(1180px, calc(100% - 36px));
407
margin: 0 auto 38px;
408
padding-top: 26px;
409
border-top: 1px solid var(--jp-line);
410
color: var(--jp-muted);
411
display: flex;
412
align-items: center;
413
justify-content: space-between;
414
gap: 20px;
415
font-size: 0.78rem;
416
}
417
.footer-brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
418
.footer-logo { width: 44px; height: 44px; object-fit: contain; opacity: 0.76; mix-blend-mode: multiply; }
419
body.dark-mode .footer-logo { mix-blend-mode: screen; opacity: 0.9; }
420
.footer-title { color: var(--jp-ink); font-weight: 950; font-size: 0.9rem; }
421
.footer-links { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }
422
.footer-links a { color: var(--jp-muted); font-weight: 850; }
423
.footer-links a:hover { color: var(--jp-accent); }
424
.footer-copy { margin-top: 4px; text-align: right; font-size: 0.72rem; }
425
.footer-version {
426
display: inline-flex;
427
align-items: center;
428
min-height: 20px;
429
padding: 0 8px;
430
margin-right: 8px;
431
border: 1px solid var(--jp-line);
432
border-radius: 999px;
433
color: var(--jp-accent);
434
font-weight: 850;
435
background: var(--jp-panel);
436
}
437
@media (max-width: 980px) {
438
.layout { grid-template-columns: 1fr; }
439
.split-editor { grid-template-columns: 1fr; }
440
.sentence-row { grid-template-columns: 40px minmax(0, 1fr); }
441
.row-tools { grid-column: 2; }
442
.line-block:nth-of-type(3) { grid-column: 2; }
443
}
444
@media (max-width: 860px) {
445
body { padding-top: 116px; }
446
.global-header { flex-wrap: wrap; min-height: 62px; padding: 10px 16px; }
447
.header-nav {
448
position: static;
449
order: 3;
450
width: 100%;
451
justify-content: center;
452
overflow-x: auto;
453
padding-top: 10px;
454
border-top: 1px solid var(--jp-line);
455
}
456
.page-head { display: block; }
457
.head-chip { margin-top: 14px; }
458
}
459
@media (max-width: 560px) {
460
.page, .site-footer { width: min(100% - 24px, 1180px); }
461
.sentence-row { grid-template-columns: 1fr; }
462
.row-tools, .line-block:nth-of-type(3) { grid-column: auto; }
463
.viewer-title-row, .site-footer { flex-direction: column; align-items: flex-start; }
464
.footer-links { justify-content: flex-start; }
465
.footer-copy { text-align: left; }
466
}
467
</style>
468
<link rel="stylesheet" href="/css/site-ui.css?v=3">
469
<link rel="stylesheet" href="/css/site-app.css?v=1">
470
</head>
471
<body data-ui-page="japaneseLounge" class="ui-shell <%= (typeof theme !== 'undefined' && theme === 'dark') ? 'dark-mode' : '' %>">
472
<%- include('partials/site-header', { uiPage: 'japaneseLounge' }) %>
473
474
<main id="ui-content" tabindex="-1" class="page">
475
<section class="page-head">
476
<div>
477
<div class="eyebrow">Japanese Lounge</div>
478
<h1>일본어 라운지</h1>
479
<p class="head-copy">일본어 원문과 한국어 문장을 나란히 두고, 업로드한 TTS 오디오를 문장별 구간으로 들을 수 있는 라운지예요.</p>
480
</div>
481
<a class="head-chip" href="/hinana/lounge"><i class="bi bi-arrow-left"></i> 라운지로</a>
482
</section>
483
484
<section class="layout">
485
<aside class="panel drawer">
486
<% if (typeof errorMessage !== 'undefined' && errorMessage) { %>
487
<div style="margin-bottom:12px; padding:10px 12px; border:1px solid var(--jp-danger); border-radius:8px; color:var(--jp-danger); background:color-mix(in srgb, var(--jp-danger) 8%, transparent); font-size:0.84rem; font-weight:850;">
488
<i class="bi bi-exclamation-triangle"></i> <%= errorMessage %>
489
</div>
490
<% } %>
491
<div class="panel-label">Archive Drawer</div>
492
<div class="entry-list">
493
<% if (entries && entries.length) { %>
494
<% entries.forEach(function(entry) { %>
495
<a class="entry-link <%= activeEntry && activeEntry.id === entry.id ? 'active' : '' %>" href="/hinana/japanese-lounge?entry=<%= encodeURIComponent(entry.id) %>">
496
<span class="entry-title"><%= entry.title %></span>
497
<span class="entry-meta"><%= fmtDate(entry.createdAt, true) %> · <%= entry.lines.length %>문장</span>
498
<span class="entry-file"><i class="bi bi-file-earmark-music"></i> <%= entry.originalAudioName %></span>
499
</a>
500
<% }); %>
501
<% } else { %>
502
<div class="empty">아직 추가된 글이 없습니다.</div>
503
<% } %>
504
</div>
505
<% if (typeof totalPages !== 'undefined' && totalPages > 1) { %>
506
<nav class="drawer-pagination" aria-label="일본어 라운지 페이지">
507
<% for (let p = 1; p <= totalPages; p++) { %>
508
<a class="drawer-page-link <%= p === currentPage ? 'active' : '' %>" href="/hinana/japanese-lounge?page=<%= p %>"><%= p %></a>
509
<% } %>
510
</nav>
511
<div class="drawer-count"><%= currentPage %> / <%= totalPages %> · 총 <%= totalEntryCount %>개</div>
512
<% } %>
513
514
<% if (canManage) { %>
515
<form class="upload-form" action="/hinana/japanese-lounge/entries" method="POST" enctype="multipart/form-data">
516
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
517
<div class="panel-label">Add Text</div>
518
<div class="form-field">
519
<label for="title">제목</label>
520
<input id="title" name="title" type="text" maxlength="120" placeholder="표시할 제목">
521
</div>
522
<div class="form-field">
523
<label for="japaneseText">일본어 원문</label>
524
<textarea id="japaneseText" name="japaneseText" placeholder="문장 또는 줄 단위로 붙여넣기" required></textarea>
525
<div class="field-actions">
526
<button class="action-btn split-text-btn" type="button" data-target="japaneseText" data-lang="ja"><i class="bi bi-text-paragraph"></i> 일본어 문장별 줄로 정리</button>
527
</div>
528
</div>
529
<div class="form-field">
530
<label for="koreanText">한국어 문장</label>
531
<textarea id="koreanText" name="koreanText" placeholder="일본어와 같은 순서로 붙여넣기" required></textarea>
532
<div class="field-actions">
533
<button class="action-btn split-text-btn" type="button" data-target="koreanText" data-lang="ko"><i class="bi bi-text-paragraph"></i> 한국어 문장별 줄로 정리</button>
534
</div>
535
</div>
536
<div class="form-field">
537
<label for="audio">TTS 오디오</label>
538
<input id="audio" name="audio" type="file" accept="audio/mpeg,audio/wav,audio/wave,.mp3,.wav" required>
539
</div>
540
<button class="action-btn primary" type="submit"><i class="bi bi-cloud-arrow-up"></i> 추가</button>
541
</form>
542
<% } %>
543
</aside>
544
545
<article class="panel">
546
<% if (activeEntry) { %>
547
<div class="viewer-head" data-entry-id="<%= activeEntry.id %>">
548
<div class="viewer-title-row">
549
<div>
550
<h2 class="viewer-title"><%= activeEntry.title %></h2>
551
<div class="viewer-meta"><%= fmtDate(activeEntry.createdAt, true) %> · <%= activeEntry.lines.length %>문장 · <%= activeEntry.originalAudioName %></div>
552
</div>
553
<% if (canManage) { %>
554
<button class="action-btn danger" id="deleteEntry" type="button"><i class="bi bi-trash"></i> 삭제</button>
555
<% } %>
556
</div>
557
<audio id="mainAudio" controls preload="metadata" src="<%= activeEntry.audioUrl %>"></audio>
558
<% if (canManage) { %>
559
<div class="meta-editor">
560
<div class="form-field">
561
<label for="displayAudioName">표시 파일명</label>
562
<input id="displayAudioName" type="text" value="<%= activeEntry.originalAudioName %>">
563
</div>
564
<button class="action-btn" id="saveMeta" type="button"><i class="bi bi-save"></i> 이름 저장</button>
565
</div>
566
<div class="split-editor">
567
<div class="form-field">
568
<label for="japaneseLinesEdit">일본어 문장 행 수정</label>
569
<textarea id="japaneseLinesEdit"><%= activeEntry.lines.map(function(line) { return line.japanese; }).join('\n') %></textarea>
570
</div>
571
<div class="form-field">
572
<label for="koreanLinesEdit">한국어 문장 행 수정</label>
573
<textarea id="koreanLinesEdit"><%= activeEntry.lines.map(function(line) { return line.korean; }).join('\n') %></textarea>
574
</div>
575
</div>
576
<div style="display:flex; gap:8px; flex-wrap:wrap;">
577
<button class="action-btn primary" id="saveLines" type="button"><i class="bi bi-save"></i> 문장 저장</button>
578
<button class="action-btn" id="addLine" type="button"><i class="bi bi-plus-lg"></i> 행 추가</button>
579
</div>
580
<% } %>
581
</div>
582
<div class="sentence-list">
583
<% activeEntry.lines.forEach(function(line) { %>
584
<section class="sentence-row" data-index="<%= line.index %>" data-start="<%= typeof line.start === 'number' ? line.start : '' %>" data-end="<%= typeof line.end === 'number' ? line.end : '' %>">
585
<div class="line-no"><%= line.index + 1 %></div>
586
<div class="line-block">
587
<div class="line-label">Japanese</div>
588
<p class="jp-text"><%= line.japanese || '—' %></p>
589
</div>
590
<div class="line-block">
591
<div class="line-label">Korean</div>
592
<p class="ko-text"><%= line.korean || '—' %></p>
593
</div>
594
<div class="row-tools">
595
<button class="action-btn play-line" type="button"><i class="bi bi-play-fill"></i> 재생</button>
596
<% if (!canManage && typeof line.start === 'number' && typeof line.end === 'number') { %>
597
<span class="entry-meta"><%= line.start %>s - <%= line.end %>s</span>
598
<% } %>
599
</div>
600
</section>
601
<% }); %>
602
</div>
603
<% } else { %>
604
<div class="empty">표시할 일본어 라운지 글이 없습니다.</div>
605
<% } %>
606
</article>
607
</section>
608
</main>
609
610
<footer class="site-footer">
611
<div class="footer-brand">
612
<img src="/image/sign.png" alt="비나래" class="footer-logo">
613
<div>
614
<div class="footer-title">비나래 라운지</div>
615
<div>Archive My Lounge</div>
616
</div>
617
</div>
618
<div>
619
<div class="footer-links">
620
<a href="/hinana/index">Archive</a>
621
<a href="/hinana/lounge">Lounge</a>
622
<a href="/hinana/gallery">Gallery</a>
623
<a href="/hinana/plaza">Plaza</a>
624
</div>
625
<div class="footer-copy"><span class="footer-version">Ver. 9.2.1.0 - Iochi Mari</span>© 2024~2026. 비나래 | hinana.moe</div>
626
</div>
627
</footer>
628
629
<div class="split-modal" id="splitModal" aria-hidden="true">
630
<div class="split-dialog" role="dialog" aria-modal="true" aria-labelledby="splitModalTitle">
631
<div class="split-dialog-head">
632
<div>
633
<h2 class="split-dialog-title" id="splitModalTitle">문장별 줄로 정리</h2>
634
<div class="split-dialog-meta" id="splitModalMeta">0개 문장</div>
635
</div>
636
<button class="icon-btn" id="splitModalClose" type="button" title="닫기"><i class="bi bi-x-lg"></i></button>
637
</div>
638
<div class="split-dialog-body">
639
<textarea id="splitModalText"></textarea>
640
</div>
641
<div class="split-dialog-foot">
642
<button class="action-btn" id="splitModalCancel" type="button">취소</button>
643
<button class="action-btn primary" id="splitModalApply" type="button"><i class="bi bi-check2"></i> 적용</button>
644
</div>
645
</div>
646
</div>
647
648
<script src="/js/popup.js"></script>
649
<script>
650
(function() {
651
var audio = document.getElementById('mainAudio');
652
var activeRow = null;
653
var stopAt = null;
654
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
655
var entryHead = document.querySelector('.viewer-head');
656
var entryId = entryHead ? entryHead.dataset.entryId : '';
657
var highlightedRow = null;
658
var autoScrollHighlight = true;
659
var splitTargetTextarea = null;
660
var splitModal = document.getElementById('splitModal');
661
var splitModalTitle = document.getElementById('splitModalTitle');
662
var splitModalMeta = document.getElementById('splitModalMeta');
663
var splitModalText = document.getElementById('splitModalText');
664
665
function selectedRow() {
666
return activeRow || document.querySelector('.sentence-row');
667
}
668
669
function splitInputText(text, lang) {
670
var normalized = String(text || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim();
671
if (!normalized) return [];
672
673
if (lang === 'ja') {
674
return normalized
675
.split(/\n+/)
676
.flatMap(function(line) {
677
var trimmed = line.trim();
678
if (!trimmed) return [];
679
return trimmed.match(/[^。!?!?]+[。!?!?」』))]*/g) || [trimmed];
680
})
681
.map(function(line) { return line.trim(); })
682
.filter(Boolean);
683
}
684
685
return normalized
686
.split(/\n+/)
687
.flatMap(function(line) {
688
var trimmed = line.trim();
689
if (!trimmed) return [];
690
var protectedLine = trimmed
691
.replace(/([.!?~]+)([”’"'」』))]*)\s+/g, '$1$2\n')
692
.replace(/(다|요|죠|니다|까요|예요|이에요|어요|아요|해요|했어요|였어요|있어요|없어요|돼요|네요|군요|거예요|겠지|겠죠|합니다|했습니다|됩니다)([~.!?]*)([”’"'」』))]*)\s+/g, '$1$2$3\n');
693
return protectedLine.split(/\n+/);
694
})
695
.map(function(line) { return line.trim(); })
696
.filter(Boolean);
697
}
698
699
document.querySelectorAll('.split-text-btn').forEach(function(button) {
700
button.addEventListener('click', async function() {
701
var textarea = document.getElementById(button.dataset.target || '');
702
if (!textarea) return;
703
var lines = splitInputText(textarea.value, button.dataset.lang);
704
if (!lines.length) {
705
if (window.showAlert) await showAlert('분리할 문장이 없습니다.');
706
return;
707
}
708
splitTargetTextarea = textarea;
709
if (splitModalTitle) splitModalTitle.textContent = button.dataset.lang === 'ja' ? '일본어 문장별 줄로 정리' : '한국어 문장별 줄로 정리';
710
if (splitModalMeta) splitModalMeta.textContent = lines.length + '개 문장';
711
if (splitModalText) splitModalText.value = lines.join('\n');
712
if (splitModal) {
713
splitModal.classList.add('is-open');
714
splitModal.setAttribute('aria-hidden', 'false');
715
}
716
setTimeout(function() { splitModalText?.focus(); }, 0);
717
});
718
});
719
720
function closeSplitModal() {
721
if (!splitModal) return;
722
splitModal.classList.remove('is-open');
723
splitModal.setAttribute('aria-hidden', 'true');
724
splitTargetTextarea = null;
725
}
726
727
['splitModalClose', 'splitModalCancel'].forEach(function(id) {
728
var button = document.getElementById(id);
729
if (button) button.addEventListener('click', closeSplitModal);
730
});
731
732
var splitModalApply = document.getElementById('splitModalApply');
733
if (splitModalApply) {
734
splitModalApply.addEventListener('click', function() {
735
if (splitTargetTextarea && splitModalText) {
736
splitTargetTextarea.value = splitModalText.value;
737
splitTargetTextarea.focus();
738
}
739
closeSplitModal();
740
});
741
}
742
743
if (splitModal) {
744
splitModal.addEventListener('click', function(event) {
745
if (event.target === splitModal) closeSplitModal();
746
});
747
}
748
749
if (splitModalText) {
750
splitModalText.addEventListener('input', function() {
751
var count = splitModalText.value.split(/\r?\n/).map(function(line) { return line.trim(); }).filter(Boolean).length;
752
if (splitModalMeta) splitModalMeta.textContent = count + '개 문장';
753
});
754
}
755
756
document.addEventListener('keydown', function(event) {
757
if (event.key === 'Escape' && splitModal?.classList.contains('is-open')) closeSplitModal();
758
});
759
760
function bindRow(row) {
761
row.addEventListener('click', function(event) {
762
if (event.target.closest('input') || event.target.closest('textarea')) return;
763
if (activeRow) activeRow.classList.remove('is-selected');
764
activeRow = row;
765
activeRow.classList.add('is-selected');
766
});
767
768
var playButton = row.querySelector('.play-line');
769
if (playButton) {
770
playButton.addEventListener('click', playLine);
771
}
772
773
}
774
775
function renumberRows() {
776
document.querySelectorAll('.sentence-row').forEach(function(row, index) {
777
row.dataset.index = String(index);
778
var lineNo = row.querySelector('.line-no');
779
if (lineNo) lineNo.textContent = String(index + 1);
780
});
781
}
782
783
function playLine() {
784
if (!audio) return;
785
var row = this.closest('.sentence-row');
786
activeRow = row;
787
autoScrollHighlight = false;
788
setHighlightedRow(row);
789
var start = Number(row.dataset.start);
790
var end = Number(row.dataset.end);
791
stopAt = Number.isFinite(end) && end > 0 ? end : null;
792
if (Number.isFinite(start) && start >= 0) audio.currentTime = start;
793
audio.play();
794
}
795
796
document.querySelectorAll('.sentence-row').forEach(bindRow);
797
798
function rowTiming(row) {
799
var start = Number(row.dataset.start);
800
var end = Number(row.dataset.end);
801
if (Number.isFinite(start) && Number.isFinite(end) && end > start) {
802
return { start: start, end: end, exact: true };
803
}
804
805
return null;
806
}
807
808
function setHighlightedRow(row) {
809
if (highlightedRow === row) return;
810
if (highlightedRow) highlightedRow.classList.remove('is-speaking');
811
highlightedRow = row;
812
if (highlightedRow) {
813
highlightedRow.classList.add('is-speaking');
814
if (autoScrollHighlight) highlightedRow.scrollIntoView({ behavior: 'smooth', block: 'center' });
815
}
816
}
817
818
function updateSpeakingHighlight() {
819
if (!audio || audio.paused) return;
820
var rows = Array.from(document.querySelectorAll('.sentence-row'));
821
var current = audio.currentTime;
822
var exactMatch = rows.find(function(row, index) {
823
var timing = rowTiming(row);
824
return timing && timing.exact && current >= timing.start && current < timing.end;
825
});
826
setHighlightedRow(exactMatch || null);
827
}
828
829
if (audio) {
830
audio.addEventListener('play', function() {
831
autoScrollHighlight = true;
832
updateSpeakingHighlight();
833
});
834
audio.addEventListener('timeupdate', function() {
835
if (stopAt !== null && audio.currentTime >= stopAt) {
836
audio.pause();
837
stopAt = null;
838
}
839
updateSpeakingHighlight();
840
});
841
audio.addEventListener('pause', function() {
842
if (stopAt === null) autoScrollHighlight = false;
843
});
844
audio.addEventListener('ended', function() {
845
setHighlightedRow(null);
846
autoScrollHighlight = true;
847
});
848
}
849
850
var addLine = document.getElementById('addLine');
851
if (addLine) {
852
addLine.addEventListener('click', function() {
853
['japaneseLinesEdit', 'koreanLinesEdit'].forEach(function(id) {
854
var textarea = document.getElementById(id);
855
if (!textarea) return;
856
textarea.value = textarea.value ? textarea.value.replace(/\s*$/, '') + '\n' : '';
857
});
858
document.getElementById('japaneseLinesEdit')?.focus();
859
});
860
}
861
862
var saveLines = document.getElementById('saveLines');
863
if (saveLines) {
864
saveLines.addEventListener('click', async function() {
865
var japaneseLines = (document.getElementById('japaneseLinesEdit')?.value || '').split(/\r?\n/);
866
var koreanLines = (document.getElementById('koreanLinesEdit')?.value || '').split(/\r?\n/);
867
var timingRows = Array.from(document.querySelectorAll('.sentence-row'));
868
var maxLines = Math.max(japaneseLines.length, koreanLines.length);
869
var lines = Array.from({ length: maxLines }).map(function(_, index) {
870
var row = timingRows[index];
871
return {
872
japanese: japaneseLines[index] || '',
873
korean: koreanLines[index] || '',
874
start: row?.dataset.start || undefined,
875
end: row?.dataset.end || undefined
876
};
877
});
878
879
var response = await fetch('/hinana/japanese-lounge/entries/' + encodeURIComponent(entryId) + '/lines', {
880
method: 'PATCH',
881
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
882
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
883
});
884
if (response.ok) {
885
if (window.showAlert) await showAlert('문장을 저장했습니다.');
886
location.reload();
887
} else if (window.showAlert) {
888
var data = await response.json().catch(function() { return {}; });
889
await showAlert(data.message || '문장 저장에 실패했습니다.');
890
}
891
});
892
}
893
894
var saveMeta = document.getElementById('saveMeta');
895
if (saveMeta) {
896
saveMeta.addEventListener('click', async function() {
897
var originalAudioName = document.getElementById('displayAudioName')?.value || '';
898
var response = await fetch('/hinana/japanese-lounge/entries/' + encodeURIComponent(entryId) + '/meta', {
899
method: 'PATCH',
900
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
901
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
902
});
903
if (response.ok) {
904
if (window.showAlert) await showAlert('파일명을 저장했습니다.');
905
location.reload();
906
} else if (window.showAlert) {
907
await showAlert('파일명 저장에 실패했습니다.');
908
}
909
});
910
}
911
912
var deleteEntry = document.getElementById('deleteEntry');
913
if (deleteEntry) {
914
deleteEntry.addEventListener('click', async function() {
915
if (window.showConfirm && !(await showConfirm('이 일본어 라운지 기록을 삭제할까요?'))) return;
916
var response = await fetch('/hinana/japanese-lounge/entries/' + encodeURIComponent(entryId), {
917
method: 'DELETE',
918
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
919
});
920
if (response.ok) location.href = '/hinana/japanese-lounge';
921
else if (window.showAlert) await showAlert('삭제에 실패했습니다.');
922
});
923
}
924
})();
925
</script>
926
</body>
927
</html>
928