youtube-transcript / static /index.html
duck3-create
Increase URL limit from 20 to 100
f328aba
<!DOCTYPE html>
<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을 한 줄에 하나씩 입력하세요&#10;&#10;https://www.youtube.com/watch?v=...&#10;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 += ' &middot; <span style="color:var(--text-tertiary);font-family:var(--font-mono);font-size:13px">~' + totalTokens.toLocaleString() + ' tokens</span>';
}
if (errorCount > 0) {
statsHtml += ' &middot; <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 += ' &middot; <span style="color:var(--text-tertiary);font-family:var(--font-mono);font-size:13px">~' + totalTokens.toLocaleString() + ' tokens</span>';
}
if (data.error_count > 0) {
statsHtml += ' &middot; <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 += ' &middot; <span style="color:var(--text-tertiary);font-family:var(--font-mono);font-size:13px">~' + totalTokens.toLocaleString() + ' tokens</span>';
}
if (currentResults.error_count > 0) {
statsHtml += ' &middot; <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>