duck3-create Claude Opus 4.6 commited on
Commit
bacd066
·
1 Parent(s): d2c03ed

UI polish: i18n toggle, Apple-style design, token count

Browse files

- Add KO/EN language toggle with full i18n translations
- Apple-style design: reduced border-radius, no glow effects
- Show token count instead of reading time in result stats
- Bigger/bolder option text, buttons, and history items
- Darker text colors for better readability
- Merge checkboxes into one row, thicker checkbox borders
- Remove footer, remove keep_newlines checkbox
- Increase URL limit from 20 to 50
- Card header shows video title instead of raw ID

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Files changed (2) hide show
  1. main.py +2 -2
  2. static/index.html +263 -131
main.py CHANGED
@@ -160,10 +160,10 @@ def _fetch_transcript(video_id: str, language: str, denoise: bool, fmt: str, kee
160
 
161
  @app.post("/api/transcripts")
162
  async def get_transcripts(request: TranscriptRequest):
163
- if len(request.urls) > 20:
164
  return JSONResponse(
165
  status_code=400,
166
- content={"error": "최대 20개의 URL만 처리할 수 있습니다."},
167
  )
168
 
169
  urls = [u.strip() for u in request.urls if u.strip()]
 
160
 
161
  @app.post("/api/transcripts")
162
  async def get_transcripts(request: TranscriptRequest):
163
+ if len(request.urls) > 50:
164
  return JSONResponse(
165
  status_code=400,
166
+ content={"error": "최대 50개의 URL만 처리할 수 있습니다."},
167
  )
168
 
169
  urls = [u.strip() for u in request.urls if u.strip()]
static/index.html CHANGED
@@ -17,8 +17,8 @@
17
  --border: #e4e4e7;
18
  --border-light: #f0f0f2;
19
  --text-primary: #18181b;
20
- --text-secondary: #71717a;
21
- --text-tertiary: #a1a1aa;
22
  --accent: #4f46e5;
23
  --accent-hover: #4338ca;
24
  --accent-subtle: rgba(79, 70, 229, 0.06);
@@ -32,8 +32,8 @@
32
  --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.06);
33
  --font-sans: 'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
34
  --font-mono: 'JetBrains Mono', monospace;
35
- --radius: 8px;
36
- --radius-lg: 12px;
37
  --transition: 200ms ease;
38
  }
39
 
@@ -47,8 +47,8 @@
47
  --border: #2e2e35;
48
  --border-light: #252529;
49
  --text-primary: #ededef;
50
- --text-secondary: #8f8f96;
51
- --text-tertiary: #5c5c63;
52
  --accent: #6366f1;
53
  --accent-hover: #818cf8;
54
  --accent-subtle: rgba(99, 102, 241, 0.06);
@@ -72,8 +72,8 @@
72
  --border: #2e2e35;
73
  --border-light: #252529;
74
  --text-primary: #ededef;
75
- --text-secondary: #8f8f96;
76
- --text-tertiary: #5c5c63;
77
  --accent: #6366f1;
78
  --accent-hover: #818cf8;
79
  --accent-subtle: rgba(99, 102, 241, 0.06);
@@ -131,7 +131,7 @@
131
  }
132
 
133
  header h1 {
134
- font-size: 28px;
135
  font-weight: 700;
136
  letter-spacing: -0.035em;
137
  line-height: 1.15;
@@ -146,6 +146,38 @@
146
  line-height: 1.5;
147
  }
148
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  /* -- Theme Toggle -- */
150
  .theme-toggle {
151
  background: var(--bg-surface);
@@ -185,7 +217,7 @@
185
 
186
  /* -- Section Label -- */
187
  .section-label {
188
- font-size: 12px;
189
  font-weight: 600;
190
  color: var(--text-tertiary);
191
  text-transform: uppercase;
@@ -227,7 +259,7 @@
227
 
228
  textarea:focus {
229
  border-color: var(--accent);
230
- box-shadow: 0 0 0 3px var(--accent-subtle);
231
  }
232
 
233
  .url-count {
@@ -275,13 +307,13 @@
275
 
276
  .option-divider {
277
  width: 1px;
278
- height: 20px;
279
  background: var(--border);
280
  flex-shrink: 0;
281
  }
282
 
283
  .option-label {
284
- font-size: 12px;
285
  font-weight: 600;
286
  color: var(--text-tertiary);
287
  text-transform: uppercase;
@@ -294,20 +326,20 @@
294
  display: flex;
295
  background: var(--bg-surface);
296
  border: 1px solid var(--border);
297
- border-radius: 6px;
298
- padding: 2px;
299
  gap: 2px;
300
  }
301
 
302
  .toggle-btn {
303
- padding: 5px 12px;
304
  font-family: var(--font-sans);
305
- font-size: 13px;
306
  font-weight: 500;
307
  color: var(--text-secondary);
308
  background: transparent;
309
  border: none;
310
- border-radius: 4px;
311
  cursor: pointer;
312
  transition: all var(--transition);
313
  white-space: nowrap;
@@ -330,7 +362,7 @@
330
  align-items: center;
331
  gap: 8px;
332
  cursor: pointer;
333
- font-size: 13px;
334
  color: var(--text-secondary);
335
  user-select: none;
336
  transition: color var(--transition);
@@ -348,10 +380,10 @@
348
  }
349
 
350
  .checkbox-custom {
351
- width: 16px;
352
- height: 16px;
353
- border: 1.5px solid var(--border);
354
- border-radius: 4px;
355
  transition: all var(--transition);
356
  position: relative;
357
  flex-shrink: 0;
@@ -369,10 +401,10 @@
369
  .checkbox-wrapper input:checked + .checkbox-custom::after {
370
  content: '';
371
  position: absolute;
372
- left: 4.5px;
373
- top: 1.5px;
374
- width: 5px;
375
- height: 9px;
376
  border: solid #fff;
377
  border-width: 0 1.5px 1.5px 0;
378
  transform: rotate(45deg);
@@ -392,15 +424,17 @@
392
  }
393
 
394
  .btn:focus-visible {
395
- box-shadow: 0 0 0 3px var(--accent-subtle);
 
 
396
  }
397
 
398
  .btn-primary {
399
  background: var(--accent);
400
  color: #fff;
401
  width: 100%;
402
- padding: 13px;
403
- font-size: 14px;
404
  font-weight: 600;
405
  letter-spacing: -0.01em;
406
  }
@@ -410,7 +444,7 @@
410
  }
411
 
412
  .btn-primary:active:not(:disabled) {
413
- transform: scale(0.995);
414
  }
415
 
416
  .btn-primary:disabled {
@@ -433,8 +467,9 @@
433
  }
434
 
435
  .btn-sm {
436
- padding: 5px 10px;
437
- font-size: 12px;
 
438
  }
439
 
440
  .btn.copied {
@@ -554,7 +589,7 @@
554
  background: var(--bg-elevated);
555
  border: 1px solid var(--border);
556
  border-radius: var(--radius);
557
- padding: 20px;
558
  transition: box-shadow var(--transition), border-color var(--transition);
559
  animation: fadeIn 0.35s ease forwards;
560
  opacity: 0;
@@ -603,12 +638,10 @@
603
  }
604
 
605
  .result-card-id {
606
- font-family: var(--font-mono);
607
- font-size: 13px;
608
  color: var(--text-secondary);
609
- overflow: hidden;
610
- text-overflow: ellipsis;
611
- white-space: nowrap;
612
  min-width: 0;
613
  }
614
 
@@ -628,20 +661,18 @@
628
  }
629
 
630
  .result-card-title {
631
- font-size: 14px;
632
- font-weight: 500;
633
  color: var(--text-primary);
634
- margin-bottom: 12px;
635
- line-height: 1.5;
636
- overflow: hidden;
637
- text-overflow: ellipsis;
638
- white-space: nowrap;
639
  }
640
 
641
  .result-card-content {
642
  font-family: var(--font-mono);
643
- font-size: 13px;
644
- line-height: 1.75;
645
  color: var(--text-primary);
646
  white-space: pre-wrap;
647
  word-break: break-word;
@@ -656,10 +687,11 @@
656
  .result-card-stats {
657
  display: flex;
658
  gap: 8px;
659
- margin-top: 10px;
660
  font-family: var(--font-mono);
661
- font-size: 12px;
662
- color: var(--text-tertiary);
 
663
  }
664
 
665
  .result-card.is-error {
@@ -718,11 +750,11 @@
718
  display: flex;
719
  justify-content: space-between;
720
  align-items: center;
721
- padding: 10px 14px;
722
  background: var(--bg-elevated);
723
  border: 1px solid var(--border-light);
724
  border-radius: 6px;
725
- font-size: 13px;
726
  cursor: pointer;
727
  transition: all var(--transition);
728
  }
@@ -734,7 +766,7 @@
734
 
735
  .history-item-title {
736
  color: var(--text-primary);
737
- font-weight: 500;
738
  overflow: hidden;
739
  text-overflow: ellipsis;
740
  white-space: nowrap;
@@ -744,26 +776,12 @@
744
 
745
  .history-item-time {
746
  font-family: var(--font-mono);
747
- font-size: 11px;
748
  color: var(--text-tertiary);
749
  flex-shrink: 0;
750
  margin-left: 12px;
751
  }
752
 
753
- /* -- Footer -- */
754
- .footer {
755
- margin-top: 64px;
756
- padding-top: 24px;
757
- border-top: 1px solid var(--border-light);
758
- text-align: center;
759
- }
760
-
761
- .footer p {
762
- font-size: 12px;
763
- color: var(--text-tertiary);
764
- line-height: 1.6;
765
- }
766
-
767
  /* -- Responsive -- */
768
  @media (max-width: 640px) {
769
  .container {
@@ -797,7 +815,11 @@
797
  }
798
 
799
  .result-card {
800
- padding: 16px;
 
 
 
 
801
  }
802
 
803
  .result-card-content {
@@ -812,24 +834,29 @@
812
  <header>
813
  <div class="header-row">
814
  <h1>YouTube Transcript</h1>
815
- <button id="themeToggle" class="theme-toggle" title="테마 전환" aria-label="Toggle theme">
816
- <svg class="theme-icon sun" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
817
- <circle cx="12" cy="12" r="5"></circle>
818
- <line x1="12" y1="1" x2="12" y2="3"></line>
819
- <line x1="12" y1="21" x2="12" y2="23"></line>
820
- <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
821
- <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
822
- <line x1="1" y1="12" x2="3" y2="12"></line>
823
- <line x1="21" y1="12" x2="23" y2="12"></line>
824
- <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
825
- <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
826
- </svg>
827
- <svg class="theme-icon moon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
828
- <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
829
- </svg>
830
- </button>
 
 
 
 
 
831
  </div>
832
- <p class="subtitle">Extract subtitles from YouTube videos</p>
833
  </header>
834
 
835
  <section class="input-section">
@@ -837,7 +864,7 @@
837
  <p class="section-label">URL 입력</p>
838
  <div class="textarea-wrapper">
839
  <textarea id="urlInput" placeholder="YouTube URL을 한 줄에 하나씩 입력하세요&#10;&#10;https://www.youtube.com/watch?v=...&#10;https://youtu.be/..." spellcheck="false"></textarea>
840
- <span class="url-count" id="urlCount">0 / 20</span>
841
  </div>
842
  </div>
843
 
@@ -865,27 +892,16 @@
865
 
866
  <div class="option-divider"></div>
867
 
868
- <div class="option-group">
869
  <label class="checkbox-wrapper">
870
  <input type="checkbox" id="denoise">
871
  <span class="checkbox-custom"></span>
872
  <span>노이즈 제거</span>
873
  </label>
874
- </div>
875
-
876
- <div class="option-group">
877
- <label class="checkbox-wrapper">
878
- <input type="checkbox" id="keepNewlines">
879
- <span class="checkbox-custom"></span>
880
- <span>줄바�� 유지</span>
881
- </label>
882
- </div>
883
-
884
- <div class="option-group">
885
  <label class="checkbox-wrapper">
886
  <input type="checkbox" id="metadata" checked>
887
  <span class="checkbox-custom"></span>
888
- <span>메타데이터 포함</span>
889
  </label>
890
  </div>
891
  </div>
@@ -924,10 +940,6 @@
924
  </div>
925
  <div id="historyList" class="history-list"></div>
926
  </section>
927
-
928
- <div class="footer" id="footer" style="display: none;">
929
- <p>youtube-transcript-api &middot; 자막 원문만 추출합니다</p>
930
- </div>
931
  </div>
932
 
933
  <script>
@@ -948,10 +960,132 @@
948
  var copyAllBtn = $('#copyAllBtn');
949
  var downloadAllBtn = $('#downloadAllBtn');
950
  var denoiseCheckbox = $('#denoise');
951
- var keepNewlinesCheckbox = $('#keepNewlines');
952
  var metadataCheckbox = $('#metadata');
953
  var urlCount = $('#urlCount');
954
- var footer = $('#footer');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
955
 
956
  // Theme toggle
957
  var themeToggle = $('#themeToggle');
@@ -989,10 +1123,10 @@
989
  var lines = urlInput.value.split('\n').filter(function (l) { return l.trim(); });
990
  var count = lines.length;
991
  if (!urlInput.value.trim()) count = 0;
992
- urlCount.textContent = count + ' / 20';
993
  urlCount.className = 'url-count';
994
  if (count > 0) urlCount.classList.add('has-urls');
995
- if (count > 20) urlCount.classList.add('limit');
996
  }
997
 
998
  urlInput.addEventListener('input', updateUrlCount);
@@ -1029,15 +1163,14 @@
1029
 
1030
  var urls = text.split('\n').map(function (u) { return u.trim(); }).filter(function (u) { return u; });
1031
  if (urls.length === 0) return;
1032
- if (urls.length > 20) {
1033
- alert('최대 20개의 URL만 입력할 수 있습니다.');
1034
  return;
1035
  }
1036
 
1037
  extractBtn.disabled = true;
1038
  loading.style.display = 'flex';
1039
  resultsSection.style.display = 'none';
1040
- footer.style.display = 'none';
1041
  resultsList.innerHTML = '';
1042
 
1043
  try {
@@ -1048,7 +1181,6 @@
1048
  urls: urls,
1049
  language: currentLanguage,
1050
  denoise: denoiseCheckbox.checked,
1051
- keep_newlines: keepNewlinesCheckbox.checked,
1052
  format: currentFormat,
1053
  }),
1054
  });
@@ -1064,7 +1196,7 @@
1064
  renderResults(data);
1065
  addToHistory(data.results);
1066
  } catch (err) {
1067
- alert('요청 중 오류가 발생했습니다: ' + err.message);
1068
  } finally {
1069
  extractBtn.disabled = false;
1070
  loading.style.display = 'none';
@@ -1073,11 +1205,10 @@
1073
 
1074
  function renderResults(data) {
1075
  resultsSection.style.display = 'block';
1076
- footer.style.display = 'block';
1077
 
1078
- var statsHtml = '<span class="success-count">' + data.success_count + ' 성공</span>';
1079
  if (data.error_count > 0) {
1080
- statsHtml += ' &middot; <span class="error-count">' + data.error_count + ' 실패</span>';
1081
  }
1082
  stats.innerHTML = statsHtml;
1083
 
@@ -1100,30 +1231,28 @@
1100
  var actionsHtml = '';
1101
  if (!result.error) {
1102
  actionsHtml = '<div class="result-card-actions">' +
1103
- '<button class="btn btn-secondary btn-sm btn-copy" data-index="' + index + '">복사</button>' +
1104
- '<button class="btn btn-secondary btn-sm btn-download" data-index="' + index + '">다운로드</button>' +
1105
  '</div>';
1106
  }
1107
 
1108
  var idHtml = '';
1109
  if (result.video_id) {
1110
- idHtml = '<a class="result-card-id" href="https://youtube.com/watch?v=' + encodeURIComponent(result.video_id) + '" target="_blank" rel="noopener">' + escapeHtml(result.video_id) + '</a>';
 
1111
  } else {
1112
  idHtml = '<span class="result-card-id">' + escapeHtml(result.url) + '</span>';
1113
  }
1114
 
1115
  var titleHtml = '';
1116
- if (result.title && !result.error) {
1117
- titleHtml = '<div class="result-card-title">' + escapeHtml(result.title) + '</div>';
1118
- }
1119
 
1120
  var cardStatsHtml = '';
1121
  if (!result.error && result.transcript) {
1122
  var text = typeof result.transcript === 'string' ? result.transcript : JSON.stringify(result.transcript);
1123
  var charCount = text.length;
1124
- var readingMinutes = Math.max(1, Math.ceil(charCount / 500));
1125
  cardStatsHtml = '<div class="result-card-stats">' +
1126
- charCount.toLocaleString() + '\uC790 \u00B7 \uC57D ' + readingMinutes + '\uBD84 \uC77D\uAE30' +
1127
  '</div>';
1128
  }
1129
 
@@ -1253,7 +1382,7 @@
1253
 
1254
  function showCopied(btn) {
1255
  var original = btn.textContent;
1256
- btn.textContent = '\uBCF5\uC0AC\uB428 \u2713';
1257
  btn.classList.add('copied');
1258
  setTimeout(function () {
1259
  btn.textContent = original;
@@ -1343,13 +1472,13 @@
1343
  var now = new Date();
1344
  var diff = now - d;
1345
  var minutes = Math.floor(diff / 60000);
1346
- if (minutes < 1) return '\uBC29\uAE08';
1347
- if (minutes < 60) return minutes + '\uBD84 \uC804';
1348
  var hours = Math.floor(minutes / 60);
1349
- if (hours < 24) return hours + '\uC2DC\uAC04 \uC804';
1350
  var days = Math.floor(hours / 24);
1351
- if (days < 7) return days + '\uC77C \uC804';
1352
- return d.toLocaleDateString('ko-KR');
1353
  }
1354
 
1355
  clearHistoryBtn.addEventListener('click', function () {
@@ -1359,6 +1488,9 @@
1359
 
1360
  // Show history on load
1361
  renderHistory();
 
 
 
1362
  })();
1363
  </script>
1364
  </body>
 
17
  --border: #e4e4e7;
18
  --border-light: #f0f0f2;
19
  --text-primary: #18181b;
20
+ --text-secondary: #3f3f46;
21
+ --text-tertiary: #71717a;
22
  --accent: #4f46e5;
23
  --accent-hover: #4338ca;
24
  --accent-subtle: rgba(79, 70, 229, 0.06);
 
32
  --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.06);
33
  --font-sans: 'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
34
  --font-mono: 'JetBrains Mono', monospace;
35
+ --radius: 6px;
36
+ --radius-lg: 8px;
37
  --transition: 200ms ease;
38
  }
39
 
 
47
  --border: #2e2e35;
48
  --border-light: #252529;
49
  --text-primary: #ededef;
50
+ --text-secondary: #b0b0b8;
51
+ --text-tertiary: #8f8f96;
52
  --accent: #6366f1;
53
  --accent-hover: #818cf8;
54
  --accent-subtle: rgba(99, 102, 241, 0.06);
 
72
  --border: #2e2e35;
73
  --border-light: #252529;
74
  --text-primary: #ededef;
75
+ --text-secondary: #b0b0b8;
76
+ --text-tertiary: #8f8f96;
77
  --accent: #6366f1;
78
  --accent-hover: #818cf8;
79
  --accent-subtle: rgba(99, 102, 241, 0.06);
 
131
  }
132
 
133
  header h1 {
134
+ font-size: 32px;
135
  font-weight: 700;
136
  letter-spacing: -0.035em;
137
  line-height: 1.15;
 
146
  line-height: 1.5;
147
  }
148
 
149
+ /* -- Header Actions -- */
150
+ .header-actions {
151
+ display: flex;
152
+ gap: 8px;
153
+ align-items: center;
154
+ }
155
+
156
+ /* -- Language Toggle -- */
157
+ .lang-toggle {
158
+ background: var(--bg-surface);
159
+ border: 1px solid var(--border);
160
+ border-radius: 8px;
161
+ padding: 0 12px;
162
+ height: 36px;
163
+ display: flex;
164
+ align-items: center;
165
+ justify-content: center;
166
+ cursor: pointer;
167
+ color: var(--text-secondary);
168
+ font-family: var(--font-sans);
169
+ font-size: 13px;
170
+ font-weight: 600;
171
+ transition: all var(--transition);
172
+ flex-shrink: 0;
173
+ }
174
+
175
+ .lang-toggle:hover {
176
+ color: var(--text-primary);
177
+ border-color: var(--text-tertiary);
178
+ background: var(--bg-elevated);
179
+ }
180
+
181
  /* -- Theme Toggle -- */
182
  .theme-toggle {
183
  background: var(--bg-surface);
 
217
 
218
  /* -- Section Label -- */
219
  .section-label {
220
+ font-size: 14px;
221
  font-weight: 600;
222
  color: var(--text-tertiary);
223
  text-transform: uppercase;
 
259
 
260
  textarea:focus {
261
  border-color: var(--accent);
262
+ box-shadow: none;
263
  }
264
 
265
  .url-count {
 
307
 
308
  .option-divider {
309
  width: 1px;
310
+ height: 24px;
311
  background: var(--border);
312
  flex-shrink: 0;
313
  }
314
 
315
  .option-label {
316
+ font-size: 14px;
317
  font-weight: 600;
318
  color: var(--text-tertiary);
319
  text-transform: uppercase;
 
326
  display: flex;
327
  background: var(--bg-surface);
328
  border: 1px solid var(--border);
329
+ border-radius: 5px;
330
+ padding: 3px;
331
  gap: 2px;
332
  }
333
 
334
  .toggle-btn {
335
+ padding: 7px 16px;
336
  font-family: var(--font-sans);
337
+ font-size: 16px;
338
  font-weight: 500;
339
  color: var(--text-secondary);
340
  background: transparent;
341
  border: none;
342
+ border-radius: 3px;
343
  cursor: pointer;
344
  transition: all var(--transition);
345
  white-space: nowrap;
 
362
  align-items: center;
363
  gap: 8px;
364
  cursor: pointer;
365
+ font-size: 16px;
366
  color: var(--text-secondary);
367
  user-select: none;
368
  transition: color var(--transition);
 
380
  }
381
 
382
  .checkbox-custom {
383
+ width: 20px;
384
+ height: 20px;
385
+ border: 2px solid var(--text-tertiary);
386
+ border-radius: 5px;
387
  transition: all var(--transition);
388
  position: relative;
389
  flex-shrink: 0;
 
401
  .checkbox-wrapper input:checked + .checkbox-custom::after {
402
  content: '';
403
  position: absolute;
404
+ left: 6px;
405
+ top: 2.5px;
406
+ width: 6px;
407
+ height: 11px;
408
  border: solid #fff;
409
  border-width: 0 1.5px 1.5px 0;
410
  transform: rotate(45deg);
 
424
  }
425
 
426
  .btn:focus-visible {
427
+ box-shadow: none;
428
+ outline: 2px solid var(--accent);
429
+ outline-offset: 2px;
430
  }
431
 
432
  .btn-primary {
433
  background: var(--accent);
434
  color: #fff;
435
  width: 100%;
436
+ padding: 15px;
437
+ font-size: 16px;
438
  font-weight: 600;
439
  letter-spacing: -0.01em;
440
  }
 
444
  }
445
 
446
  .btn-primary:active:not(:disabled) {
447
+ transform: scale(0.98);
448
  }
449
 
450
  .btn-primary:disabled {
 
467
  }
468
 
469
  .btn-sm {
470
+ padding: 8px 14px;
471
+ font-size: 14px;
472
+ font-weight: 600;
473
  }
474
 
475
  .btn.copied {
 
589
  background: var(--bg-elevated);
590
  border: 1px solid var(--border);
591
  border-radius: var(--radius);
592
+ padding: 28px;
593
  transition: box-shadow var(--transition), border-color var(--transition);
594
  animation: fadeIn 0.35s ease forwards;
595
  opacity: 0;
 
638
  }
639
 
640
  .result-card-id {
641
+ font-family: var(--font-sans);
642
+ font-size: 15px;
643
  color: var(--text-secondary);
644
+ font-weight: 600;
 
 
645
  min-width: 0;
646
  }
647
 
 
661
  }
662
 
663
  .result-card-title {
664
+ font-size: 20px;
665
+ font-weight: 700;
666
  color: var(--text-primary);
667
+ margin-bottom: 16px;
668
+ line-height: 1.4;
669
+ letter-spacing: -0.02em;
 
 
670
  }
671
 
672
  .result-card-content {
673
  font-family: var(--font-mono);
674
+ font-size: 14px;
675
+ line-height: 1.8;
676
  color: var(--text-primary);
677
  white-space: pre-wrap;
678
  word-break: break-word;
 
687
  .result-card-stats {
688
  display: flex;
689
  gap: 8px;
690
+ margin-top: 12px;
691
  font-family: var(--font-mono);
692
+ font-size: 14px;
693
+ color: var(--text-secondary);
694
+ font-weight: 500;
695
  }
696
 
697
  .result-card.is-error {
 
750
  display: flex;
751
  justify-content: space-between;
752
  align-items: center;
753
+ padding: 14px 18px;
754
  background: var(--bg-elevated);
755
  border: 1px solid var(--border-light);
756
  border-radius: 6px;
757
+ font-size: 16px;
758
  cursor: pointer;
759
  transition: all var(--transition);
760
  }
 
766
 
767
  .history-item-title {
768
  color: var(--text-primary);
769
+ font-weight: 600;
770
  overflow: hidden;
771
  text-overflow: ellipsis;
772
  white-space: nowrap;
 
776
 
777
  .history-item-time {
778
  font-family: var(--font-mono);
779
+ font-size: 13px;
780
  color: var(--text-tertiary);
781
  flex-shrink: 0;
782
  margin-left: 12px;
783
  }
784
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
785
  /* -- Responsive -- */
786
  @media (max-width: 640px) {
787
  .container {
 
815
  }
816
 
817
  .result-card {
818
+ padding: 20px;
819
+ }
820
+
821
+ .result-card-title {
822
+ font-size: 18px;
823
  }
824
 
825
  .result-card-content {
 
834
  <header>
835
  <div class="header-row">
836
  <h1>YouTube Transcript</h1>
837
+ <div class="header-actions">
838
+ <button id="langToggle" class="lang-toggle" title="Language">
839
+ <span>KO</span>
840
+ </button>
841
+ <button id="themeToggle" class="theme-toggle" title="Toggle theme" aria-label="Toggle theme">
842
+ <svg class="theme-icon sun" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
843
+ <circle cx="12" cy="12" r="5"></circle>
844
+ <line x1="12" y1="1" x2="12" y2="3"></line>
845
+ <line x1="12" y1="21" x2="12" y2="23"></line>
846
+ <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
847
+ <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
848
+ <line x1="1" y1="12" x2="3" y2="12"></line>
849
+ <line x1="21" y1="12" x2="23" y2="12"></line>
850
+ <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
851
+ <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
852
+ </svg>
853
+ <svg class="theme-icon moon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
854
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
855
+ </svg>
856
+ </button>
857
+ </div>
858
  </div>
859
+ <p class="subtitle">YouTube 영상의 자막을 추출합니다</p>
860
  </header>
861
 
862
  <section class="input-section">
 
864
  <p class="section-label">URL 입력</p>
865
  <div class="textarea-wrapper">
866
  <textarea id="urlInput" placeholder="YouTube URL을 한 줄에 하나씩 입력하세요&#10;&#10;https://www.youtube.com/watch?v=...&#10;https://youtu.be/..." spellcheck="false"></textarea>
867
+ <span class="url-count" id="urlCount">0 / 50</span>
868
  </div>
869
  </div>
870
 
 
892
 
893
  <div class="option-divider"></div>
894
 
895
+ <div class="option-group" style="gap: 16px;">
896
  <label class="checkbox-wrapper">
897
  <input type="checkbox" id="denoise">
898
  <span class="checkbox-custom"></span>
899
  <span>노이즈 제거</span>
900
  </label>
 
 
 
 
 
 
 
 
 
 
 
901
  <label class="checkbox-wrapper">
902
  <input type="checkbox" id="metadata" checked>
903
  <span class="checkbox-custom"></span>
904
+ <span>URL 포함</span>
905
  </label>
906
  </div>
907
  </div>
 
940
  </div>
941
  <div id="historyList" class="history-list"></div>
942
  </section>
 
 
 
 
943
  </div>
944
 
945
  <script>
 
960
  var copyAllBtn = $('#copyAllBtn');
961
  var downloadAllBtn = $('#downloadAllBtn');
962
  var denoiseCheckbox = $('#denoise');
 
963
  var metadataCheckbox = $('#metadata');
964
  var urlCount = $('#urlCount');
965
+
966
+ // i18n
967
+ var LANG_KEY = 'yt-transcript-lang';
968
+ var currentLang = localStorage.getItem(LANG_KEY) || 'ko';
969
+ var langToggle = $('#langToggle');
970
+
971
+ var i18n = {
972
+ ko: {
973
+ pageTitle: 'YouTube Transcript',
974
+ subtitle: 'YouTube 영상의 자막을 추출합니다',
975
+ urlLabel: 'URL 입력',
976
+ urlPlaceholder: 'YouTube URL을 한 줄에 하나씩 입력하세요\n\nhttps://www.youtube.com/watch?v=...\nhttps://youtu.be/...',
977
+ optionsLabel: '옵션',
978
+ formatLabel: '형식',
979
+ langLabel: '언어',
980
+ denoise: '노이즈 제거',
981
+ urlInclude: 'URL 포함',
982
+ extractBtn: '자막 추출',
983
+ copyAll: '전체 복사',
984
+ downloadAll: '전체 다운로드',
985
+ copy: '복사',
986
+ download: '다운로드',
987
+ copied: '복사됨 \u2713',
988
+ loading: '자막을 추출하고 있습니다',
989
+ successCount: '개 성공',
990
+ errorCount: '개 실패',
991
+ maxUrlAlert: '최대 50개의 URL만 입력할 수 있습니다.',
992
+ requestError: '요청 중 오류가 발생했습니다: ',
993
+ historyLabel: '최근 추출 기록',
994
+ clearHistory: '기록 삭제',
995
+ justNow: '방금',
996
+ minutesAgo: '분 전',
997
+ hoursAgo: '시간 전',
998
+ daysAgo: '일 전',
999
+ charCount: '자',
1000
+ },
1001
+ en: {
1002
+ pageTitle: 'YouTube Transcript',
1003
+ subtitle: 'Extract subtitles from YouTube videos',
1004
+ urlLabel: 'URLs',
1005
+ urlPlaceholder: 'Enter YouTube URLs, one per line\n\nhttps://www.youtube.com/watch?v=...\nhttps://youtu.be/...',
1006
+ optionsLabel: 'Options',
1007
+ formatLabel: 'Format',
1008
+ langLabel: 'Language',
1009
+ denoise: 'Denoise',
1010
+ urlInclude: 'Include URL',
1011
+ extractBtn: 'Extract',
1012
+ copyAll: 'Copy All',
1013
+ downloadAll: 'Download All',
1014
+ copy: 'Copy',
1015
+ download: 'Download',
1016
+ copied: 'Copied \u2713',
1017
+ loading: 'Extracting subtitles...',
1018
+ successCount: ' succeeded',
1019
+ errorCount: ' failed',
1020
+ maxUrlAlert: 'Maximum 50 URLs allowed.',
1021
+ requestError: 'Request failed: ',
1022
+ historyLabel: 'Recent History',
1023
+ clearHistory: 'Clear',
1024
+ justNow: 'Just now',
1025
+ minutesAgo: 'm ago',
1026
+ hoursAgo: 'h ago',
1027
+ daysAgo: 'd ago',
1028
+ charCount: ' chars',
1029
+ }
1030
+ };
1031
+
1032
+ function t(key) {
1033
+ return i18n[currentLang][key] || i18n['ko'][key] || key;
1034
+ }
1035
+
1036
+ function applyLanguage() {
1037
+ langToggle.querySelector('span').textContent = currentLang.toUpperCase();
1038
+
1039
+ // Update all text elements
1040
+ $('header h1').textContent = t('pageTitle');
1041
+ $('header .subtitle').textContent = t('subtitle');
1042
+
1043
+ // Section labels
1044
+ var labels = $$('.section-label');
1045
+ if (labels[0]) labels[0].textContent = t('urlLabel');
1046
+ if (labels[1]) labels[1].textContent = t('optionsLabel');
1047
+
1048
+ // Textarea placeholder
1049
+ urlInput.placeholder = t('urlPlaceholder');
1050
+
1051
+ // Option labels
1052
+ var optLabels = $$('.option-label');
1053
+ if (optLabels[0]) optLabels[0].textContent = t('formatLabel');
1054
+ if (optLabels[1]) optLabels[1].textContent = t('langLabel');
1055
+
1056
+ // Checkboxes
1057
+ var checkboxSpans = $$('.checkbox-wrapper span:last-child');
1058
+ if (checkboxSpans[0]) checkboxSpans[0].textContent = t('denoise');
1059
+ if (checkboxSpans[1]) checkboxSpans[1].textContent = t('urlInclude');
1060
+
1061
+ // Buttons
1062
+ extractBtn.textContent = t('extractBtn');
1063
+ copyAllBtn.textContent = t('copyAll');
1064
+ downloadAllBtn.textContent = t('downloadAll');
1065
+
1066
+ // Loading text
1067
+ var loadingText = $('.loading-text');
1068
+ if (loadingText) loadingText.textContent = t('loading');
1069
+
1070
+ // History
1071
+ var historyLabel = $('#history .section-label');
1072
+ if (historyLabel) historyLabel.textContent = t('historyLabel');
1073
+ if (clearHistoryBtn) clearHistoryBtn.textContent = t('clearHistory');
1074
+
1075
+ // Update copy/download buttons in results if they exist
1076
+ $$('.btn-copy').forEach(function(btn) {
1077
+ if (!btn.classList.contains('copied')) btn.textContent = t('copy');
1078
+ });
1079
+ $$('.btn-download').forEach(function(btn) { btn.textContent = t('download'); });
1080
+ }
1081
+
1082
+ langToggle.addEventListener('click', function () {
1083
+ currentLang = currentLang === 'ko' ? 'en' : 'ko';
1084
+ localStorage.setItem(LANG_KEY, currentLang);
1085
+ applyLanguage();
1086
+ // Re-render history with new language
1087
+ renderHistory();
1088
+ });
1089
 
1090
  // Theme toggle
1091
  var themeToggle = $('#themeToggle');
 
1123
  var lines = urlInput.value.split('\n').filter(function (l) { return l.trim(); });
1124
  var count = lines.length;
1125
  if (!urlInput.value.trim()) count = 0;
1126
+ urlCount.textContent = count + ' / 50';
1127
  urlCount.className = 'url-count';
1128
  if (count > 0) urlCount.classList.add('has-urls');
1129
+ if (count > 50) urlCount.classList.add('limit');
1130
  }
1131
 
1132
  urlInput.addEventListener('input', updateUrlCount);
 
1163
 
1164
  var urls = text.split('\n').map(function (u) { return u.trim(); }).filter(function (u) { return u; });
1165
  if (urls.length === 0) return;
1166
+ if (urls.length > 50) {
1167
+ alert(t('maxUrlAlert'));
1168
  return;
1169
  }
1170
 
1171
  extractBtn.disabled = true;
1172
  loading.style.display = 'flex';
1173
  resultsSection.style.display = 'none';
 
1174
  resultsList.innerHTML = '';
1175
 
1176
  try {
 
1181
  urls: urls,
1182
  language: currentLanguage,
1183
  denoise: denoiseCheckbox.checked,
 
1184
  format: currentFormat,
1185
  }),
1186
  });
 
1196
  renderResults(data);
1197
  addToHistory(data.results);
1198
  } catch (err) {
1199
+ alert(t('requestError') + err.message);
1200
  } finally {
1201
  extractBtn.disabled = false;
1202
  loading.style.display = 'none';
 
1205
 
1206
  function renderResults(data) {
1207
  resultsSection.style.display = 'block';
 
1208
 
1209
+ var statsHtml = '<span class="success-count">' + data.success_count + t('successCount') + '</span>';
1210
  if (data.error_count > 0) {
1211
+ statsHtml += ' &middot; <span class="error-count">' + data.error_count + t('errorCount') + '</span>';
1212
  }
1213
  stats.innerHTML = statsHtml;
1214
 
 
1231
  var actionsHtml = '';
1232
  if (!result.error) {
1233
  actionsHtml = '<div class="result-card-actions">' +
1234
+ '<button class="btn btn-secondary btn-sm btn-copy" data-index="' + index + '">' + t('copy') + '</button>' +
1235
+ '<button class="btn btn-secondary btn-sm btn-download" data-index="' + index + '">' + t('download') + '</button>' +
1236
  '</div>';
1237
  }
1238
 
1239
  var idHtml = '';
1240
  if (result.video_id) {
1241
+ var linkText = result.title || result.video_id;
1242
+ idHtml = '<a class="result-card-id" href="https://youtube.com/watch?v=' + encodeURIComponent(result.video_id) + '" target="_blank" rel="noopener">' + escapeHtml(linkText) + '</a>';
1243
  } else {
1244
  idHtml = '<span class="result-card-id">' + escapeHtml(result.url) + '</span>';
1245
  }
1246
 
1247
  var titleHtml = '';
 
 
 
1248
 
1249
  var cardStatsHtml = '';
1250
  if (!result.error && result.transcript) {
1251
  var text = typeof result.transcript === 'string' ? result.transcript : JSON.stringify(result.transcript);
1252
  var charCount = text.length;
1253
+ var tokenCount = Math.ceil(charCount / 2);
1254
  cardStatsHtml = '<div class="result-card-stats">' +
1255
+ charCount.toLocaleString() + t('charCount') + ' \u00B7 ~' + tokenCount.toLocaleString() + ' tokens' +
1256
  '</div>';
1257
  }
1258
 
 
1382
 
1383
  function showCopied(btn) {
1384
  var original = btn.textContent;
1385
+ btn.textContent = t('copied');
1386
  btn.classList.add('copied');
1387
  setTimeout(function () {
1388
  btn.textContent = original;
 
1472
  var now = new Date();
1473
  var diff = now - d;
1474
  var minutes = Math.floor(diff / 60000);
1475
+ if (minutes < 1) return t('justNow');
1476
+ if (minutes < 60) return minutes + t('minutesAgo');
1477
  var hours = Math.floor(minutes / 60);
1478
+ if (hours < 24) return hours + t('hoursAgo');
1479
  var days = Math.floor(hours / 24);
1480
+ if (days < 7) return days + t('daysAgo');
1481
+ return d.toLocaleDateString(currentLang === 'ko' ? 'ko-KR' : 'en-US');
1482
  }
1483
 
1484
  clearHistoryBtn.addEventListener('click', function () {
 
1488
 
1489
  // Show history on load
1490
  renderHistory();
1491
+
1492
+ // Apply language on load
1493
+ applyLanguage();
1494
  })();
1495
  </script>
1496
  </body>