commit cfa48395a326e4b033ed7fab354a5cc6efd93d19 Author: Yusuf İpek Date: Tue Jan 27 22:43:42 2026 +0300 feat: AI-powered SRT subtitle translator with marker-based alignment - Smart chunking with [B#] markers for precise block-to-timestamp mapping - 50 parallel requests for blazing fast translation - Custom instruction support for domain-specific terminology - Retry with exponential backoff for reliability - Dark theme UI with real-time progress visualization - Default model: google/gemini-3-flash-preview for best performance/cost - Supports 20+ languages via OpenRouter API diff --git a/README.md b/README.md new file mode 100644 index 0000000..7909f95 --- /dev/null +++ b/README.md @@ -0,0 +1,145 @@ +# SRT Translator + +A powerful, browser-based tool for translating SRT subtitle files using AI. Built for speed, accuracy, and ease of use. + +![SRT Translator UI](assets/ui.webp) + +## 🎯 What Problem Does It Solve? + +Translating subtitles is tedious and expensive: +- **Manual translation** is slow and costly +- **Generic AI translation** loses subtitle timing and structure +- **Existing tools** don't handle the unique challenges of SRT format (split sentences, timing markers, etc.) + +**SRT Translator** solves this by: +1. Preserving all timestamps exactly as-is +2. Using a smart marker system to maintain line-by-line correspondence +3. Processing chunks in parallel for maximum speed +4. Allowing custom instructions for domain-specific terminology + +## ✨ Features + +- **Drag & Drop** - Just drop your SRT file and go +- **50 Parallel Requests** - Blazing fast translation +- **Smart Chunking** - Processes 75 subtitle blocks per request for optimal speed/accuracy balance +- **Marker-Based Alignment** - Each subtitle block stays aligned with its timestamp +- **Custom Instructions** - Add context-specific translation rules (e.g., "Use informal 'sen' instead of formal 'siz'") +- **Custom Model Support** - Use any model available on OpenRouter +- **Multi-Language** - Translate to 15+ popular languages +- **Real-Time Progress** - Visual feedback for each chunk's status +- **Retry with Backoff** - Automatic retry on failures + +## 🚀 Quick Start + +1. Open `index.html` in your browser +2. Enter your [OpenRouter API key](https://openrouter.ai/keys) +3. Drop an SRT file +4. Select target language +5. Click "Translate" +6. Download your translated SRT + +## ⚙️ Configuration + +| Setting | Default | Description | +|---------|---------|-------------| +| **Model** | `google/gemini-3-flash-preview` | AI model for translation | +| **Chunk Size** | 75 | Number of subtitle blocks per API request | +| **Parallel Requests** | 50 | Maximum concurrent API calls | + +### Recommended Model + +For the best **performance/cost** balance, we recommend: + +``` +google/gemini-3-flash-preview +``` + +This model offers: +- Fast response times +- Excellent instruction following +- Great translation quality +- Cost-effective pricing + +Other options: +- `google/gemini-2.5-flash` - Slightly cheaper, good quality +- `anthropic/claude-3.5-sonnet` - Premium quality, higher cost +- `google/gemini-2.5-flash-lite` - Budget option, may have lower accuracy + +## 🧠 How It Works + +### The Marker System + +Traditional AI translation loses track of which text belongs to which subtitle block. We solve this with markers: + +**Input to AI:** +``` +[B1] I don't think OpenAI will +[B2] be around in 5 years. +[B3] They're burning cash. +``` + +**AI Output:** +``` +[B1] OpenAI'ın var olacağını +[B2] 5 yıl içinde sanmıyorum. +[B3] Paralarını yakıyorlar. +``` + +Each `[B#]` marker ensures the translated text maps back to the correct timestamp. + +### Translation Priority + +1. **Line Structure** (Mandatory) - Each marker line stays separate +2. **Natural Translation** - Idiomatic, not word-for-word +3. **Word Count** (Soft) - Similar length per line when possible + +## 📁 Project Structure + +``` +srt-translator/ +├── index.html # Main UI entry point +├── README.md # Documentation +├── src/ +│ ├── app.js # Core application logic +│ └── styles.css # Dark theme styling +└── assets/ + └── ui.webp # UI screenshot +``` + +## 🔧 Custom Instructions Examples + +**Turkish informal:** +``` +Türkçe çeviride "siz" yerine "sen" formu kullan. +``` + +**Technical content:** +``` +Keep technical terms like "API", "SDK", "cache" untranslated. +``` + +**YouTube tone:** +``` +Use casual, engaging language suitable for YouTube videos. +``` + +## 📝 API Requirements + +- **Provider:** [OpenRouter](https://openrouter.ai) +- **API Key:** Get one at https://openrouter.ai/keys +- **Models:** Any chat completion model on OpenRouter + +## 🛡️ Privacy + +- Your API key is stored locally in your browser (localStorage) +- SRT files are processed client-side +- Only subtitle text is sent to the AI API +- No data is stored on any server + +## 📄 License + +MIT License - Use freely for personal or commercial projects. + +--- + +Built with ❤️ for content creators who need fast, accurate subtitle translations. diff --git a/assets/ui.webp b/assets/ui.webp new file mode 100644 index 0000000..2659000 Binary files /dev/null and b/assets/ui.webp differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..a518d46 --- /dev/null +++ b/index.html @@ -0,0 +1,142 @@ + + + + + + + SRT Translator + + + + +
+
+

SRT Translator

+

AI-Powered Subtitle Translation via OpenRouter

+
+ +
+
API Configuration
+ +
+ +
+
+ +
+
Translation Settings
+
+ + + +
+
+ +
+
+ +
+
Upload SRT File
+
+ + + +

Drop your .srt file here or click to browse

+ +
+ +
+ + + + + + +
+ + + + + \ No newline at end of file diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..840b756 --- /dev/null +++ b/src/app.js @@ -0,0 +1,612 @@ +// ============================================ +// SRT Translator - App Logic +// ============================================ + +const DEFAULT_MODEL = 'google/gemini-3-flash-preview'; +const DEFAULT_PARALLEL = 50; +const MAX_RETRIES = 3; +const RETRY_DELAY_MS = 1000; // 1 second +const REQUEST_TIMEOUT_MS = 30000; // 30 seconds + +// State +let srtContent = ''; +let srtBlocks = []; +let translatedResult = ''; +let originalFileName = ''; + +// DOM Elements +const elements = { + dropZone: document.getElementById('dropZone'), + fileInput: document.getElementById('fileInput'), + dropText: document.getElementById('dropText'), + fileName: document.getElementById('fileName'), + translateBtn: document.getElementById('translateBtn'), + translateBtnText: document.getElementById('translateBtnText'), + progressCard: document.getElementById('progressCard'), + downloadBtn: document.getElementById('downloadBtn'), + apiKey: document.getElementById('apiKey'), + customModel: document.getElementById('customModel'), + targetLanguage: document.getElementById('targetLanguage'), + chunkSize: document.getElementById('chunkSize'), + parallelRequests: document.getElementById('parallelRequests'), + customInstructions: document.getElementById('customInstructions'), + chunksGrid: document.getElementById('chunksGrid'), + logContainer: document.getElementById('logContainer'), + progressFill: document.getElementById('progressFill'), + progressPercent: document.getElementById('progressPercent'), + totalChunks: document.getElementById('totalChunks'), + completedChunks: document.getElementById('completedChunks'), + failedChunks: document.getElementById('failedChunks') +}; + +// ============================================ +// Initialization +// ============================================ + +function init() { + loadSavedSettings(); + setupEventListeners(); +} + +function loadSavedSettings() { + const savedApiKey = localStorage.getItem('srt_openrouter_api_key'); + const savedModel = localStorage.getItem('srt_custom_model'); + const savedLanguage = localStorage.getItem('srt_target_language'); + const savedChunkSize = localStorage.getItem('srt_chunk_size'); + const savedParallel = localStorage.getItem('srt_parallel_requests'); + const savedInstructions = localStorage.getItem('srt_custom_instructions'); + + if (savedApiKey) elements.apiKey.value = savedApiKey; + if (savedModel) elements.customModel.value = savedModel; + if (savedLanguage) elements.targetLanguage.value = savedLanguage; + if (savedChunkSize) elements.chunkSize.value = savedChunkSize; + if (savedParallel) elements.parallelRequests.value = savedParallel; + else elements.parallelRequests.value = DEFAULT_PARALLEL; + if (savedInstructions) elements.customInstructions.value = savedInstructions; +} + +function saveSettings() { + localStorage.setItem('srt_openrouter_api_key', elements.apiKey.value); + localStorage.setItem('srt_custom_model', elements.customModel.value); + localStorage.setItem('srt_target_language', elements.targetLanguage.value); + localStorage.setItem('srt_chunk_size', elements.chunkSize.value); + localStorage.setItem('srt_parallel_requests', elements.parallelRequests.value); + localStorage.setItem('srt_custom_instructions', elements.customInstructions.value); +} + +// ============================================ +// Event Listeners +// ============================================ + +function setupEventListeners() { + // File upload + elements.dropZone.addEventListener('click', () => elements.fileInput.click()); + elements.dropZone.addEventListener('dragover', handleDragOver); + elements.dropZone.addEventListener('dragleave', handleDragLeave); + elements.dropZone.addEventListener('drop', handleDrop); + elements.fileInput.addEventListener('change', handleFileSelect); + + // Settings changes + elements.apiKey.addEventListener('input', () => { + saveSettings(); + updateTranslateButton(); + }); + elements.customModel.addEventListener('change', saveSettings); + elements.targetLanguage.addEventListener('change', () => { + saveSettings(); + updateTranslateButtonText(); + }); + elements.chunkSize.addEventListener('change', saveSettings); + elements.parallelRequests.addEventListener('change', saveSettings); + elements.customInstructions.addEventListener('change', saveSettings); + + // Actions + elements.translateBtn.addEventListener('click', startTranslation); + elements.downloadBtn.addEventListener('click', downloadResult); + + // Initial UI state + updateTranslateButton(); + updateTranslateButtonText(); +} + +function handleDragOver(e) { + e.preventDefault(); + elements.dropZone.classList.add('dragover'); +} + +function handleDragLeave() { + elements.dropZone.classList.remove('dragover'); +} + +function handleDrop(e) { + e.preventDefault(); + elements.dropZone.classList.remove('dragover'); + const file = e.dataTransfer.files[0]; + if (file && file.name.endsWith('.srt')) { + processFile(file); + } +} + +function handleFileSelect(e) { + const file = e.target.files[0]; + if (file) processFile(file); +} + +// ============================================ +// File Processing +// ============================================ + +function processFile(file) { + originalFileName = file.name; + const reader = new FileReader(); + + reader.onload = (e) => { + srtContent = e.target.result; + srtBlocks = parseSRT(srtContent); + + elements.dropText.textContent = `Loaded: ${srtBlocks.length} subtitle blocks`; + elements.fileName.textContent = file.name; + elements.fileName.classList.remove('hidden'); + + updateTranslateButton(); + }; + + reader.readAsText(file); +} + +function parseSRT(content) { + const blocks = []; + const lines = content.split('\n'); + let currentBlock = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + + if (/^\d+$/.test(line)) { + if (currentBlock) { + blocks.push(currentBlock); + } + currentBlock = { + index: parseInt(line), + timestamp: '', + text: [] + }; + } else if (currentBlock && line.includes('-->')) { + currentBlock.timestamp = line; + } else if (currentBlock && line !== '') { + currentBlock.text.push(line); + } + } + + if (currentBlock) { + blocks.push(currentBlock); + } + + return blocks; +} + +// ============================================ +// Smart Chunking +// ============================================ + +function createSmartChunks(blocks, targetSize) { + const chunks = []; + let currentChunk = []; + + for (let i = 0; i < blocks.length; i++) { + currentChunk.push(blocks[i]); + + if (currentChunk.length >= targetSize) { + let splitIndex = -1; + + // Look for sentence ending in last 10 blocks + for (let j = currentChunk.length - 1; j >= Math.max(0, currentChunk.length - 10); j--) { + const text = currentChunk[j].text.join(' '); + if (/[.!?][\s"']*$/.test(text)) { + splitIndex = j; + break; + } + } + + if (splitIndex === -1) { + chunks.push([...currentChunk]); + currentChunk = []; + } else { + chunks.push(currentChunk.slice(0, splitIndex + 1)); + currentChunk = currentChunk.slice(splitIndex + 1); + } + } + } + + if (currentChunk.length > 0) { + chunks.push(currentChunk); + } + + return chunks; +} + +function blocksToSRT(blocks) { + return blocks.map(block => { + return `${block.index}\n${block.timestamp}\n${block.text.join('\n')}`; + }).join('\n\n'); +} + +// ============================================ +// UI Updates +// ============================================ + +function updateTranslateButton() { + elements.translateBtn.disabled = !srtContent || !elements.apiKey.value; +} + +function updateTranslateButtonText() { + const lang = elements.targetLanguage.value; + elements.translateBtnText.textContent = `Translate to ${lang}`; +} + +function log(message, type = 'info') { + const time = new Date().toLocaleTimeString(); + const entry = document.createElement('div'); + entry.className = 'log-entry'; + entry.innerHTML = `${time}${message}`; + elements.logContainer.insertBefore(entry, elements.logContainer.firstChild); +} + +function updateProgress(completed, failed, total) { + elements.completedChunks.textContent = completed; + elements.failedChunks.textContent = failed; + + const percent = Math.round((completed + failed) / total * 100); + elements.progressPercent.textContent = `${percent}%`; + elements.progressFill.style.width = `${percent}%`; +} + +function setChunkStatus(index, status) { + const indicator = document.querySelector(`[data-chunk="${index}"]`); + if (indicator) { + indicator.className = `chunk-indicator ${status}`; + } +} + +// ============================================ +// Translation API +// ============================================ + +function buildSystemPrompt(targetLanguage, customInstructions) { + let prompt = `You are an expert subtitle translator for ${targetLanguage}. + +INPUT: Lines starting with markers [B1], [B2], etc. + +PRIORITY ORDER (highest to lowest): +1. LINE STRUCTURE (MANDATORY): Each [B#] line stays separate. Never merge or swap content between lines. +2. NATURAL TRANSLATION: Translate idiomatically, not word-for-word. Omit unnecessary pronouns if the language allows it. +3. WORD COUNT (SOFT): Try to keep similar word count per line, but naturalness comes first. + +RULES: +- Keep [B#] markers at the start of each output line +- Return EXACTLY the same number of lines as input +- Do NOT add any annotations, word counts, or comments +- Just output the translated text with markers, nothing else + +EXAMPLE: +Input: +[B1] I don't think OpenAI will +[B2] be around in 5 years. + +Output: +[B1] OpenAI'ın var olacağını +[B2] 5 yıl içinde sanmıyorum. + +CRITICAL: [B1] → [B1], [B2] → [B2]. Never shift content.`; + + if (customInstructions && customInstructions.trim()) { + prompt += `\n\n⚠️ MANDATORY USER INSTRUCTIONS (MUST FOLLOW):\n${customInstructions.trim()}\n\nYou MUST follow these instructions exactly. They override any conflicting rules.`; + } + + return prompt; +} + +// Extract text with block markers - one per line +function extractTextWithMarkers(blocks) { + return blocks.map((block, i) => `[B${i + 1}] ${block.text.join(' ')}`).join('\n'); +} + +// Parse translated text with markers back into blocks +function parseMarkedTranslation(blocks, translatedText) { + const result = []; + + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i]; + const markerStart = `[B${i + 1}]`; + const markerEnd = `[B${i + 2}]`; + + const startIdx = translatedText.indexOf(markerStart); + if (startIdx === -1) { + // Marker not found, use original + result.push({ + index: block.index, + timestamp: block.timestamp, + text: [block.text.join(' ')] + }); + continue; + } + + const textStart = startIdx + markerStart.length; + let textEnd; + + if (i === blocks.length - 1) { + textEnd = translatedText.length; + } else { + const endIdx = translatedText.indexOf(markerEnd); + textEnd = endIdx !== -1 ? endIdx : translatedText.length; + } + + const blockText = translatedText.substring(textStart, textEnd).trim(); + + result.push({ + index: block.index, + timestamp: block.timestamp, + text: [blockText] + }); + } + + return result; +} + +async function translateChunk(chunk, index, apiKey, model, targetLanguage, customInstructions) { + // Extract text with block markers + const markedText = extractTextWithMarkers(chunk); + const systemPrompt = buildSystemPrompt(targetLanguage, customInstructions); + + console.log(`[Chunk ${index + 1}] Sending API request...`); + + // Create abort controller for timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + console.log(`[Chunk ${index + 1}] TIMEOUT - aborting request`); + controller.abort(); + }, REQUEST_TIMEOUT_MS); + + try { + const response = await fetch('https://openrouter.ai/api/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}` + }, + body: JSON.stringify({ + model: model, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: markedText } + ], + temperature: 0.3 + }), + signal: controller.signal + }); + + console.log(`[Chunk ${index + 1}] Response received, status: ${response.status}`); + clearTimeout(timeoutId); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`API Error: ${response.status} - ${error}`); + } + + const data = await response.json(); + console.log(`[Chunk ${index + 1}] Parsed response, parsing markers...`); + const translatedText = data.choices[0].message.content; + + // Parse markers and map back to blocks + const translatedBlocks = parseMarkedTranslation(chunk, translatedText); + + // Convert back to SRT format + return translatedBlocks.map(block => { + return `${block.index}\n${block.timestamp}\n${block.text.join('\n')}`; + }).join('\n\n'); + + } catch (error) { + clearTimeout(timeoutId); + console.log(`[Chunk ${index + 1}] Error:`, error.name, error.message); + if (error.name === 'AbortError') { + throw new Error(`Request timed out after ${REQUEST_TIMEOUT_MS / 1000}s`); + } + throw error; + } +} + +// Retry wrapper with exponential backoff +async function translateChunkWithRetry(chunk, index, apiKey, model, targetLanguage, customInstructions, onRetry) { + let lastError; + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + return await translateChunk(chunk, index, apiKey, model, targetLanguage, customInstructions); + } catch (error) { + lastError = error; + + if (attempt < MAX_RETRIES) { + const delay = RETRY_DELAY_MS * Math.pow(2, attempt - 1); // Exponential backoff + onRetry(index, attempt, delay, error.message); + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + } + + throw lastError; +} + +// ============================================ +// Main Translation Flow +// ============================================ + +async function startTranslation() { + const apiKey = elements.apiKey.value; + if (!apiKey || !srtContent) return; + + // Get settings + const model = elements.customModel.value.trim() || DEFAULT_MODEL; + const targetLanguage = elements.targetLanguage.value; + const targetChunkSize = parseInt(elements.chunkSize.value) || 50; + const maxParallel = parseInt(elements.parallelRequests.value) || DEFAULT_PARALLEL; + const customInstructions = elements.customInstructions.value; + + // Create chunks + const chunks = createSmartChunks(srtBlocks, targetChunkSize); + + // Setup UI + elements.progressCard.classList.remove('hidden'); + elements.downloadBtn.classList.add('hidden'); + elements.translateBtn.disabled = true; + elements.logContainer.innerHTML = ''; + + elements.totalChunks.textContent = chunks.length; + + // Create chunk indicators + elements.chunksGrid.innerHTML = ''; + chunks.forEach((_, i) => { + const indicator = document.createElement('div'); + indicator.className = 'chunk-indicator pending'; + indicator.dataset.chunk = i; + indicator.textContent = i + 1; + elements.chunksGrid.appendChild(indicator); + }); + + log(`Starting translation to ${targetLanguage}...`, 'info'); + log(`Model: ${model}`, 'info'); + log(`Chunks: ${chunks.length} | Size: ~${targetChunkSize} | Parallel: ${maxParallel}`, 'info'); + + let completed = 0; + let failed = 0; + const results = new Array(chunks.length).fill(null); + + // Process a single chunk + async function processChunk(chunk, index) { + setChunkStatus(index, 'processing'); + const blockCount = chunk.length; + const textLength = chunk.reduce((sum, b) => sum + b.text.join(' ').length, 0); + + // Debug for problematic chunks + if (index === 107) { + log(`⚠️ CHUNK 108 DEBUG: ${blockCount} blocks, ${textLength} chars`, 'error'); + console.log('Chunk 108 content:', chunk); + } + + try { + log(`Chunk ${index + 1} starting (${blockCount} blocks, ${textLength} chars)...`, 'info'); + + const translated = await translateChunkWithRetry( + chunk, + index, + apiKey, + model, + targetLanguage, + customInstructions, + (idx, attempt, delay, errorMsg) => { + setChunkStatus(idx, 'error'); + log(`Chunk ${idx + 1} retry ${attempt}/${MAX_RETRIES}: ${errorMsg}`, 'error'); + setTimeout(() => setChunkStatus(idx, 'processing'), 100); + } + ); + + results[index] = translated; + completed++; + setChunkStatus(index, 'done'); + log(`Chunk ${index + 1} completed`, 'success'); + } catch (error) { + failed++; + setChunkStatus(index, 'error'); + log(`Chunk ${index + 1} FAILED: ${error.message}`, 'error'); + results[index] = blocksToSRT(chunk); + } + + updateProgress(completed, failed, chunks.length); + } + + // Process all chunks with controlled concurrency + const processing = new Set(); + let nextIndex = 0; + + async function processNext() { + while (nextIndex < chunks.length) { + const index = nextIndex++; + const chunk = chunks[index]; + + const promise = processChunk(chunk, index).finally(() => { + processing.delete(promise); + }); + processing.add(promise); + + // If at max concurrency, wait for one to complete + if (processing.size >= maxParallel) { + await Promise.race(processing); + } + } + // Wait for remaining + await Promise.all(processing); + } + + await processNext(); + + // Merge results + translatedResult = results.join('\n\n'); + + const status = completed === chunks.length ? 'success' : 'error'; + log(`Translation complete! ${completed} successful, ${failed} failed`, status); + + elements.translateBtn.disabled = false; + elements.downloadBtn.classList.remove('hidden'); +} + +// ============================================ +// Download +// ============================================ + +function downloadResult() { + const lang = elements.targetLanguage.value; + const langCode = getLanguageCode(lang); + + const blob = new Blob([translatedResult], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = originalFileName.replace('.srt', `_${langCode}.srt`); + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + log(`Downloaded: ${a.download}`, 'success'); +} + +function getLanguageCode(language) { + const codes = { + 'Turkish': 'TR', + 'Spanish': 'ES', + 'French': 'FR', + 'German': 'DE', + 'Italian': 'IT', + 'Portuguese': 'PT', + 'Russian': 'RU', + 'Japanese': 'JA', + 'Korean': 'KO', + 'Chinese (Simplified)': 'ZH-CN', + 'Chinese (Traditional)': 'ZH-TW', + 'Arabic': 'AR', + 'Hindi': 'HI', + 'Dutch': 'NL', + 'Polish': 'PL', + 'Swedish': 'SV', + 'Vietnamese': 'VI', + 'Thai': 'TH', + 'Indonesian': 'ID', + 'Greek': 'EL' + }; + return codes[language] || 'TRANSLATED'; +} + +// ============================================ +// Initialize App +// ============================================ + +init(); diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..01aa0e0 --- /dev/null +++ b/src/styles.css @@ -0,0 +1,385 @@ +:root { + --bg-primary: #0a0a0f; + --bg-secondary: #12121a; + --bg-tertiary: #1a1a25; + --accent: #6366f1; + --accent-glow: rgba(99, 102, 241, 0.3); + --text-primary: #f1f5f9; + --text-secondary: #94a3b8; + --success: #22c55e; + --warning: #f59e0b; + --error: #ef4444; + --border: rgba(255, 255, 255, 0.08); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + padding: 2rem; +} + +.container { + width: 100%; + max-width: 800px; +} + +header { + text-align: center; + margin-bottom: 2.5rem; +} + +h1 { + font-size: 2.5rem; + font-weight: 700; + background: linear-gradient(135deg, var(--accent) 0%, #a855f7 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin-bottom: 0.5rem; +} + +.subtitle { + color: var(--text-secondary); + font-size: 1rem; +} + +.card { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 16px; + padding: 1.5rem; + margin-bottom: 1.5rem; +} + +.card-title { + font-size: 0.875rem; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 1rem; +} + +.input-field, +.select-field, +.textarea-field { + width: 100%; + padding: 0.875rem 1rem; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 10px; + color: var(--text-primary); + font-size: 0.95rem; + font-family: inherit; + transition: all 0.2s ease; +} + +.input-field:focus, +.select-field:focus, +.textarea-field:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-glow); +} + +.select-field { + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2394a3b8'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 1rem center; + background-size: 1rem; + padding-right: 2.5rem; +} + +.select-field option { + background: var(--bg-secondary); + color: var(--text-primary); +} + +.textarea-field { + resize: vertical; + min-height: 80px; +} + +.model-row { + margin-top: 1rem; +} + +.settings-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; +} + +.settings-grid label span, +.instruction-section label span { + display: block; + font-size: 0.8rem; + color: var(--text-secondary); + margin-bottom: 0.5rem; +} + +.instruction-section { + margin-top: 1rem; +} + +.drop-zone { + border: 2px dashed var(--border); + border-radius: 12px; + padding: 3rem 2rem; + text-align: center; + cursor: pointer; + transition: all 0.3s ease; + background: var(--bg-tertiary); +} + +.drop-zone:hover, +.drop-zone.dragover { + border-color: var(--accent); + background: rgba(99, 102, 241, 0.05); +} + +.drop-zone svg { + width: 48px; + height: 48px; + color: var(--text-secondary); + margin-bottom: 1rem; +} + +.drop-zone p { + color: var(--text-secondary); + margin-bottom: 0.5rem; +} + +.drop-zone .filename { + color: var(--accent); + font-weight: 600; +} + +.hidden { + display: none !important; +} + +.btn { + width: 100%; + padding: 1rem 1.5rem; + font-size: 1rem; + font-weight: 600; + border: none; + border-radius: 10px; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.btn-primary { + background: linear-gradient(135deg, var(--accent) 0%, #8b5cf6 100%); + color: white; + box-shadow: 0 4px 15px var(--accent-glow); +} + +.btn-primary:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 6px 20px var(--accent-glow); +} + +.btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-success { + background: linear-gradient(135deg, var(--success) 0%, #16a34a 100%); + color: white; + margin-top: 1.5rem; +} + +.btn-success:hover { + transform: translateY(-2px); +} + +.progress-section { + margin-top: 1.5rem; +} + +.progress-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; +} + +.progress-label { + font-size: 0.875rem; + color: var(--text-secondary); +} + +.progress-percentage { + font-size: 0.875rem; + font-weight: 600; + color: var(--accent); +} + +.progress-bar { + height: 8px; + background: var(--bg-tertiary); + border-radius: 4px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent) 0%, #a855f7 100%); + border-radius: 4px; + transition: width 0.3s ease; + width: 0%; +} + +.chunks-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(40px, 1fr)); + gap: 0.5rem; + margin-top: 1rem; +} + +.chunk-indicator { + aspect-ratio: 1; + border-radius: 6px; + background: var(--bg-tertiary); + border: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + font-size: 0.7rem; + font-weight: 600; + color: var(--text-secondary); + transition: all 0.3s ease; +} + +.chunk-indicator.pending { + background: var(--bg-tertiary); +} + +.chunk-indicator.processing { + background: rgba(245, 158, 11, 0.2); + border-color: var(--warning); + color: var(--warning); + animation: pulse 1.5s infinite; +} + +.chunk-indicator.done { + background: rgba(34, 197, 94, 0.2); + border-color: var(--success); + color: var(--success); +} + +.chunk-indicator.error { + background: rgba(239, 68, 68, 0.2); + border-color: var(--error); + color: var(--error); +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } +} + +.stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + margin-top: 1rem; +} + +.stat { + background: var(--bg-tertiary); + padding: 1rem; + border-radius: 10px; + text-align: center; +} + +.stat-value { + font-size: 1.5rem; + font-weight: 700; + color: var(--accent); +} + +.stat-label { + font-size: 0.75rem; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-top: 0.25rem; +} + +.log-container { + max-height: 200px; + overflow-y: auto; + background: var(--bg-tertiary); + border-radius: 10px; + padding: 1rem; + font-family: 'Monaco', 'Menlo', monospace; + font-size: 0.8rem; + margin-top: 1rem; +} + +.log-entry { + padding: 0.25rem 0; + border-bottom: 1px solid var(--border); +} + +.log-entry:last-child { + border-bottom: none; +} + +.log-time { + color: var(--text-secondary); + margin-right: 0.5rem; +} + +.log-success { color: var(--success); } +.log-error { color: var(--error); } +.log-info { color: var(--accent); } + +/* Responsive */ +@media (max-width: 640px) { + body { + padding: 1rem; + } + + h1 { + font-size: 1.75rem; + } + + .settings-grid { + grid-template-columns: 1fr; + } + + .stats { + grid-template-columns: repeat(3, 1fr); + gap: 0.5rem; + } + + .stat { + padding: 0.75rem 0.5rem; + } + + .stat-value { + font-size: 1.25rem; + } +}