CVNSS commited on
Commit
f1cd1d7
·
verified ·
1 Parent(s): 56ff7e6

Upload 3 files

Browse files
Files changed (3) hide show
  1. static/app.js +566 -0
  2. static/styles.css +702 -0
  3. templates/index.html +242 -0
static/app.js ADDED
@@ -0,0 +1,566 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ Viet AutoSub Editor – Dashboard JavaScript
3
+ Tương thích cả offline (file://) và online (HF Spaces)
4
+ ============================================================ */
5
+
6
+ const state = {
7
+ jobId: null,
8
+ file: null,
9
+ segments: [],
10
+ isOnline: false, // server reachable?
11
+ };
12
+
13
+ /* --- Detect environment ------------------------------------ */
14
+ const IS_FILE_PROTOCOL = window.location.protocol === "file:";
15
+
16
+ /**
17
+ * Determine the API base URL.
18
+ * - file:// → cannot reach backend at all
19
+ * - http(s):// → use same origin (relative paths)
20
+ */
21
+ function getApiBase() {
22
+ if (IS_FILE_PROTOCOL) return null;
23
+ return ""; // same-origin: "/api/transcribe" etc.
24
+ }
25
+
26
+ /* --- DOM refs ---------------------------------------------- */
27
+ const $ = (id) => document.getElementById(id);
28
+
29
+ const els = {
30
+ fileInput: $("videoFile"),
31
+ preview: $("preview"),
32
+ videoPlaceholder:$("videoPlaceholder"),
33
+ status: $("status"),
34
+ statusText: $("statusText"),
35
+ btnTranscribe: $("btnTranscribe"),
36
+ btnAddRow: $("btnAddRow"),
37
+ btnExportSrt: $("btnExportSrt"),
38
+ btnExportMp4: $("btnExportMp4"),
39
+ btnClearFile: $("btnClearFile"),
40
+ subtitleBody: $("subtitleBody"),
41
+ segmentCount: $("segmentCount"),
42
+ downloadSrt: $("downloadSrt"),
43
+ downloadMp4: $("downloadMp4"),
44
+ downloadGroup: $("downloadGroup"),
45
+ dropZone: $("dropZone"),
46
+ uploadPanel: $("uploadPanel"),
47
+ fileInfo: $("fileInfo"),
48
+ fileName: $("fileName"),
49
+ fileSize: $("fileSize"),
50
+ progressWrap: $("progressWrap"),
51
+ progressFill: $("progressFill"),
52
+ progressText: $("progressText"),
53
+ // New: offline banner + badge
54
+ offlineBanner: $("offlineBanner"),
55
+ offlineBannerText: $("offlineBannerText"),
56
+ offlineBannerClose:$("offlineBannerClose"),
57
+ badgeEnv: $("badgeEnv"),
58
+ badgeEnvText: $("badgeEnvText"),
59
+ pulseDot: $("pulseDot"),
60
+ };
61
+
62
+ /* --- Health check ------------------------------------------ */
63
+ let healthRetryTimer = null;
64
+
65
+ async function checkHealth() {
66
+ // file:// → always offline
67
+ if (IS_FILE_PROTOCOL) {
68
+ setOnlineState(false, "Offline (file://)");
69
+ return;
70
+ }
71
+
72
+ try {
73
+ const res = await fetch("/health", { method: "GET", cache: "no-store" });
74
+ if (res.ok) {
75
+ const data = await res.json();
76
+ setOnlineState(true, "HF Space");
77
+ // Stop retrying
78
+ if (healthRetryTimer) { clearInterval(healthRetryTimer); healthRetryTimer = null; }
79
+ } else {
80
+ setOnlineState(false, "Server lỗi");
81
+ }
82
+ } catch (_) {
83
+ setOnlineState(false, "Không kết nối");
84
+ }
85
+ }
86
+
87
+ function setOnlineState(online, label) {
88
+ state.isOnline = online;
89
+
90
+ // Badge
91
+ if (els.badgeEnv) {
92
+ els.badgeEnv.classList.toggle("badge-online", online);
93
+ els.badgeEnv.classList.toggle("badge-offline", !online);
94
+ }
95
+ if (els.badgeEnvText) {
96
+ els.badgeEnvText.textContent = label || (online ? "Online" : "Offline");
97
+ }
98
+ if (els.pulseDot) {
99
+ els.pulseDot.className = online ? "pulse-dot pulse-online" : "pulse-dot pulse-offline";
100
+ }
101
+
102
+ // Offline banner
103
+ if (!online) {
104
+ if (els.offlineBanner) els.offlineBanner.hidden = false;
105
+ if (els.offlineBannerText) {
106
+ els.offlineBannerText.textContent = IS_FILE_PROTOCOL
107
+ ? "Đang chạy offline (file://) — Bạn có thể sửa subtitle và xuất SRT. Auto sub & xuất MP4 cần deploy lên HF Space."
108
+ : "Không kết nối được server — Đang thử lại mỗi 30 giây...";
109
+ }
110
+ // Auto-retry every 30s if on http but server is down
111
+ if (!IS_FILE_PROTOCOL && !healthRetryTimer) {
112
+ healthRetryTimer = setInterval(checkHealth, 30000);
113
+ }
114
+ } else {
115
+ if (els.offlineBanner) els.offlineBanner.hidden = true;
116
+ if (healthRetryTimer) { clearInterval(healthRetryTimer); healthRetryTimer = null; }
117
+ }
118
+ }
119
+
120
+ // Close banner button
121
+ if (els.offlineBannerClose) {
122
+ els.offlineBannerClose.addEventListener("click", () => {
123
+ if (els.offlineBanner) els.offlineBanner.hidden = true;
124
+ });
125
+ }
126
+
127
+ /* --- Steps ------------------------------------------------- */
128
+ function setStep(num) {
129
+ document.querySelectorAll(".step").forEach((el) => {
130
+ const s = parseInt(el.dataset.step, 10);
131
+ el.classList.toggle("active", s === num);
132
+ el.classList.toggle("done", s < num);
133
+ });
134
+ }
135
+
136
+ /* --- Status ------------------------------------------------ */
137
+ function setStatus(message, type = "idle") {
138
+ els.status.className = `status-box status-${type}`;
139
+ els.statusText.textContent = message;
140
+ }
141
+
142
+ /* --- Buttons state ----------------------------------------- */
143
+ function setEditButtons(enabled) {
144
+ els.btnAddRow.disabled = !enabled;
145
+ els.btnExportSrt.disabled = !enabled;
146
+ els.btnExportMp4.disabled = !enabled;
147
+ }
148
+
149
+ /* --- Download link helpers --------------------------------- */
150
+ function showDownload(el, url, visible) {
151
+ el.href = visible ? url : "#";
152
+ el.classList.toggle("disabled", !visible);
153
+ }
154
+ function showDownloadGroup(show) {
155
+ els.downloadGroup.hidden = !show;
156
+ }
157
+
158
+ /* --- Progress simulation ----------------------------------- */
159
+ let progressTimer = null;
160
+ function startProgress(label) {
161
+ els.progressWrap.hidden = false;
162
+ els.progressFill.style.width = "0%";
163
+ els.progressText.textContent = label || "Đang xử lý...";
164
+
165
+ let pct = 0;
166
+ clearInterval(progressTimer);
167
+ progressTimer = setInterval(() => {
168
+ const remaining = 90 - pct;
169
+ const step = Math.max(0.3, remaining * 0.04);
170
+ pct = Math.min(90, pct + step);
171
+ els.progressFill.style.width = pct + "%";
172
+ }, 300);
173
+ }
174
+ function finishProgress() {
175
+ clearInterval(progressTimer);
176
+ els.progressFill.style.width = "100%";
177
+ setTimeout(() => {
178
+ els.progressWrap.hidden = true;
179
+ els.progressFill.style.width = "0%";
180
+ }, 600);
181
+ }
182
+ function cancelProgress() {
183
+ clearInterval(progressTimer);
184
+ els.progressWrap.hidden = true;
185
+ els.progressFill.style.width = "0%";
186
+ }
187
+
188
+ /* --- File size formatter ----------------------------------- */
189
+ function formatSize(bytes) {
190
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
191
+ return (bytes / (1024 * 1024)).toFixed(1) + " MB";
192
+ }
193
+
194
+ /* --- Create table cell inputs ------------------------------ */
195
+ function createInput(value, className) {
196
+ const input = document.createElement("input");
197
+ input.type = "text";
198
+ input.value = value || "";
199
+ input.className = className;
200
+ input.spellcheck = false;
201
+ return input;
202
+ }
203
+
204
+ function createTextArea(value) {
205
+ const textarea = document.createElement("textarea");
206
+ textarea.value = value || "";
207
+ textarea.rows = 2;
208
+ textarea.className = "text-input";
209
+ return textarea;
210
+ }
211
+
212
+ /* --- Collect segments from table --------------------------- */
213
+ function collectSegmentsFromTable() {
214
+ const rows = Array.from(els.subtitleBody.querySelectorAll("tr[data-row='1']"));
215
+ return rows.map((row, index) => ({
216
+ id: index + 1,
217
+ start: row.querySelector(".start-input").value.trim(),
218
+ end: row.querySelector(".end-input").value.trim(),
219
+ text: row.querySelector(".text-input").value.trim(),
220
+ }));
221
+ }
222
+
223
+ /* --- Render table ------------------------------------------ */
224
+ function renderTable() {
225
+ els.subtitleBody.innerHTML = "";
226
+
227
+ if (!state.segments.length) {
228
+ els.subtitleBody.innerHTML = `
229
+ <tr class="empty-row">
230
+ <td colspan="5">
231
+ <div class="empty-state">
232
+ <svg viewBox="0 0 48 48" fill="none" class="empty-icon">
233
+ <rect x="6" y="10" width="36" height="28" rx="4" stroke="currentColor" stroke-width="1.5"/>
234
+ <line x1="12" y1="20" x2="36" y2="20" stroke="currentColor" stroke-width="1.5" opacity="0.3"/>
235
+ <line x1="12" y1="26" x2="30" y2="26" stroke="currentColor" stroke-width="1.5" opacity="0.3"/>
236
+ <line x1="12" y1="32" x2="24" y2="32" stroke="currentColor" stroke-width="1.5" opacity="0.3"/>
237
+ </svg>
238
+ <p>Chưa có subtitle. Upload video rồi bấm <strong>Auto sub tiếng Việt</strong> để bắt đầu.</p>
239
+ </div>
240
+ </td>
241
+ </tr>`;
242
+ els.segmentCount.textContent = "0 dòng";
243
+ setEditButtons(false);
244
+ return;
245
+ }
246
+
247
+ state.segments.forEach((seg, index) => {
248
+ const tr = document.createElement("tr");
249
+ tr.dataset.row = "1";
250
+
251
+ // #
252
+ const tdIdx = document.createElement("td");
253
+ tdIdx.className = "idx-cell";
254
+ tdIdx.textContent = String(index + 1);
255
+
256
+ // Start
257
+ const tdStart = document.createElement("td");
258
+ tdStart.appendChild(createInput(seg.start, "start-input time-input"));
259
+
260
+ // End
261
+ const tdEnd = document.createElement("td");
262
+ tdEnd.appendChild(createInput(seg.end, "end-input time-input"));
263
+
264
+ // Text
265
+ const tdText = document.createElement("td");
266
+ tdText.appendChild(createTextArea(seg.text));
267
+
268
+ // Delete
269
+ const tdAct = document.createElement("td");
270
+ tdAct.style.textAlign = "center";
271
+ const delBtn = document.createElement("button");
272
+ delBtn.className = "btn btn-danger-sm";
273
+ delBtn.innerHTML = `<svg viewBox="0 0 20 20" fill="currentColor" style="width:14px;height:14px"><path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>`;
274
+ delBtn.title = "Xóa dòng";
275
+ delBtn.addEventListener("click", () => {
276
+ state.segments = collectSegmentsFromTable();
277
+ state.segments.splice(index, 1);
278
+ renderTable();
279
+ });
280
+ tdAct.appendChild(delBtn);
281
+
282
+ tr.append(tdIdx, tdStart, tdEnd, tdText, tdAct);
283
+ els.subtitleBody.appendChild(tr);
284
+ });
285
+
286
+ els.segmentCount.textContent = `${state.segments.length} dòng`;
287
+ setEditButtons(true);
288
+ }
289
+
290
+ /* --- Transcribe -------------------------------------------- */
291
+ async function transcribeVideo() {
292
+ if (!state.file) {
293
+ setStatus("Hãy chọn video trước.", "error");
294
+ return;
295
+ }
296
+
297
+ // Offline guard
298
+ if (!state.isOnline) {
299
+ setStatus(
300
+ IS_FILE_PROTOCOL
301
+ ? "Đang offline — Auto sub cần chạy trên HF Space (server). Hãy upload ứng dụng lên HF Space trước."
302
+ : "Server không phản hồi. Đang thử kết nối lại...",
303
+ "error"
304
+ );
305
+ if (!IS_FILE_PROTOCOL) checkHealth();
306
+ return;
307
+ }
308
+
309
+ const fd = new FormData();
310
+ fd.append("file", state.file);
311
+
312
+ els.btnTranscribe.disabled = true;
313
+ els.btnTranscribe.classList.add("btn-loading");
314
+ setStatus("Đang nhận diện lời nói tiếng Việt...", "loading");
315
+ setStep(2);
316
+ startProgress("Đang upload và nhận diện giọng nói...");
317
+ showDownload(els.downloadSrt, "#", false);
318
+ showDownload(els.downloadMp4, "#", false);
319
+ showDownloadGroup(false);
320
+
321
+ try {
322
+ const res = await fetch("/api/transcribe", {
323
+ method: "POST",
324
+ body: fd,
325
+ });
326
+ const data = await res.json();
327
+ if (!res.ok) throw new Error(data.detail || "Không thể nhận diện subtitle.");
328
+
329
+ state.jobId = data.job_id;
330
+ state.segments = data.segments || [];
331
+ renderTable();
332
+ finishProgress();
333
+ setStatus(`Hoàn tất. Đã tạo ${state.segments.length} dòng subtitle.`, "success");
334
+ setStep(3);
335
+ } catch (err) {
336
+ cancelProgress();
337
+ const msg = err.message.includes("Failed to fetch")
338
+ ? "Mất kết nối server. Kiểm tra lại mạng hoặc server HF Space."
339
+ : (err.message || "Có lỗi khi auto sub.");
340
+ setStatus(msg, "error");
341
+ setStep(1);
342
+ // Re-check health
343
+ checkHealth();
344
+ } finally {
345
+ els.btnTranscribe.disabled = false;
346
+ els.btnTranscribe.classList.remove("btn-loading");
347
+ }
348
+ }
349
+
350
+ /* --- Client-side SRT generation (offline-capable) ---------- */
351
+ function generateSrtString(segments) {
352
+ let lines = [];
353
+ segments.forEach((seg, idx) => {
354
+ const start = seg.start || "00:00:00,000";
355
+ const end = seg.end || "00:00:02,000";
356
+ const text = (seg.text || "").trim();
357
+ if (!text) return;
358
+ lines.push(String(idx + 1));
359
+ lines.push(`${start} --> ${end}`);
360
+ lines.push(text);
361
+ lines.push("");
362
+ });
363
+ return lines.join("\n");
364
+ }
365
+
366
+ function downloadSrtOffline() {
367
+ const segments = collectSegmentsFromTable();
368
+ if (!segments.length) {
369
+ setStatus("Chưa có subtitle để xuất.", "error");
370
+ return;
371
+ }
372
+ const srtContent = generateSrtString(segments);
373
+ const blob = new Blob([srtContent], { type: "text/plain;charset=utf-8" });
374
+ const url = URL.createObjectURL(blob);
375
+ const a = document.createElement("a");
376
+ a.href = url;
377
+ a.download = "subtitle.srt";
378
+ document.body.appendChild(a);
379
+ a.click();
380
+ document.body.removeChild(a);
381
+ URL.revokeObjectURL(url);
382
+ setStatus("Đã xuất file SRT thành công (offline).", "success");
383
+ setStep(4);
384
+ }
385
+
386
+ /* --- Export ------------------------------------------------- */
387
+ async function exportResult(burnIn) {
388
+ // If offline or no jobId and requesting SRT only → use client-side export
389
+ if (!burnIn && (!state.isOnline || !state.jobId)) {
390
+ downloadSrtOffline();
391
+ return;
392
+ }
393
+
394
+ // MP4 burn-in requires server
395
+ if (burnIn && !state.isOnline) {
396
+ setStatus(
397
+ IS_FILE_PROTOCOL
398
+ ? "Xuất MP4 burn sub cần server HF Space. Hãy deploy ứng dụng lên HF Space trước."
399
+ : "Server không phản hồi. Xuất MP4 cần kết nối server.",
400
+ "error"
401
+ );
402
+ return;
403
+ }
404
+
405
+ if (!state.jobId) {
406
+ setStatus("Chưa có job để xuất file. Hãy bấm Auto sub trước.", "error");
407
+ return;
408
+ }
409
+
410
+ // Collect latest from table
411
+ const payload = {
412
+ job_id: state.jobId,
413
+ burn_in: burnIn,
414
+ segments: collectSegmentsFromTable(),
415
+ };
416
+
417
+ const label = burnIn ? "Đang xuất MP4 có sub..." : "Đang tạo file SRT...";
418
+ setStatus(label, "loading");
419
+ startProgress(label);
420
+ setStep(4);
421
+ els.btnExportSrt.disabled = true;
422
+ els.btnExportMp4.disabled = true;
423
+
424
+ try {
425
+ const res = await fetch("/api/export", {
426
+ method: "POST",
427
+ headers: { "Content-Type": "application/json" },
428
+ body: JSON.stringify(payload),
429
+ });
430
+ const data = await res.json();
431
+ if (!res.ok) throw new Error(data.detail || "Xuất file thất bại.");
432
+
433
+ finishProgress();
434
+ showDownloadGroup(true);
435
+ showDownload(els.downloadSrt, data.srt_url, true);
436
+ if (data.mp4_url) {
437
+ showDownload(els.downloadMp4, data.mp4_url, true);
438
+ }
439
+
440
+ const msg = burnIn
441
+ ? `Xuất MP4 thành công${data.mp4_size_mb ? ` (${data.mp4_size_mb} MB)` : ""}.`
442
+ : "Đã tạo file SRT thành công.";
443
+ setStatus(msg, "success");
444
+ } catch (err) {
445
+ cancelProgress();
446
+ const msg = err.message.includes("Failed to fetch")
447
+ ? "Mất kết nối server. Kiểm tra lại mạng hoặc server HF Space."
448
+ : (err.message || "Có lỗi khi xuất file.");
449
+ setStatus(msg, "error");
450
+ checkHealth();
451
+ } finally {
452
+ setEditButtons(true);
453
+ }
454
+ }
455
+
456
+ /* --- File selection ---------------------------------------- */
457
+ function handleFile(file) {
458
+ if (!file) return;
459
+ state.file = file;
460
+ state.jobId = null;
461
+ state.segments = [];
462
+ renderTable();
463
+ showDownload(els.downloadSrt, "#", false);
464
+ showDownload(els.downloadMp4, "#", false);
465
+ showDownloadGroup(false);
466
+
467
+ // Show video preview
468
+ const url = URL.createObjectURL(file);
469
+ els.preview.src = url;
470
+ els.preview.classList.add("has-src");
471
+ els.videoPlaceholder.classList.add("hidden");
472
+
473
+ // Show file info
474
+ els.fileInfo.hidden = false;
475
+ els.fileName.textContent = file.name;
476
+ els.fileSize.textContent = formatSize(file.size);
477
+
478
+ setStatus(`Đã chọn: ${file.name}`, "idle");
479
+ setStep(1);
480
+ }
481
+
482
+ function clearFile() {
483
+ state.file = null;
484
+ state.jobId = null;
485
+ state.segments = [];
486
+ renderTable();
487
+ els.preview.removeAttribute("src");
488
+ els.preview.classList.remove("has-src");
489
+ els.videoPlaceholder.classList.remove("hidden");
490
+ els.fileInfo.hidden = true;
491
+ els.fileInput.value = "";
492
+ showDownloadGroup(false);
493
+ setStatus("Sẵn sàng. Hãy upload video để bắt đầu.", "idle");
494
+ setStep(1);
495
+ }
496
+
497
+ /* --- Event listeners --------------------------------------- */
498
+
499
+ // File input
500
+ els.fileInput.addEventListener("change", (e) => {
501
+ const [file] = e.target.files || [];
502
+ if (file) handleFile(file);
503
+ });
504
+
505
+ // Click on drop zone
506
+ els.dropZone.addEventListener("click", () => els.fileInput.click());
507
+
508
+ // Drag & drop
509
+ els.dropZone.addEventListener("dragover", (e) => {
510
+ e.preventDefault();
511
+ els.dropZone.classList.add("drag-over");
512
+ });
513
+ els.dropZone.addEventListener("dragleave", () => {
514
+ els.dropZone.classList.remove("drag-over");
515
+ });
516
+ els.dropZone.addEventListener("drop", (e) => {
517
+ e.preventDefault();
518
+ els.dropZone.classList.remove("drag-over");
519
+ const file = e.dataTransfer.files[0];
520
+ if (file) {
521
+ const dt = new DataTransfer();
522
+ dt.items.add(file);
523
+ els.fileInput.files = dt.files;
524
+ handleFile(file);
525
+ }
526
+ });
527
+
528
+ // Clear file
529
+ els.btnClearFile.addEventListener("click", clearFile);
530
+
531
+ // Transcribe
532
+ els.btnTranscribe.addEventListener("click", transcribeVideo);
533
+
534
+ // Export
535
+ els.btnExportSrt.addEventListener("click", () => exportResult(false));
536
+ els.btnExportMp4.addEventListener("click", () => exportResult(true));
537
+
538
+ // Add row
539
+ els.btnAddRow.addEventListener("click", () => {
540
+ state.segments = collectSegmentsFromTable();
541
+ state.segments.push({
542
+ id: state.segments.length + 1,
543
+ start: "00:00:00,000",
544
+ end: "00:00:02,000",
545
+ text: "Subtitle mới",
546
+ });
547
+ renderTable();
548
+ const scroll = $("tableScroll");
549
+ if (scroll) scroll.scrollTop = scroll.scrollHeight;
550
+ });
551
+
552
+ // Collapse table toggle
553
+ const btnCollapse = $("btnCollapseTable");
554
+ const tableScroll = $("tableScroll");
555
+ if (btnCollapse && tableScroll) {
556
+ btnCollapse.addEventListener("click", () => {
557
+ const collapsed = tableScroll.style.display === "none";
558
+ tableScroll.style.display = collapsed ? "" : "none";
559
+ btnCollapse.querySelector("svg").style.transform = collapsed ? "" : "rotate(180deg)";
560
+ });
561
+ }
562
+
563
+ /* --- Init -------------------------------------------------- */
564
+ setStep(1);
565
+ renderTable();
566
+ checkHealth();
static/styles.css ADDED
@@ -0,0 +1,702 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ Viet AutoSub Editor – Dashboard Stylesheet
3
+ Dark theme — Tương thích offline + HF Spaces online
4
+ ============================================================ */
5
+
6
+ /* --- Tokens ------------------------------------------------- */
7
+ :root {
8
+ --bg-base: #0a0e1a;
9
+ --bg-surface: #111827;
10
+ --bg-raised: #1a2236;
11
+ --bg-input: #0f1629;
12
+ --border: rgba(255,255,255,0.08);
13
+ --border-focus: #6366f1;
14
+
15
+ --text-primary: #f1f5f9;
16
+ --text-secondary: #94a3b8;
17
+ --text-muted: #64748b;
18
+
19
+ --accent: #6366f1;
20
+ --accent-hover: #818cf8;
21
+ --accent-glow: rgba(99,102,241,0.25);
22
+
23
+ --success: #10b981;
24
+ --success-bg: rgba(16,185,129,0.12);
25
+ --danger: #ef4444;
26
+ --danger-bg: rgba(239,68,68,0.10);
27
+ --warning: #f59e0b;
28
+ --warning-bg: rgba(245,158,11,0.12);
29
+
30
+ --radius-sm: 8px;
31
+ --radius: 12px;
32
+ --radius-lg: 16px;
33
+ --radius-xl: 20px;
34
+
35
+ --font-sans: 'Inter', system-ui, -apple-system, sans-serif;
36
+ --font-mono: 'JetBrains Mono', ui-monospace, monospace;
37
+
38
+ --shadow-sm: 0 1px 3px rgba(0,0,0,0.3);
39
+ --shadow: 0 4px 16px rgba(0,0,0,0.35);
40
+ --shadow-lg: 0 12px 40px rgba(0,0,0,0.45);
41
+ }
42
+
43
+ /* --- Reset -------------------------------------------------- */
44
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
45
+ html { font-size: 15px; -webkit-font-smoothing: antialiased; }
46
+ body {
47
+ font-family: var(--font-sans);
48
+ background: var(--bg-base);
49
+ color: var(--text-primary);
50
+ min-height: 100vh;
51
+ line-height: 1.55;
52
+ }
53
+
54
+ /* --- Offline Banner ----------------------------------------- */
55
+ .offline-banner {
56
+ display: flex;
57
+ align-items: center;
58
+ gap: 10px;
59
+ padding: 10px 20px;
60
+ background: linear-gradient(90deg, rgba(245,158,11,0.18), rgba(245,158,11,0.08));
61
+ border-bottom: 1px solid rgba(245,158,11,0.3);
62
+ color: #fde68a;
63
+ font-size: 0.84rem;
64
+ font-weight: 500;
65
+ position: relative;
66
+ z-index: 60;
67
+ }
68
+ .offline-banner-icon {
69
+ width: 18px;
70
+ height: 18px;
71
+ flex-shrink: 0;
72
+ color: #f59e0b;
73
+ }
74
+ .offline-banner-close {
75
+ margin-left: auto;
76
+ background: none;
77
+ border: none;
78
+ color: #fde68a;
79
+ font-size: 1.2rem;
80
+ cursor: pointer;
81
+ padding: 2px 6px;
82
+ border-radius: 4px;
83
+ opacity: 0.6;
84
+ transition: opacity 0.2s;
85
+ line-height: 1;
86
+ }
87
+ .offline-banner-close:hover {
88
+ opacity: 1;
89
+ background: rgba(245,158,11,0.15);
90
+ }
91
+
92
+ /* --- Top Nav ------------------------------------------------ */
93
+ .topbar {
94
+ position: sticky; top: 0; z-index: 50;
95
+ background: rgba(10,14,26,0.82);
96
+ backdrop-filter: blur(16px) saturate(1.4);
97
+ border-bottom: 1px solid var(--border);
98
+ }
99
+ .topbar-inner {
100
+ max-width: 1320px;
101
+ margin: 0 auto;
102
+ padding: 0 24px;
103
+ height: 56px;
104
+ display: flex;
105
+ align-items: center;
106
+ justify-content: space-between;
107
+ }
108
+ .logo-group {
109
+ display: flex;
110
+ align-items: center;
111
+ gap: 10px;
112
+ }
113
+ .logo-icon { width: 28px; height: 28px; color: var(--accent); }
114
+ .logo-text {
115
+ font-size: 1.05rem;
116
+ font-weight: 700;
117
+ letter-spacing: -0.02em;
118
+ background: linear-gradient(135deg, #818cf8, #6366f1);
119
+ -webkit-background-clip: text;
120
+ -webkit-text-fill-color: transparent;
121
+ }
122
+ .topbar-right { display: flex; align-items: center; gap: 8px; }
123
+
124
+ /* --- Badges ------------------------------------------------- */
125
+ .badge {
126
+ display: inline-flex;
127
+ align-items: center;
128
+ gap: 6px;
129
+ padding: 4px 10px;
130
+ border-radius: 999px;
131
+ font-size: 0.73rem;
132
+ font-weight: 600;
133
+ letter-spacing: 0.02em;
134
+ text-transform: uppercase;
135
+ transition: all 0.3s;
136
+ }
137
+ /* Online state (green) */
138
+ .badge-env.badge-online,
139
+ .badge-env:not(.badge-offline) {
140
+ background: rgba(16,185,129,0.12);
141
+ color: #6ee7b7;
142
+ border: 1px solid rgba(16,185,129,0.25);
143
+ }
144
+ /* Offline state (amber/red) */
145
+ .badge-env.badge-offline {
146
+ background: rgba(239,68,68,0.12);
147
+ color: #fca5a5;
148
+ border: 1px solid rgba(239,68,68,0.25);
149
+ }
150
+ .badge-model {
151
+ background: rgba(99,102,241,0.12);
152
+ color: #a5b4fc;
153
+ border: 1px solid rgba(99,102,241,0.25);
154
+ font-family: var(--font-mono);
155
+ }
156
+ .pulse-dot {
157
+ width: 6px; height: 6px;
158
+ border-radius: 50%;
159
+ animation: pulse 2s ease-in-out infinite;
160
+ }
161
+ .pulse-dot.pulse-online {
162
+ background: var(--success);
163
+ }
164
+ .pulse-dot.pulse-offline {
165
+ background: var(--danger);
166
+ animation: pulse-fast 1.2s ease-in-out infinite;
167
+ }
168
+ @keyframes pulse {
169
+ 0%, 100% { opacity: 1; }
170
+ 50% { opacity: 0.35; }
171
+ }
172
+ @keyframes pulse-fast {
173
+ 0%, 100% { opacity: 1; }
174
+ 50% { opacity: 0.25; }
175
+ }
176
+
177
+ /* --- Main Layout -------------------------------------------- */
178
+ .main {
179
+ max-width: 1320px;
180
+ margin: 0 auto;
181
+ padding: 20px 24px 40px;
182
+ display: flex;
183
+ flex-direction: column;
184
+ gap: 18px;
185
+ }
186
+
187
+ /* --- Step Indicator ----------------------------------------- */
188
+ .steps {
189
+ display: flex;
190
+ align-items: center;
191
+ justify-content: center;
192
+ gap: 0;
193
+ padding: 14px 0 4px;
194
+ }
195
+ .step {
196
+ display: flex;
197
+ align-items: center;
198
+ gap: 8px;
199
+ opacity: 0.38;
200
+ transition: opacity 0.3s;
201
+ }
202
+ .step.active { opacity: 1; }
203
+ .step.done { opacity: 0.7; }
204
+ .step-num {
205
+ width: 28px; height: 28px;
206
+ display: grid;
207
+ place-items: center;
208
+ border-radius: 50%;
209
+ font-size: 0.78rem;
210
+ font-weight: 700;
211
+ background: var(--bg-raised);
212
+ border: 1.5px solid var(--border);
213
+ color: var(--text-secondary);
214
+ transition: all 0.3s;
215
+ }
216
+ .step.active .step-num {
217
+ background: var(--accent);
218
+ border-color: var(--accent);
219
+ color: #fff;
220
+ box-shadow: 0 0 12px var(--accent-glow);
221
+ }
222
+ .step.done .step-num {
223
+ background: var(--success);
224
+ border-color: var(--success);
225
+ color: #fff;
226
+ }
227
+ .step-label {
228
+ font-size: 0.82rem;
229
+ font-weight: 500;
230
+ color: var(--text-secondary);
231
+ white-space: nowrap;
232
+ }
233
+ .step.active .step-label { color: var(--text-primary); }
234
+ .step-line {
235
+ width: 40px;
236
+ height: 2px;
237
+ background: var(--border);
238
+ margin: 0 6px;
239
+ flex-shrink: 0;
240
+ }
241
+
242
+ /* --- Panel (card) ------------------------------------------- */
243
+ .panel {
244
+ background: var(--bg-surface);
245
+ border: 1px solid var(--border);
246
+ border-radius: var(--radius-lg);
247
+ box-shadow: var(--shadow);
248
+ overflow: hidden;
249
+ }
250
+ .panel-head {
251
+ display: flex;
252
+ align-items: center;
253
+ justify-content: space-between;
254
+ padding: 14px 18px;
255
+ border-bottom: 1px solid var(--border);
256
+ }
257
+ .panel-title {
258
+ display: flex;
259
+ align-items: center;
260
+ gap: 8px;
261
+ font-size: 0.9rem;
262
+ font-weight: 600;
263
+ color: var(--text-primary);
264
+ }
265
+ .icon-sm { width: 16px; height: 16px; flex-shrink: 0; }
266
+ .icon-xs { width: 14px; height: 14px; }
267
+ .icon-btn { width: 16px; height: 16px; flex-shrink: 0; }
268
+
269
+ /* --- Upload Panel ------------------------------------------- */
270
+ .upload-panel { padding: 0; }
271
+ .drop-zone {
272
+ display: flex;
273
+ flex-direction: column;
274
+ align-items: center;
275
+ justify-content: center;
276
+ gap: 8px;
277
+ padding: 36px 24px;
278
+ cursor: pointer;
279
+ border: 2px dashed transparent;
280
+ transition: all 0.25s;
281
+ background: linear-gradient(180deg, rgba(99,102,241,0.04), transparent);
282
+ }
283
+ .drop-zone.drag-over {
284
+ border-color: var(--accent);
285
+ background: rgba(99,102,241,0.08);
286
+ }
287
+ .drop-icon { width: 44px; height: 44px; color: var(--accent); opacity: 0.7; }
288
+ .drop-title {
289
+ font-size: 1rem;
290
+ font-weight: 600;
291
+ color: var(--text-primary);
292
+ }
293
+ .drop-hint {
294
+ font-size: 0.8rem;
295
+ color: var(--text-muted);
296
+ }
297
+ .file-info {
298
+ display: flex;
299
+ align-items: center;
300
+ justify-content: space-between;
301
+ padding: 12px 18px;
302
+ background: rgba(99,102,241,0.06);
303
+ border-top: 1px solid var(--border);
304
+ }
305
+ .file-meta {
306
+ display: flex;
307
+ align-items: center;
308
+ gap: 8px;
309
+ font-size: 0.85rem;
310
+ font-weight: 500;
311
+ }
312
+ .file-icon { width: 16px; height: 16px; color: var(--accent); }
313
+ .file-size {
314
+ color: var(--text-muted);
315
+ font-size: 0.78rem;
316
+ font-family: var(--font-mono);
317
+ }
318
+
319
+ /* --- Two-column Grid ---------------------------------------- */
320
+ .grid-two {
321
+ display: grid;
322
+ grid-template-columns: 1.3fr 0.7fr;
323
+ gap: 18px;
324
+ }
325
+ @media (max-width: 960px) {
326
+ .grid-two { grid-template-columns: 1fr; }
327
+ .steps { flex-wrap: wrap; gap: 4px; }
328
+ .step-label { display: none; }
329
+ }
330
+
331
+ /* --- Video -------------------------------------------------- */
332
+ .video-panel .panel-head + * { padding: 0; }
333
+ .video-wrap {
334
+ position: relative;
335
+ background: #000;
336
+ aspect-ratio: 16/9;
337
+ display: flex;
338
+ align-items: center;
339
+ justify-content: center;
340
+ }
341
+ .video-wrap video {
342
+ width: 100%;
343
+ height: 100%;
344
+ object-fit: contain;
345
+ display: none;
346
+ }
347
+ .video-wrap video.has-src { display: block; }
348
+ .video-placeholder {
349
+ display: flex;
350
+ flex-direction: column;
351
+ align-items: center;
352
+ gap: 8px;
353
+ color: var(--text-muted);
354
+ font-size: 0.85rem;
355
+ }
356
+ .video-placeholder.hidden { display: none; }
357
+ .placeholder-icon { width: 56px; height: 56px; opacity: 0.3; }
358
+
359
+ /* --- Action Panel ------------------------------------------- */
360
+ .action-panel { display: flex; flex-direction: column; }
361
+ .action-stack {
362
+ padding: 18px;
363
+ display: flex;
364
+ flex-direction: column;
365
+ gap: 14px;
366
+ flex: 1;
367
+ }
368
+ .divider {
369
+ border: none;
370
+ border-top: 1px solid var(--border);
371
+ margin: 2px 0;
372
+ }
373
+ .export-title {
374
+ font-size: 0.78rem;
375
+ font-weight: 600;
376
+ color: var(--text-muted);
377
+ text-transform: uppercase;
378
+ letter-spacing: 0.06em;
379
+ margin-bottom: 6px;
380
+ }
381
+
382
+ /* --- Buttons ------------------------------------------------ */
383
+ .btn {
384
+ display: inline-flex;
385
+ align-items: center;
386
+ justify-content: center;
387
+ gap: 8px;
388
+ font-family: var(--font-sans);
389
+ font-size: 0.85rem;
390
+ font-weight: 600;
391
+ border: 1px solid var(--border);
392
+ border-radius: var(--radius);
393
+ padding: 10px 16px;
394
+ cursor: pointer;
395
+ background: var(--bg-raised);
396
+ color: var(--text-primary);
397
+ transition: all 0.2s ease;
398
+ white-space: nowrap;
399
+ }
400
+ .btn:hover:not(:disabled) {
401
+ transform: translateY(-1px);
402
+ box-shadow: var(--shadow-sm);
403
+ }
404
+ .btn:active:not(:disabled) {
405
+ transform: translateY(0);
406
+ }
407
+ .btn:disabled {
408
+ opacity: 0.35;
409
+ cursor: not-allowed;
410
+ transform: none;
411
+ }
412
+ .btn-primary {
413
+ background: var(--accent);
414
+ border-color: transparent;
415
+ color: #fff;
416
+ }
417
+ .btn-primary:hover:not(:disabled) {
418
+ background: var(--accent-hover);
419
+ box-shadow: 0 4px 20px var(--accent-glow);
420
+ }
421
+ .btn-success {
422
+ background: var(--success);
423
+ border-color: transparent;
424
+ color: #fff;
425
+ }
426
+ .btn-success:hover:not(:disabled) {
427
+ background: #34d399;
428
+ box-shadow: 0 4px 20px rgba(16,185,129,0.3);
429
+ }
430
+ .btn-outline {
431
+ background: transparent;
432
+ border-color: var(--border);
433
+ }
434
+ .btn-outline:hover:not(:disabled) {
435
+ background: var(--bg-raised);
436
+ border-color: rgba(255,255,255,0.15);
437
+ }
438
+ .btn-ghost {
439
+ background: transparent;
440
+ border: none;
441
+ color: var(--text-secondary);
442
+ padding: 6px 10px;
443
+ }
444
+ .btn-ghost:hover:not(:disabled) {
445
+ color: var(--text-primary);
446
+ background: rgba(255,255,255,0.05);
447
+ }
448
+ .btn-danger-sm {
449
+ background: var(--danger-bg);
450
+ border: 1px solid rgba(239,68,68,0.25);
451
+ color: #fca5a5;
452
+ padding: 6px 10px;
453
+ font-size: 0.78rem;
454
+ }
455
+ .btn-danger-sm:hover:not(:disabled) {
456
+ background: rgba(239,68,68,0.2);
457
+ }
458
+ .btn-sm { padding: 6px 12px; font-size: 0.8rem; }
459
+ .btn-lg { padding: 12px 20px; font-size: 0.92rem; }
460
+ .btn-full { width: 100%; }
461
+ .btn-row {
462
+ display: flex;
463
+ gap: 8px;
464
+ flex-wrap: wrap;
465
+ }
466
+
467
+ /* --- Progress ----------------------------------------------- */
468
+ .progress-wrap {
469
+ display: flex;
470
+ flex-direction: column;
471
+ gap: 6px;
472
+ }
473
+ .progress-bar {
474
+ height: 6px;
475
+ background: var(--bg-raised);
476
+ border-radius: 99px;
477
+ overflow: hidden;
478
+ }
479
+ .progress-fill {
480
+ height: 100%;
481
+ width: 0%;
482
+ background: linear-gradient(90deg, var(--accent), #818cf8);
483
+ border-radius: 99px;
484
+ transition: width 0.4s ease;
485
+ animation: progressPulse 1.5s ease-in-out infinite;
486
+ }
487
+ @keyframes progressPulse {
488
+ 0%, 100% { opacity: 1; }
489
+ 50% { opacity: 0.6; }
490
+ }
491
+ .progress-text {
492
+ font-size: 0.78rem;
493
+ color: var(--text-secondary);
494
+ font-weight: 500;
495
+ }
496
+
497
+ /* --- Status Box --------------------------------------------- */
498
+ .status-box {
499
+ display: flex;
500
+ align-items: center;
501
+ gap: 8px;
502
+ padding: 10px 14px;
503
+ border-radius: var(--radius);
504
+ font-size: 0.83rem;
505
+ font-weight: 500;
506
+ transition: all 0.3s;
507
+ }
508
+ .status-icon { width: 16px; height: 16px; flex-shrink: 0; }
509
+ .status-idle {
510
+ background: rgba(255,255,255,0.03);
511
+ color: var(--text-secondary);
512
+ }
513
+ .status-loading {
514
+ background: var(--warning-bg);
515
+ color: #fde68a;
516
+ }
517
+ .status-success {
518
+ background: var(--success-bg);
519
+ color: #6ee7b7;
520
+ }
521
+ .status-error {
522
+ background: var(--danger-bg);
523
+ color: #fca5a5;
524
+ }
525
+
526
+ /* --- Download Links ----------------------------------------- */
527
+ .download-group {
528
+ display: flex;
529
+ gap: 10px;
530
+ flex-wrap: wrap;
531
+ }
532
+ .dl-link {
533
+ display: inline-flex;
534
+ align-items: center;
535
+ gap: 8px;
536
+ padding: 10px 16px;
537
+ border-radius: var(--radius);
538
+ font-size: 0.85rem;
539
+ font-weight: 600;
540
+ text-decoration: none;
541
+ transition: all 0.2s;
542
+ }
543
+ .dl-srt {
544
+ background: rgba(99,102,241,0.12);
545
+ color: #a5b4fc;
546
+ border: 1px solid rgba(99,102,241,0.25);
547
+ }
548
+ .dl-srt:hover { background: rgba(99,102,241,0.2); }
549
+ .dl-mp4 {
550
+ background: rgba(16,185,129,0.12);
551
+ color: #6ee7b7;
552
+ border: 1px solid rgba(16,185,129,0.25);
553
+ }
554
+ .dl-mp4:hover { background: rgba(16,185,129,0.2); }
555
+ .dl-link.disabled { pointer-events: none; opacity: 0.35; }
556
+
557
+ /* --- Table -------------------------------------------------- */
558
+ .table-panel { }
559
+ .table-meta {
560
+ display: flex;
561
+ align-items: center;
562
+ gap: 10px;
563
+ }
564
+ .seg-count {
565
+ font-size: 0.78rem;
566
+ font-weight: 600;
567
+ color: var(--text-muted);
568
+ font-family: var(--font-mono);
569
+ background: var(--bg-raised);
570
+ padding: 3px 10px;
571
+ border-radius: 999px;
572
+ }
573
+ .table-scroll {
574
+ overflow-x: auto;
575
+ max-height: 480px;
576
+ overflow-y: auto;
577
+ }
578
+ table {
579
+ width: 100%;
580
+ border-collapse: collapse;
581
+ min-width: 720px;
582
+ }
583
+ thead { position: sticky; top: 0; z-index: 5; }
584
+ th {
585
+ text-align: left;
586
+ padding: 10px 14px;
587
+ font-size: 0.73rem;
588
+ font-weight: 600;
589
+ text-transform: uppercase;
590
+ letter-spacing: 0.06em;
591
+ color: var(--text-muted);
592
+ background: var(--bg-raised);
593
+ border-bottom: 1px solid var(--border);
594
+ }
595
+ td {
596
+ padding: 8px 14px;
597
+ vertical-align: top;
598
+ border-bottom: 1px solid var(--border);
599
+ font-size: 0.85rem;
600
+ }
601
+ tr:last-child td { border-bottom: none; }
602
+ tr:hover td { background: rgba(255,255,255,0.02); }
603
+ .col-idx { width: 48px; text-align: center; }
604
+ .col-time { width: 155px; }
605
+ .col-text { }
606
+ .col-act { width: 72px; text-align: center; }
607
+
608
+ /* Row index number */
609
+ td.idx-cell {
610
+ text-align: center;
611
+ font-family: var(--font-mono);
612
+ font-size: 0.78rem;
613
+ color: var(--text-muted);
614
+ font-weight: 600;
615
+ }
616
+
617
+ /* --- Table Inputs ------------------------------------------- */
618
+ .time-input, .text-input {
619
+ width: 100%;
620
+ border-radius: var(--radius-sm);
621
+ border: 1px solid var(--border);
622
+ background: var(--bg-input);
623
+ color: var(--text-primary);
624
+ padding: 8px 10px;
625
+ font-family: var(--font-mono);
626
+ font-size: 0.82rem;
627
+ transition: border-color 0.2s, box-shadow 0.2s;
628
+ }
629
+ .time-input:focus, .text-input:focus {
630
+ outline: none;
631
+ border-color: var(--border-focus);
632
+ box-shadow: 0 0 0 3px var(--accent-glow);
633
+ }
634
+ .text-input {
635
+ font-family: var(--font-sans);
636
+ resize: vertical;
637
+ min-height: 54px;
638
+ line-height: 1.45;
639
+ }
640
+
641
+ /* --- Empty State -------------------------------------------- */
642
+ .empty-row td { padding: 40px 20px; }
643
+ .empty-state {
644
+ display: flex;
645
+ flex-direction: column;
646
+ align-items: center;
647
+ gap: 12px;
648
+ color: var(--text-muted);
649
+ text-align: center;
650
+ }
651
+ .empty-icon { width: 48px; height: 48px; opacity: 0.35; }
652
+ .empty-state p { font-size: 0.85rem; max-width: 380px; }
653
+
654
+ /* --- Footer ------------------------------------------------- */
655
+ .footer {
656
+ text-align: center;
657
+ padding: 20px 24px;
658
+ font-size: 0.75rem;
659
+ color: var(--text-muted);
660
+ border-top: 1px solid var(--border);
661
+ }
662
+
663
+ /* --- Utilities ---------------------------------------------- */
664
+ [hidden] { display: none !important; }
665
+
666
+ /* --- Scrollbar ---------------------------------------------- */
667
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
668
+ ::-webkit-scrollbar-track { background: transparent; }
669
+ ::-webkit-scrollbar-thumb {
670
+ background: rgba(255,255,255,0.1);
671
+ border-radius: 99px;
672
+ }
673
+ ::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.18); }
674
+
675
+ /* --- Spinner for loading button ----------------------------- */
676
+ .btn-loading {
677
+ position: relative;
678
+ pointer-events: none;
679
+ color: transparent !important;
680
+ }
681
+ .btn-loading::after {
682
+ content: '';
683
+ position: absolute;
684
+ width: 18px; height: 18px;
685
+ border: 2px solid rgba(255,255,255,0.3);
686
+ border-top-color: #fff;
687
+ border-radius: 50%;
688
+ animation: spin 0.6s linear infinite;
689
+ }
690
+ @keyframes spin { to { transform: rotate(360deg); } }
691
+
692
+ /* --- Responsive tweaks -------------------------------------- */
693
+ @media (max-width: 640px) {
694
+ .main { padding: 12px 12px 32px; gap: 12px; }
695
+ .topbar-inner { padding: 0 14px; }
696
+ .drop-zone { padding: 24px 16px; }
697
+ .action-stack { padding: 14px; }
698
+ .btn-lg { padding: 10px 14px; font-size: 0.85rem; }
699
+ table { min-width: 580px; }
700
+ .panel-head { padding: 12px 14px; }
701
+ .offline-banner { padding: 8px 14px; font-size: 0.78rem; }
702
+ }
templates/index.html ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="vi">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Viet AutoSub Editor</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
10
+ <link rel="stylesheet" href="static/styles.css" />
11
+ </head>
12
+ <body>
13
+
14
+ <!-- ===== OFFLINE BANNER ===== -->
15
+ <div class="offline-banner" id="offlineBanner" hidden>
16
+ <svg viewBox="0 0 20 20" fill="currentColor" class="offline-banner-icon">
17
+ <path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
18
+ </svg>
19
+ <span id="offlineBannerText">Đang chạy offline — Chức năng AI (auto sub, xuất MP4) cần kết nối server HF Space.</span>
20
+ <button class="offline-banner-close" id="offlineBannerClose" title="Đóng">&times;</button>
21
+ </div>
22
+
23
+ <!-- ===== TOP NAV ===== -->
24
+ <nav class="topbar">
25
+ <div class="topbar-inner">
26
+ <div class="logo-group">
27
+ <svg class="logo-icon" viewBox="0 0 32 32" fill="none" aria-label="Viet AutoSub">
28
+ <rect x="2" y="6" width="28" height="20" rx="4" stroke="currentColor" stroke-width="2"/>
29
+ <rect x="6" y="20" width="20" height="4" rx="1.5" fill="currentColor" opacity="0.25"/>
30
+ <rect x="8" y="21" width="7" height="2" rx="1" fill="currentColor"/>
31
+ <rect x="17" y="21" width="5" height="2" rx="1" fill="currentColor" opacity="0.6"/>
32
+ <circle cx="16" cy="13" r="4" stroke="currentColor" stroke-width="1.5"/>
33
+ <polygon points="14.5,11.5 18.5,13 14.5,14.5" fill="currentColor"/>
34
+ </svg>
35
+ <span class="logo-text">Viet AutoSub</span>
36
+ </div>
37
+ <div class="topbar-right">
38
+ <span class="badge badge-env" id="badgeEnv">
39
+ <span class="pulse-dot" id="pulseDot"></span>
40
+ <span id="badgeEnvText">Đang kiểm tra...</span>
41
+ </span>
42
+ <span class="badge badge-model" id="modelBadge">whisper-small</span>
43
+ </div>
44
+ </div>
45
+ </nav>
46
+
47
+ <!-- ===== MAIN LAYOUT ===== -->
48
+ <main class="main">
49
+
50
+ <!-- ===== STEP INDICATOR ===== -->
51
+ <div class="steps">
52
+ <div class="step active" data-step="1">
53
+ <div class="step-num">1</div>
54
+ <div class="step-label">Upload video</div>
55
+ </div>
56
+ <div class="step-line"></div>
57
+ <div class="step" data-step="2">
58
+ <div class="step-num">2</div>
59
+ <div class="step-label">Auto sub tiếng Việt</div>
60
+ </div>
61
+ <div class="step-line"></div>
62
+ <div class="step" data-step="3">
63
+ <div class="step-num">3</div>
64
+ <div class="step-label">Chỉnh sửa subtitle</div>
65
+ </div>
66
+ <div class="step-line"></div>
67
+ <div class="step" data-step="4">
68
+ <div class="step-num">4</div>
69
+ <div class="step-label">Xuất SRT / MP4</div>
70
+ </div>
71
+ </div>
72
+
73
+ <!-- ===== UPLOAD ZONE ===== -->
74
+ <section class="panel upload-panel" id="uploadPanel">
75
+ <div class="drop-zone" id="dropZone">
76
+ <svg class="drop-icon" viewBox="0 0 48 48" fill="none">
77
+ <rect x="4" y="8" width="40" height="32" rx="6" stroke="currentColor" stroke-width="2" stroke-dasharray="4 3"/>
78
+ <path d="M24 18v12M18 24l6-6 6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
79
+ </svg>
80
+ <p class="drop-title">Kéo thả video vào đây</p>
81
+ <p class="drop-hint">hoặc click để chọn file &mdash; MP4, MOV, MKV, AVI, WebM &le; 250 MB</p>
82
+ <input id="videoFile" type="file" accept="video/*" hidden />
83
+ </div>
84
+ <div class="file-info" id="fileInfo" hidden>
85
+ <div class="file-meta">
86
+ <svg viewBox="0 0 20 20" fill="currentColor" class="file-icon"><path d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V7.414A2 2 0 0017.414 6L14 2.586A2 2 0 0012.586 2H4zm8 1.414L15.586 8H13a1 1 0 01-1-1V4.414zM4 5h6v2a3 3 0 003 3h2v5a1 1 0 01-1 1H4a1 1 0 01-1-1V5a1 1 0 011-1z"/></svg>
87
+ <span id="fileName">video.mp4</span>
88
+ <span class="file-size" id="fileSize">0 MB</span>
89
+ </div>
90
+ <button class="btn btn-ghost btn-sm" id="btnClearFile">Đổi file</button>
91
+ </div>
92
+ </section>
93
+
94
+ <!-- ===== TWO-COLUMN: VIDEO + CONTROLS ===== -->
95
+ <div class="grid-two">
96
+
97
+ <!-- LEFT: Video Preview -->
98
+ <section class="panel video-panel">
99
+ <div class="panel-head">
100
+ <h2 class="panel-title">
101
+ <svg viewBox="0 0 20 20" fill="currentColor" class="icon-sm"><path d="M6.672 1.911a1 1 0 10-1.932.518l.259.966a1 1 0 001.932-.518l-.26-.966zM2.429 4.74a1 1 0 10-.517 1.932l.966.259a1 1 0 00.517-1.932l-.966-.26zm8.814-.569a1 1 0 00-1.415-1.414l-.707.707a1 1 0 101.415 1.415l.707-.708zm-7.071 7.072l.707-.707A1 1 0 003.465 9.12l-.708.707a1 1 0 001.415 1.415zm3.2-5.171a1 1 0 00-1.3 1.3l4 10a1 1 0 001.823.075l1.38-2.759 3.018 3.02a1 1 0 001.414-1.415l-3.019-3.02 2.76-1.379a1 1 0 00-.076-1.822l-10-4z"/></svg>
102
+ Xem trước
103
+ </h2>
104
+ </div>
105
+ <div class="video-wrap">
106
+ <video id="preview" controls playsinline></video>
107
+ <div class="video-placeholder" id="videoPlaceholder">
108
+ <svg viewBox="0 0 64 64" fill="none" class="placeholder-icon">
109
+ <rect x="8" y="14" width="48" height="36" rx="6" stroke="currentColor" stroke-width="2"/>
110
+ <polygon points="26,24 42,32 26,40" fill="currentColor" opacity="0.3"/>
111
+ </svg>
112
+ <span>Chưa có video</span>
113
+ </div>
114
+ </div>
115
+ </section>
116
+
117
+ <!-- RIGHT: Action Panel -->
118
+ <section class="panel action-panel">
119
+ <div class="panel-head">
120
+ <h2 class="panel-title">
121
+ <svg viewBox="0 0 20 20" fill="currentColor" class="icon-sm"><path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"/></svg>
122
+ Điều khiển
123
+ </h2>
124
+ </div>
125
+
126
+ <div class="action-stack">
127
+ <!-- Transcribe -->
128
+ <button id="btnTranscribe" class="btn btn-primary btn-lg btn-full">
129
+ <svg viewBox="0 0 20 20" fill="currentColor" class="icon-btn"><path fill-rule="evenodd" d="M7 4a3 3 0 016 0v4a3 3 0 11-6 0V4zm4 10.93A7.001 7.001 0 0017 8a1 1 0 10-2 0A5 5 0 015 8a1 1 0 00-2 0 7.001 7.001 0 006 6.93V17H6a1 1 0 100 2h8a1 1 0 100-2h-3v-2.07z" clip-rule="evenodd"/></svg>
130
+ Auto sub tiếng Việt
131
+ </button>
132
+
133
+ <!-- Progress Bar -->
134
+ <div class="progress-wrap" id="progressWrap" hidden>
135
+ <div class="progress-bar">
136
+ <div class="progress-fill" id="progressFill"></div>
137
+ </div>
138
+ <span class="progress-text" id="progressText">Đang xử lý...</span>
139
+ </div>
140
+
141
+ <!-- Status -->
142
+ <div id="status" class="status-box status-idle">
143
+ <svg viewBox="0 0 20 20" fill="currentColor" class="status-icon"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/></svg>
144
+ <span id="statusText">Sẵn sàng. Hãy upload video để bắt đầu.</span>
145
+ </div>
146
+
147
+ <hr class="divider" />
148
+
149
+ <!-- Edit Actions -->
150
+ <div class="btn-row">
151
+ <button id="btnAddRow" class="btn btn-outline" disabled>
152
+ <svg viewBox="0 0 20 20" fill="currentColor" class="icon-btn"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>
153
+ Thêm dòng
154
+ </button>
155
+ </div>
156
+
157
+ <hr class="divider" />
158
+
159
+ <!-- Export Actions -->
160
+ <div class="export-group">
161
+ <h3 class="export-title">Xuất file</h3>
162
+ <div class="btn-row">
163
+ <button id="btnExportSrt" class="btn btn-outline" disabled>
164
+ <svg viewBox="0 0 20 20" fill="currentColor" class="icon-btn"><path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
165
+ Xuất .SRT
166
+ </button>
167
+ <button id="btnExportMp4" class="btn btn-success" disabled>
168
+ <svg viewBox="0 0 20 20" fill="currentColor" class="icon-btn"><path d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm12 12H4l4-8 3 6 2-4 3 6z"/></svg>
169
+ Xuất .MP4 burn sub
170
+ </button>
171
+ </div>
172
+ </div>
173
+
174
+ <!-- Download Links -->
175
+ <div class="download-group" id="downloadGroup" hidden>
176
+ <a id="downloadSrt" class="dl-link dl-srt" href="#" download>
177
+ <svg viewBox="0 0 20 20" fill="currentColor" class="icon-btn"><path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
178
+ Tải .SRT
179
+ </a>
180
+ <a id="downloadMp4" class="dl-link dl-mp4" href="#" download>
181
+ <svg viewBox="0 0 20 20" fill="currentColor" class="icon-btn"><path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
182
+ Tải .MP4
183
+ </a>
184
+ </div>
185
+ </div>
186
+ </section>
187
+ </div>
188
+
189
+ <!-- ===== SUBTITLE TABLE ===== -->
190
+ <section class="panel table-panel">
191
+ <div class="panel-head">
192
+ <h2 class="panel-title">
193
+ <svg viewBox="0 0 20 20" fill="currentColor" class="icon-sm"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"/></svg>
194
+ Bảng Subtitle
195
+ </h2>
196
+ <div class="table-meta">
197
+ <span class="seg-count" id="segmentCount">0 dòng</span>
198
+ <button class="btn btn-ghost btn-sm" id="btnCollapseTable" title="Thu gọn">
199
+ <svg viewBox="0 0 20 20" fill="currentColor" class="icon-xs"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
200
+ </button>
201
+ </div>
202
+ </div>
203
+ <div class="table-scroll" id="tableScroll">
204
+ <table>
205
+ <thead>
206
+ <tr>
207
+ <th class="col-idx">#</th>
208
+ <th class="col-time">Bắt đầu</th>
209
+ <th class="col-time">Kết thúc</th>
210
+ <th class="col-text">Nội dung</th>
211
+ <th class="col-act">Thao tác</th>
212
+ </tr>
213
+ </thead>
214
+ <tbody id="subtitleBody">
215
+ <tr class="empty-row">
216
+ <td colspan="5">
217
+ <div class="empty-state">
218
+ <svg viewBox="0 0 48 48" fill="none" class="empty-icon">
219
+ <rect x="6" y="10" width="36" height="28" rx="4" stroke="currentColor" stroke-width="1.5"/>
220
+ <line x1="12" y1="20" x2="36" y2="20" stroke="currentColor" stroke-width="1.5" opacity="0.3"/>
221
+ <line x1="12" y1="26" x2="30" y2="26" stroke="currentColor" stroke-width="1.5" opacity="0.3"/>
222
+ <line x1="12" y1="32" x2="24" y2="32" stroke="currentColor" stroke-width="1.5" opacity="0.3"/>
223
+ </svg>
224
+ <p>Chưa có subtitle. Upload video rồi bấm <strong>Auto sub tiếng Việt</strong> để bắt đầu.</p>
225
+ </div>
226
+ </td>
227
+ </tr>
228
+ </tbody>
229
+ </table>
230
+ </div>
231
+ </section>
232
+
233
+ </main>
234
+
235
+ <!-- ===== FOOTER ===== -->
236
+ <footer class="footer">
237
+ <span>Viet AutoSub Editor &mdash; Nhận diện giọng nói tiếng Việt bằng Whisper</span>
238
+ </footer>
239
+
240
+ <script src="static/app.js"></script>
241
+ </body>
242
+ </html>