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
</head>
469
<body class="<%= (typeof theme !== 'undefined' && theme === 'dark') ? 'dark-mode' : '' %>">
470
<header class="global-header">
471
<a href="/hinana/lounge" aria-label="비나래 라운지">
472
<img src="/image/<%= (typeof theme !== 'undefined' && theme === 'dark') ? 'lounge1.png' : 'lounge.png' %>" alt="비나래 라운지" class="header-logo">
473
</a>
474
<nav class="header-nav">
475
<a href="/hinana/index" class="nav-link">Archive</a>
476
<a href="/hinana/lounge" class="nav-link active">Lounge</a>
477
<a href="/hinana/gallery" class="nav-link">Gallery</a>
478
<a href="/hinana/plaza" class="nav-link">Plaza</a>
479
</nav>
480
<div class="header-controls">
481
<a href="/hinana/gallery#brand-assets" class="map-link"><i class="bi bi-grid-3x3-gap"></i> 사이트 맵</a>
482
<form action="/toggle-theme" method="POST" style="margin:0;">
483
<button type="submit" class="icon-btn" title="테마 변경">
484
<i class="bi <%= (typeof theme !== 'undefined' && theme === 'dark') ? 'bi-moon-stars-fill' : 'bi-sun-fill' %>"></i>
485
</button>
486
</form>
487
</div>
488
</header>
489
490
<main class="page">
491
<section class="page-head">
492
<div>
493
<div class="eyebrow">Japanese Lounge</div>
494
<h1>일본어 라운지</h1>
495
<p class="head-copy">일본어 원문과 한국어 문장을 나란히 두고, 업로드한 TTS 오디오를 문장별 구간으로 들을 수 있는 라운지예요.</p>
496
</div>
497
<a class="head-chip" href="/hinana/lounge"><i class="bi bi-arrow-left"></i> 라운지로</a>
498
</section>
499
500
<section class="layout">
501
<aside class="panel drawer">
502
<% if (typeof errorMessage !== 'undefined' && errorMessage) { %>
503
<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;">
504
<i class="bi bi-exclamation-triangle"></i> <%= errorMessage %>
505
</div>
506
<% } %>
507
<div class="panel-label">Archive Drawer</div>
508
<div class="entry-list">
509
<% if (entries && entries.length) { %>
510
<% entries.forEach(function(entry) { %>
511
<a class="entry-link <%= activeEntry && activeEntry.id === entry.id ? 'active' : '' %>" href="/hinana/japanese-lounge?entry=<%= encodeURIComponent(entry.id) %>">
512
<span class="entry-title"><%= entry.title %></span>
513
<span class="entry-meta"><%= fmtDate(entry.createdAt, true) %> · <%= entry.lines.length %>문장</span>
514
<span class="entry-file"><i class="bi bi-file-earmark-music"></i> <%= entry.originalAudioName %></span>
515
</a>
516
<% }); %>
517
<% } else { %>
518
<div class="empty">아직 추가된 글이 없습니다.</div>
519
<% } %>
520
</div>
521
<% if (typeof totalPages !== 'undefined' && totalPages > 1) { %>
522
<nav class="drawer-pagination" aria-label="일본어 라운지 페이지">
523
<% for (let p = 1; p <= totalPages; p++) { %>
524
<a class="drawer-page-link <%= p === currentPage ? 'active' : '' %>" href="/hinana/japanese-lounge?page=<%= p %>"><%= p %></a>
525
<% } %>
526
</nav>
527
<div class="drawer-count"><%= currentPage %> / <%= totalPages %> · 총 <%= totalEntryCount %>개</div>
528
<% } %>
529
530
<% if (canManage) { %>
531
<form class="upload-form" action="/hinana/japanese-lounge/entries" method="POST" enctype="multipart/form-data">
532
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
533
<div class="panel-label">Add Text</div>
534
<div class="form-field">
535
<label for="title">제목</label>
536
<input id="title" name="title" type="text" maxlength="120" placeholder="표시할 제목">
537
</div>
538
<div class="form-field">
539
<label for="japaneseText">일본어 원문</label>
540
<textarea id="japaneseText" name="japaneseText" placeholder="문장 또는 줄 단위로 붙여넣기" required></textarea>
541
<div class="field-actions">
542
<button class="action-btn split-text-btn" type="button" data-target="japaneseText" data-lang="ja"><i class="bi bi-text-paragraph"></i> 일본어 문장별 줄로 정리</button>
543
</div>
544
</div>
545
<div class="form-field">
546
<label for="koreanText">한국어 문장</label>
547
<textarea id="koreanText" name="koreanText" placeholder="일본어와 같은 순서로 붙여넣기" required></textarea>
548
<div class="field-actions">
549
<button class="action-btn split-text-btn" type="button" data-target="koreanText" data-lang="ko"><i class="bi bi-text-paragraph"></i> 한국어 문장별 줄로 정리</button>
550
</div>
551
</div>
552
<div class="form-field">
553
<label for="audio">TTS 오디오</label>
554
<input id="audio" name="audio" type="file" accept="audio/mpeg,audio/wav,audio/wave,.mp3,.wav" required>
555
</div>
556
<button class="action-btn primary" type="submit"><i class="bi bi-cloud-arrow-up"></i> 추가</button>
557
</form>
558
<% } %>
559
</aside>
560
561
<article class="panel">
562
<% if (activeEntry) { %>
563
<div class="viewer-head" data-entry-id="<%= activeEntry.id %>">
564
<div class="viewer-title-row">
565
<div>
566
<h2 class="viewer-title"><%= activeEntry.title %></h2>
567
<div class="viewer-meta"><%= fmtDate(activeEntry.createdAt, true) %> · <%= activeEntry.lines.length %>문장 · <%= activeEntry.originalAudioName %></div>
568
</div>
569
<% if (canManage) { %>
570
<button class="action-btn danger" id="deleteEntry" type="button"><i class="bi bi-trash"></i> 삭제</button>
571
<% } %>
572
</div>
573
<audio id="mainAudio" controls preload="metadata" src="<%= activeEntry.audioUrl %>"></audio>
574
<% if (canManage) { %>
575
<div class="meta-editor">
576
<div class="form-field">
577
<label for="displayAudioName">표시 파일명</label>
578
<input id="displayAudioName" type="text" value="<%= activeEntry.originalAudioName %>">
579
</div>
580
<button class="action-btn" id="saveMeta" type="button"><i class="bi bi-save"></i> 이름 저장</button>
581
</div>
582
<div class="split-editor">
583
<div class="form-field">
584
<label for="japaneseLinesEdit">일본어 문장 행 수정</label>
585
<textarea id="japaneseLinesEdit"><%= activeEntry.lines.map(function(line) { return line.japanese; }).join('\n') %></textarea>
586
</div>
587
<div class="form-field">
588
<label for="koreanLinesEdit">한국어 문장 행 수정</label>
589
<textarea id="koreanLinesEdit"><%= activeEntry.lines.map(function(line) { return line.korean; }).join('\n') %></textarea>
590
</div>
591
</div>
592
<div style="display:flex; gap:8px; flex-wrap:wrap;">
593
<button class="action-btn primary" id="saveLines" type="button"><i class="bi bi-save"></i> 문장 저장</button>
594
<button class="action-btn" id="addLine" type="button"><i class="bi bi-plus-lg"></i> 행 추가</button>
595
</div>
596
<% } %>
597
</div>
598
<div class="sentence-list">
599
<% activeEntry.lines.forEach(function(line) { %>
600
<section class="sentence-row" data-index="<%= line.index %>" data-start="<%= typeof line.start === 'number' ? line.start : '' %>" data-end="<%= typeof line.end === 'number' ? line.end : '' %>">
601
<div class="line-no"><%= line.index + 1 %></div>
602
<div class="line-block">
603
<div class="line-label">Japanese</div>
604
<p class="jp-text"><%= line.japanese || '—' %></p>
605
</div>
606
<div class="line-block">
607
<div class="line-label">Korean</div>
608
<p class="ko-text"><%= line.korean || '—' %></p>
609
</div>
610
<div class="row-tools">
611
<button class="action-btn play-line" type="button"><i class="bi bi-play-fill"></i> 재생</button>
612
<% if (!canManage && typeof line.start === 'number' && typeof line.end === 'number') { %>
613
<span class="entry-meta"><%= line.start %>s - <%= line.end %>s</span>
614
<% } %>
615
</div>
616
</section>
617
<% }); %>
618
</div>
619
<% } else { %>
620
<div class="empty">표시할 일본어 라운지 글이 없습니다.</div>
621
<% } %>
622
</article>
623
</section>
624
</main>
625
626
<footer class="site-footer">
627
<div class="footer-brand">
628
<img src="/image/sign.png" alt="비나래" class="footer-logo">
629
<div>
630
<div class="footer-title">비나래 라운지</div>
631
<div>Archive My Lounge</div>
632
</div>
633
</div>
634
<div>
635
<div class="footer-links">
636
<a href="/hinana/index">Archive</a>
637
<a href="/hinana/lounge">Lounge</a>
638
<a href="/hinana/gallery">Gallery</a>
639
<a href="/hinana/plaza">Plaza</a>
640
</div>
641
<div class="footer-copy"><span class="footer-version">Ver. 8.0.3.2-Utsumi Aoba</span>© 2024~2026. 비나래 | hinana.moe</div>
642
</div>
643
</footer>
644
645
<div class="split-modal" id="splitModal" aria-hidden="true">
646
<div class="split-dialog" role="dialog" aria-modal="true" aria-labelledby="splitModalTitle">
647
<div class="split-dialog-head">
648
<div>
649
<h2 class="split-dialog-title" id="splitModalTitle">문장별 줄로 정리</h2>
650
<div class="split-dialog-meta" id="splitModalMeta">0개 문장</div>
651
</div>
652
<button class="icon-btn" id="splitModalClose" type="button" title="닫기"><i class="bi bi-x-lg"></i></button>
653
</div>
654
<div class="split-dialog-body">
655
<textarea id="splitModalText"></textarea>
656
</div>
657
<div class="split-dialog-foot">
658
<button class="action-btn" id="splitModalCancel" type="button">취소</button>
659
<button class="action-btn primary" id="splitModalApply" type="button"><i class="bi bi-check2"></i> 적용</button>
660
</div>
661
</div>
662
</div>
663
664
<script src="/js/popup.js"></script>
665
<script>
666
(function() {
667
var audio = document.getElementById('mainAudio');
668
var activeRow = null;
669
var stopAt = null;
670
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
671
var entryHead = document.querySelector('.viewer-head');
672
var entryId = entryHead ? entryHead.dataset.entryId : '';
673
var highlightedRow = null;
674
var autoScrollHighlight = true;
675
var splitTargetTextarea = null;
676
var splitModal = document.getElementById('splitModal');
677
var splitModalTitle = document.getElementById('splitModalTitle');
678
var splitModalMeta = document.getElementById('splitModalMeta');
679
var splitModalText = document.getElementById('splitModalText');
680
681
function selectedRow() {
682
return activeRow || document.querySelector('.sentence-row');
683
}
684
685
function splitInputText(text, lang) {
686
var normalized = String(text || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim();
687
if (!normalized) return [];
688
689
if (lang === 'ja') {
690
return normalized
691
.split(/\n+/)
692
.flatMap(function(line) {
693
var trimmed = line.trim();
694
if (!trimmed) return [];
695
return trimmed.match(/[^。!?!?]+[。!?!?」』))]*/g) || [trimmed];
696
})
697
.map(function(line) { return line.trim(); })
698
.filter(Boolean);
699
}
700
701
return normalized
702
.split(/\n+/)
703
.flatMap(function(line) {
704
var trimmed = line.trim();
705
if (!trimmed) return [];
706
var protectedLine = trimmed
707
.replace(/([.!?~]+)([”’"'」』))]*)\s+/g, '$1$2\n')
708
.replace(/(다|요|죠|니다|까요|예요|이에요|어요|아요|해요|했어요|였어요|있어요|없어요|돼요|네요|군요|거예요|겠지|겠죠|합니다|했습니다|됩니다)([~.!?]*)([”’"'」』))]*)\s+/g, '$1$2$3\n');
709
return protectedLine.split(/\n+/);
710
})
711
.map(function(line) { return line.trim(); })
712
.filter(Boolean);
713
}
714
715
document.querySelectorAll('.split-text-btn').forEach(function(button) {
716
button.addEventListener('click', async function() {
717
var textarea = document.getElementById(button.dataset.target || '');
718
if (!textarea) return;
719
var lines = splitInputText(textarea.value, button.dataset.lang);
720
if (!lines.length) {
721
if (window.showAlert) await showAlert('분리할 문장이 없습니다.');
722
return;
723
}
724
splitTargetTextarea = textarea;
725
if (splitModalTitle) splitModalTitle.textContent = button.dataset.lang === 'ja' ? '일본어 문장별 줄로 정리' : '한국어 문장별 줄로 정리';
726
if (splitModalMeta) splitModalMeta.textContent = lines.length + '개 문장';
727
if (splitModalText) splitModalText.value = lines.join('\n');
728
if (splitModal) {
729
splitModal.classList.add('is-open');
730
splitModal.setAttribute('aria-hidden', 'false');
731
}
732
setTimeout(function() { splitModalText?.focus(); }, 0);
733
});
734
});
735
736
function closeSplitModal() {
737
if (!splitModal) return;
738
splitModal.classList.remove('is-open');
739
splitModal.setAttribute('aria-hidden', 'true');
740
splitTargetTextarea = null;
741
}
742
743
['splitModalClose', 'splitModalCancel'].forEach(function(id) {
744
var button = document.getElementById(id);
745
if (button) button.addEventListener('click', closeSplitModal);
746
});
747
748
var splitModalApply = document.getElementById('splitModalApply');
749
if (splitModalApply) {
750
splitModalApply.addEventListener('click', function() {
751
if (splitTargetTextarea && splitModalText) {
752
splitTargetTextarea.value = splitModalText.value;
753
splitTargetTextarea.focus();
754
}
755
closeSplitModal();
756
});
757
}
758
759
if (splitModal) {
760
splitModal.addEventListener('click', function(event) {
761
if (event.target === splitModal) closeSplitModal();
762
});
763
}
764
765
if (splitModalText) {
766
splitModalText.addEventListener('input', function() {
767
var count = splitModalText.value.split(/\r?\n/).map(function(line) { return line.trim(); }).filter(Boolean).length;
768
if (splitModalMeta) splitModalMeta.textContent = count + '개 문장';
769
});
770
}
771
772
document.addEventListener('keydown', function(event) {
773
if (event.key === 'Escape' && splitModal?.classList.contains('is-open')) closeSplitModal();
774
});
775
776
function bindRow(row) {
777
row.addEventListener('click', function(event) {
778
if (event.target.closest('input') || event.target.closest('textarea')) return;
779
if (activeRow) activeRow.classList.remove('is-selected');
780
activeRow = row;
781
activeRow.classList.add('is-selected');
782
});
783
784
var playButton = row.querySelector('.play-line');
785
if (playButton) {
786
playButton.addEventListener('click', playLine);
787
}
788
789
}
790
791
function renumberRows() {
792
document.querySelectorAll('.sentence-row').forEach(function(row, index) {
793
row.dataset.index = String(index);
794
var lineNo = row.querySelector('.line-no');
795
if (lineNo) lineNo.textContent = String(index + 1);
796
});
797
}
798
799
function playLine() {
800
if (!audio) return;
801
var row = this.closest('.sentence-row');
802
activeRow = row;
803
autoScrollHighlight = false;
804
setHighlightedRow(row);
805
var start = Number(row.dataset.start);
806
var end = Number(row.dataset.end);
807
stopAt = Number.isFinite(end) && end > 0 ? end : null;
808
if (Number.isFinite(start) && start >= 0) audio.currentTime = start;
809
audio.play();
810
}
811
812
document.querySelectorAll('.sentence-row').forEach(bindRow);
813
814
function rowTiming(row) {
815
var start = Number(row.dataset.start);
816
var end = Number(row.dataset.end);
817
if (Number.isFinite(start) && Number.isFinite(end) && end > start) {
818
return { start: start, end: end, exact: true };
819
}
820
821
return null;
822
}
823
824
function setHighlightedRow(row) {
825
if (highlightedRow === row) return;
826
if (highlightedRow) highlightedRow.classList.remove('is-speaking');
827
highlightedRow = row;
828
if (highlightedRow) {
829
highlightedRow.classList.add('is-speaking');
830
if (autoScrollHighlight) highlightedRow.scrollIntoView({ behavior: 'smooth', block: 'center' });
831
}
832
}
833
834
function updateSpeakingHighlight() {
835
if (!audio || audio.paused) return;
836
var rows = Array.from(document.querySelectorAll('.sentence-row'));
837
var current = audio.currentTime;
838
var exactMatch = rows.find(function(row, index) {
839
var timing = rowTiming(row);
840
return timing && timing.exact && current >= timing.start && current < timing.end;
841
});
842
setHighlightedRow(exactMatch || null);
843
}
844
845
if (audio) {
846
audio.addEventListener('play', function() {
847
autoScrollHighlight = true;
848
updateSpeakingHighlight();
849
});
850
audio.addEventListener('timeupdate', function() {
851
if (stopAt !== null && audio.currentTime >= stopAt) {
852
audio.pause();
853
stopAt = null;
854
}
855
updateSpeakingHighlight();
856
});
857
audio.addEventListener('pause', function() {
858
if (stopAt === null) autoScrollHighlight = false;
859
});
860
audio.addEventListener('ended', function() {
861
setHighlightedRow(null);
862
autoScrollHighlight = true;
863
});
864
}
865
866
var addLine = document.getElementById('addLine');
867
if (addLine) {
868
addLine.addEventListener('click', function() {
869
['japaneseLinesEdit', 'koreanLinesEdit'].forEach(function(id) {
870
var textarea = document.getElementById(id);
871
if (!textarea) return;
872
textarea.value = textarea.value ? textarea.value.replace(/\s*$/, '') + '\n' : '';
873
});
874
document.getElementById('japaneseLinesEdit')?.focus();
875
});
876
}
877
878
var saveLines = document.getElementById('saveLines');
879
if (saveLines) {
880
saveLines.addEventListener('click', async function() {
881
var japaneseLines = (document.getElementById('japaneseLinesEdit')?.value || '').split(/\r?\n/);
882
var koreanLines = (document.getElementById('koreanLinesEdit')?.value || '').split(/\r?\n/);
883
var timingRows = Array.from(document.querySelectorAll('.sentence-row'));
884
var maxLines = Math.max(japaneseLines.length, koreanLines.length);
885
var lines = Array.from({ length: maxLines }).map(function(_, index) {
886
var row = timingRows[index];
887
return {
888
japanese: japaneseLines[index] || '',
889
korean: koreanLines[index] || '',
890
start: row?.dataset.start || undefined,
891
end: row?.dataset.end || undefined
892
};
893
});
894
895
var response = await fetch('/hinana/japanese-lounge/entries/' + encodeURIComponent(entryId) + '/lines', {
896
method: 'PATCH',
897
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
898
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
899
});
900
if (response.ok) {
901
if (window.showAlert) await showAlert('문장을 저장했습니다.');
902
location.reload();
903
} else if (window.showAlert) {
904
var data = await response.json().catch(function() { return {}; });
905
await showAlert(data.message || '문장 저장에 실패했습니다.');
906
}
907
});
908
}
909
910
var saveMeta = document.getElementById('saveMeta');
911
if (saveMeta) {
912
saveMeta.addEventListener('click', async function() {
913
var originalAudioName = document.getElementById('displayAudioName')?.value || '';
914
var response = await fetch('/hinana/japanese-lounge/entries/' + encodeURIComponent(entryId) + '/meta', {
915
method: 'PATCH',
916
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
917
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
918
});
919
if (response.ok) {
920
if (window.showAlert) await showAlert('파일명을 저장했습니다.');
921
location.reload();
922
} else if (window.showAlert) {
923
await showAlert('파일명 저장에 실패했습니다.');
924
}
925
});
926
}
927
928
var deleteEntry = document.getElementById('deleteEntry');
929
if (deleteEntry) {
930
deleteEntry.addEventListener('click', async function() {
931
if (window.showConfirm && !(await showConfirm('이 일본어 라운지 기록을 삭제할까요?'))) return;
932
var response = await fetch('/hinana/japanese-lounge/entries/' + encodeURIComponent(entryId), {
933
method: 'DELETE',
934
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
935
});
936
if (response.ok) location.href = '/hinana/japanese-lounge';
937
else if (window.showAlert) await showAlert('삭제에 실패했습니다.');
938
});
939
}
940
})();
941
</script>
942
</body>
943
</html>
944