본문으로 건너뛰기
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 <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>&copy; 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