bigbossmonster commited on
Commit
7060e3a
·
verified ·
1 Parent(s): 667821f

Update static/index.html

Browse files
Files changed (1) hide show
  1. static/index.html +482 -217
static/index.html CHANGED
@@ -1,218 +1,483 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>Subtitle QC Client</title>
7
-
8
- <script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
9
- <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
10
- <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
11
- <script src="https://cdn.tailwindcss.com"></script>
12
-
13
- <style>
14
- body { font-family: 'Segoe UI', sans-serif; }
15
- </style>
16
- </head>
17
- <body class="bg-slate-50 text-slate-900">
18
- <div id="root"></div>
19
-
20
- <script type="text/babel">
21
- const { useState, useEffect } = React;
22
-
23
- const Icon = ({ d, className }) => (
24
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><path d={d}/></svg>
25
- );
26
- const Icons = {
27
- Upload: ({className}) => <Icon className={className} d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"/>,
28
- Check: ({className}) => <Icon className={className} d="M22 11.08V12a10 10 0 1 1-5.93-9.14 22 4 12 14.01 9 11.01"/>,
29
- X: ({className}) => <Icon className={className} d="M18 6 6 18M6 6l12 12"/>,
30
- Download: ({className}) => <Icon className={className} d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/>,
31
- Settings: ({className}) => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
32
- };
33
-
34
- const App = () => {
35
- const [apiKeys, setApiKeys] = useState(() => localStorage.getItem('qc_api_keys') || '');
36
- const [srtFile, setSrtFile] = useState(null);
37
- const [mediaFiles, setMediaFiles] = useState([]);
38
-
39
- const [isUploading, setIsUploading] = useState(false);
40
- const [results, setResults] = useState([]);
41
- const [error, setError] = useState(null);
42
- const [showSettings, setShowSettings] = useState(false);
43
-
44
- useEffect(() => {
45
- localStorage.setItem('qc_api_keys', apiKeys);
46
- }, [apiKeys]);
47
-
48
- const handleAnalyze = async () => {
49
- if (!srtFile || mediaFiles.length === 0 || !apiKeys) {
50
- setError("Please provide SRT, Media files, and API Key.");
51
- return;
52
- }
53
-
54
- setIsUploading(true);
55
- setError(null);
56
- setResults([]);
57
-
58
- const formData = new FormData();
59
- formData.append("srt_file", srtFile);
60
- for (let i = 0; i < mediaFiles.length; i++) {
61
- formData.append("media_files", mediaFiles[i]);
62
- }
63
- formData.append("api_keys", apiKeys);
64
- formData.append("batch_size", 20);
65
-
66
- try {
67
- // Send to current origin /api/analyze
68
- const response = await fetch('/api/analyze', {
69
- method: 'POST',
70
- body: formData
71
- });
72
-
73
- if (!response.ok) {
74
- const errText = await response.text();
75
- throw new Error(`Backend Error (${response.status}): ${errText}`);
76
- }
77
-
78
- const data = await response.json();
79
- if (data.status === 'success') {
80
- setResults(data.results);
81
- } else {
82
- throw new Error(data.message || "Unknown error");
83
- }
84
- } catch (err) {
85
- setError(err.message);
86
- } finally {
87
- setIsUploading(false);
88
- }
89
- };
90
-
91
- const downloadCorrectedSRT = () => {
92
- if (results.length === 0) return;
93
- let content = "";
94
- results.forEach(r => {
95
- content += `${r.srt_id}\n${r.srt_time}\n${r.status === 'mismatch' ? r.detected : r.expected}\n\n`;
96
- });
97
- const blob = new Blob([content], {type: "text/plain"});
98
- const url = URL.createObjectURL(blob);
99
- const a = document.createElement("a");
100
- a.href = url;
101
- a.download = "corrected_subtitles.srt";
102
- document.body.appendChild(a);
103
- a.click();
104
- document.body.removeChild(a);
105
- };
106
-
107
- return (
108
- <div className="min-h-screen bg-slate-100 font-sans text-slate-900 pb-20">
109
- <header className="bg-slate-900 text-white p-4 shadow-lg sticky top-0 z-50">
110
- <div className="max-w-6xl mx-auto flex justify-between items-center">
111
- <h1 className="text-xl font-bold">Subtitle QC <span className="text-slate-400 font-normal">Docker</span></h1>
112
- <button onClick={() => setShowSettings(!showSettings)} className="p-2 hover:bg-slate-800 rounded-full">
113
- <Icons.Settings className="w-5 h-5" />
114
- </button>
115
- </div>
116
- </header>
117
-
118
- {showSettings && (
119
- <div className="bg-slate-800 text-white p-6 shadow-inner animate-in slide-in-from-top-4">
120
- <div className="max-w-6xl mx-auto">
121
- <label className="block text-sm font-medium mb-1 text-blue-300">Gemini API Keys (One per line)</label>
122
- <textarea
123
- value={apiKeys}
124
- onChange={e => setApiKeys(e.target.value)}
125
- className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 text-sm h-20 font-mono"
126
- placeholder="AIzaSy..."
127
- />
128
- </div>
129
- </div>
130
- )}
131
-
132
- <main className="max-w-6xl mx-auto p-6 space-y-6">
133
- <div className="bg-white p-6 rounded-xl shadow-sm border border-slate-200">
134
- <div className="grid md:grid-cols-2 gap-6">
135
- <div className="border-2 border-dashed border-slate-300 rounded-lg p-8 text-center hover:bg-slate-50 transition-colors">
136
- <input type="file" accept=".srt" id="srt" className="hidden" onChange={e => setSrtFile(e.target.files[0])} />
137
- <label htmlFor="srt" className="cursor-pointer block">
138
- <div className="text-lg font-semibold text-slate-700">{srtFile ? srtFile.name : "Select SRT File"}</div>
139
- <div className="text-xs text-slate-400 mt-2">.srt subtitle file</div>
140
- </label>
141
- </div>
142
- <div className="border-2 border-dashed border-slate-300 rounded-lg p-8 text-center hover:bg-slate-50 transition-colors">
143
- <input type="file" accept=".rar,.zip,image/*" multiple id="media" className="hidden" onChange={e => setMediaFiles(e.target.files)} />
144
- <label htmlFor="media" className="cursor-pointer block">
145
- <div className="text-lg font-semibold text-slate-700">{mediaFiles.length > 0 ? `${mediaFiles.length} Files Selected` : "Select Media"}</div>
146
- <div className="text-xs text-slate-400 mt-2">Images, .rar or .zip archives</div>
147
- </label>
148
- </div>
149
- </div>
150
-
151
- <div className="mt-6 flex justify-end gap-3">
152
- {results.length > 0 && (
153
- <button onClick={downloadCorrectedSRT} className="flex items-center gap-2 px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 font-bold transition-all">
154
- <Icons.Download className="w-5 h-5" /> Download Corrected
155
- </button>
156
- )}
157
- <button
158
- onClick={handleAnalyze}
159
- disabled={isUploading}
160
- className={`flex items-center gap-2 px-8 py-3 rounded-lg font-bold text-white transition-all ${isUploading ? 'bg-slate-400 cursor-wait' : 'bg-blue-600 hover:bg-blue-700 shadow-md hover:shadow-lg'}`}
161
- >
162
- {isUploading ? "Processing..." : "Analyze"}
163
- {!isUploading && <Icons.Upload className="w-5 h-5" />}
164
- </button>
165
- </div>
166
-
167
- {error && (
168
- <div className="mt-4 p-4 bg-red-50 text-red-700 rounded-lg border border-red-200 flex items-center gap-2">
169
- <Icons.X className="w-5 h-5" /> {error}
170
- </div>
171
- )}
172
- </div>
173
-
174
- {results.length > 0 && (
175
- <div className="space-y-4 animate-in fade-in duration-500">
176
- <div className="flex justify-between items-center">
177
- <h2 className="text-xl font-bold">Analysis Results ({results.length})</h2>
178
- <div className="flex gap-2 text-sm">
179
- <span className="px-2 py-1 bg-green-100 text-green-700 rounded">Match: {results.filter(r => r.status === 'match').length}</span>
180
- <span className="px-2 py-1 bg-red-100 text-red-700 rounded">Mismatch: {results.filter(r => r.status === 'mismatch').length}</span>
181
- </div>
182
- </div>
183
-
184
- {results.map((item, idx) => (
185
- <div key={idx} className={`bg-white rounded-lg shadow-sm border p-4 flex gap-4 ${item.status === 'mismatch' ? 'border-red-200 bg-red-50' : 'border-slate-200'}`}>
186
- <div className="w-32 h-20 bg-slate-200 rounded overflow-hidden flex-shrink-0">
187
- <img src={item.thumb} alt="thumb" className="w-full h-full object-cover" />
188
- </div>
189
- <div className="flex-1 grid md:grid-cols-2 gap-4">
190
- <div>
191
- <div className="text-xs text-slate-500 font-mono mb-1">{item.srt_id} | {item.filename}</div>
192
- <div className="font-medium text-slate-800">{item.expected}</div>
193
- </div>
194
- <div className="border-l pl-4 border-slate-200">
195
- <div className="flex items-center gap-2 mb-1">
196
- <span className="text-xs font-bold uppercase text-slate-400">Detected</span>
197
- {item.status === 'match' ? <Icons.Check className="w-4 h-4 text-green-600" /> : <Icons.X className="w-4 h-4 text-red-600" />}
198
- </div>
199
- <div className={`text-sm ${item.status === 'mismatch' ? 'text-red-700 font-semibold' : 'text-slate-600'}`}>
200
- {item.detected || "(No text detected)"}
201
- </div>
202
- {item.reason && <div className="text-xs text-slate-400 mt-1 italic">{item.reason}</div>}
203
- </div>
204
- </div>
205
- </div>
206
- ))}
207
- </div>
208
- )}
209
- </main>
210
- </div>
211
- );
212
- };
213
-
214
- const root = ReactDOM.createRoot(document.getElementById('root'));
215
- root.render(<App />);
216
- </script>
217
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  </html>
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Subtitle QC Assistant</title>
7
+
8
+ <!-- React & ReactDOM -->
9
+ <script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
10
+ <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
11
+
12
+ <!-- Babel for JSX -->
13
+ <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
14
+
15
+ <!-- Tailwind CSS -->
16
+ <script src="https://cdn.tailwindcss.com"></script>
17
+
18
+ <style>
19
+ body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; }
20
+ </style>
21
+ </head>
22
+ <body class="bg-slate-50 text-slate-900">
23
+ <div id="root"></div>
24
+
25
+ <script type="text/babel">
26
+ const { useState, useEffect, useRef, useCallback } = React;
27
+
28
+ // --- Icons ---
29
+ const IconWrapper = ({ children, className }) => (
30
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
31
+ {children}
32
+ </svg>
33
+ );
34
+
35
+ const FileText = ({ className }) => <IconWrapper className={className}><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></IconWrapper>;
36
+ const LucideImage = ({ className }) => <IconWrapper className={className}><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></IconWrapper>;
37
+ const CheckCircle = ({ className }) => <IconWrapper className={className}><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></IconWrapper>;
38
+ const XCircle = ({ className }) => <IconWrapper className={className}><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></IconWrapper>;
39
+ const Play = ({ className }) => <IconWrapper className={className}><polygon points="5 3 19 12 5 21 5 3"/></IconWrapper>;
40
+ const AlertCircle = ({ className }) => <IconWrapper className={className}><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></IconWrapper>;
41
+ const Loader2 = ({ className }) => <IconWrapper className={className}><path d="M21 12a9 9 0 1 1-6.219-8.56"/></IconWrapper>;
42
+ const Trash2 = ({ className }) => <IconWrapper className={className}><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></IconWrapper>;
43
+ const Clock = ({ className }) => <IconWrapper className={className}><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></IconWrapper>;
44
+ const ArrowRightLeft = ({ className }) => <IconWrapper className={className}><path d="m16 21 5-5-5-5"/><path d="M21 16H3"/><path d="m8 3-5 5 5 5"/><path d="M3 8h18"/></IconWrapper>;
45
+ const SettingsIcon = ({ className }) => <IconWrapper className={className}><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.1a2 2 0 0 1-1-1.74v-.51a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></IconWrapper>;
46
+ const Zap = ({ className }) => <IconWrapper className={className}><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></IconWrapper>;
47
+ const Download = ({ className }) => <IconWrapper className={className}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></IconWrapper>;
48
+ const Package = ({ className }) => <IconWrapper className={className}><line x1="16.5" y1="9.4" x2="7.5" y2="4.21"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></IconWrapper>;
49
+
50
+ // --- Components ---
51
+
52
+ const StatusBadge = ({ status }) => {
53
+ if (status === 'match') return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"><CheckCircle className="w-3 h-3 mr-1"/> Match</span>;
54
+ if (status === 'mismatch') return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800"><XCircle className="w-3 h-3 mr-1"/> Mismatch</span>;
55
+ return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">Pending</span>;
56
+ };
57
+
58
+ const Header = ({ onOpenSettings, activeWorkers }) => (
59
+ <header className="bg-slate-900 text-white p-6 shadow-lg">
60
+ <div className="max-w-6xl mx-auto flex items-center justify-between">
61
+ <div className="flex items-center space-x-3">
62
+ <div className="bg-blue-600 p-2 rounded-lg">
63
+ <FileText className="w-6 h-6 text-white" />
64
+ </div>
65
+ <div>
66
+ <h1 className="text-xl font-bold tracking-tight">Subtitle QC Assistant</h1>
67
+ <p className="text-slate-400 text-sm">Verify image subtitles against SRT files using Gemini</p>
68
+ </div>
69
+ </div>
70
+ <div className="flex items-center space-x-4">
71
+ <div className="hidden md:block text-xs text-slate-500 text-right">
72
+ {activeWorkers > 0 ? (
73
+ <span className="text-green-400 font-bold flex items-center justify-end gap-1">
74
+ <Zap className="w-3 h-3 animate-pulse"/>
75
+ {activeWorkers} Parallel Key(s) Active
76
+ </span>
77
+ ) : (
78
+ "Mode: Sequential Sort (Time Ascending)"
79
+ )}
80
+ <br/>
81
+ Supports Multi-Key Parallel Batching
82
+ </div>
83
+ <button
84
+ onClick={onOpenSettings}
85
+ className="p-2 bg-slate-800 hover:bg-slate-700 rounded-full transition-colors text-slate-300 hover:text-white"
86
+ title="API Key Settings"
87
+ >
88
+ <SettingsIcon className="w-5 h-5" />
89
+ </button>
90
+ </div>
91
+ </div>
92
+ </header>
93
+ );
94
+
95
+ const SettingsModal = ({ isOpen, onClose, config, setConfig }) => {
96
+ if (!isOpen) return null;
97
+ return (
98
+ <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
99
+ <div className="bg-white rounded-xl shadow-2xl max-w-md w-full p-6 animate-in fade-in zoom-in duration-200">
100
+ <div className="flex justify-between items-center mb-4">
101
+ <h3 className="text-lg font-bold text-slate-800">Configuration</h3>
102
+ <button onClick={onClose} className="text-slate-400 hover:text-slate-600">
103
+ <XCircle className="w-6 h-6" />
104
+ </button>
105
+ </div>
106
+ <div className="space-y-4">
107
+ <div>
108
+ <label className="block text-sm font-medium text-slate-700 mb-1">Gemini API Keys</label>
109
+ <p className="text-xs text-slate-500 mb-2">Enter one key per line. Multiple keys will run in parallel.</p>
110
+ <textarea
111
+ value={config.apiKeys}
112
+ onChange={(e) => setConfig({...config, apiKeys: e.target.value})}
113
+ placeholder="AIzaSy...&#10;AIzaSy..."
114
+ rows={3}
115
+ className="w-full p-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none font-mono text-xs"
116
+ />
117
+ </div>
118
+
119
+ <div className="grid grid-cols-2 gap-4">
120
+ <div>
121
+ <label className="block text-sm font-medium text-slate-700 mb-1">Model Name</label>
122
+ <select
123
+ value={config.modelName}
124
+ onChange={(e) => setConfig({...config, modelName: e.target.value})}
125
+ className="w-full p-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
126
+ >
127
+ <option value="gemini-3-flash-preview">Gemini 3 Flash</option>
128
+ <option value="gemini-3-pro-preview">Gemini 3 Pro</option>
129
+ </select>
130
+ </div>
131
+ <div>
132
+ <label className="block text-sm font-medium text-slate-700 mb-1">Batch Size</label>
133
+ <input
134
+ type="number"
135
+ min="1" max="50"
136
+ value={config.batchSize}
137
+ onChange={(e) => setConfig({...config, batchSize: Number(e.target.value)})}
138
+ className="w-full p-2 border border-slate-300 rounded-lg text-sm"
139
+ />
140
+ </div>
141
+ </div>
142
+
143
+ <div>
144
+ <label className="block text-sm font-medium text-slate-700 mb-1">
145
+ Compression Quality: <span className="text-blue-600 font-bold">{config.quality}</span>
146
+ </label>
147
+ <input
148
+ type="range"
149
+ min="0.1"
150
+ max="1.0"
151
+ step="0.1"
152
+ value={config.quality}
153
+ onChange={(e) => setConfig({...config, quality: Number(e.target.value)})}
154
+ className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer"
155
+ />
156
+ <div className="flex justify-between text-xs text-slate-400 mt-1">
157
+ <span>0.1 (Smallest)</span>
158
+ <span>0.7 (Default)</span>
159
+ <span>1.0 (Best)</span>
160
+ </div>
161
+ </div>
162
+
163
+ <div className="flex justify-end pt-2">
164
+ <button onClick={onClose} className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
165
+ Save & Close
166
+ </button>
167
+ </div>
168
+ </div>
169
+ </div>
170
+ </div>
171
+ );
172
+ };
173
+
174
+ const App = () => {
175
+ const [srtFile, setSrtFile] = useState(null);
176
+ const [mediaFiles, setMediaFiles] = useState([]);
177
+ const [pairs, setPairs] = useState([]);
178
+
179
+ const [isProcessing, setIsProcessing] = useState(false);
180
+ const [activeWorkers, setActiveWorkers] = useState(0);
181
+ const [error, setError] = useState(null);
182
+ const [loadingMessage, setLoadingMessage] = useState('');
183
+ const [showSettings, setShowSettings] = useState(false);
184
+
185
+ // Centralized Config State
186
+ const [config, setConfig] = useState(() => ({
187
+ apiKeys: localStorage.getItem('gemini_api_keys_multi') || '',
188
+ batchSize: Number(localStorage.getItem('gemini_batch_size')) || 20,
189
+ modelName: localStorage.getItem('gemini_model_name') || 'gemini-2.0-flash',
190
+ quality: Number(localStorage.getItem('gemini_quality')) || 0.7
191
+ }));
192
+
193
+ useEffect(() => {
194
+ localStorage.setItem('gemini_api_keys_multi', config.apiKeys);
195
+ localStorage.setItem('gemini_batch_size', config.batchSize);
196
+ localStorage.setItem('gemini_model_name', config.modelName);
197
+ localStorage.setItem('gemini_quality', config.quality);
198
+ }, [config]);
199
+
200
+ const [isDraggingSrt, setIsDraggingSrt] = useState(false);
201
+ const [isDraggingImages, setIsDraggingImages] = useState(false);
202
+
203
+ const handleAnalyze = async () => {
204
+ if (!srtFile || mediaFiles.length === 0) {
205
+ setError("Please upload both SRT file and Media files.");
206
+ return;
207
+ }
208
+ if (!config.apiKeys.trim()) {
209
+ setError("API Key missing. Please check settings.");
210
+ setShowSettings(true);
211
+ return;
212
+ }
213
+
214
+ setIsProcessing(true);
215
+ setError(null);
216
+ setPairs([]);
217
+ setLoadingMessage("Uploading and analyzing on backend...");
218
+
219
+ // Count workers simply by keys present
220
+ const keyCount = config.apiKeys.split('\n').filter(k => k.trim()).length;
221
+ setActiveWorkers(keyCount);
222
+
223
+ const formData = new FormData();
224
+ formData.append("srt_file", srtFile);
225
+ for (let i = 0; i < mediaFiles.length; i++) {
226
+ formData.append("media_files", mediaFiles[i]);
227
+ }
228
+ formData.append("api_keys", config.apiKeys);
229
+ formData.append("batch_size", config.batchSize);
230
+ formData.append("model_name", config.modelName);
231
+ formData.append("compression_quality", config.quality);
232
+
233
+ try {
234
+ const response = await fetch('/api/analyze', {
235
+ method: 'POST',
236
+ body: formData
237
+ });
238
+
239
+ if (!response.ok) {
240
+ const errText = await response.text();
241
+ throw new Error(`Backend Error (${response.status}): ${errText}`);
242
+ }
243
+
244
+ const data = await response.json();
245
+ if (data.status === 'success') {
246
+ // The backend returns a unified list of results
247
+ // We map this to the structure expected by the UI renderer
248
+ const mappedPairs = data.results.map((res, idx) => ({
249
+ id: res.id,
250
+ image: { originalFile: { name: res.filename }, dataUrl: res.thumb },
251
+ subtitle: { text: res.expected, time: res.srt_time },
252
+ status: res.status,
253
+ analysis: { detected_text: res.detected, reason: res.reason },
254
+ matchNote: res.status === 'match' ? 'Match' : 'Mismatch' // simplified for remote logic
255
+ }));
256
+ setPairs(mappedPairs);
257
+ } else {
258
+ throw new Error(data.message || "Unknown server error");
259
+ }
260
+ } catch (err) {
261
+ setError(err.message);
262
+ } finally {
263
+ setIsProcessing(false);
264
+ setLoadingMessage('');
265
+ setActiveWorkers(0);
266
+ }
267
+ };
268
+
269
+ const handleDownloadSRT = () => {
270
+ if (pairs.length === 0) return;
271
+ let srtContent = "";
272
+ pairs.forEach((pair) => {
273
+ const text = (pair.status === 'mismatch' && pair.analysis?.detected_text)
274
+ ? pair.analysis.detected_text
275
+ : pair.subtitle.text;
276
+ // Note: Backend simplified passing back SRT IDs, ensuring they exist
277
+ srtContent += `${pair.id + 1}\n${pair.subtitle.time}\n${text}\n\n`;
278
+ });
279
+ const blob = new Blob([srtContent], { type: 'text/plain' });
280
+ const url = URL.createObjectURL(blob);
281
+ const a = document.createElement('a');
282
+ a.href = url;
283
+ a.download = "corrected_subtitles.srt";
284
+ document.body.appendChild(a);
285
+ a.click();
286
+ document.body.removeChild(a);
287
+ URL.revokeObjectURL(url);
288
+ };
289
+
290
+ const handleReset = () => {
291
+ setSrtFile(null);
292
+ setMediaFiles([]);
293
+ setPairs([]);
294
+ setError(null);
295
+ };
296
+
297
+ return (
298
+ <div className="min-h-screen bg-slate-50 text-slate-900 font-sans">
299
+ <Header onOpenSettings={() => setShowSettings(true)} activeWorkers={activeWorkers} />
300
+
301
+ <SettingsModal
302
+ isOpen={showSettings}
303
+ onClose={() => setShowSettings(false)}
304
+ config={config}
305
+ setConfig={setConfig}
306
+ />
307
+
308
+ <main className="max-w-6xl mx-auto p-6 space-y-8">
309
+ {/* Upload Section */}
310
+ <section className="grid md:grid-cols-2 gap-6">
311
+ {/* SRT Uploader */}
312
+ <div
313
+ className={`relative border-2 border-dashed rounded-xl p-8 flex flex-col items-center justify-center transition-all duration-200
314
+ ${isDraggingSrt ? 'border-blue-500 bg-blue-50 scale-[1.02] shadow-xl' : 'border-slate-300 hover:border-blue-400 bg-white'}
315
+ ${srtFile ? 'border-green-400 bg-green-50' : ''}`}
316
+ onDragOver={(e) => { e.preventDefault(); setIsDraggingSrt(true); }}
317
+ onDragLeave={(e) => { e.preventDefault(); setIsDraggingSrt(false); }}
318
+ onDrop={(e) => {
319
+ e.preventDefault(); setIsDraggingSrt(false);
320
+ if(e.dataTransfer.files.length) setSrtFile(e.dataTransfer.files[0]);
321
+ }}
322
+ >
323
+ <input type="file" accept=".srt" onChange={(e) => setSrtFile(e.target.files[0])} className="hidden" id="srt-upload" />
324
+ <label htmlFor="srt-upload" className="cursor-pointer flex flex-col items-center text-center w-full z-10">
325
+ <FileText className={`w-12 h-12 mb-4 transition-colors ${srtFile ? 'text-green-600' : 'text-slate-400'}`} />
326
+ <span className="font-semibold text-lg mb-1">
327
+ {srtFile ? srtFile.name : 'Drag & Drop SRT File'}
328
+ </span>
329
+ </label>
330
+ </div>
331
+
332
+ {/* Image Uploader */}
333
+ <div
334
+ className={`relative border-2 border-dashed rounded-xl p-8 flex flex-col items-center justify-center transition-all duration-200
335
+ ${isDraggingImages ? 'border-blue-500 bg-blue-50 scale-[1.02] shadow-xl' : 'border-slate-300 hover:border-blue-400 bg-white'}
336
+ ${mediaFiles.length > 0 ? 'border-green-400 bg-green-50' : ''}`}
337
+ onDragOver={(e) => { e.preventDefault(); setIsDraggingImages(true); }}
338
+ onDragLeave={(e) => { e.preventDefault(); setIsDraggingImages(false); }}
339
+ onDrop={(e) => {
340
+ e.preventDefault(); setIsDraggingImages(false);
341
+ if(e.dataTransfer.files.length) setMediaFiles(e.dataTransfer.files);
342
+ }}
343
+ >
344
+ <input type="file" accept="image/*,.rar,.zip" multiple onChange={(e) => setMediaFiles(e.target.files)} className="hidden" id="img-upload" />
345
+ <label htmlFor="img-upload" className="cursor-pointer flex flex-col items-center text-center w-full z-10">
346
+ <div className="flex justify-center space-x-2">
347
+ <LucideImage className={`w-12 h-12 mb-4 transition-colors ${mediaFiles.length > 0 ? 'text-green-600' : 'text-slate-400'}`} />
348
+ <Package className={`w-12 h-12 mb-4 transition-colors ${mediaFiles.length > 0 ? 'text-green-600' : 'text-slate-400'}`} />
349
+ </div>
350
+ <span className="font-semibold text-lg mb-1">
351
+ {mediaFiles.length > 0 ? `${mediaFiles.length} Files Selected` : 'Drag & Drop Images/RAR/ZIP'}
352
+ </span>
353
+ </label>
354
+ </div>
355
+ </section>
356
+
357
+ {/* Processing & Error State */}
358
+ {isProcessing && (
359
+ <div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-center justify-center space-x-3 text-blue-800 animate-pulse">
360
+ <Loader2 className="w-5 h-5 animate-spin" />
361
+ <span className="font-medium">{loadingMessage}</span>
362
+ </div>
363
+ )}
364
+
365
+ {error && (
366
+ <div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center space-x-3 text-red-800">
367
+ <AlertCircle className="w-5 h-5" />
368
+ <span>{error}</span>
369
+ </div>
370
+ )}
371
+
372
+ {/* Actions & Results */}
373
+ {(pairs.length > 0 || (srtFile && mediaFiles.length > 0 && !isProcessing)) && (
374
+ <section className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
375
+ <div className="p-4 border-b border-slate-200 bg-slate-50 flex items-center justify-between sticky top-0 z-10">
376
+ <div className="flex items-center space-x-4">
377
+ <h2 className="font-bold text-lg">Results ({pairs.length})</h2>
378
+ {pairs.length > 0 && (
379
+ <div className="flex space-x-2 text-sm">
380
+ <span className="px-2 py-1 bg-green-100 text-green-700 rounded-md">
381
+ {pairs.filter(p => p.status === 'match').length} Match
382
+ </span>
383
+ <span className="px-2 py-1 bg-red-100 text-red-700 rounded-md">
384
+ {pairs.filter(p => p.status === 'mismatch').length} Mismatch
385
+ </span>
386
+ </div>
387
+ )}
388
+ </div>
389
+ <div className="flex space-x-3">
390
+ {pairs.length > 0 && (
391
+ <button
392
+ onClick={handleDownloadSRT}
393
+ className="flex items-center space-x-2 px-4 py-2 text-sm font-medium text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors"
394
+ >
395
+ <Download className="w-4 h-4" />
396
+ <span>Corrected SRT</span>
397
+ </button>
398
+ )}
399
+ <button
400
+ onClick={handleReset}
401
+ className="flex items-center space-x-2 px-4 py-2 text-sm font-medium text-slate-600 hover:text-red-600 transition-colors"
402
+ >
403
+ <Trash2 className="w-4 h-4" />
404
+ <span>Clear All</span>
405
+ </button>
406
+ <button
407
+ onClick={handleAnalyze}
408
+ disabled={isProcessing}
409
+ className={`flex items-center space-x-2 px-6 py-2 rounded-lg font-semibold text-white transition-all shadow-md ${isProcessing ? 'bg-slate-400 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700 hover:shadow-lg'}`}
410
+ >
411
+ {isProcessing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
412
+ <span>{pairs.length > 0 ? 'Re-Analyze' : 'Run Analysis'}</span>
413
+ </button>
414
+ </div>
415
+ </div>
416
+
417
+ <div className="divide-y divide-slate-100">
418
+ {pairs.map((pair, index) => (
419
+ <div key={index} className={`grid grid-cols-12 gap-4 p-4 hover:bg-slate-50 transition-colors ${pair.status === 'mismatch' ? 'bg-red-50/50' : ''}`}>
420
+ <div className="col-span-12 md:col-span-3">
421
+ <div className="relative aspect-video bg-slate-100 rounded-lg overflow-hidden border border-slate-200">
422
+ {pair.image.dataUrl ? (
423
+ <img src={pair.image.dataUrl} alt={`Frame ${index}`} className="w-full h-full object-contain" />
424
+ ) : (
425
+ <div className="w-full h-full flex items-center justify-center text-xs text-gray-400">No Image</div>
426
+ )}
427
+ <div className="absolute top-0 left-0 right-0 bg-black/70 p-1">
428
+ <div className="text-white text-[10px] truncate font-mono">{pair.image.originalFile?.name}</div>
429
+ </div>
430
+ </div>
431
+ </div>
432
+
433
+ <div className="col-span-12 md:col-span-4 flex flex-col justify-center">
434
+ <div className="text-xs font-bold text-slate-400 uppercase mb-1">Expected</div>
435
+ <div className="p-3 bg-slate-100 rounded-lg text-sm font-medium">{pair.subtitle.text}</div>
436
+ <div className="mt-1 text-xs text-slate-400 font-mono flex items-center">
437
+ <Clock className="w-3 h-3 mr-1"/> {pair.subtitle.time}
438
+ </div>
439
+ </div>
440
+
441
+ <div className="col-span-12 md:col-span-5 flex flex-col justify-center">
442
+ <div className="flex justify-between items-center mb-1">
443
+ <div className="text-xs font-bold text-slate-400 uppercase">Actual (Gemini)</div>
444
+ <StatusBadge status={pair.status} />
445
+ </div>
446
+ <div className={`p-3 rounded-lg text-sm border min-h-[60px] flex items-center ${
447
+ pair.status === 'match' ? 'bg-green-50 border-green-200 text-green-800' :
448
+ pair.status === 'mismatch' ? 'bg-red-50 border-red-200 text-red-800' :
449
+ 'bg-white border-slate-200 text-slate-400 italic'
450
+ }`}>
451
+ <div>
452
+ <span className="font-semibold block mb-1">Detected: "{pair.analysis.detected_text}"</span>
453
+ {pair.analysis.reason && <span className="text-xs opacity-75 block">{pair.analysis.reason}</span>}
454
+ </div>
455
+ </div>
456
+ </div>
457
+ </div>
458
+ ))}
459
+ </div>
460
+ </section>
461
+ )}
462
+
463
+ {pairs.length === 0 && !srtFile && (
464
+ <div className="text-center py-20 opacity-50">
465
+ <div className="w-16 h-16 bg-slate-200 rounded-full flex items-center justify-center mx-auto mb-4">
466
+ <ArrowRightLeft className="w-8 h-8 text-slate-400" />
467
+ </div>
468
+ <p className="text-lg font-medium text-slate-500">
469
+ Server-Side Processing Mode.<br/>
470
+ Upload files to sort, pair, and analyze remotely.
471
+ </p>
472
+ </div>
473
+ )}
474
+ </main>
475
+ </div>
476
+ );
477
+ };
478
+
479
+ const root = ReactDOM.createRoot(document.getElementById('root'));
480
+ root.render(<App />);
481
+ </script>
482
+ </body>
483
  </html>