feat: implement main renderer application with file upload, transcription configuration, and Electron integration
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Voicext is a high-performance desktop application built with **Electron** and **React** that provides local, private, and fast speech-to-text transcription. It leverages the power of GPU-accelerated AI to convert audio files into professional subtitle formats.
|
Voicext is a high-performance desktop application built with **Electron** and **React** that provides local, private, and fast speech-to-text transcription. It leverages the power of GPU-accelerated AI to convert audio files into professional subtitle formats.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## 🚀 Features
|
## 🚀 Features
|
||||||
|
|
||||||
|
|||||||
+48
-1
@@ -1,9 +1,10 @@
|
|||||||
import { app, shell, BrowserWindow, ipcMain } from 'electron'
|
import { app, shell, BrowserWindow, ipcMain } from 'electron'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
|
import fs from 'fs'
|
||||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||||
import icon from '../../resources/icon.png?asset'
|
import icon from '../../resources/icon.png?asset'
|
||||||
|
|
||||||
import { detectHardware, transcribe } from './engine/transcription'
|
import { detectHardware, transcribe, fixSrt } from './engine/transcription'
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
@@ -76,6 +77,52 @@ function createWindow() {
|
|||||||
ipcMain.on('open-explorer', (event, path) => {
|
ipcMain.on('open-explorer', (event, path) => {
|
||||||
shell.showItemInFolder(path)
|
shell.showItemInFolder(path)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Handle SRT Reading
|
||||||
|
ipcMain.handle('read-srt', async (event, filePath) => {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8')
|
||||||
|
return content
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Read SRT Error:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle SRT Saving
|
||||||
|
ipcMain.handle('save-srt', async (event, { filePath, content }) => {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(filePath, content, 'utf-8')
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Save SRT Error:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle File Dialog
|
||||||
|
ipcMain.handle('select-file', async (event, filters) => {
|
||||||
|
const { dialog } = require('electron')
|
||||||
|
const result = await dialog.showOpenDialog({
|
||||||
|
properties: ['openFile'],
|
||||||
|
filters: filters || []
|
||||||
|
})
|
||||||
|
if (result.canceled) return null
|
||||||
|
return result.filePaths[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle SRT Optimization
|
||||||
|
ipcMain.handle('optimize-srt', async (event, filePath) => {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8')
|
||||||
|
const optimized = fixSrt(content)
|
||||||
|
fs.writeFileSync(filePath, optimized, 'utf-8')
|
||||||
|
return { success: true, content: optimized }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Optimize SRT Error:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ const api = {
|
|||||||
onTranscriptionProgress: (callback) => ipcRenderer.on('transcription-progress', (_, progress) => callback(progress)),
|
onTranscriptionProgress: (callback) => ipcRenderer.on('transcription-progress', (_, progress) => callback(progress)),
|
||||||
onTranscriptionData: (callback) => ipcRenderer.on('transcription-data', (_, data) => callback(data)),
|
onTranscriptionData: (callback) => ipcRenderer.on('transcription-data', (_, data) => callback(data)),
|
||||||
detectHardware: () => ipcRenderer.invoke('detect-hardware'),
|
detectHardware: () => ipcRenderer.invoke('detect-hardware'),
|
||||||
openExplorer: (path) => ipcRenderer.send('open-explorer', path)
|
openExplorer: (path) => ipcRenderer.send('open-explorer', path),
|
||||||
|
readSrt: (filePath) => ipcRenderer.invoke('read-srt', filePath),
|
||||||
|
saveSrt: (filePath, content) => ipcRenderer.invoke('save-srt', { filePath, content }),
|
||||||
|
selectFile: (filters) => ipcRenderer.invoke('select-file', filters),
|
||||||
|
optimizeSrt: (filePath) => ipcRenderer.invoke('optimize-srt', filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.contextIsolated) {
|
if (process.contextIsolated) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import logo from './assets/logo.png'
|
|||||||
|
|
||||||
import CustomSelect from './components/CustomSelect'
|
import CustomSelect from './components/CustomSelect'
|
||||||
import ResultModal from './components/ResultModal'
|
import ResultModal from './components/ResultModal'
|
||||||
|
import SrtEditor from './components/SrtEditor'
|
||||||
|
|
||||||
const languages = [
|
const languages = [
|
||||||
{ id: 'auto', name: 'AUTO (Recommended)' },
|
{ id: 'auto', name: 'AUTO (Recommended)' },
|
||||||
@@ -51,6 +52,7 @@ function App() {
|
|||||||
format: 'srt'
|
format: 'srt'
|
||||||
})
|
})
|
||||||
const [showResult, setShowResult] = useState(null)
|
const [showResult, setShowResult] = useState(null)
|
||||||
|
const [selectedSrt, setSelectedSrt] = useState(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!window.api) {
|
if (!window.api) {
|
||||||
@@ -120,6 +122,14 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSelectSrt = async () => {
|
||||||
|
const filters = [{ name: 'Subtitle Files', extensions: ['srt'] }]
|
||||||
|
const path = await window.api.selectFile(filters)
|
||||||
|
if (path) {
|
||||||
|
setSelectedSrt(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-container">
|
<div className="app-container">
|
||||||
{/* Titlebar */}
|
{/* Titlebar */}
|
||||||
@@ -296,6 +306,35 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'queue' && (
|
||||||
|
<motion.div
|
||||||
|
key="queue"
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 1.05 }}
|
||||||
|
className="queue-view"
|
||||||
|
style={{ flex: 1, display: 'flex', flexDirection: 'column' }}
|
||||||
|
>
|
||||||
|
{selectedSrt ? (
|
||||||
|
<SrtEditor filePath={selectedSrt} onClose={() => setSelectedSrt(null)} />
|
||||||
|
) : (
|
||||||
|
<div className="queue-empty glass" style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '20px' }}>
|
||||||
|
<div className="drop-icon-container">
|
||||||
|
<div className="drop-icon-bg"></div>
|
||||||
|
<ListMusic size={80} color="white" style={{ position: 'relative', zIndex: 1 }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<h2>SRT EDITOR & QUEUE</h2>
|
||||||
|
<p style={{ color: 'var(--text-muted)', marginBottom: '20px' }}>Select an SRT file to fix timestamps and edit text.</p>
|
||||||
|
<button className="btn-primary" style={{ maxWidth: '300px', margin: '0 auto' }} onClick={handleSelectSrt}>
|
||||||
|
Browse SRT Files
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -318,6 +357,10 @@ function App() {
|
|||||||
isOpen={!!showResult}
|
isOpen={!!showResult}
|
||||||
path={showResult}
|
path={showResult}
|
||||||
onClose={() => setShowResult(null)}
|
onClose={() => setShowResult(null)}
|
||||||
|
onEdit={(path) => {
|
||||||
|
setSelectedSrt(path)
|
||||||
|
setActiveTab('queue')
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { CheckCircle2, FolderOpen, X } from 'lucide-react'
|
import { CheckCircle2, FolderOpen, X, FileEdit } from 'lucide-react'
|
||||||
|
|
||||||
export default function ResultModal({ isOpen, path, onClose }) {
|
export default function ResultModal({ isOpen, path, onClose, onEdit }) {
|
||||||
const handleOpenFolder = () => {
|
const handleOpenFolder = () => {
|
||||||
if (window.api && window.api.openExplorer) {
|
if (window.api && window.api.openExplorer) {
|
||||||
window.api.openExplorer(path)
|
window.api.openExplorer(path)
|
||||||
@@ -45,6 +45,12 @@ export default function ResultModal({ isOpen, path, onClose }) {
|
|||||||
<FolderOpen size={18} />
|
<FolderOpen size={18} />
|
||||||
OPEN FOLDER
|
OPEN FOLDER
|
||||||
</button>
|
</button>
|
||||||
|
{path && path.toLowerCase().endsWith('.srt') && (
|
||||||
|
<button className="btn-primary-small" onClick={() => { onEdit(path); onClose(); }}>
|
||||||
|
<FileEdit size={18} />
|
||||||
|
EDIT
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
import { Save, Download, Zap, ChevronUp, ChevronDown, CheckCircle, ArrowLeft } from 'lucide-react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
|
||||||
|
const SrtEditor = ({ filePath, onClose }) => {
|
||||||
|
const [segments, setSegments] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [fileName, setFileName] = useState('')
|
||||||
|
const scrollRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (filePath) {
|
||||||
|
loadSrt(filePath)
|
||||||
|
setFileName(filePath.split(/[\\/]/).pop())
|
||||||
|
}
|
||||||
|
}, [filePath])
|
||||||
|
|
||||||
|
const loadSrt = async (path) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const content = await window.api.readSrt(path)
|
||||||
|
const parsed = parseSrt(content)
|
||||||
|
setSegments(parsed)
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error loading SRT: ' + error.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseSrt = (content) => {
|
||||||
|
const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||||
|
const blocks = normalized.trim().split(/\n\n+/)
|
||||||
|
return blocks.map((block, idx) => {
|
||||||
|
const lines = block.trim().split('\n')
|
||||||
|
if (lines.length < 2) return null
|
||||||
|
|
||||||
|
const timeLineIdx = lines.findIndex(l => l.includes('-->'))
|
||||||
|
if (timeLineIdx === -1) return null
|
||||||
|
|
||||||
|
const times = lines[timeLineIdx].split('-->').map(t => t.trim())
|
||||||
|
const text = lines.slice(timeLineIdx + 1).join('\n')
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: idx,
|
||||||
|
start: times[0],
|
||||||
|
end: times[1],
|
||||||
|
text: text
|
||||||
|
}
|
||||||
|
}).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stringifySrt = (segs) => {
|
||||||
|
return segs.map((s, i) => {
|
||||||
|
return `${i + 1}\n${s.start} --> ${s.end}\n${s.text}`
|
||||||
|
}).join('\n\n') + '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const content = stringifySrt(segments)
|
||||||
|
await window.api.saveSrt(filePath, content)
|
||||||
|
// Show some success feedback
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error saving SRT: ' + error.message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOptimize = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
// First save current state
|
||||||
|
const currentContent = stringifySrt(segments)
|
||||||
|
await window.api.saveSrt(filePath, currentContent)
|
||||||
|
|
||||||
|
// Then optimize
|
||||||
|
const result = await window.api.optimizeSrt(filePath)
|
||||||
|
if (result.success) {
|
||||||
|
const parsed = parseSrt(result.content)
|
||||||
|
setSegments(parsed)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error optimizing SRT: ' + error.message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSegment = (id, field, value) => {
|
||||||
|
setSegments(prev => prev.map(s => s.id === id ? { ...s, [field]: value } : s))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.shiftKey && e.key === 'S') {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [segments])
|
||||||
|
|
||||||
|
if (loading) return <div className="editor-loading">Loading SRT...</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="srt-editor-container glass">
|
||||||
|
<div className="editor-header">
|
||||||
|
<button className="btn-back" onClick={onClose}>
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
</button>
|
||||||
|
<div className="file-info">
|
||||||
|
<span className="file-label">FILE:</span>
|
||||||
|
<span className="file-name">{fileName}</span>
|
||||||
|
<span className="preview-label">(Preview)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="segments-list" ref={scrollRef}>
|
||||||
|
<div className="segments-header">
|
||||||
|
<div className="col-index">Index</div>
|
||||||
|
<div className="col-start">Start</div>
|
||||||
|
<div className="col-end">End</div>
|
||||||
|
<div className="col-text">Subtitle Text</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{segments.map((seg, idx) => (
|
||||||
|
<motion.div
|
||||||
|
key={seg.id}
|
||||||
|
className="segment-row"
|
||||||
|
initial={{ opacity: 0, y: 5 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: Math.min(idx * 0.02, 0.5) }}
|
||||||
|
>
|
||||||
|
<div className="col-index">{(idx + 1).toString().padStart(2, '0')}</div>
|
||||||
|
<div className="col-start">
|
||||||
|
<div className="time-input-container">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={seg.start}
|
||||||
|
onChange={(e) => updateSegment(seg.id, 'start', e.target.value)}
|
||||||
|
className="time-input"
|
||||||
|
/>
|
||||||
|
<div className="time-controls">
|
||||||
|
<ChevronUp size={10} />
|
||||||
|
<ChevronDown size={10} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-end">
|
||||||
|
<div className="time-input-container">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={seg.end}
|
||||||
|
onChange={(e) => updateSegment(seg.id, 'end', e.target.value)}
|
||||||
|
className="time-input"
|
||||||
|
/>
|
||||||
|
<div className="time-controls">
|
||||||
|
<ChevronUp size={10} />
|
||||||
|
<ChevronDown size={10} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-text">
|
||||||
|
<textarea
|
||||||
|
value={seg.text}
|
||||||
|
onChange={(e) => updateSegment(seg.id, 'text', e.target.value)}
|
||||||
|
className="text-input"
|
||||||
|
rows={1}
|
||||||
|
onInput={(e) => {
|
||||||
|
e.target.style.height = 'auto'
|
||||||
|
e.target.style.height = e.target.scrollHeight + 'px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="editor-footer">
|
||||||
|
<div className="footer-actions-left">
|
||||||
|
<button className="btn-footer-secondary" onClick={handleSave} disabled={saving}>
|
||||||
|
<Save size={16} />
|
||||||
|
{saving ? 'SAVING...' : 'SAVE CORRECTIONS (Shift+S)'}
|
||||||
|
</button>
|
||||||
|
<button className="btn-footer-secondary">
|
||||||
|
<Download size={16} />
|
||||||
|
EXPORT FINISHED SRT
|
||||||
|
<span className="btn-subtext">(Premiere Compatible)</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="btn-one-click-export" onClick={handleOptimize} disabled={saving}>
|
||||||
|
<div className="btn-content">
|
||||||
|
<span className="main-text">PREMIERE ONE-CLICK OPTIMIZED EXPORT</span>
|
||||||
|
<span className="sub-text">(Standartlara En Uygun Format - UTF-8)</span>
|
||||||
|
</div>
|
||||||
|
<div className="btn-icon">
|
||||||
|
<Zap size={24} fill="currentColor" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SrtEditor
|
||||||
@@ -598,3 +598,269 @@ body {
|
|||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
border-color: rgba(255, 255, 255, 0.2);
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* SRT Editor Styles */
|
||||||
|
.srt-editor-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
padding: 25px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(0, 242, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-header {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-back {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-back:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: white;
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
color: var(--primary-cyan);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segments-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segments-list::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segments-list::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segments-list::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segments-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 60px 120px 120px 1fr;
|
||||||
|
padding: 10px 15px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 60px 120px 120px 1fr;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 15px;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-row:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-row:focus-within {
|
||||||
|
border-color: var(--primary-cyan);
|
||||||
|
background: rgba(0, 242, 255, 0.03);
|
||||||
|
box-shadow: 0 0 15px rgba(0, 242, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-index {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-input-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-input {
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
color: var(--text-main);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: monospace;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-controls {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input {
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
color: var(--text-main);
|
||||||
|
font-size: 14px;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input:focus {
|
||||||
|
border-color: var(--primary-cyan);
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-actions-left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-footer-secondary {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-footer-secondary:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: white;
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-subtext {
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.6;
|
||||||
|
font-weight: 400;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-one-click-export {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 450px;
|
||||||
|
height: 80px;
|
||||||
|
background: linear-gradient(135deg, #4b1d3f 0%, #1d1b4b 100%);
|
||||||
|
border: 2px solid #ff00e5;
|
||||||
|
border-radius: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 30px;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 0 30px rgba(255, 0, 229, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-one-click-export:hover {
|
||||||
|
transform: translateY(-3px) scale(1.02);
|
||||||
|
box-shadow: 0 0 50px rgba(255, 0, 229, 0.4);
|
||||||
|
filter: brightness(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-one-click-export .btn-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-one-click-export .main-text {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-one-click-export .sub-text {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-one-click-export .btn-icon {
|
||||||
|
color: #ffaa00;
|
||||||
|
filter: drop-shadow(0 0 8px #ffaa00);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
font-size: 20px;
|
||||||
|
color: var(--primary-cyan);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user