Public Source Viewer

비나래아카이브 개발자 포털

실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.

Redacted View
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>&copy; 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