feat: initialize project scaffold with Electron structure, transcription engine, and dark-themed UI components

This commit is contained in:
Ümit Tunç
2026-04-23 07:40:05 +03:00
parent 89c69114bd
commit 9dfc3ad9bf
14 changed files with 7032 additions and 3 deletions
+1
View File
@@ -0,0 +1 @@
node_modules
+55
View File
@@ -0,0 +1,55 @@
"use strict";
const electron = require("electron");
const path = require("path");
const utils = require("@electron-toolkit/utils");
const icon = path.join(__dirname, "../../resources/icon.png");
function createWindow() {
const mainWindow = new electron.BrowserWindow({
width: 1100,
height: 670,
show: false,
autoHideMenuBar: true,
...process.platform === "linux" ? { icon } : {},
webPreferences: {
preload: path.join(__dirname, "../preload/index.mjs"),
sandbox: false
},
frame: false,
// Custom frame for premium look
transparent: true,
backgroundColor: "#00000000"
});
mainWindow.on("ready-to-show", () => {
mainWindow.show();
});
mainWindow.webContents.setWindowOpenHandler((details) => {
electron.shell.openExternal(details.url);
return { action: "deny" };
});
if (utils.is.dev && process.env["ELECTRON_RENDERER_URL"]) {
mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"]);
} else {
mainWindow.loadFile(path.join(__dirname, "../renderer/index.html"));
}
}
electron.app.whenReady().then(() => {
utils.electronApp.setAppUserModelId("com.voicext.app");
electron.app.on("browser-window-created", (_, window) => {
utils.optimizer.watchWindowShortcuts(window);
});
electron.ipcMain.on("window-controls", (event, action) => {
const win = electron.BrowserWindow.fromWebContents(event.sender);
if (action === "minimize") win.minimize();
if (action === "maximize") win.isMaximized() ? win.unmaximize() : win.maximize();
if (action === "close") win.close();
});
createWindow();
electron.app.on("activate", function() {
if (electron.BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
electron.app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
electron.app.quit();
}
});
+17
View File
@@ -0,0 +1,17 @@
"use strict";
const electron = require("electron");
const preload = require("@electron-toolkit/preload");
const api = {
windowControls: (action) => electron.ipcRenderer.send("window-controls", action)
};
if (process.contextIsolated) {
try {
preload.exposeElectronAPI();
electron.contextBridge.exposeInMainWorld("api", api);
} catch (error) {
console.error(error);
}
} else {
window.electron = preload.exposeElectronAPI();
window.api = api;
}
+6130
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -12,6 +12,8 @@
"postinstall": "electron-builder install-app-deps" "postinstall": "electron-builder install-app-deps"
}, },
"dependencies": { "dependencies": {
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^3.0.0",
"@headlessui/react": "^2.0.0", "@headlessui/react": "^2.0.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^11.2.10", "framer-motion": "^11.2.10",
@@ -22,7 +24,6 @@
"zustand": "^4.5.2" "zustand": "^4.5.2"
}, },
"devDependencies": { "devDependencies": {
"@electron-vite/cli": "^2.3.0",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
"electron": "^31.0.1", "electron": "^31.0.1",
"electron-builder": "^24.13.3", "electron-builder": "^24.13.3",
Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

+84
View File
@@ -0,0 +1,84 @@
import { spawn } from 'child_process'
import path from 'path'
import fs from 'fs'
import { app } from 'electron'
const BIN_PATH = app.isPackaged
? path.join(process.resourcesPath, 'bin')
: path.join(app.getAppPath(), 'bin')
export async function detectHardware() {
// Simple check for NVIDIA GPU via nvidia-smi
return new Promise((resolve) => {
const smi = spawn('nvidia-smi')
smi.on('error', () => resolve({ gpu: false, name: 'CPU' }))
smi.on('close', (code) => {
if (code === 0) {
resolve({ gpu: true, name: 'NVIDIA GPU (CUDA)' })
} else {
resolve({ gpu: false, name: 'CPU' })
}
})
})
}
export function transcribe(filePath, options, onProgress, onData) {
return new Promise((resolve, reject) => {
const { model = 'base', language = 'tr', outputFormat = 'srt' } = options
// 1. FFmpeg Pre-processing (Convert to 16kHz mono WAV)
const tempWav = path.join(app.getPath('temp'), `voicext_${Date.now()}.wav`)
const ffmpegPath = path.join(BIN_PATH, 'ffmpeg.exe')
const ffmpeg = spawn(ffmpegPath, [
'-i', filePath,
'-ar', '16000',
'-ac', '1',
'-c:a', 'pcm_s16le',
tempWav,
'-y'
])
ffmpeg.on('close', (code) => {
if (code !== 0) {
return reject(new Error('FFmpeg conversion failed'))
}
// 2. Whisper.cpp Inference
const whisperPath = path.join(BIN_PATH, 'whisper.exe')
const modelPath = path.join(BIN_PATH, 'models', `ggml-${model}.bin`)
const whisper = spawn(whisperPath, [
'-m', modelPath,
'-f', tempWav,
`-o${outputFormat}`,
'-l', language,
'--max-len', '42'
])
whisper.stdout.on('data', (data) => {
onData(data.toString())
})
whisper.stderr.on('data', (data) => {
// Whisper.cpp outputs progress to stderr
const output = data.toString()
const progressMatch = output.match(/progress\s*=\s*(\d+)%/)
if (progressMatch) {
onProgress(parseInt(progressMatch[1]))
}
})
whisper.on('close', (code) => {
// Cleanup temp file
if (fs.existsSync(tempWav)) fs.unlinkSync(tempWav)
if (code === 0) {
resolve({ success: true, outputPath: filePath.replace(/\.[^/.]+$/, `.${outputFormat}`) })
} else {
reject(new Error('Whisper transcription failed'))
}
})
})
})
}
+29 -2
View File
@@ -3,6 +3,8 @@ import { join } from 'path'
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'
function createWindow() { function createWindow() {
const mainWindow = new BrowserWindow({ const mainWindow = new BrowserWindow({
width: 1100, width: 1100,
@@ -14,7 +16,7 @@ function createWindow() {
preload: join(__dirname, '../preload/index.mjs'), preload: join(__dirname, '../preload/index.mjs'),
sandbox: false sandbox: false
}, },
frame: false, // Custom frame for premium look frame: false,
transparent: true, transparent: true,
backgroundColor: '#00000000' backgroundColor: '#00000000'
}) })
@@ -33,10 +35,35 @@ function createWindow() {
} else { } else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html')) mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
} }
// Handle transcription request
ipcMain.handle('start-transcription', async (event, { filePath, options }) => {
try {
const result = await transcribe(
filePath,
options,
(progress) => {
mainWindow.webContents.send('transcription-progress', progress)
},
(data) => {
mainWindow.webContents.send('transcription-data', data)
}
)
return result
} catch (error) {
console.error('Transcription Error:', error)
throw error
}
})
// Handle hardware detection
ipcMain.handle('detect-hardware', async () => {
return await detectHardware()
})
} }
app.whenReady().then(() => { app.whenReady().then(() => {
electronApp.setAppId('com.voicext.app') electronApp.setAppUserModelId('com.voicext.app')
app.on('browser-window-created', (_, window) => { app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window) optimizer.watchWindowShortcuts(window)
+22
View File
@@ -0,0 +1,22 @@
import { contextBridge, ipcRenderer } from 'electron'
import { exposeElectronAPI } from '@electron-toolkit/preload'
const api = {
windowControls: (action) => ipcRenderer.send('window-controls', action),
startTranscription: (filePath, options) => ipcRenderer.invoke('start-transcription', { filePath, options }),
onTranscriptionProgress: (callback) => ipcRenderer.on('transcription-progress', (_, progress) => callback(progress)),
onTranscriptionData: (callback) => ipcRenderer.on('transcription-data', (_, data) => callback(data)),
detectHardware: () => ipcRenderer.invoke('detect-hardware')
}
if (process.contextIsolated) {
try {
exposeElectronAPI()
contextBridge.exposeInMainWorld('api', api)
} catch (error) {
console.error(error)
}
} else {
window.electron = exposeElectronAPI()
window.api = api
}
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Voicext</title>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: asset:;"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+283
View File
@@ -0,0 +1,283 @@
import React, { useState } from 'react'
import {
LayoutDashboard,
Box,
ListMusic,
Settings,
X,
Minus,
Square,
UploadCloud,
ChevronDown,
Cpu,
Monitor
} from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion'
import logo from './assets/logo.png'
function App() {
const [activeTab, setActiveTab] = useState('dashboard')
const [dragActive, setDragActive] = useState(false)
const [file, setFile] = useState(null)
const [isTranscribing, setIsTranscribing] = useState(false)
const [progress, setProgress] = useState(0)
const handleControl = (action) => {
window.api.windowControls(action)
}
const handleDrag = (e) => {
e.preventDefault()
e.stopPropagation()
if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true)
} else if (e.type === 'dragleave') {
setDragActive(false)
}
}
const handleDrop = (e) => {
e.preventDefault()
e.stopPropagation()
setDragActive(false)
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
setFile(e.dataTransfer.files[0])
}
}
const handleFileSelect = () => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.mp3,.wav,.m4a'
input.onchange = (e) => {
if (e.target.files && e.target.files[0]) {
setFile(e.target.files[0])
}
}
input.click()
}
const startTranscription = () => {
if (!file) return
setIsTranscribing(true)
// Mock progress for now
let p = 0
const interval = setInterval(() => {
p += Math.random() * 5
if (p >= 100) {
p = 100
clearInterval(interval)
setTimeout(() => {
setIsTranscribing(false)
setProgress(0)
alert('Transcription Complete!')
}, 1000)
}
setProgress(p)
}, 200)
}
return (
<div className="app-container">
{/* Titlebar */}
<div className="titlebar">
<div className="window-controls">
<button onClick={() => handleControl('minimize')} className="control-btn"><Minus size={16} /></button>
<button onClick={() => handleControl('maximize')} className="control-btn"><Square size={12} /></button>
<button onClick={() => handleControl('close')} className="control-btn close"><X size={16} /></button>
</div>
</div>
<div className="main-layout">
{/* Header */}
<header className="header">
<div className="logo-section">
<img src={logo} alt="Voicext Logo" style={{ height: '32px' }} />
</div>
<nav className="nav-tabs">
{['dashboard', 'models', 'queue', 'settings'].map((tab) => (
<div
key={tab}
className={`nav-item ${activeTab === tab ? 'active' : ''}`}
onClick={() => setActiveTab(tab)}
>
{tab === 'dashboard' && <LayoutDashboard size={18} />}
{tab === 'models' && <Box size={18} />}
{tab === 'queue' && <ListMusic size={18} />}
{tab === 'settings' && <Settings size={18} />}
{tab.charAt(0).toUpperCase() + tab.slice(1)}
</div>
))}
</nav>
</header>
{/* Content Area */}
<AnimatePresence mode="wait">
{activeTab === 'dashboard' && (
<motion.div
key="dashboard"
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 1.02 }}
className="dashboard-grid"
>
<div
className={`drop-zone glass ${dragActive ? 'drag-active' : ''}`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
onClick={handleFileSelect}
>
{isTranscribing ? (
<div className="transcription-status">
<div className="progress-circle">
<svg width="120" height="120">
<circle cx="60" cy="60" r="54" fill="none" stroke="rgba(255,255,255,0.1)" strokeWidth="8" />
<motion.circle
cx="60" cy="60" r="54" fill="none"
stroke="url(#gradient)" strokeWidth="8"
strokeDasharray="339.29"
initial={{ strokeDashoffset: 339.29 }}
animate={{ strokeDashoffset: 339.29 - (339.29 * progress) / 100 }}
strokeLinecap="round"
/>
<defs>
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="var(--primary-cyan)" />
<stop offset="100%" stopColor="var(--primary-magenta)" />
</linearGradient>
</defs>
</svg>
<div className="progress-text">{Math.round(progress)}%</div>
</div>
<h3>Transcribing...</h3>
<p>{file?.name}</p>
</div>
) : (
<>
<div className="drop-icon-container">
<div className="drop-icon-bg"></div>
<UploadCloud size={80} color="white" style={{ position: 'relative', zIndex: 1 }} />
</div>
{file ? (
<div style={{ textAlign: 'center' }}>
<h2 style={{ fontSize: '20px', color: 'var(--primary-cyan)' }}>{file.name}</h2>
<p>File Ready for Transcription</p>
<button
className="btn-secondary"
onClick={(e) => { e.stopPropagation(); setFile(null); }}
style={{ marginTop: '10px', background: 'none', border: '1px solid rgba(255,255,255,0.1)', color: '#666', padding: '5px 15px', borderRadius: '20px', cursor: 'pointer' }}
>
Change File
</button>
</div>
) : (
<>
<h2>DRAG & DROP YOUR AUDIO FILE HERE</h2>
<p>OR CLICK TO BROWSE</p>
<span style={{ fontSize: '10px', color: '#666', marginTop: '20px' }}>
Supported: MP3, WAV, M4A (Max: 2GB)
</span>
</>
)}
</>
)}
</div>
<aside className="sidebar-config">
<div className="config-card glass">
<div className="config-title">
<Monitor size={14} />
Quick Configuration
</div>
<div className="input-group">
<label>Language:</label>
<select className="select-custom">
<option>AUTO (Recommended)</option>
<option>Turkish</option>
<option>English</option>
</select>
</div>
<div className="input-group">
<label>Model:</label>
<select className="select-custom">
<option>BASE (Fastest, 140MB)</option>
<option>SMALL (Balanced)</option>
<option>MEDIUM (Accurate)</option>
<option>LARGE (Best Quality)</option>
</select>
</div>
<div className="input-group">
<label>Format:</label>
<select className="select-custom">
<option>.SRT (Premiere Pro Compatible)</option>
<option>.VTT (Web Standard)</option>
<option>.TXT (Plain Text)</option>
</select>
</div>
<button
className="btn-primary"
style={{ marginTop: '10px', opacity: file ? 1 : 0.5, cursor: file ? 'pointer' : 'not-allowed' }}
disabled={!file || isTranscribing}
onClick={startTranscription}
>
{isTranscribing ? 'Processing...' : 'Start Transcription'}
</button>
</div>
</aside>
</motion.div>
)}
{activeTab === 'models' && (
<motion.div
key="models"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="models-view glass"
style={{ flex: 1, padding: '40px', display: 'flex', flexDirection: 'column', gap: '20px' }}
>
<h2>Model Management</h2>
<p style={{ color: 'var(--text-muted)' }}>Download and manage local AI models for offline transcription.</p>
<div className="models-list" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: '20px', marginTop: '20px' }}>
{['Base', 'Small', 'Medium', 'Large'].map(m => (
<div key={m} className="glass" style={{ padding: '20px', textAlign: 'center' }}>
<h3 style={{ marginBottom: '10px' }}>{m}</h3>
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginBottom: '15px' }}>
{m === 'Base' ? '140 MB' : m === 'Small' ? '460 MB' : m === 'Medium' ? '1.5 GB' : '2.9 GB'}
</div>
<button className="btn-secondary" style={{ padding: '8px 15px', borderRadius: '15px', border: '1px solid var(--primary-cyan)', color: 'var(--primary-cyan)', background: 'none' }}>
Download
</button>
</div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Status Bar */}
<footer className="status-bar">
<div className="status-indicator">
<span>SYSTEM: RTX 5080 DETECTED (GPU Acceleration ON)</span>
</div>
<div style={{ display: 'flex', gap: '20px', alignItems: 'center' }}>
<span>READY</span>
<div className="offline-badge">
OFFLINE MODE ACTIVE
</div>
</div>
</footer>
</div>
)
}
export default App
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

+384
View File
@@ -0,0 +1,384 @@
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap');
:root {
--bg-color: #0b0c14;
--panel-bg: rgba(255, 255, 255, 0.03);
--glass-border: rgba(255, 255, 255, 0.1);
--primary-cyan: #00f2ff;
--primary-magenta: #ff00e5;
--text-main: #ffffff;
--text-muted: #a0a0a0;
--accent-glow: rgba(0, 242, 255, 0.3);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Outfit', sans-serif;
}
body {
background-color: var(--bg-color);
color: var(--text-main);
overflow: hidden;
height: 100vh;
width: 100vw;
}
#root {
height: 100%;
}
.app-container {
height: 100%;
display: flex;
flex-direction: column;
background: radial-gradient(circle at 50% -20%, #1a1b2e 0%, #0b0c14 100%);
border: 1px solid var(--glass-border);
border-radius: 12px;
overflow: hidden;
position: relative;
}
/* Glassmorphism utility */
.glass {
background: var(--panel-bg);
backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
border-radius: 12px;
}
/* Custom Titlebar */
.titlebar {
height: 40px;
display: flex;
justify-content: flex-end;
align-items: center;
padding: 0 10px;
-webkit-app-region: drag;
}
.window-controls {
display: flex;
gap: 15px;
-webkit-app-region: no-drag;
}
.control-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
transition: color 0.2s;
}
.control-btn:hover {
color: var(--text-main);
}
.control-btn.close:hover {
color: #ff4b4b;
}
/* Main Content */
.main-layout {
flex: 1;
display: flex;
flex-direction: column;
padding: 20px;
padding-top: 0;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
}
.logo-section {
display: flex;
align-items: center;
gap: 12px;
}
.logo-icon {
width: 40px;
height: 40px;
background: linear-gradient(135deg, var(--primary-cyan), var(--primary-magenta));
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 20px rgba(0, 242, 255, 0.4);
}
.logo-text h1 {
font-size: 24px;
font-weight: 700;
letter-spacing: 0.5px;
}
.logo-text span {
font-size: 10px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1px;
}
/* Navigation */
.nav-tabs {
display: flex;
gap: 10px;
background: rgba(255, 255, 255, 0.05);
padding: 5px;
border-radius: 10px;
border: 1px solid var(--glass-border);
}
.nav-item {
padding: 8px 20px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
color: var(--text-muted);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
gap: 8px;
text-transform: uppercase;
}
.nav-item.active {
color: var(--text-main);
background: rgba(255, 255, 255, 0.1);
box-shadow: 0 0 15px rgba(0, 242, 255, 0.2);
border: 1px solid rgba(0, 242, 255, 0.3);
}
/* Dashboard Grid */
.dashboard-grid {
display: grid;
grid-template-columns: 1fr 300px;
gap: 20px;
flex: 1;
}
/* Drop Zone */
.drop-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 2px dashed rgba(255, 255, 255, 0.1);
transition: all 0.3s;
cursor: pointer;
position: relative;
overflow: hidden;
}
.drop-zone:hover {
border-color: var(--primary-cyan);
background: rgba(0, 242, 255, 0.02);
}
.drop-zone::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 80%;
height: 80%;
border-radius: 20px;
box-shadow: 0 0 50px rgba(255, 0, 229, 0.1);
pointer-events: none;
}
.drop-icon-container {
width: 120px;
height: 120px;
margin-bottom: 20px;
position: relative;
}
.drop-icon-bg {
position: absolute;
inset: 0;
background: linear-gradient(45deg, var(--primary-cyan), var(--primary-magenta));
filter: blur(20px);
opacity: 0.3;
animation: pulse 4s infinite;
}
@keyframes pulse {
0% { transform: scale(1); opacity: 0.3; }
50% { transform: scale(1.1); opacity: 0.5; }
100% { transform: scale(1); opacity: 0.3; }
}
.drop-zone h2 {
font-size: 32px;
font-weight: 600;
margin-bottom: 8px;
text-align: center;
}
.drop-zone p {
color: var(--text-muted);
}
/* Sidebar Config */
.sidebar-config {
display: flex;
flex-direction: column;
gap: 20px;
}
.config-card {
padding: 20px;
}
.config-title {
font-size: 12px;
font-weight: 600;
color: var(--primary-cyan);
text-transform: uppercase;
letter-spacing: 1.5px;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 8px;
}
.input-group {
margin-bottom: 15px;
}
.input-group label {
display: block;
font-size: 12px;
color: var(--text-muted);
margin-bottom: 6px;
}
.select-custom {
width: 100%;
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--glass-border);
border-radius: 8px;
padding: 10px 12px;
color: var(--text-main);
font-size: 14px;
outline: none;
cursor: pointer;
transition: border-color 0.2s;
}
.select-custom:focus {
border-color: var(--primary-cyan);
}
.btn-primary {
width: 100%;
padding: 14px;
border-radius: 30px;
border: none;
background: linear-gradient(90deg, #8a2be2, #4b0082);
color: white;
font-weight: 600;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 1px;
cursor: pointer;
transition: all 0.3s;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(138, 43, 226, 0.4);
filter: brightness(1.2);
}
/* Status Bar */
.status-bar {
height: 35px;
background: rgba(0, 0, 0, 0.2);
border-top: 1px solid var(--glass-border);
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 20px;
font-size: 11px;
color: var(--text-muted);
}
.status-indicator {
display: flex;
align-items: center;
gap: 6px;
}
.indicator-dot {
width: 6px;
height: 6px;
background: #00ff88;
border-radius: 50%;
box-shadow: 0 0 8px #00ff88;
}
.drop-zone.drag-active {
border-color: var(--primary-cyan);
background: rgba(0, 242, 255, 0.05);
transform: scale(1.01);
}
.transcription-status {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.progress-circle {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.progress-text {
position: absolute;
font-size: 24px;
font-weight: 700;
color: var(--text-main);
}
.transcription-status h3 {
font-size: 24px;
background: linear-gradient(90deg, var(--primary-cyan), var(--primary-magenta));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: shimmer 2s infinite linear;
}
@keyframes shimmer {
0% { filter: hue-rotate(0deg); }
100% { filter: hue-rotate(360deg); }
}
/* Models View */
.models-view h2 {
font-size: 32px;
margin-bottom: 10px;
}
.models-list .glass:hover {
border-color: var(--primary-cyan);
background: rgba(255, 255, 255, 0.05);
transform: translateY(-5px);
transition: all 0.3s;
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
)