Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Transcript Extractor - YouTube & Instagram</title> | |
| <meta name="description" content="Free YouTube & Instagram transcript extractor. Download subtitles and transcripts in multiple formats (TXT, JSON, SRT, VTT) with support for multiple languages including Korean, English, Spanish, Japanese, and Portuguese."> | |
| <meta name="keywords" content="YouTube transcript, Instagram transcript, subtitle extractor, YouTube subtitles, Instagram reels, 유튜브 자막, 인스타그램 대본, 자막 추출, transcribe YouTube, transcribe Instagram"> | |
| <meta name="author" content="Transcript Extractor"> | |
| <meta name="robots" content="index, follow"> | |
| <meta property="og:title" content="Transcript Extractor - YouTube & Instagram"> | |
| <meta property="og:description" content="Extract subtitles and transcripts from YouTube and Instagram videos in multiple formats and languages for free."> | |
| <meta property="og:type" content="website"> | |
| <meta property="og:url" content="https://youtube-transcript-production-7407.up.railway.app/"> | |
| <meta name="twitter:card" content="summary_large_image"> | |
| <meta name="twitter:title" content="Transcript Extractor - YouTube & Instagram"> | |
| <meta name="twitter:description" content="Extract subtitles and transcripts from YouTube and Instagram videos in multiple formats and languages for free."> | |
| <meta name="google-site-verification" content="463xDo0kn86_G1LdLPoNCdJAulfj0AIq4L9ld7kHD2s" /> | |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.min.css"> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { | |
| --bg-primary: #fafafa; | |
| --bg-secondary: #ffffff; | |
| --bg-surface: #f4f4f5; | |
| --bg-elevated: #ffffff; | |
| --border: #e4e4e7; | |
| --border-light: #f0f0f2; | |
| --text-primary: #18181b; | |
| --text-secondary: #3f3f46; | |
| --text-tertiary: #71717a; | |
| --accent: #4f46e5; | |
| --accent-hover: #4338ca; | |
| --accent-subtle: rgba(79, 70, 229, 0.06); | |
| --accent-light: rgba(79, 70, 229, 0.10); | |
| --error: #ef4444; | |
| --error-light: rgba(239, 68, 68, 0.06); | |
| --error-border: rgba(239, 68, 68, 0.2); | |
| --success: #16a34a; | |
| --success-light: rgba(22, 163, 74, 0.06); | |
| --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04); | |
| --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.06); | |
| --font-sans: 'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; | |
| --font-mono: 'JetBrains Mono', monospace; | |
| --radius: 6px; | |
| --radius-lg: 8px; | |
| --transition: 200ms ease; | |
| } | |
| /* Dark theme: system preference (when no manual override) */ | |
| @media (prefers-color-scheme: dark) { | |
| :root:not([data-theme="light"]) { | |
| --bg-primary: #111113; | |
| --bg-secondary: #1b1b1f; | |
| --bg-surface: #1b1b1f; | |
| --bg-elevated: #232328; | |
| --border: #2e2e35; | |
| --border-light: #252529; | |
| --text-primary: #ededef; | |
| --text-secondary: #b0b0b8; | |
| --text-tertiary: #8f8f96; | |
| --accent: #6366f1; | |
| --accent-hover: #818cf8; | |
| --accent-subtle: rgba(99, 102, 241, 0.06); | |
| --accent-light: rgba(99, 102, 241, 0.12); | |
| --error: #f87171; | |
| --error-light: rgba(248, 113, 113, 0.08); | |
| --error-border: rgba(248, 113, 113, 0.2); | |
| --success: #4ade80; | |
| --success-light: rgba(74, 222, 128, 0.08); | |
| --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2); | |
| --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.3); | |
| } | |
| } | |
| /* Dark theme: manual override */ | |
| [data-theme="dark"] { | |
| --bg-primary: #111113; | |
| --bg-secondary: #1b1b1f; | |
| --bg-surface: #1b1b1f; | |
| --bg-elevated: #232328; | |
| --border: #2e2e35; | |
| --border-light: #252529; | |
| --text-primary: #ededef; | |
| --text-secondary: #b0b0b8; | |
| --text-tertiary: #8f8f96; | |
| --accent: #6366f1; | |
| --accent-hover: #818cf8; | |
| --accent-subtle: rgba(99, 102, 241, 0.06); | |
| --accent-light: rgba(99, 102, 241, 0.12); | |
| --error: #f87171; | |
| --error-light: rgba(248, 113, 113, 0.08); | |
| --error-border: rgba(248, 113, 113, 0.2); | |
| --success: #4ade80; | |
| --success-light: rgba(74, 222, 128, 0.08); | |
| --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2); | |
| --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.3); | |
| } | |
| *, *::before, *::after { | |
| box-sizing: border-box; | |
| margin: 0; | |
| padding: 0; | |
| } | |
| html { | |
| font-size: 16px; | |
| -webkit-font-smoothing: antialiased; | |
| -moz-osx-font-smoothing: grayscale; | |
| } | |
| body { | |
| font-family: var(--font-sans); | |
| background: var(--bg-primary); | |
| color: var(--text-primary); | |
| line-height: 1.6; | |
| letter-spacing: -0.011em; | |
| min-height: 100vh; | |
| } | |
| ::selection { | |
| background: var(--accent-light); | |
| color: var(--text-primary); | |
| } | |
| .container { | |
| max-width: 680px; | |
| margin: 0 auto; | |
| padding: 72px 24px 120px; | |
| } | |
| /* -- Header -- */ | |
| header { | |
| margin-bottom: 56px; | |
| } | |
| .header-row { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| } | |
| header h1 { | |
| font-size: 32px; | |
| font-weight: 700; | |
| letter-spacing: -0.035em; | |
| line-height: 1.15; | |
| color: var(--text-primary); | |
| } | |
| header .subtitle { | |
| font-size: 15px; | |
| color: var(--text-secondary); | |
| margin-top: 10px; | |
| font-weight: 400; | |
| line-height: 1.5; | |
| } | |
| /* -- Header Actions -- */ | |
| .header-actions { | |
| display: flex; | |
| gap: 8px; | |
| align-items: center; | |
| } | |
| /* -- UI Language Dropdown -- */ | |
| .ui-lang-dropdown { | |
| position: relative; | |
| } | |
| /* -- Language Toggle -- */ | |
| .lang-toggle { | |
| background: var(--bg-surface); | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| padding: 0 12px; | |
| height: 36px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| cursor: pointer; | |
| color: var(--text-secondary); | |
| font-family: var(--font-sans); | |
| font-size: 13px; | |
| font-weight: 600; | |
| transition: all var(--transition); | |
| flex-shrink: 0; | |
| min-width: 80px; | |
| } | |
| .lang-toggle:hover { | |
| color: var(--text-primary); | |
| border-color: var(--text-tertiary); | |
| background: var(--bg-elevated); | |
| } | |
| /* -- Theme Toggle -- */ | |
| .theme-toggle { | |
| background: var(--bg-surface); | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| width: 36px; | |
| height: 36px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| cursor: pointer; | |
| color: var(--text-secondary); | |
| transition: all var(--transition); | |
| flex-shrink: 0; | |
| } | |
| .theme-toggle:hover { | |
| color: var(--text-primary); | |
| border-color: var(--text-tertiary); | |
| background: var(--bg-elevated); | |
| } | |
| /* Show sun icon in dark mode, moon icon in light mode */ | |
| .theme-icon.sun { display: none; } | |
| .theme-icon.moon { display: block; } | |
| @media (prefers-color-scheme: dark) { | |
| :root:not([data-theme="light"]) .theme-icon.sun { display: block; } | |
| :root:not([data-theme="light"]) .theme-icon.moon { display: none; } | |
| } | |
| [data-theme="dark"] .theme-icon.sun { display: block; } | |
| [data-theme="dark"] .theme-icon.moon { display: none; } | |
| [data-theme="light"] .theme-icon.sun { display: none; } | |
| [data-theme="light"] .theme-icon.moon { display: block; } | |
| /* -- Section Label -- */ | |
| .section-label { | |
| font-size: 14px; | |
| font-weight: 600; | |
| color: var(--text-tertiary); | |
| text-transform: uppercase; | |
| letter-spacing: 0.06em; | |
| margin-bottom: 12px; | |
| } | |
| /* -- Input Section -- */ | |
| .input-section { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 24px; | |
| } | |
| .textarea-wrapper { | |
| position: relative; | |
| } | |
| textarea { | |
| width: 100%; | |
| min-height: 180px; | |
| padding: 16px 18px; | |
| font-family: var(--font-mono); | |
| font-size: 13px; | |
| line-height: 1.8; | |
| color: var(--text-primary); | |
| background: var(--bg-elevated); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| resize: vertical; | |
| outline: none; | |
| transition: border-color var(--transition), box-shadow var(--transition); | |
| } | |
| textarea::placeholder { | |
| color: var(--text-tertiary); | |
| font-size: 13px; | |
| } | |
| textarea:focus { | |
| border-color: var(--accent); | |
| box-shadow: none; | |
| } | |
| .url-count { | |
| position: absolute; | |
| bottom: 10px; | |
| right: 12px; | |
| font-family: var(--font-mono); | |
| font-size: 11px; | |
| color: var(--text-tertiary); | |
| background: var(--bg-elevated); | |
| padding: 2px 6px; | |
| border-radius: 4px; | |
| pointer-events: none; | |
| transition: color var(--transition); | |
| } | |
| .url-count.has-urls { | |
| color: var(--text-secondary); | |
| } | |
| .url-count.limit { | |
| color: var(--error); | |
| } | |
| /* -- Options Panel -- */ | |
| .options-panel { | |
| background: var(--bg-elevated); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| padding: 18px 20px; | |
| } | |
| .options { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 20px; | |
| align-items: center; | |
| } | |
| .option-group { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .option-divider { | |
| width: 1px; | |
| height: 24px; | |
| background: var(--border); | |
| flex-shrink: 0; | |
| } | |
| .option-label { | |
| font-size: 14px; | |
| font-weight: 600; | |
| color: var(--text-tertiary); | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| white-space: nowrap; | |
| } | |
| /* -- Toggle Group -- */ | |
| .toggle-group { | |
| display: flex; | |
| background: var(--bg-surface); | |
| border: 1px solid var(--border); | |
| border-radius: 5px; | |
| padding: 3px; | |
| gap: 2px; | |
| } | |
| .toggle-btn { | |
| padding: 7px 16px; | |
| font-family: var(--font-sans); | |
| font-size: 16px; | |
| font-weight: 500; | |
| color: var(--text-secondary); | |
| background: transparent; | |
| border: none; | |
| border-radius: 3px; | |
| cursor: pointer; | |
| transition: all var(--transition); | |
| white-space: nowrap; | |
| } | |
| .toggle-btn.active { | |
| background: var(--accent); | |
| color: #fff; | |
| box-shadow: var(--shadow-sm); | |
| } | |
| .toggle-btn:hover:not(.active) { | |
| color: var(--text-primary); | |
| background: var(--accent-subtle); | |
| } | |
| /* -- Custom Checkbox -- */ | |
| .checkbox-wrapper { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| cursor: pointer; | |
| font-size: 16px; | |
| color: var(--text-secondary); | |
| user-select: none; | |
| transition: color var(--transition); | |
| } | |
| .checkbox-wrapper:hover { | |
| color: var(--text-primary); | |
| } | |
| .checkbox-wrapper input { | |
| position: absolute; | |
| opacity: 0; | |
| width: 0; | |
| height: 0; | |
| } | |
| .checkbox-custom { | |
| width: 20px; | |
| height: 20px; | |
| border: 2px solid var(--text-tertiary); | |
| border-radius: 5px; | |
| transition: all var(--transition); | |
| position: relative; | |
| flex-shrink: 0; | |
| } | |
| .checkbox-wrapper:hover .checkbox-custom { | |
| border-color: var(--accent); | |
| } | |
| .checkbox-wrapper input:checked + .checkbox-custom { | |
| background: var(--accent); | |
| border-color: var(--accent); | |
| } | |
| .checkbox-wrapper input:checked + .checkbox-custom::after { | |
| content: ''; | |
| position: absolute; | |
| left: 6px; | |
| top: 2.5px; | |
| width: 6px; | |
| height: 11px; | |
| border: solid #fff; | |
| border-width: 0 1.5px 1.5px 0; | |
| transform: rotate(45deg); | |
| } | |
| /* -- Language Dropdown -- */ | |
| .lang-dropdown { | |
| position: relative; | |
| } | |
| .lang-dropdown-trigger { | |
| padding: 7px 16px; | |
| font-family: var(--font-sans); | |
| font-size: 16px; | |
| font-weight: 500; | |
| color: var(--text-secondary); | |
| background: var(--bg-surface); | |
| border: 1px solid var(--border); | |
| border-radius: 5px; | |
| cursor: pointer; | |
| transition: all var(--transition); | |
| white-space: nowrap; | |
| min-width: 120px; | |
| text-align: left; | |
| } | |
| .lang-dropdown-trigger:hover { | |
| color: var(--text-primary); | |
| background: var(--accent-subtle); | |
| } | |
| .lang-dropdown-menu { | |
| position: absolute; | |
| top: calc(100% + 4px); | |
| left: 0; | |
| min-width: 100%; | |
| max-height: 200px; | |
| overflow-y: auto; | |
| background: var(--bg-elevated); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| box-shadow: var(--shadow-md); | |
| z-index: 100; | |
| display: none; | |
| } | |
| .lang-dropdown-menu.show { | |
| display: block; | |
| } | |
| .lang-dropdown-item { | |
| padding: 10px 16px; | |
| font-family: var(--font-sans); | |
| font-size: 15px; | |
| color: var(--text-secondary); | |
| cursor: pointer; | |
| transition: all var(--transition); | |
| } | |
| .lang-dropdown-item:hover { | |
| background: var(--accent-subtle); | |
| color: var(--text-primary); | |
| } | |
| .lang-dropdown-item.active { | |
| background: var(--accent); | |
| color: #fff; | |
| font-weight: 600; | |
| } | |
| /* -- Download Mode Toggle -- */ | |
| .download-mode-toggle { | |
| display: none; | |
| align-items: center; | |
| gap: 8px; | |
| margin-left: 12px; | |
| } | |
| .download-mode-toggle.show { | |
| display: inline-flex; | |
| } | |
| /* -- Buttons -- */ | |
| .btn { | |
| font-family: var(--font-sans); | |
| font-size: 14px; | |
| font-weight: 500; | |
| border-radius: var(--radius); | |
| cursor: pointer; | |
| transition: all var(--transition); | |
| border: none; | |
| outline: none; | |
| white-space: nowrap; | |
| } | |
| .btn:focus-visible { | |
| box-shadow: none; | |
| outline: 2px solid var(--accent); | |
| outline-offset: 2px; | |
| } | |
| .btn-primary { | |
| background: var(--accent); | |
| color: #fff; | |
| width: 100%; | |
| padding: 15px; | |
| font-size: 16px; | |
| font-weight: 600; | |
| letter-spacing: -0.01em; | |
| } | |
| .btn-primary:hover:not(:disabled) { | |
| background: var(--accent-hover); | |
| } | |
| .btn-primary:active:not(:disabled) { | |
| transform: scale(0.98); | |
| } | |
| .btn-primary:disabled { | |
| opacity: 0.5; | |
| cursor: not-allowed; | |
| } | |
| .btn-secondary { | |
| background: var(--bg-elevated); | |
| color: var(--text-secondary); | |
| border: 1px solid var(--border); | |
| padding: 7px 14px; | |
| font-size: 13px; | |
| } | |
| .btn-secondary:hover { | |
| background: var(--bg-surface); | |
| color: var(--text-primary); | |
| border-color: var(--text-tertiary); | |
| } | |
| .btn-sm { | |
| padding: 8px 14px; | |
| font-size: 14px; | |
| font-weight: 600; | |
| } | |
| .btn.copied { | |
| color: var(--success); | |
| border-color: var(--success); | |
| background: var(--success-light); | |
| } | |
| .keyboard-hint { | |
| font-size: 12px; | |
| color: var(--text-tertiary); | |
| text-align: center; | |
| margin-top: -4px; | |
| } | |
| kbd { | |
| font-family: var(--font-mono); | |
| font-size: 11px; | |
| padding: 2px 6px; | |
| background: var(--bg-surface); | |
| border: 1px solid var(--border); | |
| border-radius: 4px; | |
| box-shadow: 0 1px 0 var(--border); | |
| } | |
| /* -- Divider -- */ | |
| .section-divider { | |
| height: 1px; | |
| background: var(--border-light); | |
| margin: 48px 0; | |
| } | |
| /* -- Loading -- */ | |
| .loading-section { | |
| margin-top: 48px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 12px; | |
| } | |
| .progress-info { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: baseline; | |
| } | |
| .progress-label { | |
| font-family: var(--font-mono); | |
| font-size: 14px; | |
| font-weight: 500; | |
| color: var(--text-secondary); | |
| } | |
| .progress-percent { | |
| font-family: var(--font-mono); | |
| font-size: 13px; | |
| color: var(--text-tertiary); | |
| } | |
| .progress-bar { | |
| width: 100%; | |
| height: 6px; | |
| background: var(--border); | |
| border-radius: 3px; | |
| overflow: hidden; | |
| } | |
| .progress-bar-fill { | |
| width: 0%; | |
| height: 100%; | |
| background: var(--accent); | |
| border-radius: 3px; | |
| transition: width 0.3s ease; | |
| } | |
| .progress-eta { | |
| font-size: 13px; | |
| color: var(--text-tertiary); | |
| text-align: center; | |
| } | |
| /* -- Results -- */ | |
| .results-section { | |
| margin-top: 48px; | |
| } | |
| .results-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 24px; | |
| padding-bottom: 16px; | |
| border-bottom: 1px solid var(--border-light); | |
| } | |
| .results-stats { | |
| font-size: 14px; | |
| color: var(--text-secondary); | |
| font-weight: 500; | |
| } | |
| .results-stats .success-count { | |
| color: var(--success); | |
| font-weight: 600; | |
| } | |
| .results-stats .error-count { | |
| color: var(--error); | |
| font-weight: 600; | |
| } | |
| .results-actions { | |
| display: flex; | |
| gap: 8px; | |
| } | |
| .results-list { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 16px; | |
| } | |
| /* -- Result Card -- */ | |
| .result-card { | |
| background: var(--bg-elevated); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| padding: 28px; | |
| transition: box-shadow var(--transition), border-color var(--transition); | |
| animation: fadeIn 0.35s ease forwards; | |
| opacity: 0; | |
| } | |
| .result-card:hover { | |
| box-shadow: var(--shadow-md); | |
| border-color: var(--text-tertiary); | |
| } | |
| @keyframes fadeIn { | |
| from { | |
| opacity: 0; | |
| transform: translateY(10px); | |
| } | |
| to { | |
| opacity: 1; | |
| transform: translateY(0); | |
| } | |
| } | |
| .result-card-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 14px; | |
| gap: 12px; | |
| } | |
| .result-card-meta { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| min-width: 0; | |
| } | |
| .result-card-index { | |
| font-family: var(--font-mono); | |
| font-size: 11px; | |
| font-weight: 500; | |
| color: var(--text-tertiary); | |
| background: var(--bg-surface); | |
| padding: 2px 7px; | |
| border-radius: 4px; | |
| flex-shrink: 0; | |
| } | |
| .result-card-id { | |
| font-family: var(--font-sans); | |
| font-size: 15px; | |
| color: var(--text-secondary); | |
| font-weight: 600; | |
| min-width: 0; | |
| } | |
| a.result-card-id { | |
| text-decoration: none; | |
| transition: color var(--transition); | |
| } | |
| a.result-card-id:hover { | |
| color: var(--accent); | |
| } | |
| .result-card-actions { | |
| display: flex; | |
| gap: 6px; | |
| flex-shrink: 0; | |
| } | |
| .result-card-title { | |
| font-size: 20px; | |
| font-weight: 700; | |
| color: var(--text-primary); | |
| margin-bottom: 16px; | |
| line-height: 1.4; | |
| letter-spacing: -0.02em; | |
| } | |
| .result-card-content { | |
| font-family: var(--font-mono); | |
| font-size: 14px; | |
| line-height: 1.8; | |
| color: var(--text-primary); | |
| white-space: pre-wrap; | |
| word-break: break-word; | |
| max-height: 360px; | |
| overflow-y: auto; | |
| padding: 16px; | |
| background: var(--bg-surface); | |
| border: 1px solid var(--border-light); | |
| border-radius: 6px; | |
| } | |
| .result-card-stats { | |
| display: flex; | |
| gap: 8px; | |
| margin-top: 12px; | |
| font-family: var(--font-mono); | |
| font-size: 14px; | |
| color: var(--text-secondary); | |
| font-weight: 500; | |
| } | |
| .result-card.is-error { | |
| border-color: var(--error-border); | |
| background: var(--error-light); | |
| } | |
| .result-card.is-error .result-card-content { | |
| color: var(--error); | |
| background: transparent; | |
| border: none; | |
| padding: 0; | |
| max-height: none; | |
| font-family: var(--font-sans); | |
| font-size: 14px; | |
| line-height: 1.6; | |
| } | |
| /* Scrollbar */ | |
| .result-card-content::-webkit-scrollbar { | |
| width: 4px; | |
| } | |
| .result-card-content::-webkit-scrollbar-track { | |
| background: transparent; | |
| } | |
| .result-card-content::-webkit-scrollbar-thumb { | |
| background: var(--border); | |
| border-radius: 2px; | |
| } | |
| .result-card-content::-webkit-scrollbar-thumb:hover { | |
| background: var(--text-tertiary); | |
| } | |
| /* -- History -- */ | |
| .history-section { | |
| margin-top: 48px; | |
| } | |
| .history-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 12px; | |
| } | |
| .history-list { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 6px; | |
| } | |
| .history-item { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| padding: 14px 18px; | |
| background: var(--bg-elevated); | |
| border: 1px solid var(--border-light); | |
| border-radius: 6px; | |
| font-size: 16px; | |
| cursor: pointer; | |
| transition: all var(--transition); | |
| } | |
| .history-item:hover { | |
| border-color: var(--border); | |
| background: var(--bg-surface); | |
| } | |
| .history-item-title { | |
| color: var(--text-primary); | |
| font-weight: 600; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| min-width: 0; | |
| flex: 1; | |
| } | |
| .history-item-time { | |
| font-family: var(--font-mono); | |
| font-size: 13px; | |
| color: var(--text-tertiary); | |
| flex-shrink: 0; | |
| margin-left: 12px; | |
| } | |
| /* -- Feedback -- */ | |
| .feedback-btn { | |
| position: fixed; | |
| bottom: 24px; | |
| right: 24px; | |
| width: 48px; | |
| height: 48px; | |
| border-radius: 50%; | |
| background: var(--accent); | |
| color: #fff; | |
| border: none; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3); | |
| transition: all var(--transition); | |
| z-index: 50; | |
| } | |
| .feedback-btn:hover { | |
| background: var(--accent-hover); | |
| transform: scale(1.1); | |
| } | |
| .feedback-overlay { | |
| position: fixed; | |
| inset: 0; | |
| background: rgba(0, 0, 0, 0.4); | |
| z-index: 200; | |
| display: none; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .feedback-overlay.show { | |
| display: flex; | |
| } | |
| .feedback-modal { | |
| background: var(--bg-elevated); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-lg); | |
| padding: 28px; | |
| width: 90%; | |
| max-width: 480px; | |
| box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); | |
| } | |
| .feedback-modal h3 { | |
| font-size: 18px; | |
| font-weight: 700; | |
| margin-bottom: 6px; | |
| color: var(--text-primary); | |
| } | |
| .feedback-modal p { | |
| font-size: 14px; | |
| color: var(--text-tertiary); | |
| margin-bottom: 16px; | |
| } | |
| .feedback-modal textarea { | |
| width: 100%; | |
| min-height: 120px; | |
| padding: 12px 14px; | |
| font-family: var(--font-sans); | |
| font-size: 14px; | |
| line-height: 1.6; | |
| color: var(--text-primary); | |
| background: var(--bg-surface); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| resize: vertical; | |
| outline: none; | |
| transition: border-color var(--transition); | |
| } | |
| .feedback-modal textarea:focus { | |
| border-color: var(--accent); | |
| } | |
| .feedback-type-group { | |
| display: flex; | |
| gap: 8px; | |
| margin-bottom: 12px; | |
| } | |
| .feedback-type-btn { | |
| padding: 6px 14px; | |
| font-family: var(--font-sans); | |
| font-size: 13px; | |
| font-weight: 500; | |
| color: var(--text-secondary); | |
| background: var(--bg-surface); | |
| border: 1px solid var(--border); | |
| border-radius: 20px; | |
| cursor: pointer; | |
| transition: all var(--transition); | |
| } | |
| .feedback-type-btn.active { | |
| background: var(--accent); | |
| color: #fff; | |
| border-color: var(--accent); | |
| } | |
| .feedback-type-btn:hover:not(.active) { | |
| border-color: var(--text-tertiary); | |
| } | |
| .feedback-actions { | |
| display: flex; | |
| justify-content: flex-end; | |
| gap: 8px; | |
| margin-top: 16px; | |
| } | |
| .feedback-char-count { | |
| font-family: var(--font-mono); | |
| font-size: 11px; | |
| color: var(--text-tertiary); | |
| text-align: right; | |
| margin-top: 4px; | |
| } | |
| /* -- Responsive -- */ | |
| @media (max-width: 640px) { | |
| .container { | |
| padding: 40px 16px 80px; | |
| } | |
| header { | |
| margin-bottom: 40px; | |
| } | |
| header h1 { | |
| font-size: 24px; | |
| } | |
| .options-panel { | |
| padding: 14px 16px; | |
| } | |
| .options { | |
| gap: 14px; | |
| } | |
| .option-divider { | |
| display: none; | |
| } | |
| .results-header { | |
| flex-direction: column; | |
| align-items: flex-start; | |
| gap: 12px; | |
| } | |
| .result-card { | |
| padding: 20px; | |
| } | |
| .result-card-title { | |
| font-size: 18px; | |
| } | |
| .result-card-content { | |
| padding: 12px; | |
| max-height: 280px; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <header> | |
| <div class="header-row"> | |
| <h1>YouTube Transcript</h1> | |
| <div class="header-actions"> | |
| <div class="ui-lang-dropdown" id="uiLangDropdown"> | |
| <button class="lang-toggle" id="uiLangTrigger" title="UI Language"> | |
| <span>Auto</span> | |
| </button> | |
| <div class="lang-dropdown-menu" id="uiLangMenu"> | |
| <div class="lang-dropdown-item active" data-lang="auto">Auto</div> | |
| <div class="lang-dropdown-item" data-lang="ko">한국어</div> | |
| <div class="lang-dropdown-item" data-lang="en">English</div> | |
| <div class="lang-dropdown-item" data-lang="es">Español</div> | |
| <div class="lang-dropdown-item" data-lang="ja">日本語</div> | |
| <div class="lang-dropdown-item" data-lang="pt">Português</div> | |
| </div> | |
| </div> | |
| <button id="themeToggle" class="theme-toggle" title="Toggle theme" aria-label="Toggle theme"> | |
| <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"> | |
| <circle cx="12" cy="12" r="5"></circle> | |
| <line x1="12" y1="1" x2="12" y2="3"></line> | |
| <line x1="12" y1="21" x2="12" y2="23"></line> | |
| <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line> | |
| <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line> | |
| <line x1="1" y1="12" x2="3" y2="12"></line> | |
| <line x1="21" y1="12" x2="23" y2="12"></line> | |
| <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line> | |
| <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line> | |
| </svg> | |
| <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"> | |
| <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path> | |
| </svg> | |
| </button> | |
| </div> | |
| </div> | |
| <p class="subtitle"></p> | |
| </header> | |
| <section class="input-section"> | |
| <div> | |
| <p class="section-label">URL 입력</p> | |
| <div class="textarea-wrapper"> | |
| <textarea id="urlInput" placeholder="YouTube 또는 Instagram URL을 한 줄에 하나씩 입력하세요 https://www.youtube.com/watch?v=... https://www.instagram.com/reel/..." spellcheck="false"></textarea> | |
| <span class="url-count" id="urlCount">0 / 100</span> | |
| </div> | |
| </div> | |
| <div> | |
| <p class="section-label">옵션</p> | |
| <div class="options-panel"> | |
| <div class="options"> | |
| <div class="option-group"> | |
| <span class="option-label">형식</span> | |
| <div class="toggle-group" data-name="format"> | |
| <button class="toggle-btn active" data-value="text">Text</button> | |
| <button class="toggle-btn" data-value="json">JSON</button> | |
| <button class="toggle-btn" data-value="srt">SRT</button> | |
| <button class="toggle-btn" data-value="vtt">VTT</button> | |
| </div> | |
| </div> | |
| <div class="option-divider"></div> | |
| <div class="option-group"> | |
| <span class="option-label">언어</span> | |
| <div class="lang-dropdown" id="langDropdown"> | |
| <button class="lang-dropdown-trigger" id="langDropdownTrigger">Auto</button> | |
| <div class="lang-dropdown-menu" id="langDropdownMenu"> | |
| <div class="lang-dropdown-item active" data-value="auto">Auto</div> | |
| <div class="lang-dropdown-item" data-value="en">English</div> | |
| <div class="lang-dropdown-item" data-value="es">Español</div> | |
| <div class="lang-dropdown-item" data-value="ja">日本語</div> | |
| <div class="lang-dropdown-item" data-value="pt">Português</div> | |
| <div class="lang-dropdown-item" data-value="ko">한국어</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="option-divider"></div> | |
| <div class="option-group" style="gap: 16px;"> | |
| <label class="checkbox-wrapper"> | |
| <input type="checkbox" id="denoise"> | |
| <span class="checkbox-custom"></span> | |
| <span>노이즈 제거</span> | |
| </label> | |
| <label class="checkbox-wrapper"> | |
| <input type="checkbox" id="metadata" checked> | |
| <span class="checkbox-custom"></span> | |
| <span>URL 포함</span> | |
| </label> | |
| <label class="checkbox-wrapper" id="timestampOption"> | |
| <input type="checkbox" id="timestamps"> | |
| <span class="checkbox-custom"></span> | |
| <span>Timestamps</span> | |
| </label> | |
| </div> | |
| <div class="option-divider"></div> | |
| <div class="option-group" style="gap: 16px;"> | |
| <label class="checkbox-wrapper"> | |
| <input type="checkbox" id="autoCopy" checked> | |
| <span class="checkbox-custom"></span> | |
| <span>Subtitle Auto Copy</span> | |
| </label> | |
| <label class="checkbox-wrapper"> | |
| <input type="checkbox" id="autoDownload"> | |
| <span class="checkbox-custom"></span> | |
| <span>Auto Subtitle Download</span> | |
| </label> | |
| </div> | |
| <div class="download-mode-toggle" id="downloadModeToggle"> | |
| <div class="toggle-group" data-name="downloadMode"> | |
| <button class="toggle-btn" data-value="individual">Per File</button> | |
| <button class="toggle-btn active" data-value="combined">All at Once</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div> | |
| <button id="extractBtn" class="btn btn-primary">자막 추출</button> | |
| <p class="keyboard-hint"><kbd>Ctrl</kbd> + <kbd>Enter</kbd></p> | |
| </div> | |
| </section> | |
| <div id="loading" class="loading-section" style="display: none;"> | |
| <div class="progress-info"> | |
| <span class="progress-label" id="progressLabel">0 / 0</span> | |
| <span class="progress-percent" id="progressPercent">0%</span> | |
| </div> | |
| <div class="progress-bar"> | |
| <div class="progress-bar-fill" id="progressFill"></div> | |
| </div> | |
| <p class="progress-eta" id="progressEta"></p> | |
| </div> | |
| <section id="results" class="results-section" style="display: none;"> | |
| <div class="results-header"> | |
| <div class="results-stats" id="stats"></div> | |
| <div class="results-actions"> | |
| <button class="btn btn-secondary" id="retryFailedBtn" style="display:none">실패 항목 재시도</button> | |
| <button class="btn btn-secondary" id="copyAllBtn">전체 복사</button> | |
| <button class="btn btn-secondary" id="downloadBtn">Download</button> | |
| </div> | |
| </div> | |
| <div id="resultsList" class="results-list"></div> | |
| </section> | |
| <section id="history" class="history-section" style="display: none;"> | |
| <div class="section-divider"></div> | |
| <div class="history-header"> | |
| <p class="section-label">최근 추출 기록</p> | |
| <button class="btn btn-secondary btn-sm" id="clearHistoryBtn">기록 삭제</button> | |
| </div> | |
| <div id="historyList" class="history-list"></div> | |
| </section> | |
| </div> | |
| <!-- Feedback Button --> | |
| <button class="feedback-btn" id="feedbackBtn" title="Feedback"> | |
| <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path> | |
| </svg> | |
| </button> | |
| <!-- Feedback Modal --> | |
| <div class="feedback-overlay" id="feedbackOverlay"> | |
| <div class="feedback-modal"> | |
| <h3 id="feedbackTitle">Feedback</h3> | |
| <p id="feedbackDesc">Share your thoughts, suggestions, or report issues.</p> | |
| <div class="feedback-type-group" id="feedbackTypeGroup"> | |
| <button class="feedback-type-btn active" data-type="suggestion">Suggestion</button> | |
| <button class="feedback-type-btn" data-type="bug">Bug</button> | |
| <button class="feedback-type-btn" data-type="general">General</button> | |
| </div> | |
| <textarea id="feedbackText" placeholder="Write your feedback here..." maxlength="2000"></textarea> | |
| <div class="feedback-char-count"><span id="feedbackCharCount">0</span> / 2000</div> | |
| <div class="feedback-actions"> | |
| <button class="btn btn-secondary" id="feedbackCancelBtn">Cancel</button> | |
| <button class="btn btn-primary" id="feedbackSubmitBtn" style="width:auto;padding:10px 24px;font-size:14px">Submit</button> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| (function () { | |
| var currentFormat = 'text'; | |
| var currentLanguage = 'auto'; | |
| var currentResults = null; | |
| var downloadMode = 'combined'; | |
| var $ = function (sel) { return document.querySelector(sel); }; | |
| var $$ = function (sel) { return document.querySelectorAll(sel); }; | |
| var urlInput = $('#urlInput'); | |
| var extractBtn = $('#extractBtn'); | |
| var loading = $('#loading'); | |
| var resultsSection = $('#results'); | |
| var resultsList = $('#resultsList'); | |
| var stats = $('#stats'); | |
| var copyAllBtn = $('#copyAllBtn'); | |
| var downloadBtn = $('#downloadBtn'); | |
| var retryFailedBtn = $('#retryFailedBtn'); | |
| var denoiseCheckbox = $('#denoise'); | |
| var metadataCheckbox = $('#metadata'); | |
| var autoCopyCheckbox = $('#autoCopy'); | |
| var autoDownloadCheckbox = $('#autoDownload'); | |
| var timestampsCheckbox = $('#timestamps'); | |
| var timestampOption = $('#timestampOption'); | |
| var urlCount = $('#urlCount'); | |
| // i18n | |
| var LANG_KEY = 'yt-transcript-lang'; | |
| // 브라우저 언어 감지 | |
| function detectBrowserLanguage() { | |
| var browserLang = navigator.language.split('-')[0]; | |
| var supportedLangs = ['ko', 'en', 'es', 'ja', 'pt']; | |
| return supportedLangs.includes(browserLang) ? browserLang : 'en'; | |
| } | |
| var currentLang = localStorage.getItem(LANG_KEY) || 'auto'; | |
| // auto일 때 실제 언어 반환 | |
| function getEffectiveLang() { | |
| return currentLang === 'auto' ? detectBrowserLanguage() : currentLang; | |
| } | |
| var i18n = { | |
| ko: { | |
| pageTitle: 'Transcript Extractor', | |
| subtitle: 'YouTube & Instagram 영상의 자막과 대본을 추출합니다', | |
| urlLabel: 'URL 입력', | |
| urlPlaceholder: 'YouTube 또는 Instagram URL을 한 줄에 하나씩 입력하세요\n\nhttps://www.youtube.com/watch?v=...\nhttps://www.instagram.com/reel/...', | |
| optionsLabel: '옵션', | |
| formatLabel: '형식', | |
| langLabel: '언어', | |
| langAuto: 'Auto', | |
| langKo: '한국어', | |
| langEn: 'English', | |
| langEs: 'Español', | |
| langJa: '日本語', | |
| langPt: 'Português', | |
| denoise: '노이즈 제거', | |
| urlInclude: 'URL 포함', | |
| autoCopy: '자막 자동복사', | |
| autoSubtitleDownload: '자동 자막 다운로드', | |
| perFile: '파일별', | |
| allAtOnce: '한번에', | |
| extractBtn: '자막 추출', | |
| copyAll: '전체 복사', | |
| downloadBtn: '다운로드', | |
| copy: '복사', | |
| download: '다운로드', | |
| copied: '복사됨 \u2713', | |
| loading: '자막을 추출하고 있습니다', | |
| successCount: '개 성공', | |
| errorCount: '개 실패', | |
| timestamps: '타임스탬프', | |
| maxUrlAlert: '최대 100개의 URL만 입력할 수 있습니다.', | |
| requestError: '요청 중 오류가 발생했습니다: ', | |
| historyLabel: '최근 추출 기록', | |
| clearHistory: '기록 삭제', | |
| justNow: '방금', | |
| minutesAgo: '분 전', | |
| hoursAgo: '시간 전', | |
| daysAgo: '일 전', | |
| charCount: '자', | |
| progressOf: ' / ', | |
| progressEta: '예상 남은 시간', | |
| progressDone: '완료!', | |
| progressSec: '초', | |
| retryFailed: '실패 항목 재시도', | |
| feedbackTitle: '피드백', | |
| feedbackDesc: '의견, 제안 또는 버그를 알려주세요.', | |
| feedbackSuggestion: '제안', | |
| feedbackBug: '버그', | |
| feedbackGeneral: '기타', | |
| feedbackPlaceholder: '피드백을 입력해주세요...', | |
| feedbackSubmit: '제출', | |
| feedbackCancel: '취소', | |
| feedbackDone: '감사합니다!', | |
| }, | |
| en: { | |
| pageTitle: 'Transcript Extractor', | |
| subtitle: 'Extract subtitles & transcripts from YouTube and Instagram', | |
| urlLabel: 'URLs', | |
| urlPlaceholder: 'Enter YouTube or Instagram URLs, one per line\n\nhttps://www.youtube.com/watch?v=...\nhttps://www.instagram.com/reel/...', | |
| optionsLabel: 'Options', | |
| formatLabel: 'Format', | |
| langLabel: 'Language', | |
| langAuto: 'Auto', | |
| langKo: '한국어', | |
| langEn: 'English', | |
| langEs: 'Español', | |
| langJa: '日本語', | |
| langPt: 'Português', | |
| denoise: 'Denoise', | |
| urlInclude: 'Include URL', | |
| autoCopy: 'Subtitle Auto Copy', | |
| autoSubtitleDownload: 'Auto Subtitle Download', | |
| perFile: 'Per File', | |
| allAtOnce: 'All at Once', | |
| extractBtn: 'Extract', | |
| copyAll: 'Copy All', | |
| downloadBtn: 'Download', | |
| copy: 'Copy', | |
| download: 'Download', | |
| copied: 'Copied \u2713', | |
| loading: 'Extracting subtitles...', | |
| successCount: ' succeeded', | |
| errorCount: ' failed', | |
| timestamps: 'Timestamps', | |
| maxUrlAlert: 'Maximum 100 URLs allowed.', | |
| requestError: 'Request failed: ', | |
| historyLabel: 'Recent History', | |
| clearHistory: 'Clear', | |
| justNow: 'Just now', | |
| minutesAgo: 'm ago', | |
| hoursAgo: 'h ago', | |
| daysAgo: 'd ago', | |
| charCount: ' chars', | |
| progressOf: ' / ', | |
| progressEta: 'Est. remaining', | |
| progressDone: 'Done!', | |
| progressSec: 's', | |
| retryFailed: 'Retry Failed', | |
| feedbackTitle: 'Feedback', | |
| feedbackDesc: 'Share your thoughts, suggestions, or report issues.', | |
| feedbackSuggestion: 'Suggestion', | |
| feedbackBug: 'Bug', | |
| feedbackGeneral: 'General', | |
| feedbackPlaceholder: 'Write your feedback here...', | |
| feedbackSubmit: 'Submit', | |
| feedbackCancel: 'Cancel', | |
| feedbackDone: 'Thank you!', | |
| }, | |
| es: { | |
| pageTitle: 'Transcript Extractor', | |
| subtitle: 'Extrae subtítulos y transcripciones de YouTube e Instagram', | |
| urlLabel: 'URLs', | |
| urlPlaceholder: 'Ingrese URLs de YouTube o Instagram, una por línea\n\nhttps://www.youtube.com/watch?v=...\nhttps://www.instagram.com/reel/...', | |
| optionsLabel: 'Opciones', | |
| formatLabel: 'Formato', | |
| langLabel: 'Idioma', | |
| langAuto: 'Auto', | |
| langKo: '한국어', | |
| langEn: 'English', | |
| langEs: 'Español', | |
| langJa: '日本語', | |
| langPt: 'Português', | |
| denoise: 'Reducir ruido', | |
| urlInclude: 'Incluir URL', | |
| autoCopy: 'Copia automática de subtítulos', | |
| autoSubtitleDownload: 'Descarga automática de subtítulos', | |
| perFile: 'Por archivo', | |
| allAtOnce: 'Todo a la vez', | |
| extractBtn: 'Extraer', | |
| copyAll: 'Copiar todo', | |
| downloadBtn: 'Descargar', | |
| copy: 'Copiar', | |
| download: 'Descargar', | |
| copied: 'Copiado ✓', | |
| loading: 'Extrayendo subtítulos...', | |
| successCount: ' exitoso', | |
| errorCount: ' fallido', | |
| timestamps: 'Marcas de tiempo', | |
| maxUrlAlert: 'Máximo 100 URLs permitidas.', | |
| requestError: 'Error en la solicitud: ', | |
| historyLabel: 'Historial reciente', | |
| clearHistory: 'Borrar', | |
| justNow: 'Justo ahora', | |
| minutesAgo: 'm atrás', | |
| hoursAgo: 'h atrás', | |
| daysAgo: 'd atrás', | |
| charCount: ' caracteres', | |
| progressOf: ' / ', | |
| progressEta: 'Tiempo restante est.', | |
| progressDone: '¡Hecho!', | |
| progressSec: 's', | |
| retryFailed: 'Reintentar fallidos', | |
| feedbackTitle: 'Comentarios', | |
| feedbackDesc: 'Comparta sus ideas, sugerencias o informe problemas.', | |
| feedbackSuggestion: 'Sugerencia', | |
| feedbackBug: 'Error', | |
| feedbackGeneral: 'General', | |
| feedbackPlaceholder: 'Escriba sus comentarios aquí...', | |
| feedbackSubmit: 'Enviar', | |
| feedbackCancel: 'Cancelar', | |
| feedbackDone: '¡Gracias!', | |
| }, | |
| ja: { | |
| pageTitle: 'Transcript Extractor', | |
| subtitle: 'YouTube・Instagramの字幕とトランスクリプトを抽出', | |
| urlLabel: 'URL', | |
| urlPlaceholder: 'YouTubeまたはInstagramのURLを1行に1つずつ入力\n\nhttps://www.youtube.com/watch?v=...\nhttps://www.instagram.com/reel/...', | |
| optionsLabel: 'オプション', | |
| formatLabel: '形式', | |
| langLabel: '言語', | |
| langAuto: '自動', | |
| langKo: '한국어', | |
| langEn: 'English', | |
| langEs: 'Español', | |
| langJa: '日本語', | |
| langPt: 'Português', | |
| denoise: 'ノイズ除去', | |
| urlInclude: 'URLを含む', | |
| autoCopy: '字幕自動コピー', | |
| autoSubtitleDownload: '字幕自動ダウンロード', | |
| perFile: 'ファイル別', | |
| allAtOnce: '一括', | |
| extractBtn: '抽出', | |
| copyAll: 'すべてコピー', | |
| downloadBtn: 'ダウンロード', | |
| copy: 'コピー', | |
| download: 'ダウンロード', | |
| copied: 'コピー完了 ✓', | |
| loading: '字幕を抽出中...', | |
| successCount: ' 成功', | |
| errorCount: ' 失敗', | |
| timestamps: 'タイムスタンプ', | |
| maxUrlAlert: '最大100個のURLまで可能です。', | |
| requestError: 'リクエストエラー: ', | |
| historyLabel: '最近の履歴', | |
| clearHistory: 'クリア', | |
| justNow: 'たった今', | |
| minutesAgo: '分前', | |
| hoursAgo: '時間前', | |
| daysAgo: '日前', | |
| charCount: ' 文字', | |
| progressOf: ' / ', | |
| progressEta: '残り時間', | |
| progressDone: '完了!', | |
| progressSec: '秒', | |
| retryFailed: '失敗を再試行', | |
| feedbackTitle: 'フィードバック', | |
| feedbackDesc: 'ご意見、ご提案、バグ報告をお聞かせください。', | |
| feedbackSuggestion: '提案', | |
| feedbackBug: 'バグ', | |
| feedbackGeneral: 'その他', | |
| feedbackPlaceholder: 'フィードバックを入力してください...', | |
| feedbackSubmit: '送信', | |
| feedbackCancel: 'キャンセル', | |
| feedbackDone: 'ありがとうございます!', | |
| }, | |
| pt: { | |
| pageTitle: 'Transcript Extractor', | |
| subtitle: 'Extraia legendas e transcrições do YouTube e Instagram', | |
| urlLabel: 'URLs', | |
| urlPlaceholder: 'Digite URLs do YouTube ou Instagram, uma por linha\n\nhttps://www.youtube.com/watch?v=...\nhttps://www.instagram.com/reel/...', | |
| optionsLabel: 'Opções', | |
| formatLabel: 'Formato', | |
| langLabel: 'Idioma', | |
| langAuto: 'Auto', | |
| langKo: '한국어', | |
| langEn: 'English', | |
| langEs: 'Español', | |
| langJa: '日本語', | |
| langPt: 'Português', | |
| denoise: 'Reduzir ruído', | |
| urlInclude: 'Incluir URL', | |
| autoCopy: 'Cópia automática de legenda', | |
| autoSubtitleDownload: 'Download automático de legendas', | |
| perFile: 'Por arquivo', | |
| allAtOnce: 'Tudo de uma vez', | |
| extractBtn: 'Extrair', | |
| copyAll: 'Copiar tudo', | |
| downloadBtn: 'Baixar', | |
| copy: 'Copiar', | |
| download: 'Baixar', | |
| copied: 'Copiado ✓', | |
| loading: 'Extraindo legendas...', | |
| successCount: ' sucesso', | |
| errorCount: ' falha', | |
| timestamps: 'Carimbos de tempo', | |
| maxUrlAlert: 'Máximo de 100 URLs permitido.', | |
| requestError: 'Erro na solicitação: ', | |
| historyLabel: 'Histórico recente', | |
| clearHistory: 'Limpar', | |
| justNow: 'Agora mesmo', | |
| minutesAgo: 'm atrás', | |
| hoursAgo: 'h atrás', | |
| daysAgo: 'd atrás', | |
| charCount: ' caracteres', | |
| progressOf: ' / ', | |
| progressEta: 'Tempo restante est.', | |
| progressDone: 'Concluído!', | |
| progressSec: 's', | |
| retryFailed: 'Repetir falhas', | |
| feedbackTitle: 'Feedback', | |
| feedbackDesc: 'Compartilhe suas ideias, sugestões ou reporte problemas.', | |
| feedbackSuggestion: 'Sugestão', | |
| feedbackBug: 'Bug', | |
| feedbackGeneral: 'Geral', | |
| feedbackPlaceholder: 'Escreva seu feedback aqui...', | |
| feedbackSubmit: 'Enviar', | |
| feedbackCancel: 'Cancelar', | |
| feedbackDone: 'Obrigado!', | |
| } | |
| }; | |
| function t(key) { | |
| var lang = getEffectiveLang(); | |
| return (i18n[lang] && i18n[lang][key]) || i18n['en'][key] || key; | |
| } | |
| function applyLanguage() { | |
| // Update UI language dropdown trigger | |
| var uiLangTrigger = $('#uiLangTrigger'); | |
| if (uiLangTrigger) { | |
| if (currentLang === 'auto') { | |
| uiLangTrigger.querySelector('span').textContent = 'Auto'; | |
| } else { | |
| var langNames = { | |
| 'ko': '한국어', | |
| 'en': 'English', | |
| 'es': 'Español', | |
| 'ja': '日本語', | |
| 'pt': 'Português' | |
| }; | |
| uiLangTrigger.querySelector('span').textContent = langNames[currentLang] || 'English'; | |
| } | |
| } | |
| // Update all text elements | |
| $('header h1').textContent = t('pageTitle'); | |
| $('header .subtitle').textContent = t('subtitle'); | |
| // Section labels | |
| var labels = $$('.section-label'); | |
| if (labels[0]) labels[0].textContent = t('urlLabel'); | |
| if (labels[1]) labels[1].textContent = t('optionsLabel'); | |
| // Textarea placeholder | |
| urlInput.placeholder = t('urlPlaceholder'); | |
| // Option labels | |
| var optLabels = $$('.option-label'); | |
| if (optLabels[0]) optLabels[0].textContent = t('formatLabel'); | |
| if (optLabels[1]) optLabels[1].textContent = t('langLabel'); | |
| // Subtitle language dropdown items (Auto는 updateAutoLabel()에서 처리) | |
| var langDropdownItems = document.querySelectorAll('#langDropdownMenu .lang-dropdown-item'); | |
| // index: 0=Auto, 1=English, 2=Español, 3=日本語, 4=Português, 5=한국어 | |
| if (langDropdownItems[1]) langDropdownItems[1].textContent = t('langEn'); | |
| if (langDropdownItems[2]) langDropdownItems[2].textContent = t('langEs'); | |
| if (langDropdownItems[3]) langDropdownItems[3].textContent = t('langJa'); | |
| if (langDropdownItems[4]) langDropdownItems[4].textContent = t('langPt'); | |
| if (langDropdownItems[5]) langDropdownItems[5].textContent = t('langKo'); | |
| // Checkboxes | |
| var checkboxSpans = $$('.checkbox-wrapper span:last-child'); | |
| if (checkboxSpans[0]) checkboxSpans[0].textContent = t('denoise'); | |
| if (checkboxSpans[1]) checkboxSpans[1].textContent = t('urlInclude'); | |
| if (checkboxSpans[2]) checkboxSpans[2].textContent = t('timestamps'); | |
| if (checkboxSpans[3]) checkboxSpans[3].textContent = t('autoCopy'); | |
| if (checkboxSpans[4]) checkboxSpans[4].textContent = t('autoSubtitleDownload'); | |
| // Download mode toggle | |
| var downloadModeToggle = $('#downloadModeToggle'); | |
| if (downloadModeToggle) { | |
| var modeBtns = downloadModeToggle.querySelectorAll('.toggle-btn'); | |
| if (modeBtns[0]) modeBtns[0].textContent = t('perFile'); | |
| if (modeBtns[1]) modeBtns[1].textContent = t('allAtOnce'); | |
| } | |
| // Buttons | |
| extractBtn.textContent = t('extractBtn'); | |
| retryFailedBtn.textContent = t('retryFailed'); | |
| copyAllBtn.textContent = t('copyAll'); | |
| downloadBtn.textContent = t('downloadBtn'); | |
| // Progress text is updated dynamically during extraction | |
| // History | |
| var historyLabel = $('#history .section-label'); | |
| if (historyLabel) historyLabel.textContent = t('historyLabel'); | |
| if (clearHistoryBtn) clearHistoryBtn.textContent = t('clearHistory'); | |
| // Update copy/download buttons in results if they exist | |
| $$('.btn-copy').forEach(function(btn) { | |
| if (!btn.classList.contains('copied')) btn.textContent = t('copy'); | |
| }); | |
| $$('.btn-download').forEach(function(btn) { btn.textContent = t('download'); }); | |
| // Feedback modal | |
| var fbTitle = $('#feedbackTitle'); | |
| var fbDesc = $('#feedbackDesc'); | |
| var fbText = $('#feedbackText'); | |
| var fbSubmit = $('#feedbackSubmitBtn'); | |
| var fbCancel = $('#feedbackCancelBtn'); | |
| if (fbTitle) fbTitle.textContent = t('feedbackTitle'); | |
| if (fbDesc) fbDesc.textContent = t('feedbackDesc'); | |
| if (fbText) fbText.placeholder = t('feedbackPlaceholder'); | |
| if (fbSubmit && !fbSubmit.disabled) fbSubmit.textContent = t('feedbackSubmit'); | |
| if (fbCancel) fbCancel.textContent = t('feedbackCancel'); | |
| var fbTypeBtns = $$('.feedback-type-btn'); | |
| if (fbTypeBtns[0]) fbTypeBtns[0].textContent = t('feedbackSuggestion'); | |
| if (fbTypeBtns[1]) fbTypeBtns[1].textContent = t('feedbackBug'); | |
| if (fbTypeBtns[2]) fbTypeBtns[2].textContent = t('feedbackGeneral'); | |
| } | |
| // UI Language dropdown | |
| var uiLangDropdown = $('#uiLangDropdown'); | |
| var uiLangTrigger = $('#uiLangTrigger'); | |
| var uiLangMenu = $('#uiLangMenu'); | |
| uiLangTrigger.addEventListener('click', function (e) { | |
| e.stopPropagation(); | |
| uiLangMenu.classList.toggle('show'); | |
| }); | |
| $$('#uiLangMenu .lang-dropdown-item').forEach(function (item) { | |
| item.addEventListener('click', function () { | |
| currentLang = item.dataset.lang; | |
| localStorage.setItem(LANG_KEY, currentLang); | |
| $$('#uiLangMenu .lang-dropdown-item').forEach(function (i) { i.classList.remove('active'); }); | |
| item.classList.add('active'); | |
| uiLangMenu.classList.remove('show'); | |
| applyLanguage(); | |
| renderHistory(); | |
| }); | |
| }); | |
| // Theme toggle | |
| var themeToggle = $('#themeToggle'); | |
| var THEME_KEY = 'yt-transcript-theme'; | |
| function getSystemTheme() { | |
| return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; | |
| } | |
| function getCurrentTheme() { | |
| var saved = localStorage.getItem(THEME_KEY); | |
| if (saved === 'dark' || saved === 'light') return saved; | |
| return getSystemTheme(); | |
| } | |
| function applyTheme(theme) { | |
| document.documentElement.setAttribute('data-theme', theme); | |
| localStorage.setItem(THEME_KEY, theme); | |
| } | |
| // Initialize theme from localStorage | |
| var savedTheme = localStorage.getItem(THEME_KEY); | |
| if (savedTheme) { | |
| applyTheme(savedTheme); | |
| } | |
| themeToggle.addEventListener('click', function () { | |
| var current = getCurrentTheme(); | |
| var next = current === 'dark' ? 'light' : 'dark'; | |
| applyTheme(next); | |
| }); | |
| // URL count | |
| function updateUrlCount() { | |
| var lines = urlInput.value.split('\n').filter(function (l) { return l.trim(); }); | |
| var count = lines.length; | |
| if (!urlInput.value.trim()) count = 0; | |
| urlCount.textContent = count + ' / 100'; | |
| urlCount.className = 'url-count'; | |
| if (count > 0) urlCount.classList.add('has-urls'); | |
| if (count > 100) urlCount.classList.add('limit'); | |
| } | |
| // 붙여넣기 시 URL 자동 분리 + 플레이리스트 자동 확장 | |
| urlInput.addEventListener('paste', function (e) { | |
| setTimeout(async function () { | |
| var text = urlInput.value; | |
| // YouTube URL 패턴으로 분리 (http/https 앞에서 줄바꿈) | |
| var separated = text.replace(/(https?:\/\/)/g, '\n$1').trim(); | |
| // 빈 줄 제거하고 정리 | |
| var lines = separated.split('\n').map(function (l) { return l.trim(); }).filter(function (l) { return l; }); | |
| var cleaned = lines.join('\n'); | |
| if (cleaned !== text) { | |
| urlInput.value = cleaned; | |
| } | |
| updateUrlCount(); | |
| // 플레이리스트 URL 감지 및 확장 | |
| var currentLines = urlInput.value.split('\n').map(function (l) { return l.trim(); }).filter(function (l) { return l; }); | |
| var hasPlaylist = currentLines.some(function (l) { return /[?&]list=/.test(l); }); | |
| if (hasPlaylist) { | |
| var expanded = []; | |
| for (var i = 0; i < currentLines.length; i++) { | |
| var line = currentLines[i]; | |
| if (/[?&]list=/.test(line) && !/[?&]v=/.test(line)) { | |
| // Pure playlist URL - resolve it | |
| try { | |
| var resp = await fetch('/api/playlist', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ url: line }), | |
| }); | |
| var data = await resp.json(); | |
| if (data.urls && data.urls.length > 0) { | |
| expanded = expanded.concat(data.urls); | |
| } else { | |
| expanded.push(line); | |
| } | |
| } catch (err) { | |
| expanded.push(line); | |
| } | |
| } else { | |
| expanded.push(line); | |
| } | |
| } | |
| if (expanded.length > currentLines.length) { | |
| urlInput.value = expanded.join('\n'); | |
| updateUrlCount(); | |
| } | |
| } | |
| }, 0); | |
| }); | |
| urlInput.addEventListener('input', updateUrlCount); | |
| updateUrlCount(); | |
| // Language dropdown | |
| var langDropdown = $('#langDropdown'); | |
| var langDropdownTrigger = $('#langDropdownTrigger'); | |
| var langDropdownMenu = $('#langDropdownMenu'); | |
| langDropdownTrigger.addEventListener('click', function (e) { | |
| e.stopPropagation(); | |
| langDropdownMenu.classList.toggle('show'); | |
| }); | |
| $$('.lang-dropdown-item').forEach(function (item) { | |
| item.addEventListener('click', function () { | |
| currentLanguage = item.dataset.value; | |
| if (item.dataset.value === 'auto') { | |
| langDropdownTrigger.textContent = 'Auto'; | |
| } else { | |
| langDropdownTrigger.textContent = item.textContent; | |
| } | |
| $$('.lang-dropdown-item').forEach(function (i) { i.classList.remove('active'); }); | |
| item.classList.add('active'); | |
| langDropdownMenu.classList.remove('show'); | |
| }); | |
| }); | |
| // Close dropdown when clicking outside | |
| document.addEventListener('click', function (e) { | |
| if (langDropdownMenu && !langDropdown.contains(e.target)) { | |
| langDropdownMenu.classList.remove('show'); | |
| } | |
| if (uiLangMenu && !uiLangDropdown.contains(e.target)) { | |
| uiLangMenu.classList.remove('show'); | |
| } | |
| }); | |
| // Auto 라벨은 항상 "Auto"로 표시 | |
| function updateAutoLabel() { | |
| var autoItem = document.querySelector('.lang-dropdown-item[data-value="auto"]'); | |
| if (autoItem) { | |
| autoItem.textContent = 'Auto'; | |
| } | |
| } | |
| // Auto download checkbox - show/hide download mode toggle | |
| var downloadModeToggle = $('#downloadModeToggle'); | |
| autoDownloadCheckbox.addEventListener('change', function () { | |
| if (autoDownloadCheckbox.checked) { | |
| downloadModeToggle.classList.add('show'); | |
| } else { | |
| downloadModeToggle.classList.remove('show'); | |
| } | |
| }); | |
| // Toggle groups (format and downloadMode) | |
| $$('.toggle-group').forEach(function (group) { | |
| var name = group.dataset.name; | |
| group.querySelectorAll('.toggle-btn').forEach(function (btn) { | |
| btn.addEventListener('click', function () { | |
| group.querySelectorAll('.toggle-btn').forEach(function (b) { | |
| b.classList.remove('active'); | |
| }); | |
| btn.classList.add('active'); | |
| if (name === 'format') { | |
| currentFormat = btn.dataset.value; | |
| // Show timestamp option only for text format | |
| if (timestampOption) { | |
| timestampOption.style.display = (currentFormat === 'text') ? '' : 'none'; | |
| } | |
| } | |
| if (name === 'downloadMode') downloadMode = btn.dataset.value; | |
| }); | |
| }); | |
| }); | |
| // Extract | |
| extractBtn.addEventListener('click', handleExtract); | |
| urlInput.addEventListener('keydown', function (e) { | |
| if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { | |
| e.preventDefault(); | |
| handleExtract(); | |
| } | |
| }); | |
| async function handleExtract() { | |
| var text = urlInput.value.trim(); | |
| if (!text) return; | |
| var urls = text.split('\n').map(function (u) { return u.trim(); }).filter(function (u) { return u; }); | |
| if (urls.length === 0) return; | |
| if (urls.length > 100) { | |
| alert(t('maxUrlAlert')); | |
| return; | |
| } | |
| extractBtn.disabled = true; | |
| loading.style.display = 'flex'; | |
| resultsSection.style.display = 'block'; | |
| resultsList.innerHTML = ''; | |
| stats.innerHTML = ''; | |
| var total = urls.length; | |
| var completed = 0; | |
| var successCount = 0; | |
| var errorCount = 0; | |
| var allResults = []; | |
| var startTime = Date.now(); | |
| // Initialize progress | |
| var progressLabel = $('#progressLabel'); | |
| var progressPercent = $('#progressPercent'); | |
| var progressFill = $('#progressFill'); | |
| var progressEta = $('#progressEta'); | |
| progressLabel.textContent = '0' + t('progressOf') + total; | |
| progressPercent.textContent = '0%'; | |
| progressFill.style.width = '0%'; | |
| progressEta.textContent = ''; | |
| function updateProgress() { | |
| var pct = Math.round((completed / total) * 100); | |
| progressLabel.textContent = completed + t('progressOf') + total; | |
| progressPercent.textContent = pct + '%'; | |
| progressFill.style.width = pct + '%'; | |
| if (completed > 0 && completed < total) { | |
| var elapsed = (Date.now() - startTime) / 1000; | |
| var avgTime = elapsed / completed; | |
| var remaining = Math.ceil(avgTime * (total - completed)); | |
| progressEta.textContent = t('progressEta') + ' ~' + remaining + t('progressSec'); | |
| } else if (completed === total) { | |
| var totalTime = ((Date.now() - startTime) / 1000).toFixed(1); | |
| progressEta.textContent = t('progressDone') + ' (' + totalTime + t('progressSec') + ')'; | |
| } | |
| } | |
| // Send individual requests with concurrency limit of 5 | |
| var CONCURRENCY = 5; | |
| var queue = urls.map(function(url, i) { return { url: url, index: i }; }); | |
| var running = 0; | |
| var queueIndex = 0; | |
| await new Promise(function(resolveAll) { | |
| function runNext() { | |
| while (running < CONCURRENCY && queueIndex < queue.length) { | |
| var item = queue[queueIndex++]; | |
| running++; | |
| (function(item) { | |
| fetchOne(item.url, item.index).finally(function() { | |
| running--; | |
| completed++; | |
| updateProgress(); | |
| if (completed === total) { | |
| resolveAll(); | |
| } else { | |
| runNext(); | |
| } | |
| }); | |
| })(item); | |
| } | |
| } | |
| function fetchOne(url, index) { | |
| return fetch('/api/transcripts', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| urls: [url], | |
| language: currentLanguage, | |
| denoise: denoiseCheckbox.checked, | |
| format: currentFormat, | |
| timestamps: timestampsCheckbox.checked, | |
| }), | |
| }) | |
| .then(function(response) { return response.json(); }) | |
| .then(function(data) { | |
| var result = data.results[0]; | |
| allResults[index] = result; | |
| if (result.error) { | |
| errorCount++; | |
| } else { | |
| successCount++; | |
| } | |
| renderSingleResult(result, index); | |
| }) | |
| .catch(function(err) { | |
| allResults[index] = { | |
| url: url, | |
| video_id: null, | |
| title: null, | |
| transcript: null, | |
| error: err.message || 'Network error', | |
| }; | |
| errorCount++; | |
| renderSingleResult(allResults[index], index); | |
| }); | |
| } | |
| runNext(); | |
| }); | |
| // All done | |
| currentResults = { results: allResults, total: total, success_count: successCount, error_count: errorCount }; | |
| // Show/hide retry button | |
| retryFailedBtn.style.display = errorCount > 0 ? 'inline-block' : 'none'; | |
| retryFailedBtn.textContent = t('retryFailed'); | |
| // Update stats header | |
| var totalTokens = 0; | |
| allResults.forEach(function(r) { | |
| if (!r.error && r.transcript) { | |
| var txt = typeof r.transcript === 'string' ? r.transcript : JSON.stringify(r.transcript); | |
| totalTokens += Math.ceil(txt.length / 2); | |
| } | |
| }); | |
| var statsHtml = '<span class="success-count">' + successCount + t('successCount') + '</span>'; | |
| if (totalTokens > 0) { | |
| statsHtml += ' · <span style="color:var(--text-tertiary);font-family:var(--font-mono);font-size:13px">~' + totalTokens.toLocaleString() + ' tokens</span>'; | |
| } | |
| if (errorCount > 0) { | |
| statsHtml += ' · <span class="error-count">' + errorCount + t('errorCount') + '</span>'; | |
| } | |
| stats.innerHTML = statsHtml; | |
| addToHistory(allResults); | |
| // Auto copy | |
| if (autoCopyCheckbox.checked && successCount > 0) { | |
| var successResults = currentResults.results.filter(function (r) { return !r.error; }); | |
| var allText = successResults.map(function (r) { return getResultText(r); }).join('\n\n---\n\n'); | |
| try { | |
| await navigator.clipboard.writeText(allText); | |
| showCopied(copyAllBtn); | |
| } catch (err) { | |
| fallbackCopy(allText); | |
| showCopied(copyAllBtn); | |
| } | |
| } | |
| // Auto download | |
| if (autoDownloadCheckbox.checked && successCount > 0) { | |
| if (downloadMode === 'combined') { | |
| doDownloadAll(); | |
| } else { | |
| doDownloadEach(); | |
| } | |
| } | |
| extractBtn.disabled = false; | |
| loading.style.display = 'none'; | |
| } | |
| function renderSingleResult(result, index) { | |
| var card = document.createElement('div'); | |
| card.className = 'result-card' + (result.error ? ' is-error' : ''); | |
| card.style.animationDelay = '0ms'; | |
| card.setAttribute('data-result-index', index); | |
| var contentText = ''; | |
| if (result.error) { | |
| contentText = result.error; | |
| } else if (currentFormat === 'json') { | |
| contentText = JSON.stringify(result.transcript, null, 2); | |
| } else { | |
| contentText = result.transcript; | |
| } | |
| var actionsHtml = ''; | |
| if (!result.error) { | |
| actionsHtml = '<div class="result-card-actions">' + | |
| '<button class="btn btn-secondary btn-sm btn-copy" data-index="' + index + '">' + t('copy') + '</button>' + | |
| '<button class="btn btn-secondary btn-sm btn-download" data-index="' + index + '">' + t('download') + '</button>' + | |
| '</div>'; | |
| } | |
| var idHtml = ''; | |
| if (result.video_id) { | |
| var linkText = result.title || result.video_id; | |
| if (result.platform === 'instagram') { | |
| idHtml = '<a class="result-card-id" href="https://instagram.com/reel/' + encodeURIComponent(result.video_id) + '/" target="_blank" rel="noopener">' + escapeHtml(linkText) + '</a>'; | |
| } else { | |
| idHtml = '<a class="result-card-id" href="https://youtube.com/watch?v=' + encodeURIComponent(result.video_id) + '" target="_blank" rel="noopener">' + escapeHtml(linkText) + '</a>'; | |
| } | |
| } else { | |
| idHtml = '<span class="result-card-id">' + escapeHtml(result.url) + '</span>'; | |
| } | |
| var cardStatsHtml = ''; | |
| if (!result.error && result.transcript) { | |
| var text = typeof result.transcript === 'string' ? result.transcript : JSON.stringify(result.transcript); | |
| var charCount = text.length; | |
| var tokenCount = Math.ceil(charCount / 2); | |
| cardStatsHtml = '<div class="result-card-stats">' + | |
| charCount.toLocaleString() + t('charCount') + ' \u00B7 ~' + tokenCount.toLocaleString() + ' tokens' + | |
| '</div>'; | |
| } | |
| card.innerHTML = | |
| '<div class="result-card-header">' + | |
| '<div class="result-card-meta">' + | |
| '<span class="result-card-index">#' + (index + 1) + '</span>' + | |
| idHtml + | |
| '</div>' + | |
| actionsHtml + | |
| '</div>' + | |
| '<div class="result-card-content">' + escapeHtml(contentText) + '</div>' + | |
| cardStatsHtml; | |
| // Insert in correct position (maintain order even if responses come back out of order) | |
| var inserted = false; | |
| var existingCards = resultsList.querySelectorAll('.result-card'); | |
| for (var i = 0; i < existingCards.length; i++) { | |
| var existingIndex = parseInt(existingCards[i].getAttribute('data-result-index')); | |
| if (existingIndex > index) { | |
| resultsList.insertBefore(card, existingCards[i]); | |
| inserted = true; | |
| break; | |
| } | |
| } | |
| if (!inserted) { | |
| resultsList.appendChild(card); | |
| } | |
| // Bind copy/download for this card | |
| var copyBtn = card.querySelector('.btn-copy'); | |
| if (copyBtn) { | |
| copyBtn.addEventListener('click', function () { | |
| copyResult(parseInt(copyBtn.dataset.index), copyBtn); | |
| }); | |
| } | |
| var dlBtn = card.querySelector('.btn-download'); | |
| if (dlBtn) { | |
| dlBtn.addEventListener('click', function () { | |
| downloadResult(parseInt(dlBtn.dataset.index)); | |
| }); | |
| } | |
| } | |
| function showError(msg) { | |
| resultsSection.style.display = 'block'; | |
| stats.innerHTML = ''; | |
| resultsList.innerHTML = ''; | |
| var errorDiv = document.createElement('div'); | |
| errorDiv.className = 'result-card is-error'; | |
| errorDiv.style.animationDelay = '0ms'; | |
| errorDiv.innerHTML = | |
| '<div class="result-card-content">' + escapeHtml(msg) + '</div>' + | |
| '<button class="btn btn-primary" style="margin-top:16px" onclick="document.querySelector(\'#extractBtn\').click()">' + | |
| (getEffectiveLang() === 'ko' ? '다시 시도' : 'Retry') + '</button>'; | |
| resultsList.appendChild(errorDiv); | |
| } | |
| function renderResults(data) { | |
| resultsSection.style.display = 'block'; | |
| var totalTokens = 0; | |
| data.results.forEach(function(r) { | |
| if (!r.error && r.transcript) { | |
| var txt = typeof r.transcript === 'string' ? r.transcript : JSON.stringify(r.transcript); | |
| totalTokens += Math.ceil(txt.length / 2); | |
| } | |
| }); | |
| var statsHtml = '<span class="success-count">' + data.success_count + t('successCount') + '</span>'; | |
| if (totalTokens > 0) { | |
| statsHtml += ' · <span style="color:var(--text-tertiary);font-family:var(--font-mono);font-size:13px">~' + totalTokens.toLocaleString() + ' tokens</span>'; | |
| } | |
| if (data.error_count > 0) { | |
| statsHtml += ' · <span class="error-count">' + data.error_count + t('errorCount') + '</span>'; | |
| } | |
| stats.innerHTML = statsHtml; | |
| resultsList.innerHTML = ''; | |
| data.results.forEach(function (result, index) { | |
| var card = document.createElement('div'); | |
| card.className = 'result-card' + (result.error ? ' is-error' : ''); | |
| card.style.animationDelay = (index * 80) + 'ms'; | |
| var contentText = ''; | |
| if (result.error) { | |
| contentText = result.error; | |
| } else if (currentFormat === 'json') { | |
| contentText = JSON.stringify(result.transcript, null, 2); | |
| } else { | |
| contentText = result.transcript; | |
| } | |
| var actionsHtml = ''; | |
| if (!result.error) { | |
| actionsHtml = '<div class="result-card-actions">' + | |
| '<button class="btn btn-secondary btn-sm btn-copy" data-index="' + index + '">' + t('copy') + '</button>' + | |
| '<button class="btn btn-secondary btn-sm btn-download" data-index="' + index + '">' + t('download') + '</button>' + | |
| '</div>'; | |
| } | |
| var idHtml = ''; | |
| if (result.video_id) { | |
| var linkText = result.title || result.video_id; | |
| if (result.platform === 'instagram') { | |
| idHtml = '<a class="result-card-id" href="https://instagram.com/reel/' + encodeURIComponent(result.video_id) + '/" target="_blank" rel="noopener">' + escapeHtml(linkText) + '</a>'; | |
| } else { | |
| idHtml = '<a class="result-card-id" href="https://youtube.com/watch?v=' + encodeURIComponent(result.video_id) + '" target="_blank" rel="noopener">' + escapeHtml(linkText) + '</a>'; | |
| } | |
| } else { | |
| idHtml = '<span class="result-card-id">' + escapeHtml(result.url) + '</span>'; | |
| } | |
| var titleHtml = ''; | |
| var cardStatsHtml = ''; | |
| if (!result.error && result.transcript) { | |
| var text = typeof result.transcript === 'string' ? result.transcript : JSON.stringify(result.transcript); | |
| var charCount = text.length; | |
| var tokenCount = Math.ceil(charCount / 2); | |
| cardStatsHtml = '<div class="result-card-stats">' + | |
| charCount.toLocaleString() + t('charCount') + ' \u00B7 ~' + tokenCount.toLocaleString() + ' tokens' + | |
| '</div>'; | |
| } | |
| card.innerHTML = | |
| '<div class="result-card-header">' + | |
| '<div class="result-card-meta">' + | |
| '<span class="result-card-index">#' + (index + 1) + '</span>' + | |
| idHtml + | |
| '</div>' + | |
| actionsHtml + | |
| '</div>' + | |
| titleHtml + | |
| '<div class="result-card-content">' + escapeHtml(contentText) + '</div>' + | |
| cardStatsHtml; | |
| resultsList.appendChild(card); | |
| }); | |
| // Bind copy/download | |
| $$('.btn-copy').forEach(function (btn) { | |
| btn.addEventListener('click', function () { | |
| copyResult(parseInt(btn.dataset.index), btn); | |
| }); | |
| }); | |
| $$('.btn-download').forEach(function (btn) { | |
| btn.addEventListener('click', function () { | |
| downloadResult(parseInt(btn.dataset.index)); | |
| }); | |
| }); | |
| } | |
| function getResultText(result) { | |
| var includeMetadata = metadataCheckbox.checked; | |
| if (currentFormat === 'json') { | |
| if (includeMetadata) { | |
| return JSON.stringify({ | |
| video_id: result.video_id, | |
| url: result.url, | |
| transcript: result.transcript, | |
| }, null, 2); | |
| } | |
| return JSON.stringify(result.transcript, null, 2); | |
| } | |
| var text = ''; | |
| if (includeMetadata) { | |
| if (result.video_id) text += 'Video ID: ' + result.video_id + '\n'; | |
| if (result.url) text += 'URL: ' + result.url + '\n'; | |
| text += '\n'; | |
| } | |
| text += result.transcript; | |
| return text; | |
| } | |
| async function copyResult(index, btn) { | |
| var result = currentResults.results[index]; | |
| var text = getResultText(result); | |
| try { | |
| await navigator.clipboard.writeText(text); | |
| showCopied(btn); | |
| } catch (err) { | |
| fallbackCopy(text); | |
| showCopied(btn); | |
| } | |
| } | |
| function getFileExt() { | |
| if (currentFormat === 'json') return 'json'; | |
| if (currentFormat === 'srt') return 'srt'; | |
| if (currentFormat === 'vtt') return 'vtt'; | |
| return 'txt'; | |
| } | |
| function getMimeType() { | |
| if (currentFormat === 'json') return 'application/json'; | |
| if (currentFormat === 'srt') return 'application/x-subrip'; | |
| if (currentFormat === 'vtt') return 'text/vtt'; | |
| return 'text/plain'; | |
| } | |
| function downloadResult(index) { | |
| var result = currentResults.results[index]; | |
| var text = getResultText(result); | |
| var ext = getFileExt(); | |
| var filename = (result.video_id || 'transcript') + '.' + ext; | |
| downloadFile(filename, text, getMimeType()); | |
| } | |
| // Copy all | |
| copyAllBtn.addEventListener('click', async function () { | |
| if (!currentResults) return; | |
| var successResults = currentResults.results.filter(function (r) { return !r.error; }); | |
| var allText = successResults.map(function (r) { return getResultText(r); }).join('\n\n---\n\n'); | |
| try { | |
| await navigator.clipboard.writeText(allText); | |
| showCopied(copyAllBtn); | |
| } catch (err) { | |
| fallbackCopy(allText); | |
| showCopied(copyAllBtn); | |
| } | |
| }); | |
| // Download all (combined) | |
| function doDownloadAll() { | |
| if (!currentResults) return; | |
| var successResults = currentResults.results.filter(function (r) { return !r.error; }); | |
| // SRT/VTT: combined doesn't make sense, fallback to per-file | |
| if (currentFormat === 'srt' || currentFormat === 'vtt') { | |
| doDownloadEach(); | |
| return; | |
| } | |
| if (currentFormat === 'json') { | |
| var includeMetadata = metadataCheckbox.checked; | |
| var data = successResults.map(function (r) { | |
| if (includeMetadata) { | |
| return { video_id: r.video_id, url: r.url, transcript: r.transcript }; | |
| } | |
| return r.transcript; | |
| }); | |
| downloadFile('transcripts.json', JSON.stringify(data, null, 2), 'application/json'); | |
| } else { | |
| var allText = successResults.map(function (r) { return getResultText(r); }).join('\n\n---\n\n'); | |
| downloadFile('transcripts.txt', allText, 'text/plain'); | |
| } | |
| } | |
| // Download button - respects download mode | |
| downloadBtn.addEventListener('click', function () { | |
| if (downloadMode === 'combined') { | |
| doDownloadAll(); | |
| } else { | |
| doDownloadEach(); | |
| } | |
| }); | |
| // Download each (individual files) | |
| function doDownloadEach() { | |
| if (!currentResults) return; | |
| var successResults = currentResults.results.filter(function (r) { return !r.error; }); | |
| successResults.forEach(function (r) { | |
| var text = getResultText(r); | |
| var ext = getFileExt(); | |
| var filename = (r.video_id || 'transcript') + '.' + ext; | |
| downloadFile(filename, text, getMimeType()); | |
| }); | |
| } | |
| // Retry failed | |
| retryFailedBtn.addEventListener('click', async function () { | |
| if (!currentResults) return; | |
| var failedIndices = []; | |
| currentResults.results.forEach(function (r, i) { | |
| if (r.error) failedIndices.push(i); | |
| }); | |
| if (failedIndices.length === 0) return; | |
| retryFailedBtn.disabled = true; | |
| retryFailedBtn.textContent = '...'; | |
| var CONCURRENCY = 3; | |
| var running = 0; | |
| var queueIndex = 0; | |
| await new Promise(function (resolveAll) { | |
| function runNext() { | |
| while (running < CONCURRENCY && queueIndex < failedIndices.length) { | |
| var idx = failedIndices[queueIndex++]; | |
| running++; | |
| (function (idx) { | |
| var url = currentResults.results[idx].url; | |
| fetch('/api/transcripts', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| urls: [url], | |
| language: currentLanguage, | |
| denoise: denoiseCheckbox.checked, | |
| format: currentFormat, | |
| timestamps: timestampsCheckbox.checked, | |
| }), | |
| }) | |
| .then(function (response) { return response.json(); }) | |
| .then(function (data) { | |
| var result = data.results[0]; | |
| currentResults.results[idx] = result; | |
| if (result.error) { | |
| // still failed | |
| } else { | |
| currentResults.success_count++; | |
| currentResults.error_count--; | |
| } | |
| var oldCard = resultsList.querySelector('[data-result-index="' + idx + '"]'); | |
| if (oldCard) oldCard.remove(); | |
| renderSingleResult(result, idx); | |
| }) | |
| .catch(function () {}) | |
| .finally(function () { | |
| running--; | |
| if (queueIndex >= failedIndices.length && running === 0) { | |
| resolveAll(); | |
| } else { | |
| runNext(); | |
| } | |
| }); | |
| })(idx); | |
| } | |
| } | |
| runNext(); | |
| }); | |
| // Update stats | |
| var totalTokens = 0; | |
| currentResults.results.forEach(function (r) { | |
| if (!r.error && r.transcript) { | |
| var txt = typeof r.transcript === 'string' ? r.transcript : JSON.stringify(r.transcript); | |
| totalTokens += Math.ceil(txt.length / 2); | |
| } | |
| }); | |
| var statsHtml = '<span class="success-count">' + currentResults.success_count + t('successCount') + '</span>'; | |
| if (totalTokens > 0) { | |
| statsHtml += ' · <span style="color:var(--text-tertiary);font-family:var(--font-mono);font-size:13px">~' + totalTokens.toLocaleString() + ' tokens</span>'; | |
| } | |
| if (currentResults.error_count > 0) { | |
| statsHtml += ' · <span class="error-count">' + currentResults.error_count + t('errorCount') + '</span>'; | |
| } | |
| stats.innerHTML = statsHtml; | |
| retryFailedBtn.disabled = false; | |
| retryFailedBtn.textContent = t('retryFailed'); | |
| retryFailedBtn.style.display = currentResults.error_count > 0 ? 'inline-block' : 'none'; | |
| }); | |
| function downloadFile(filename, content, mimeType) { | |
| var blob = new Blob([content], { type: mimeType + ';charset=utf-8' }); | |
| var url = URL.createObjectURL(blob); | |
| var a = document.createElement('a'); | |
| a.href = url; | |
| a.download = filename; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| } | |
| function showCopied(btn) { | |
| var original = btn.textContent; | |
| btn.textContent = t('copied'); | |
| btn.classList.add('copied'); | |
| setTimeout(function () { | |
| btn.textContent = original; | |
| btn.classList.remove('copied'); | |
| }, 1500); | |
| } | |
| function fallbackCopy(text) { | |
| var ta = document.createElement('textarea'); | |
| ta.value = text; | |
| ta.style.position = 'fixed'; | |
| ta.style.opacity = '0'; | |
| document.body.appendChild(ta); | |
| ta.select(); | |
| document.execCommand('copy'); | |
| document.body.removeChild(ta); | |
| } | |
| function escapeHtml(text) { | |
| var div = document.createElement('div'); | |
| div.textContent = text; | |
| return div.innerHTML; | |
| } | |
| // History | |
| var HISTORY_KEY = 'yt-transcript-history'; | |
| var MAX_HISTORY = 10; | |
| var historySection = $('#history'); | |
| var historyList = $('#historyList'); | |
| var clearHistoryBtn = $('#clearHistoryBtn'); | |
| function getHistory() { | |
| try { | |
| return JSON.parse(localStorage.getItem(HISTORY_KEY)) || []; | |
| } catch (e) { | |
| return []; | |
| } | |
| } | |
| function saveHistory(items) { | |
| localStorage.setItem(HISTORY_KEY, JSON.stringify(items)); | |
| } | |
| function addToHistory(results) { | |
| var history = getHistory(); | |
| results.forEach(function (r) { | |
| if (r.error || !r.video_id) return; | |
| // Remove duplicate | |
| history = history.filter(function (h) { return h.video_id !== r.video_id; }); | |
| history.unshift({ | |
| video_id: r.video_id, | |
| title: r.title || r.video_id, | |
| time: new Date().toISOString(), | |
| }); | |
| }); | |
| history = history.slice(0, MAX_HISTORY); | |
| saveHistory(history); | |
| renderHistory(); | |
| } | |
| function renderHistory() { | |
| var history = getHistory(); | |
| if (history.length === 0) { | |
| historySection.style.display = 'none'; | |
| return; | |
| } | |
| historySection.style.display = 'block'; | |
| historyList.innerHTML = ''; | |
| history.forEach(function (item) { | |
| var div = document.createElement('div'); | |
| div.className = 'history-item'; | |
| var timeStr = formatTime(item.time); | |
| div.innerHTML = | |
| '<span class="history-item-title">' + escapeHtml(item.title) + '</span>' + | |
| '<span class="history-item-time">' + timeStr + '</span>'; | |
| div.addEventListener('click', function () { | |
| urlInput.value = 'https://www.youtube.com/watch?v=' + item.video_id; | |
| updateUrlCount(); | |
| window.scrollTo({ top: 0, behavior: 'smooth' }); | |
| }); | |
| historyList.appendChild(div); | |
| }); | |
| } | |
| function formatTime(isoStr) { | |
| var d = new Date(isoStr); | |
| var now = new Date(); | |
| var diff = now - d; | |
| var minutes = Math.floor(diff / 60000); | |
| if (minutes < 1) return t('justNow'); | |
| if (minutes < 60) return minutes + t('minutesAgo'); | |
| var hours = Math.floor(minutes / 60); | |
| if (hours < 24) return hours + t('hoursAgo'); | |
| var days = Math.floor(hours / 24); | |
| if (days < 7) return days + t('daysAgo'); | |
| var effLang = getEffectiveLang(); | |
| var localeMap = { 'ko': 'ko-KR', 'en': 'en-US', 'es': 'es-ES', 'ja': 'ja-JP', 'pt': 'pt-BR' }; | |
| return d.toLocaleDateString(localeMap[effLang] || 'en-US'); | |
| } | |
| clearHistoryBtn.addEventListener('click', function () { | |
| localStorage.removeItem(HISTORY_KEY); | |
| renderHistory(); | |
| }); | |
| // Show history on load | |
| renderHistory(); | |
| // Apply language on load | |
| applyLanguage(); | |
| updateAutoLabel(); | |
| // Feedback modal | |
| var feedbackBtn = $('#feedbackBtn'); | |
| var feedbackOverlay = $('#feedbackOverlay'); | |
| var feedbackText = $('#feedbackText'); | |
| var feedbackSubmitBtn = $('#feedbackSubmitBtn'); | |
| var feedbackCancelBtn = $('#feedbackCancelBtn'); | |
| var feedbackCharCount = $('#feedbackCharCount'); | |
| var feedbackType = 'suggestion'; | |
| feedbackBtn.addEventListener('click', function () { | |
| feedbackOverlay.classList.add('show'); | |
| feedbackText.focus(); | |
| }); | |
| feedbackCancelBtn.addEventListener('click', function () { | |
| feedbackOverlay.classList.remove('show'); | |
| feedbackText.value = ''; | |
| feedbackCharCount.textContent = '0'; | |
| }); | |
| feedbackOverlay.addEventListener('click', function (e) { | |
| if (e.target === feedbackOverlay) { | |
| feedbackOverlay.classList.remove('show'); | |
| } | |
| }); | |
| feedbackText.addEventListener('input', function () { | |
| feedbackCharCount.textContent = feedbackText.value.length; | |
| }); | |
| $$('.feedback-type-btn').forEach(function (btn) { | |
| btn.addEventListener('click', function () { | |
| $$('.feedback-type-btn').forEach(function (b) { b.classList.remove('active'); }); | |
| btn.classList.add('active'); | |
| feedbackType = btn.dataset.type; | |
| }); | |
| }); | |
| feedbackSubmitBtn.addEventListener('click', async function () { | |
| var msg = feedbackText.value.trim(); | |
| if (!msg) return; | |
| feedbackSubmitBtn.disabled = true; | |
| feedbackSubmitBtn.textContent = '...'; | |
| try { | |
| var response = await fetch('/api/feedback', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ message: msg, type: feedbackType }), | |
| }); | |
| if (response.ok) { | |
| feedbackText.value = ''; | |
| feedbackCharCount.textContent = '0'; | |
| feedbackSubmitBtn.textContent = t('feedbackDone') || 'Thank you!'; | |
| setTimeout(function () { | |
| feedbackOverlay.classList.remove('show'); | |
| feedbackSubmitBtn.textContent = t('feedbackSubmit') || 'Submit'; | |
| feedbackSubmitBtn.disabled = false; | |
| }, 1500); | |
| } else { | |
| feedbackSubmitBtn.textContent = 'Error'; | |
| setTimeout(function () { | |
| feedbackSubmitBtn.textContent = t('feedbackSubmit') || 'Submit'; | |
| feedbackSubmitBtn.disabled = false; | |
| }, 1500); | |
| } | |
| } catch (err) { | |
| feedbackSubmitBtn.textContent = 'Error'; | |
| setTimeout(function () { | |
| feedbackSubmitBtn.textContent = t('feedbackSubmit') || 'Submit'; | |
| feedbackSubmitBtn.disabled = false; | |
| }, 1500); | |
| } | |
| }); | |
| // Set active state for UI language dropdown | |
| $$('#uiLangMenu .lang-dropdown-item').forEach(function (item) { | |
| item.classList.remove('active'); | |
| if (item.dataset.lang === currentLang) { | |
| item.classList.add('active'); | |
| } | |
| }); | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |