|
|
@@ -0,0 +1,2427 @@
|
|
|
+<!DOCTYPE html>
|
|
|
+<html lang="es">
|
|
|
+<head>
|
|
|
+<meta charset="UTF-8">
|
|
|
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
+<title>Transcriptor</title>
|
|
|
+<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
|
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
|
+<link href="https://fonts.googleapis.com/css2?family=Syne:wght@600;700;800&family=IBM+Plex+Mono:wght@400;500&family=Manrope:wght@300;400;500;600&display=swap" rel="stylesheet">
|
|
|
+<style>
|
|
|
+:root {
|
|
|
+ --bg: #080808;
|
|
|
+ --s1: #111111;
|
|
|
+ --s2: #181818;
|
|
|
+ --s3: #222222;
|
|
|
+ --border: #2a2a2a;
|
|
|
+ --border2: #383838;
|
|
|
+ --accent: #e8a000;
|
|
|
+ --accent-lo: rgba(232,160,0,0.12);
|
|
|
+ --accent-md: rgba(232,160,0,0.25);
|
|
|
+ --text: #e4dfd6;
|
|
|
+ --text2: #7a756e;
|
|
|
+ --text3: #3d3a36;
|
|
|
+ --ok: #4caf74;
|
|
|
+ --err: #e05252;
|
|
|
+ --radius: 6px;
|
|
|
+}
|
|
|
+
|
|
|
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
+
|
|
|
+html, body {
|
|
|
+ height: 100%;
|
|
|
+ background: var(--bg);
|
|
|
+ color: var(--text);
|
|
|
+ font-family: 'Manrope', sans-serif;
|
|
|
+ font-size: 14px;
|
|
|
+ line-height: 1.6;
|
|
|
+ -webkit-font-smoothing: antialiased;
|
|
|
+}
|
|
|
+
|
|
|
+body::before {
|
|
|
+ content: '';
|
|
|
+ position: fixed;
|
|
|
+ inset: 0;
|
|
|
+ background-image: radial-gradient(circle, #282420 1px, transparent 1px);
|
|
|
+ background-size: 28px 28px;
|
|
|
+ opacity: 0.55;
|
|
|
+ pointer-events: none;
|
|
|
+ z-index: 0;
|
|
|
+}
|
|
|
+
|
|
|
+::-webkit-scrollbar { width: 6px; }
|
|
|
+::-webkit-scrollbar-track { background: var(--s1); }
|
|
|
+::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 3px; }
|
|
|
+
|
|
|
+.mono { font-family: 'IBM Plex Mono', monospace; }
|
|
|
+.hidden { display: none !important; }
|
|
|
+
|
|
|
+/* ─── INPUT ─── */
|
|
|
+input, select {
|
|
|
+ width: 100%;
|
|
|
+ background: var(--s2);
|
|
|
+ border: 1px solid var(--border);
|
|
|
+ border-radius: var(--radius);
|
|
|
+ color: var(--text);
|
|
|
+ font-family: 'Manrope', sans-serif;
|
|
|
+ font-size: 14px;
|
|
|
+ padding: 10px 14px;
|
|
|
+ outline: none;
|
|
|
+ transition: border-color .2s, box-shadow .2s;
|
|
|
+}
|
|
|
+input:focus, select:focus {
|
|
|
+ border-color: var(--accent);
|
|
|
+ box-shadow: 0 0 0 3px var(--accent-lo);
|
|
|
+}
|
|
|
+select option { background: var(--s2); }
|
|
|
+input::placeholder { color: var(--text3); }
|
|
|
+
|
|
|
+/* ─── BUTTON ─── */
|
|
|
+.btn {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ gap: 8px;
|
|
|
+ padding: 11px 22px;
|
|
|
+ border: none;
|
|
|
+ border-radius: var(--radius);
|
|
|
+ font-family: 'Syne', sans-serif;
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 700;
|
|
|
+ letter-spacing: .06em;
|
|
|
+ text-transform: uppercase;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: transform .15s, opacity .15s, box-shadow .2s;
|
|
|
+}
|
|
|
+.btn:active { transform: scale(.97); }
|
|
|
+.btn-primary {
|
|
|
+ background: var(--accent);
|
|
|
+ color: #000;
|
|
|
+}
|
|
|
+.btn-primary:hover { box-shadow: 0 0 24px rgba(232,160,0,.35); }
|
|
|
+.btn-primary:disabled { opacity: .35; cursor: not-allowed; transform: none; box-shadow: none; }
|
|
|
+.btn-ghost {
|
|
|
+ background: var(--s2);
|
|
|
+ border: 1px solid var(--border);
|
|
|
+ color: var(--text2);
|
|
|
+ font-size: 12px;
|
|
|
+ padding: 7px 14px;
|
|
|
+}
|
|
|
+.btn-ghost:hover { border-color: var(--border2); color: var(--text); }
|
|
|
+.btn-sm { padding: 7px 14px; font-size: 11px; }
|
|
|
+.btn-danger {
|
|
|
+ background: rgba(224,82,82,.1);
|
|
|
+ border: 1px solid rgba(224,82,82,.25);
|
|
|
+ color: var(--err);
|
|
|
+ font-size: 12px;
|
|
|
+ padding: 7px 14px;
|
|
|
+}
|
|
|
+.btn-danger:hover { background: rgba(224,82,82,.18); border-color: rgba(224,82,82,.5); }
|
|
|
+.btn-success {
|
|
|
+ background: rgba(76,175,116,.1);
|
|
|
+ border: 1px solid rgba(76,175,116,.25);
|
|
|
+ color: var(--ok);
|
|
|
+ font-size: 12px;
|
|
|
+ padding: 7px 14px;
|
|
|
+}
|
|
|
+.btn-success:hover { background: rgba(76,175,116,.18); border-color: rgba(76,175,116,.5); }
|
|
|
+
|
|
|
+/* ═══════════════════════════════════════════
|
|
|
+ LOGIN / CHANGE-PASSWORD SCREENS
|
|
|
+═══════════════════════════════════════════ */
|
|
|
+#login-screen, #change-password-screen {
|
|
|
+ position: fixed;
|
|
|
+ inset: 0;
|
|
|
+ z-index: 10;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ animation: fadeIn .4s ease;
|
|
|
+}
|
|
|
+
|
|
|
+.login-card {
|
|
|
+ position: relative;
|
|
|
+ width: 100%;
|
|
|
+ max-width: 420px;
|
|
|
+ margin: 0 16px;
|
|
|
+ background: var(--s1);
|
|
|
+ border: 1px solid var(--border);
|
|
|
+ border-top: 2px solid var(--accent);
|
|
|
+ border-radius: var(--radius);
|
|
|
+ padding: 40px 36px 36px;
|
|
|
+ box-shadow: 0 32px 80px rgba(0,0,0,.6);
|
|
|
+}
|
|
|
+
|
|
|
+.login-logo {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 12px;
|
|
|
+ margin-bottom: 28px;
|
|
|
+}
|
|
|
+.login-logo .waveform {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 3px;
|
|
|
+ height: 28px;
|
|
|
+}
|
|
|
+.login-logo .waveform span {
|
|
|
+ display: block;
|
|
|
+ width: 3px;
|
|
|
+ border-radius: 2px;
|
|
|
+ background: var(--accent);
|
|
|
+}
|
|
|
+.login-logo h1 {
|
|
|
+ font-family: 'Syne', sans-serif;
|
|
|
+ font-size: 22px;
|
|
|
+ font-weight: 800;
|
|
|
+ letter-spacing: .08em;
|
|
|
+ text-transform: uppercase;
|
|
|
+}
|
|
|
+.login-logo small {
|
|
|
+ display: block;
|
|
|
+ color: var(--text2);
|
|
|
+ font-size: 10px;
|
|
|
+ letter-spacing: .12em;
|
|
|
+ text-transform: uppercase;
|
|
|
+ margin-top: 2px;
|
|
|
+}
|
|
|
+
|
|
|
+.login-card .field { margin-bottom: 16px; }
|
|
|
+.login-card label {
|
|
|
+ display: block;
|
|
|
+ font-size: 11px;
|
|
|
+ font-weight: 600;
|
|
|
+ letter-spacing: .1em;
|
|
|
+ text-transform: uppercase;
|
|
|
+ color: var(--text2);
|
|
|
+ margin-bottom: 7px;
|
|
|
+}
|
|
|
+.input-wrap { position: relative; }
|
|
|
+.input-wrap input { padding-right: 44px; }
|
|
|
+.input-wrap .eye {
|
|
|
+ position: absolute;
|
|
|
+ right: 12px;
|
|
|
+ top: 50%;
|
|
|
+ transform: translateY(-50%);
|
|
|
+ background: none;
|
|
|
+ border: none;
|
|
|
+ color: var(--text3);
|
|
|
+ cursor: pointer;
|
|
|
+ padding: 4px;
|
|
|
+ line-height: 1;
|
|
|
+ transition: color .2s;
|
|
|
+}
|
|
|
+.input-wrap .eye:hover { color: var(--text2); }
|
|
|
+
|
|
|
+.login-card .btn-primary { width: 100%; margin-top: 8px; }
|
|
|
+
|
|
|
+.login-error {
|
|
|
+ margin-top: 14px;
|
|
|
+ padding: 10px 14px;
|
|
|
+ background: rgba(224,82,82,.1);
|
|
|
+ border: 1px solid rgba(224,82,82,.3);
|
|
|
+ border-radius: var(--radius);
|
|
|
+ color: var(--err);
|
|
|
+ font-size: 12px;
|
|
|
+ text-align: center;
|
|
|
+ animation: shake .3s ease;
|
|
|
+}
|
|
|
+
|
|
|
+.login-subtitle {
|
|
|
+ font-size: 13px;
|
|
|
+ color: var(--text2);
|
|
|
+ margin-bottom: 24px;
|
|
|
+ line-height: 1.5;
|
|
|
+}
|
|
|
+.login-subtitle strong { color: var(--accent); }
|
|
|
+
|
|
|
+.cp-back-link {
|
|
|
+ display: inline-block;
|
|
|
+ margin-bottom: 20px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: var(--text2);
|
|
|
+ cursor: pointer;
|
|
|
+ background: none;
|
|
|
+ border: none;
|
|
|
+ padding: 0;
|
|
|
+ text-decoration: underline;
|
|
|
+ text-underline-offset: 3px;
|
|
|
+}
|
|
|
+.cp-back-link:hover { color: var(--text); }
|
|
|
+
|
|
|
+/* ═══════════════════════════════════════════
|
|
|
+ APP / ADMIN SCREENS (shared layout)
|
|
|
+═══════════════════════════════════════════ */
|
|
|
+#app-screen, #admin-screen {
|
|
|
+ position: relative;
|
|
|
+ z-index: 1;
|
|
|
+ min-height: 100vh;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+}
|
|
|
+
|
|
|
+.app-header {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ padding: 14px 28px;
|
|
|
+ border-bottom: 1px solid var(--border);
|
|
|
+ background: rgba(8,8,8,.85);
|
|
|
+ backdrop-filter: blur(8px);
|
|
|
+ position: sticky;
|
|
|
+ top: 0;
|
|
|
+ z-index: 5;
|
|
|
+}
|
|
|
+.header-logo {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+ font-family: 'Syne', sans-serif;
|
|
|
+ font-size: 17px;
|
|
|
+ font-weight: 800;
|
|
|
+ letter-spacing: .1em;
|
|
|
+ text-transform: uppercase;
|
|
|
+}
|
|
|
+.header-logo .dot {
|
|
|
+ width: 8px;
|
|
|
+ height: 8px;
|
|
|
+ border-radius: 50%;
|
|
|
+ background: var(--accent);
|
|
|
+ box-shadow: 0 0 8px var(--accent);
|
|
|
+}
|
|
|
+.header-left {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 20px;
|
|
|
+}
|
|
|
+.header-right {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+}
|
|
|
+.user-badge {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+ padding: 6px 12px 6px 8px;
|
|
|
+ background: var(--s2);
|
|
|
+ border: 1px solid var(--border);
|
|
|
+ border-radius: 20px;
|
|
|
+}
|
|
|
+.user-badge .avatar {
|
|
|
+ width: 26px;
|
|
|
+ height: 26px;
|
|
|
+ border-radius: 50%;
|
|
|
+ background: var(--accent-lo);
|
|
|
+ border: 1px solid var(--accent-md);
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ font-family: 'Syne', sans-serif;
|
|
|
+ font-size: 11px;
|
|
|
+ font-weight: 700;
|
|
|
+ color: var(--accent);
|
|
|
+}
|
|
|
+.user-badge .user-info { line-height: 1.3; }
|
|
|
+.user-badge .user-name { font-size: 12px; font-weight: 600; }
|
|
|
+.user-badge .user-email { font-size: 10px; color: var(--text2); font-family: 'IBM Plex Mono', monospace; }
|
|
|
+
|
|
|
+.app-main {
|
|
|
+ flex: 1;
|
|
|
+ max-width: 720px;
|
|
|
+ width: 100%;
|
|
|
+ margin: 0 auto;
|
|
|
+ padding: 36px 20px 60px;
|
|
|
+}
|
|
|
+.app-main-wide {
|
|
|
+ flex: 1;
|
|
|
+ max-width: 1000px;
|
|
|
+ width: 100%;
|
|
|
+ margin: 0 auto;
|
|
|
+ padding: 36px 20px 60px;
|
|
|
+}
|
|
|
+
|
|
|
+/* ─── SECTION HEADING ─── */
|
|
|
+.section-label {
|
|
|
+ font-size: 10px;
|
|
|
+ font-weight: 700;
|
|
|
+ letter-spacing: .18em;
|
|
|
+ text-transform: uppercase;
|
|
|
+ color: var(--text2);
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+ margin-bottom: 14px;
|
|
|
+}
|
|
|
+.section-label::after {
|
|
|
+ content: '';
|
|
|
+ flex: 1;
|
|
|
+ height: 1px;
|
|
|
+ background: var(--border);
|
|
|
+}
|
|
|
+
|
|
|
+/* ─── CARD ─── */
|
|
|
+.card {
|
|
|
+ background: var(--s1);
|
|
|
+ border: 1px solid var(--border);
|
|
|
+ border-radius: var(--radius);
|
|
|
+ padding: 22px;
|
|
|
+ margin-bottom: 16px;
|
|
|
+}
|
|
|
+
|
|
|
+/* ─── UPLOAD ZONE ─── */
|
|
|
+.upload-zone {
|
|
|
+ border: 1.5px dashed var(--border2);
|
|
|
+ border-radius: var(--radius);
|
|
|
+ padding: 44px 24px;
|
|
|
+ text-align: center;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: border-color .2s, background .2s;
|
|
|
+ position: relative;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+.upload-zone:hover, .upload-zone.drag-over {
|
|
|
+ border-color: var(--accent);
|
|
|
+ background: var(--accent-lo);
|
|
|
+}
|
|
|
+.upload-zone input[type=file] {
|
|
|
+ position: absolute;
|
|
|
+ inset: 0;
|
|
|
+ opacity: 0;
|
|
|
+ cursor: pointer;
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+}
|
|
|
+.upload-icon {
|
|
|
+ margin: 0 auto 16px;
|
|
|
+ width: 48px;
|
|
|
+ height: 48px;
|
|
|
+ border: 1.5px solid var(--border2);
|
|
|
+ border-radius: 12px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ transition: border-color .2s;
|
|
|
+}
|
|
|
+.upload-zone:hover .upload-icon { border-color: var(--accent); }
|
|
|
+.upload-zone h3 {
|
|
|
+ font-family: 'Syne', sans-serif;
|
|
|
+ font-size: 15px;
|
|
|
+ font-weight: 700;
|
|
|
+ margin-bottom: 6px;
|
|
|
+}
|
|
|
+.upload-zone p { color: var(--text2); font-size: 12px; }
|
|
|
+
|
|
|
+.file-chip {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ padding: 8px 14px;
|
|
|
+ background: var(--s3);
|
|
|
+ border: 1px solid var(--border2);
|
|
|
+ border-radius: 20px;
|
|
|
+ font-size: 12px;
|
|
|
+ font-family: 'IBM Plex Mono', monospace;
|
|
|
+ color: var(--text);
|
|
|
+ margin-top: 12px;
|
|
|
+}
|
|
|
+.file-chip .remove {
|
|
|
+ background: none;
|
|
|
+ border: none;
|
|
|
+ color: var(--text2);
|
|
|
+ cursor: pointer;
|
|
|
+ line-height: 1;
|
|
|
+ padding: 0 2px;
|
|
|
+ font-size: 16px;
|
|
|
+ transition: color .15s;
|
|
|
+}
|
|
|
+.file-chip .remove:hover { color: var(--err); }
|
|
|
+
|
|
|
+/* ─── SETTINGS GRID ─── */
|
|
|
+.settings-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: 1fr 1fr;
|
|
|
+ gap: 12px;
|
|
|
+}
|
|
|
+.field label {
|
|
|
+ display: block;
|
|
|
+ font-size: 11px;
|
|
|
+ font-weight: 600;
|
|
|
+ letter-spacing: .1em;
|
|
|
+ text-transform: uppercase;
|
|
|
+ color: var(--text2);
|
|
|
+ margin-bottom: 7px;
|
|
|
+}
|
|
|
+
|
|
|
+/* ─── FORMAT CHECKBOXES ─── */
|
|
|
+.formats-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(4, 1fr);
|
|
|
+ gap: 10px;
|
|
|
+ margin-top: 4px;
|
|
|
+}
|
|
|
+.fmt-check { position: relative; }
|
|
|
+.fmt-check input[type=checkbox] { position: absolute; opacity: 0; width: 0; height: 0; }
|
|
|
+.fmt-check label {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+ padding: 14px 8px;
|
|
|
+ background: var(--s2);
|
|
|
+ border: 1.5px solid var(--border);
|
|
|
+ border-radius: var(--radius);
|
|
|
+ cursor: pointer;
|
|
|
+ transition: border-color .2s, background .2s;
|
|
|
+ text-transform: none;
|
|
|
+ letter-spacing: 0;
|
|
|
+ color: var(--text2);
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: 500;
|
|
|
+}
|
|
|
+.fmt-check label .fmt-tag {
|
|
|
+ font-family: 'IBM Plex Mono', monospace;
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 500;
|
|
|
+ color: var(--text);
|
|
|
+}
|
|
|
+.fmt-check label .fmt-desc { font-size: 10px; color: var(--text3); text-align: center; line-height: 1.4; }
|
|
|
+.fmt-check input:checked + label { border-color: var(--accent); background: var(--accent-lo); color: var(--text); }
|
|
|
+.fmt-check input:checked + label .fmt-tag { color: var(--accent); }
|
|
|
+.fmt-check input:checked + label .fmt-desc { color: var(--text2); }
|
|
|
+.fmt-check label:hover { border-color: var(--border2); }
|
|
|
+
|
|
|
+.submit-row {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: flex-end;
|
|
|
+ gap: 12px;
|
|
|
+ margin-top: 20px;
|
|
|
+}
|
|
|
+.submit-row .btn-primary { min-width: 160px; }
|
|
|
+
|
|
|
+/* ─── JOB STATUS ─── */
|
|
|
+.job-card {
|
|
|
+ background: var(--s1);
|
|
|
+ border: 1px solid var(--border);
|
|
|
+ border-radius: var(--radius);
|
|
|
+ overflow: hidden;
|
|
|
+ margin-bottom: 16px;
|
|
|
+ animation: slideDown .3s ease;
|
|
|
+}
|
|
|
+.job-card-top {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ padding: 16px 20px;
|
|
|
+ border-bottom: 1px solid var(--border);
|
|
|
+}
|
|
|
+.job-card-top .filename {
|
|
|
+ font-family: 'IBM Plex Mono', monospace;
|
|
|
+ font-size: 13px;
|
|
|
+ color: var(--text);
|
|
|
+ max-width: 60%;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+
|
|
|
+.status-pill {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 7px;
|
|
|
+ padding: 4px 11px;
|
|
|
+ border-radius: 20px;
|
|
|
+ font-size: 11px;
|
|
|
+ font-weight: 700;
|
|
|
+ letter-spacing: .08em;
|
|
|
+ text-transform: uppercase;
|
|
|
+}
|
|
|
+.status-dot { width: 7px; height: 7px; border-radius: 50%; }
|
|
|
+.status-pending { background: rgba(100,100,100,.15); color: var(--text2); }
|
|
|
+.status-pending .status-dot { background: var(--text2); }
|
|
|
+.status-active { background: rgba(232,160,0,.12); color: var(--accent); }
|
|
|
+.status-active .status-dot { background: var(--accent); animation: pulse 1.2s ease-in-out infinite; }
|
|
|
+.status-completed { background: rgba(76,175,116,.12); color: var(--ok); }
|
|
|
+.status-completed .status-dot { background: var(--ok); }
|
|
|
+.status-failed { background: rgba(224,82,82,.12); color: var(--err); }
|
|
|
+.status-failed .status-dot { background: var(--err); }
|
|
|
+
|
|
|
+.job-card-body { padding: 20px; }
|
|
|
+
|
|
|
+.waveform-anim {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 4px;
|
|
|
+ height: 48px;
|
|
|
+ justify-content: center;
|
|
|
+ margin-bottom: 16px;
|
|
|
+}
|
|
|
+.waveform-anim .bar {
|
|
|
+ width: 4px;
|
|
|
+ border-radius: 2px;
|
|
|
+ background: var(--accent);
|
|
|
+ transform-origin: bottom center;
|
|
|
+ animation: wave 1.4s ease-in-out infinite;
|
|
|
+}
|
|
|
+.waveform-anim .bar:nth-child(1) { height: 12px; animation-delay: 0.00s; }
|
|
|
+.waveform-anim .bar:nth-child(2) { height: 22px; animation-delay: 0.07s; }
|
|
|
+.waveform-anim .bar:nth-child(3) { height: 34px; animation-delay: 0.14s; }
|
|
|
+.waveform-anim .bar:nth-child(4) { height: 28px; animation-delay: 0.21s; }
|
|
|
+.waveform-anim .bar:nth-child(5) { height: 40px; animation-delay: 0.28s; }
|
|
|
+.waveform-anim .bar:nth-child(6) { height: 32px; animation-delay: 0.35s; }
|
|
|
+.waveform-anim .bar:nth-child(7) { height: 44px; animation-delay: 0.42s; }
|
|
|
+.waveform-anim .bar:nth-child(8) { height: 36px; animation-delay: 0.49s; }
|
|
|
+.waveform-anim .bar:nth-child(9) { height: 48px; animation-delay: 0.56s; }
|
|
|
+.waveform-anim .bar:nth-child(10) { height: 40px; animation-delay: 0.63s; }
|
|
|
+.waveform-anim .bar:nth-child(11) { height: 48px; animation-delay: 0.70s; }
|
|
|
+.waveform-anim .bar:nth-child(12) { height: 36px; animation-delay: 0.77s; }
|
|
|
+.waveform-anim .bar:nth-child(13) { height: 44px; animation-delay: 0.84s; }
|
|
|
+.waveform-anim .bar:nth-child(14) { height: 28px; animation-delay: 0.91s; }
|
|
|
+.waveform-anim .bar:nth-child(15) { height: 38px; animation-delay: 0.98s; }
|
|
|
+.waveform-anim .bar:nth-child(16) { height: 22px; animation-delay: 1.05s; }
|
|
|
+.waveform-anim .bar:nth-child(17) { height: 32px; animation-delay: 1.12s; }
|
|
|
+.waveform-anim .bar:nth-child(18) { height: 16px; animation-delay: 1.19s; }
|
|
|
+.waveform-anim .bar:nth-child(19) { height: 24px; animation-delay: 1.26s; }
|
|
|
+.waveform-anim .bar:nth-child(20) { height: 12px; animation-delay: 1.33s; }
|
|
|
+
|
|
|
+.status-text { text-align: center; font-size: 13px; color: var(--text2); }
|
|
|
+.status-text strong { color: var(--accent); }
|
|
|
+
|
|
|
+.pipeline-steps {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 0;
|
|
|
+ margin-top: 18px;
|
|
|
+}
|
|
|
+.pipeline-step {
|
|
|
+ flex: 1;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+ font-size: 10px;
|
|
|
+ letter-spacing: .08em;
|
|
|
+ text-transform: uppercase;
|
|
|
+ color: var(--text3);
|
|
|
+ position: relative;
|
|
|
+}
|
|
|
+.pipeline-step::after {
|
|
|
+ content: '';
|
|
|
+ position: absolute;
|
|
|
+ top: 13px;
|
|
|
+ left: calc(50% + 14px);
|
|
|
+ right: calc(-50% + 14px);
|
|
|
+ height: 1px;
|
|
|
+ background: var(--border);
|
|
|
+}
|
|
|
+.pipeline-step:last-child::after { display: none; }
|
|
|
+.step-node {
|
|
|
+ width: 28px;
|
|
|
+ height: 28px;
|
|
|
+ border-radius: 50%;
|
|
|
+ border: 1.5px solid var(--border2);
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ font-size: 10px;
|
|
|
+ transition: all .3s;
|
|
|
+}
|
|
|
+.pipeline-step.active .step-node { border-color: var(--accent); background: var(--accent-lo); color: var(--accent); box-shadow: 0 0 12px var(--accent-lo); }
|
|
|
+.pipeline-step.done .step-node { border-color: var(--ok); background: rgba(76,175,116,.12); color: var(--ok); }
|
|
|
+.pipeline-step.active { color: var(--accent); }
|
|
|
+.pipeline-step.done { color: var(--ok); }
|
|
|
+
|
|
|
+/* ─── RESULTS ─── */
|
|
|
+.results-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
|
|
+ gap: 10px;
|
|
|
+ margin-bottom: 20px;
|
|
|
+}
|
|
|
+.result-btn {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ padding: 16px 12px;
|
|
|
+ background: var(--s2);
|
|
|
+ border: 1.5px solid var(--border);
|
|
|
+ border-radius: var(--radius);
|
|
|
+ cursor: pointer;
|
|
|
+ transition: border-color .2s, background .2s, transform .15s;
|
|
|
+ font-family: 'IBM Plex Mono', monospace;
|
|
|
+ font-size: 13px;
|
|
|
+ color: var(--text);
|
|
|
+}
|
|
|
+.result-btn:hover { border-color: var(--accent); background: var(--accent-lo); transform: translateY(-2px); }
|
|
|
+.result-btn .icon { font-size: 22px; }
|
|
|
+.result-btn .format { font-weight: 500; color: var(--accent); }
|
|
|
+.result-btn .label { font-size: 10px; color: var(--text2); font-family: 'Manrope', sans-serif; text-align: center; }
|
|
|
+
|
|
|
+.segments-preview { margin-top: 4px; }
|
|
|
+.segment-row {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: 110px 90px 1fr;
|
|
|
+ gap: 12px;
|
|
|
+ padding: 10px 0;
|
|
|
+ border-bottom: 1px solid var(--border);
|
|
|
+ font-size: 12px;
|
|
|
+ align-items: start;
|
|
|
+}
|
|
|
+.segment-row:last-child { border-bottom: none; }
|
|
|
+.seg-time { font-family: 'IBM Plex Mono', monospace; color: var(--text2); font-size: 11px; padding-top: 1px; }
|
|
|
+.seg-speaker {
|
|
|
+ font-family: 'IBM Plex Mono', monospace;
|
|
|
+ font-size: 11px;
|
|
|
+ padding: 2px 8px;
|
|
|
+ border-radius: 3px;
|
|
|
+ background: var(--accent-lo);
|
|
|
+ color: var(--accent);
|
|
|
+ text-align: center;
|
|
|
+ align-self: start;
|
|
|
+ width: fit-content;
|
|
|
+}
|
|
|
+.seg-text { color: var(--text); line-height: 1.5; }
|
|
|
+.preview-more { text-align: center; color: var(--text3); font-size: 11px; padding-top: 10px; font-family: 'IBM Plex Mono', monospace; }
|
|
|
+
|
|
|
+/* ─── JOBS HISTORY ─── */
|
|
|
+.history-table {
|
|
|
+ width: 100%;
|
|
|
+ border-collapse: collapse;
|
|
|
+ font-size: 12px;
|
|
|
+}
|
|
|
+.history-table th {
|
|
|
+ text-align: left;
|
|
|
+ padding: 8px 12px;
|
|
|
+ color: var(--text2);
|
|
|
+ font-size: 10px;
|
|
|
+ font-weight: 700;
|
|
|
+ letter-spacing: .12em;
|
|
|
+ text-transform: uppercase;
|
|
|
+ border-bottom: 1px solid var(--border);
|
|
|
+}
|
|
|
+.history-table td {
|
|
|
+ padding: 10px 12px;
|
|
|
+ border-bottom: 1px solid var(--border);
|
|
|
+ vertical-align: middle;
|
|
|
+}
|
|
|
+.history-table tr:last-child td { border-bottom: none; }
|
|
|
+.history-table .mono { color: var(--text2); }
|
|
|
+.history-empty { text-align: center; color: var(--text3); font-size: 12px; padding: 24px; }
|
|
|
+
|
|
|
+/* ═══════════════════════════════════════════
|
|
|
+ ADMIN PANEL
|
|
|
+═══════════════════════════════════════════ */
|
|
|
+.tab-nav {
|
|
|
+ display: flex;
|
|
|
+ gap: 2px;
|
|
|
+ align-items: center;
|
|
|
+}
|
|
|
+.tab-btn {
|
|
|
+ padding: 6px 16px;
|
|
|
+ border: none;
|
|
|
+ background: none;
|
|
|
+ color: var(--text2);
|
|
|
+ font-family: 'Syne', sans-serif;
|
|
|
+ font-weight: 700;
|
|
|
+ font-size: 11px;
|
|
|
+ letter-spacing: .08em;
|
|
|
+ text-transform: uppercase;
|
|
|
+ cursor: pointer;
|
|
|
+ border-radius: var(--radius);
|
|
|
+ transition: all .15s;
|
|
|
+}
|
|
|
+.tab-btn.active { background: var(--accent-lo); color: var(--accent); }
|
|
|
+.tab-btn:hover:not(.active) { background: var(--s2); color: var(--text); }
|
|
|
+
|
|
|
+/* Role + status badges */
|
|
|
+.badge {
|
|
|
+ display: inline-block;
|
|
|
+ padding: 2px 8px;
|
|
|
+ border-radius: 3px;
|
|
|
+ font-size: 10px;
|
|
|
+ font-weight: 700;
|
|
|
+ font-family: 'IBM Plex Mono', monospace;
|
|
|
+ letter-spacing: .06em;
|
|
|
+ text-transform: uppercase;
|
|
|
+}
|
|
|
+.badge-admin { background: rgba(232,160,0,.15); color: var(--accent); border: 1px solid rgba(232,160,0,.3); }
|
|
|
+.badge-user { background: var(--s3); color: var(--text2); border: 1px solid var(--border); }
|
|
|
+.badge-default { background: rgba(224,82,82,.1); color: var(--err); border: 1px solid rgba(224,82,82,.25); }
|
|
|
+.badge-set { background: rgba(76,175,116,.1); color: var(--ok); border: 1px solid rgba(76,175,116,.25); }
|
|
|
+.badge-yes { background: rgba(76,175,116,.1); color: var(--ok); border: 1px solid rgba(76,175,116,.25); }
|
|
|
+.badge-no { background: var(--s3); color: var(--text3); border: 1px solid var(--border); }
|
|
|
+
|
|
|
+/* Metrics */
|
|
|
+.metrics-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(4, 1fr);
|
|
|
+ gap: 12px;
|
|
|
+ margin-bottom: 20px;
|
|
|
+}
|
|
|
+.metric-card {
|
|
|
+ background: var(--s1);
|
|
|
+ border: 1px solid var(--border);
|
|
|
+ border-radius: var(--radius);
|
|
|
+ padding: 20px 16px 16px;
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+.metric-value {
|
|
|
+ font-family: 'Syne', sans-serif;
|
|
|
+ font-size: 32px;
|
|
|
+ font-weight: 800;
|
|
|
+ color: var(--text);
|
|
|
+ line-height: 1;
|
|
|
+}
|
|
|
+.metric-label {
|
|
|
+ font-size: 10px;
|
|
|
+ color: var(--text2);
|
|
|
+ text-transform: uppercase;
|
|
|
+ letter-spacing: .12em;
|
|
|
+ margin-top: 6px;
|
|
|
+}
|
|
|
+.metric-card.accent .metric-value { color: var(--accent); }
|
|
|
+.metric-card.ok .metric-value { color: var(--ok); }
|
|
|
+.metric-card.err .metric-value { color: var(--err); }
|
|
|
+
|
|
|
+.metrics-cols {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: 1fr 1fr 1fr;
|
|
|
+ gap: 14px;
|
|
|
+ margin-bottom: 14px;
|
|
|
+}
|
|
|
+.bar-list { display: flex; flex-direction: column; gap: 10px; }
|
|
|
+.bar-item { display: flex; align-items: center; gap: 10px; font-size: 12px; }
|
|
|
+.bar-label {
|
|
|
+ width: 90px;
|
|
|
+ font-family: 'IBM Plex Mono', monospace;
|
|
|
+ color: var(--text2);
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+ font-size: 11px;
|
|
|
+}
|
|
|
+.bar-track {
|
|
|
+ flex: 1;
|
|
|
+ height: 5px;
|
|
|
+ background: var(--s3);
|
|
|
+ border-radius: 3px;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+.bar-fill { height: 100%; background: var(--accent); border-radius: 3px; transition: width .6s ease; }
|
|
|
+.bar-count { width: 28px; text-align: right; color: var(--text2); font-family: 'IBM Plex Mono', monospace; font-size: 11px; }
|
|
|
+
|
|
|
+/* ═══════════════════════════════════════════
|
|
|
+ MODAL
|
|
|
+═══════════════════════════════════════════ */
|
|
|
+#modal-overlay {
|
|
|
+ position: fixed;
|
|
|
+ inset: 0;
|
|
|
+ background: rgba(0,0,0,.72);
|
|
|
+ z-index: 50;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ animation: fadeIn .2s ease;
|
|
|
+}
|
|
|
+.modal {
|
|
|
+ background: var(--s1);
|
|
|
+ border: 1px solid var(--border);
|
|
|
+ border-top: 2px solid var(--accent);
|
|
|
+ border-radius: var(--radius);
|
|
|
+ padding: 28px 32px 32px;
|
|
|
+ width: 100%;
|
|
|
+ max-width: 440px;
|
|
|
+ margin: 0 16px;
|
|
|
+ box-shadow: 0 24px 60px rgba(0,0,0,.6);
|
|
|
+}
|
|
|
+.modal-title {
|
|
|
+ font-family: 'Syne', sans-serif;
|
|
|
+ font-size: 16px;
|
|
|
+ font-weight: 700;
|
|
|
+ letter-spacing: .04em;
|
|
|
+ margin-bottom: 20px;
|
|
|
+}
|
|
|
+.modal-field { margin-bottom: 14px; }
|
|
|
+.modal-field label {
|
|
|
+ display: block;
|
|
|
+ font-size: 11px;
|
|
|
+ font-weight: 600;
|
|
|
+ letter-spacing: .1em;
|
|
|
+ text-transform: uppercase;
|
|
|
+ color: var(--text2);
|
|
|
+ margin-bottom: 7px;
|
|
|
+}
|
|
|
+.modal-actions {
|
|
|
+ display: flex;
|
|
|
+ gap: 10px;
|
|
|
+ justify-content: flex-end;
|
|
|
+ margin-top: 24px;
|
|
|
+}
|
|
|
+.modal-note {
|
|
|
+ font-size: 11px;
|
|
|
+ color: var(--text2);
|
|
|
+ margin-top: 8px;
|
|
|
+ line-height: 1.5;
|
|
|
+}
|
|
|
+.key-display {
|
|
|
+ font-family: 'IBM Plex Mono', monospace;
|
|
|
+ font-size: 12px;
|
|
|
+ background: var(--s2);
|
|
|
+ border: 1px solid var(--border);
|
|
|
+ border-radius: var(--radius);
|
|
|
+ padding: 12px 14px;
|
|
|
+ word-break: break-all;
|
|
|
+ color: var(--accent);
|
|
|
+ margin: 12px 0;
|
|
|
+ line-height: 1.6;
|
|
|
+}
|
|
|
+.modal-error {
|
|
|
+ margin-top: 10px;
|
|
|
+ padding: 8px 12px;
|
|
|
+ background: rgba(224,82,82,.1);
|
|
|
+ border: 1px solid rgba(224,82,82,.3);
|
|
|
+ border-radius: var(--radius);
|
|
|
+ color: var(--err);
|
|
|
+ font-size: 12px;
|
|
|
+}
|
|
|
+.role-toggle {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: 1fr 1fr;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+.role-option input[type=radio] { position: absolute; opacity: 0; width: 0; height: 0; }
|
|
|
+.role-option {
|
|
|
+ position: relative;
|
|
|
+}
|
|
|
+.role-option label {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ gap: 4px;
|
|
|
+ padding: 12px 8px;
|
|
|
+ background: var(--s2);
|
|
|
+ border: 1.5px solid var(--border);
|
|
|
+ border-radius: var(--radius);
|
|
|
+ cursor: pointer;
|
|
|
+ transition: border-color .2s, background .2s;
|
|
|
+ text-transform: none;
|
|
|
+ letter-spacing: 0;
|
|
|
+ color: var(--text2);
|
|
|
+ font-size: 12px;
|
|
|
+}
|
|
|
+.role-option input:checked + label {
|
|
|
+ border-color: var(--accent);
|
|
|
+ background: var(--accent-lo);
|
|
|
+ color: var(--text);
|
|
|
+}
|
|
|
+
|
|
|
+/* ─── TOAST ─── */
|
|
|
+#toast {
|
|
|
+ position: fixed;
|
|
|
+ bottom: 24px;
|
|
|
+ left: 50%;
|
|
|
+ transform: translateX(-50%) translateY(20px);
|
|
|
+ background: var(--s3);
|
|
|
+ border: 1px solid var(--border2);
|
|
|
+ border-radius: var(--radius);
|
|
|
+ padding: 10px 20px;
|
|
|
+ font-size: 13px;
|
|
|
+ color: var(--text);
|
|
|
+ opacity: 0;
|
|
|
+ transition: opacity .25s, transform .25s;
|
|
|
+ z-index: 100;
|
|
|
+ pointer-events: none;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+#toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
|
+
|
|
|
+/* ─── ANIMATIONS ─── */
|
|
|
+@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
|
|
+@keyframes slideDown { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }
|
|
|
+@keyframes shake { 0%,100% { transform: translateX(0); } 25% { transform: translateX(-6px); } 75% { transform: translateX(6px); } }
|
|
|
+@keyframes pulse { 0%,100% { opacity: 1; box-shadow: 0 0 0 0 var(--accent); } 50% { opacity: .6; box-shadow: 0 0 0 4px transparent; } }
|
|
|
+@keyframes wave { 0%,100% { transform: scaleY(.25); opacity: .4; } 50% { transform: scaleY(1); opacity: 1; } }
|
|
|
+
|
|
|
+@media (max-width: 600px) {
|
|
|
+ .settings-grid { grid-template-columns: 1fr; }
|
|
|
+ .formats-grid { grid-template-columns: repeat(2, 1fr); }
|
|
|
+ .pipeline-steps { flex-wrap: wrap; gap: 10px; }
|
|
|
+ .pipeline-step::after { display: none; }
|
|
|
+ .metrics-grid { grid-template-columns: repeat(2, 1fr); }
|
|
|
+ .metrics-cols { grid-template-columns: 1fr; }
|
|
|
+}
|
|
|
+
|
|
|
+/* ─── LANG TOGGLE ─── */
|
|
|
+.lang-toggle-btn {
|
|
|
+ font-family: 'IBM Plex Mono', monospace;
|
|
|
+ font-size: 11px;
|
|
|
+ font-weight: 500;
|
|
|
+ letter-spacing: .08em;
|
|
|
+ min-width: 36px;
|
|
|
+ padding: 6px 10px;
|
|
|
+ background: var(--s2);
|
|
|
+ border: 1px solid var(--border);
|
|
|
+ color: var(--text2);
|
|
|
+ border-radius: var(--radius);
|
|
|
+ cursor: pointer;
|
|
|
+ transition: border-color .2s, color .2s;
|
|
|
+}
|
|
|
+.lang-toggle-btn:hover { border-color: var(--border2); color: var(--text); }
|
|
|
+.card-lang-toggle { position: absolute; top: 14px; right: 16px; }
|
|
|
+</style>
|
|
|
+</head>
|
|
|
+<body>
|
|
|
+
|
|
|
+<!-- ═══════════════════ LOGIN ═══════════════════ -->
|
|
|
+<div id="login-screen">
|
|
|
+ <div class="login-card">
|
|
|
+ <button class="lang-toggle-btn card-lang-toggle" onclick="toggleLanguage()">EN</button>
|
|
|
+ <div class="login-logo">
|
|
|
+ <div class="waveform" aria-hidden="true">
|
|
|
+ <span style="height:8px"></span>
|
|
|
+ <span style="height:14px"></span>
|
|
|
+ <span style="height:22px"></span>
|
|
|
+ <span style="height:18px"></span>
|
|
|
+ <span style="height:28px"></span>
|
|
|
+ <span style="height:20px"></span>
|
|
|
+ <span style="height:12px"></span>
|
|
|
+ <span style="height:6px"></span>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <h1>Transcriptor</h1>
|
|
|
+ <small data-i18n="login.subtitle">Inteligencia de audio</small>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="field">
|
|
|
+ <label for="login-email" data-i18n="login.email">Correo electrónico</label>
|
|
|
+ <input type="email" id="login-email" placeholder="tu@correo.com" data-i18n-placeholder="login.email_ph" autocomplete="email">
|
|
|
+ </div>
|
|
|
+ <div class="field">
|
|
|
+ <label for="login-password" data-i18n="login.password">Contraseña</label>
|
|
|
+ <div class="input-wrap">
|
|
|
+ <input type="password" id="login-password" placeholder="••••••••" autocomplete="current-password">
|
|
|
+ <button class="eye" id="toggle-password" type="button" title="Show/hide password">
|
|
|
+ <svg id="eye-show" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
|
|
+ <svg id="eye-hide" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:none"><path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <button class="btn btn-primary" onclick="login()">
|
|
|
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M15 3h4a2 2 0 012 2v14a2 2 0 01-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
|
|
|
+ <span data-i18n="login.sign_in">Iniciar sesión</span>
|
|
|
+ </button>
|
|
|
+
|
|
|
+ <div id="login-error" class="login-error hidden"></div>
|
|
|
+ </div>
|
|
|
+</div>
|
|
|
+
|
|
|
+<!-- ═══════════════════ CHANGE PASSWORD ═══════════════════ -->
|
|
|
+<div id="change-password-screen" class="hidden">
|
|
|
+ <div class="login-card">
|
|
|
+ <button class="lang-toggle-btn card-lang-toggle" onclick="toggleLanguage()">EN</button>
|
|
|
+ <button class="cp-back-link hidden" id="cp-back-btn" onclick="cancelChangePassword()" data-i18n="cp.back">← Volver</button>
|
|
|
+ <div class="login-logo">
|
|
|
+ <div class="waveform" aria-hidden="true">
|
|
|
+ <span style="height:8px"></span>
|
|
|
+ <span style="height:14px"></span>
|
|
|
+ <span style="height:22px"></span>
|
|
|
+ <span style="height:18px"></span>
|
|
|
+ <span style="height:28px"></span>
|
|
|
+ <span style="height:20px"></span>
|
|
|
+ <span style="height:12px"></span>
|
|
|
+ <span style="height:6px"></span>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <h1 data-i18n="cp.title">Nueva contraseña</h1>
|
|
|
+ <small data-i18n="cp.head">Seguridad de cuenta</small>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <p class="login-subtitle" id="cp-subtitle"></p>
|
|
|
+
|
|
|
+ <div class="field hidden" id="cp-current-field">
|
|
|
+ <label for="cp-current" data-i18n="cp.current">Contraseña actual</label>
|
|
|
+ <input type="password" id="cp-current" placeholder="Tu contraseña actual" data-i18n-placeholder="cp.current_ph" autocomplete="current-password">
|
|
|
+ </div>
|
|
|
+ <div class="field">
|
|
|
+ <label for="cp-new" data-i18n="cp.new">Nueva contraseña</label>
|
|
|
+ <input type="password" id="cp-new" placeholder="Mínimo 8 caracteres" data-i18n-placeholder="cp.new_ph" autocomplete="new-password">
|
|
|
+ </div>
|
|
|
+ <div class="field">
|
|
|
+ <label for="cp-confirm" data-i18n="cp.confirm">Confirmar contraseña</label>
|
|
|
+ <input type="password" id="cp-confirm" placeholder="Repite la nueva contraseña" data-i18n-placeholder="cp.confirm_ph" autocomplete="new-password">
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <button class="btn btn-primary" onclick="submitChangePassword()" data-i18n="cp.submit">Establecer contraseña</button>
|
|
|
+ <div id="cp-error" class="login-error hidden"></div>
|
|
|
+ </div>
|
|
|
+</div>
|
|
|
+
|
|
|
+<!-- ═══════════════════ APP ═══════════════════ -->
|
|
|
+<div id="app-screen" class="hidden">
|
|
|
+ <header class="app-header">
|
|
|
+ <div class="header-logo">
|
|
|
+ <div class="dot"></div>
|
|
|
+ Transcriptor
|
|
|
+ </div>
|
|
|
+ <div class="header-right">
|
|
|
+ <div class="user-badge">
|
|
|
+ <div class="avatar user-avatar-el">?</div>
|
|
|
+ <div class="user-info">
|
|
|
+ <div class="user-name user-name-el">—</div>
|
|
|
+ <div class="user-email user-email-el">—</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <button class="lang-toggle-btn" onclick="toggleLanguage()">EN</button>
|
|
|
+ <button id="admin-btn" class="btn btn-ghost btn-sm hidden" onclick="showAdmin()" data-i18n="app.admin_panel">Panel de admin</button>
|
|
|
+ <button class="btn btn-ghost btn-sm" onclick="startChangePassword()" data-i18n="app.password">Contraseña</button>
|
|
|
+ <button class="btn btn-ghost btn-sm" onclick="logout()" data-i18n="app.sign_out">Cerrar sesión</button>
|
|
|
+ </div>
|
|
|
+ </header>
|
|
|
+
|
|
|
+ <main class="app-main">
|
|
|
+
|
|
|
+ <div class="section-label" data-i18n="s.upload">01 — Subir archivo</div>
|
|
|
+ <div class="card" style="padding: 0; overflow: hidden;">
|
|
|
+ <div class="upload-zone" id="upload-zone">
|
|
|
+ <input type="file" id="file-input" accept="audio/*,video/*,.mp4,.mp3,.wav,.ogg,.m4a,.webm,.mkv" onchange="onFileSelect(event)">
|
|
|
+ <div class="upload-icon">
|
|
|
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
|
|
+ <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
|
|
|
+ <polyline points="17 8 12 3 7 8"/>
|
|
|
+ <line x1="12" y1="3" x2="12" y2="15"/>
|
|
|
+ </svg>
|
|
|
+ </div>
|
|
|
+ <h3 data-i18n="upload.h3">Arrastra tu audio o video</h3>
|
|
|
+ <p data-i18n="upload.p">MP3, MP4, WAV, OGG, M4A, WebM — cualquier formato</p>
|
|
|
+ <div id="file-chip" class="hidden" style="justify-content:center;display:flex;margin-top:12px;" onclick="event.stopPropagation()">
|
|
|
+ <span class="file-chip">
|
|
|
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
|
|
+ <span id="file-name-display"></span>
|
|
|
+ <button class="remove" onclick="clearFile()" title="Remove file">×</button>
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="section-label" style="margin-top:24px;" data-i18n="s.settings">02 — Configuración</div>
|
|
|
+ <div class="card">
|
|
|
+ <div class="settings-grid" style="margin-bottom:20px;">
|
|
|
+ <div class="field">
|
|
|
+ <label for="lang-select" data-i18n="settings.language">Idioma</label>
|
|
|
+ <select id="lang-select">
|
|
|
+ <option value="es" selected>Spanish (es)</option>
|
|
|
+ <option value="en">English (en)</option>
|
|
|
+ <option value="pt">Portuguese (pt)</option>
|
|
|
+ <option value="fr">French (fr)</option>
|
|
|
+ <option value="de">German (de)</option>
|
|
|
+ <option value="it">Italian (it)</option>
|
|
|
+ <option value="ja">Japanese (ja)</option>
|
|
|
+ <option value="zh">Chinese (zh)</option>
|
|
|
+ <option value="auto">Auto-detect</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ <div class="field">
|
|
|
+ <label for="model-select" data-i18n="settings.model">Modelo</label>
|
|
|
+ <select id="model-select">
|
|
|
+ <option value="large-v3" selected>large-v3 (best)</option>
|
|
|
+ <option value="large-v2">large-v2</option>
|
|
|
+ <option value="medium">medium</option>
|
|
|
+ <option value="small">small (fast)</option>
|
|
|
+ <option value="base">base (fastest)</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="field">
|
|
|
+ <label data-i18n="settings.formats">Formatos de salida</label>
|
|
|
+ <div class="formats-grid">
|
|
|
+ <div class="fmt-check">
|
|
|
+ <input type="checkbox" id="fmt-txt" checked>
|
|
|
+ <label for="fmt-txt">
|
|
|
+ <span class="fmt-tag">.txt</span>
|
|
|
+ <span class="fmt-desc" data-i18n="fmt.txt_spk">Texto con hablantes</span>
|
|
|
+ </label>
|
|
|
+ </div>
|
|
|
+ <div class="fmt-check">
|
|
|
+ <input type="checkbox" id="fmt-srt" checked>
|
|
|
+ <label for="fmt-srt">
|
|
|
+ <span class="fmt-tag">.srt</span>
|
|
|
+ <span class="fmt-desc" data-i18n="fmt.srt_spk">Subtítulos con hablantes</span>
|
|
|
+ </label>
|
|
|
+ </div>
|
|
|
+ <div class="fmt-check">
|
|
|
+ <input type="checkbox" id="fmt-txt_nh">
|
|
|
+ <label for="fmt-txt_nh">
|
|
|
+ <span class="fmt-tag">.txt</span>
|
|
|
+ <span class="fmt-desc" data-i18n="fmt.txt_only">Solo texto</span>
|
|
|
+ </label>
|
|
|
+ </div>
|
|
|
+ <div class="fmt-check">
|
|
|
+ <input type="checkbox" id="fmt-srt_nh">
|
|
|
+ <label for="fmt-srt_nh">
|
|
|
+ <span class="fmt-tag">.srt</span>
|
|
|
+ <span class="fmt-desc" data-i18n="fmt.srt_only">Solo subtítulos</span>
|
|
|
+ </label>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="field" style="margin-top:20px;">
|
|
|
+ <label style="display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none;">
|
|
|
+ <span data-i18n="settings.prompt">Prompt inicial</span>
|
|
|
+ <span style="font-size:10px;color:var(--text3);font-weight:400;" data-i18n="settings.prompt_optional">(opcional)</span>
|
|
|
+ <button type="button" onclick="togglePromptField()" id="prompt-toggle-btn" style="background:none;border:none;color:var(--text3);cursor:pointer;padding:0;font-size:11px;margin-left:auto;" data-i18n="settings.prompt_expand">▼ Mostrar</button>
|
|
|
+ </label>
|
|
|
+ <div id="prompt-field" class="hidden" style="margin-top:8px;">
|
|
|
+ <textarea id="initial-prompt" rows="3" placeholder="" data-i18n-placeholder="settings.prompt_ph" style="width:100%;resize:vertical;font-family:'IBM Plex Mono',monospace;font-size:12px;background:var(--s2);border:1px solid var(--brd);border-radius:8px;color:var(--text1);padding:10px 12px;box-sizing:border-box;"></textarea>
|
|
|
+ <p style="font-size:11px;color:var(--text3);margin:4px 0 0;" data-i18n="settings.prompt_hint">Nombres propios, términos técnicos o contexto que ayude a Whisper a transcribir con mayor precisión.</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="submit-row">
|
|
|
+ <span id="submit-hint" style="color:var(--text3);font-size:12px;"></span>
|
|
|
+ <button class="btn btn-primary" id="submit-btn" onclick="submitJob()" disabled>
|
|
|
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
|
|
+ <span data-i18n="submit.transcribe">Transcribir</span>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div id="job-section" class="hidden">
|
|
|
+ <div class="section-label" style="margin-top:24px;" data-i18n="s.processing">03 — Procesando</div>
|
|
|
+ <div class="job-card" id="job-card">
|
|
|
+ <div class="job-card-top">
|
|
|
+ <span class="filename mono" id="job-filename"></span>
|
|
|
+ <span class="status-pill" id="status-pill">
|
|
|
+ <span class="status-dot"></span>
|
|
|
+ <span id="status-text-pill">Pending</span>
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div class="job-card-body" id="job-body">
|
|
|
+ <div class="waveform-anim" id="wave-anim">
|
|
|
+ <div class="bar"></div><div class="bar"></div><div class="bar"></div>
|
|
|
+ <div class="bar"></div><div class="bar"></div><div class="bar"></div>
|
|
|
+ <div class="bar"></div><div class="bar"></div><div class="bar"></div>
|
|
|
+ <div class="bar"></div><div class="bar"></div><div class="bar"></div>
|
|
|
+ <div class="bar"></div><div class="bar"></div><div class="bar"></div>
|
|
|
+ <div class="bar"></div><div class="bar"></div><div class="bar"></div>
|
|
|
+ <div class="bar"></div><div class="bar"></div>
|
|
|
+ </div>
|
|
|
+ <div class="status-text" id="job-status-desc" data-i18n="status.initializing">Inicializando…</div>
|
|
|
+ <div class="pipeline-steps" style="margin-top:22px;">
|
|
|
+ <div class="pipeline-step" id="step-transcribing">
|
|
|
+ <div class="step-node">1</div><span data-i18n="step.transcribe">Transcribir</span>
|
|
|
+ </div>
|
|
|
+ <div class="pipeline-step" id="step-diarizing">
|
|
|
+ <div class="step-node">2</div><span data-i18n="step.diarize">Identificar</span>
|
|
|
+ </div>
|
|
|
+ <div class="pipeline-step" id="step-combining">
|
|
|
+ <div class="step-node">3</div><span data-i18n="step.combine">Combinar</span>
|
|
|
+ </div>
|
|
|
+ <div class="pipeline-step" id="step-completed">
|
|
|
+ <div class="step-node">✓</div><span data-i18n="step.done">Listo</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div id="results-section" class="hidden">
|
|
|
+ <div class="section-label" style="margin-top:24px;" data-i18n="s.results">04 — Resultados</div>
|
|
|
+ <div class="card">
|
|
|
+ <div class="results-grid" id="results-grid"></div>
|
|
|
+ <div class="section-label" style="margin-top:4px;margin-bottom:12px;font-size:10px;" data-i18n="s.preview">Vista previa — primeros segmentos</div>
|
|
|
+ <div class="segments-preview" id="segments-preview"></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div style="margin-top:32px;">
|
|
|
+ <div class="section-label" style="justify-content:space-between;">
|
|
|
+ <span style="display:flex;align-items:center;gap:10px;"><span data-i18n="history.title">Historial de trabajos</span> <span id="history-count" style="font-family:'IBM Plex Mono',monospace;background:var(--s3);padding:1px 7px;border-radius:10px;font-size:10px;color:var(--text2);">0</span></span>
|
|
|
+ <button class="btn btn-ghost btn-sm" onclick="loadHistory()" style="font-size:10px;padding:4px 10px;" data-i18n="history.refresh">↻ Actualizar</button>
|
|
|
+ </div>
|
|
|
+ <div class="card" style="padding:0;overflow:hidden;">
|
|
|
+ <table class="history-table">
|
|
|
+ <thead>
|
|
|
+ <tr>
|
|
|
+ <th data-i18n="history.th_file">Archivo</th>
|
|
|
+ <th data-i18n="history.th_status">Estado</th>
|
|
|
+ <th data-i18n="history.th_language">Idioma</th>
|
|
|
+ <th data-i18n="history.th_segments">Segmentos</th>
|
|
|
+ <th data-i18n="history.th_time">Tiempo</th>
|
|
|
+ <th data-i18n="history.th_actions">Acciones</th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody id="history-tbody">
|
|
|
+ <tr><td colspan="6" class="history-empty">Loading…</td></tr>
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ </main>
|
|
|
+</div>
|
|
|
+
|
|
|
+<!-- ═══════════════════ ADMIN PANEL ═══════════════════ -->
|
|
|
+<div id="admin-screen" class="hidden">
|
|
|
+ <header class="app-header">
|
|
|
+ <div class="header-left">
|
|
|
+ <div class="header-logo">
|
|
|
+ <div class="dot"></div>
|
|
|
+ <span data-i18n="admin.title">Admin</span>
|
|
|
+ </div>
|
|
|
+ <div class="tab-nav">
|
|
|
+ <button class="tab-btn active" id="tab-btn-users" onclick="switchAdminTab('users')" data-i18n="admin.tab_users">Usuarios</button>
|
|
|
+ <button class="tab-btn" id="tab-btn-history" onclick="switchAdminTab('history')" data-i18n="admin.tab_history">Historia</button>
|
|
|
+ <button class="tab-btn" id="tab-btn-metrics" onclick="switchAdminTab('metrics')" data-i18n="admin.tab_metrics">Métricas</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="header-right">
|
|
|
+ <div class="user-badge">
|
|
|
+ <div class="avatar user-avatar-el">?</div>
|
|
|
+ <div class="user-info">
|
|
|
+ <div class="user-name user-name-el">—</div>
|
|
|
+ <div class="user-email user-email-el">—</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <button class="lang-toggle-btn" onclick="toggleLanguage()">EN</button>
|
|
|
+ <button class="btn btn-ghost btn-sm" onclick="showApp()" data-i18n="admin.back_app">← Aplicación</button>
|
|
|
+ <button class="btn btn-ghost btn-sm" onclick="logout()" data-i18n="app.sign_out">Cerrar sesión</button>
|
|
|
+ </div>
|
|
|
+ </header>
|
|
|
+
|
|
|
+ <main class="app-main-wide">
|
|
|
+
|
|
|
+ <!-- Users tab -->
|
|
|
+ <div id="admin-tab-users">
|
|
|
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
|
|
|
+ <div class="section-label" style="margin-bottom:0;flex:1;" data-i18n="admin.users_title">Cuentas de usuario</div>
|
|
|
+ <button class="btn btn-primary btn-sm" onclick="openCreateUserModal()" data-i18n="admin.create_user">+ Crear usuario</button>
|
|
|
+ </div>
|
|
|
+ <div class="card" style="padding:0;overflow:hidden;">
|
|
|
+ <table class="history-table">
|
|
|
+ <thead>
|
|
|
+ <tr>
|
|
|
+ <th data-i18n="admin.th_name">Nombre</th>
|
|
|
+ <th data-i18n="admin.th_email">Correo</th>
|
|
|
+ <th data-i18n="admin.th_role">Rol</th>
|
|
|
+ <th data-i18n="admin.th_password">Contraseña</th>
|
|
|
+ <th data-i18n="admin.th_api_key">Clave API</th>
|
|
|
+ <th data-i18n="admin.th_created">Creado</th>
|
|
|
+ <th data-i18n="admin.th_actions">Acciones</th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody id="admin-users-tbody">
|
|
|
+ <tr><td colspan="7" class="history-empty">Loading…</td></tr>
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- History tab -->
|
|
|
+ <div id="admin-tab-history" class="hidden">
|
|
|
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
|
|
|
+ <div class="section-label" style="margin-bottom:0;flex:1;" data-i18n="admin.history_title">Historial global de trabajos</div>
|
|
|
+ <button class="btn btn-ghost btn-sm" onclick="loadAdminJobs()" style="font-size:10px;padding:4px 10px;" data-i18n="history.refresh">↻ Actualizar</button>
|
|
|
+ </div>
|
|
|
+ <div class="card" style="padding:0;overflow:hidden;">
|
|
|
+ <table class="history-table">
|
|
|
+ <thead>
|
|
|
+ <tr>
|
|
|
+ <th data-i18n="history.th_file">Archivo</th>
|
|
|
+ <th data-i18n="admin.th_user">Usuario</th>
|
|
|
+ <th data-i18n="history.th_status">Estado</th>
|
|
|
+ <th data-i18n="history.th_language">Idioma</th>
|
|
|
+ <th data-i18n="settings.model">Modelo</th>
|
|
|
+ <th data-i18n="history.th_time">Tiempo</th>
|
|
|
+ <th data-i18n="admin.th_created">Creado</th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody id="admin-history-tbody">
|
|
|
+ <tr><td colspan="7" class="history-empty">Loading…</td></tr>
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Metrics tab -->
|
|
|
+ <div id="admin-tab-metrics" class="hidden">
|
|
|
+ <div class="section-label" data-i18n="metrics.overview">Resumen</div>
|
|
|
+ <div id="metrics-content">
|
|
|
+ <div class="history-empty">Loading metrics…</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ </main>
|
|
|
+</div>
|
|
|
+
|
|
|
+<!-- ═══════════════════ MODAL ═══════════════════ -->
|
|
|
+<div id="modal-overlay" class="hidden" onclick="if(event.target===this)closeModal()">
|
|
|
+ <div id="modal-box" class="modal">
|
|
|
+ <div id="modal-title" class="modal-title"></div>
|
|
|
+ <div id="modal-body"></div>
|
|
|
+ <div id="modal-actions" class="modal-actions"></div>
|
|
|
+ </div>
|
|
|
+</div>
|
|
|
+
|
|
|
+<!-- Toast -->
|
|
|
+<div id="toast"></div>
|
|
|
+
|
|
|
+<script>
|
|
|
+// ─── I18N ─────────────────────────────────────────────────────
|
|
|
+let currentLang = localStorage.getItem('tx_lang') || 'es';
|
|
|
+
|
|
|
+const T = {
|
|
|
+ en: {
|
|
|
+ 'login.subtitle':'Audio Intelligence','login.email':'Email','login.email_ph':'your@email.com',
|
|
|
+ 'login.password':'Password','login.sign_in':'Sign In',
|
|
|
+ 'login.err_fill':'Please fill in both fields.','login.err_invalid':'Invalid email or password.',
|
|
|
+ 'login.err_server':'Could not reach the server.',
|
|
|
+ 'cp.title':'New Password','cp.head':'Account Security','cp.back':'← Back',
|
|
|
+ 'cp.subtitle_voluntary':'Enter your current password, then choose a new one.',
|
|
|
+ 'cp.subtitle_forced':'Welcome, <strong>{name}</strong>! Please set a new password to activate your account.',
|
|
|
+ 'cp.fallback_name':'there',
|
|
|
+ 'cp.current':'Current Password','cp.current_ph':'Your current password',
|
|
|
+ 'cp.new':'New Password','cp.new_ph':'Minimum 8 characters',
|
|
|
+ 'cp.confirm':'Confirm Password','cp.confirm_ph':'Repeat new password',
|
|
|
+ 'cp.submit':'Set Password',
|
|
|
+ 'cp.err_short':'Password must be at least 8 characters.','cp.err_mismatch':'Passwords do not match.',
|
|
|
+ 'cp.err_current_required':'Current password is required.','cp.err_failed':'Could not change password.',
|
|
|
+ 'cp.err_network':'Network error — please try again.','cp.success':'Password updated successfully',
|
|
|
+ 'app.admin_panel':'Admin Panel','app.password':'Password','app.sign_out':'Sign out',
|
|
|
+ 's.upload':'01 — Upload','upload.h3':'Drop your audio or video',
|
|
|
+ 'upload.p':'MP3, MP4, WAV, OGG, M4A, WebM — any format',
|
|
|
+ 's.settings':'02 — Settings','settings.language':'Language','settings.model':'Model',
|
|
|
+ 'settings.prompt':'Initial Prompt','settings.prompt_optional':'(optional)','settings.prompt_expand':'▼ Show','settings.prompt_collapse':'▲ Hide',
|
|
|
+ 'settings.prompt_ph':'e.g. proper nouns, technical terms, or context to help Whisper…',
|
|
|
+ 'settings.prompt_hint':'Proper nouns, technical terms, or context that helps Whisper transcribe more accurately.',
|
|
|
+ 'settings.formats':'Output Formats','fmt.txt_spk':'Text with speakers',
|
|
|
+ 'fmt.srt_spk':'Subtitles with speakers','fmt.txt_only':'Text only','fmt.srt_only':'Subtitles only',
|
|
|
+ 'submit.transcribe':'Transcribe','submit.select_fmt':'Select at least one output format.',
|
|
|
+ 's.processing':'03 — Processing','step.transcribe':'Transcribe','step.diarize':'Diarize',
|
|
|
+ 'step.combine':'Combine','step.done':'Done','status.initializing':'Initializing…',
|
|
|
+ 'status.pending':'Pending','status.converting':'Converting','status.transcribing':'Transcribing',
|
|
|
+ 'status.diarizing':'Identifying speakers','status.combining':'Combining',
|
|
|
+ 'status.completed':'Completed','status.failed':'Failed',
|
|
|
+ 'status.pending_desc':'Queued — waiting to start…','status.converting_desc':'Converting video to audio…',
|
|
|
+ 'status.transcribing_desc':'<strong>Step 1</strong> — Running Whisper speech recognition…',
|
|
|
+ 'status.diarizing_desc':'<strong>Step 2</strong> — Running PyAnnote speaker diarization…',
|
|
|
+ 'status.combining_desc':'<strong>Step 3</strong> — Aligning words with speaker turns…',
|
|
|
+ 'status.completed_desc':'Done! Your transcription is ready.','status.failed_desc':'An error occurred.',
|
|
|
+ 's.results':'04 — Results','s.preview':'Preview — first segments',
|
|
|
+ 'results.more':'+ {n} more segments','results.downloaded':'Downloaded {fmt}',
|
|
|
+ 'results.dl_failed':'Download failed','results.err':'Error: {msg}',
|
|
|
+ 'fmtlabel.txt':'TXT with speakers','fmtlabel.srt':'SRT with speakers',
|
|
|
+ 'fmtlabel.txt_nh':'TXT plain text','fmtlabel.srt_nh':'SRT plain text',
|
|
|
+ 'fmt.no_spk':'(no spk)',
|
|
|
+ 'history.title':'Jobs History','history.refresh':'↻ Refresh',
|
|
|
+ 'history.th_file':'File','history.th_status':'Status','history.th_language':'Language',
|
|
|
+ 'history.th_segments':'Segments','history.th_time':'Time','history.th_actions':'Actions',
|
|
|
+ 'history.loading':'Loading…','history.empty':'No jobs yet','history.job_is':'Job is {status}',
|
|
|
+ 'admin.title':'Admin','admin.tab_users':'Users','admin.tab_history':'History','admin.tab_metrics':'Metrics','admin.back_app':'← App',
|
|
|
+ 'admin.history_title':'Global Job History','admin.th_user':'User',
|
|
|
+ 'admin.users_title':'User Accounts','admin.create_user':'+ Create User',
|
|
|
+ 'admin.th_name':'Name','admin.th_email':'Email','admin.th_role':'Role',
|
|
|
+ 'admin.th_password':'Password','admin.th_api_key':'API Key','admin.th_created':'Created',
|
|
|
+ 'admin.th_actions':'Actions','admin.loading':'Loading…','admin.no_users':'No users yet',
|
|
|
+ 'admin.pwd_default':'Default','admin.pwd_set':'Set','admin.api_yes':'Yes','admin.api_no':'No',
|
|
|
+ 'admin.revoke_key':'Revoke Key','admin.gen_key':'Gen Key','admin.reset_pwd':'Reset Pwd',
|
|
|
+ 'admin.delete':'Delete','admin.load_failed':'Failed to load users',
|
|
|
+ 'metrics.overview':'Overview','metrics.loading':'Loading metrics…',
|
|
|
+ 'metrics.failed':'Failed to load metrics','metrics.total_jobs':'Total Jobs',
|
|
|
+ 'metrics.completed':'Completed','metrics.failed_label':'Failed',
|
|
|
+ 'metrics.avg_transcription':'Avg Transcription','metrics.by_model':'By Model',
|
|
|
+ 'metrics.by_language':'By Language','metrics.by_user':'By User','metrics.no_data':'No data',
|
|
|
+ 'modal.create_user':'Create User','modal.email':'Email','modal.email_ph':'user@example.com',
|
|
|
+ 'modal.full_name':'Full Name','modal.full_name_ph':'Jane Smith',
|
|
|
+ 'modal.default_password':'Default Password',
|
|
|
+ 'modal.default_password_ph':'Temporary password (min 8 chars)',
|
|
|
+ 'modal.default_password_note':'User will be required to change this on first login.',
|
|
|
+ 'modal.role':'Role','modal.role_user':'User','modal.role_user_desc':'Standard access',
|
|
|
+ 'modal.role_admin':'Admin','modal.role_admin_desc':'Full access',
|
|
|
+ 'modal.cancel':'Cancel','modal.create':'Create',
|
|
|
+ 'modal.all_required':'All fields are required.',
|
|
|
+ 'modal.pwd_short':'Password must be at least 8 characters.',
|
|
|
+ 'modal.user_created':'User {name} created','modal.user_create_failed':'Could not create user.',
|
|
|
+ 'modal.network_error':'Network error.',
|
|
|
+ 'modal.reset_pwd_title':'Reset Password',
|
|
|
+ 'modal.reset_pwd_desc':'Set a new default password for <strong style="color:var(--text)">{name}</strong>. They will be required to change it on next login.',
|
|
|
+ 'modal.new_default_pwd':'New Default Password','modal.min_8_ph':'Minimum 8 characters',
|
|
|
+ 'modal.reset':'Reset','modal.reset_failed':'Failed.',
|
|
|
+ 'modal.pwd_reset_success':'Password reset — user must change on next login',
|
|
|
+ 'modal.confirm_delete':'Delete user "{name}"? This cannot be undone.',
|
|
|
+ 'modal.user_deleted':'User {name} deleted','modal.delete_failed':'Failed to delete user',
|
|
|
+ 'modal.api_generated_title':'API Key Generated',
|
|
|
+ 'modal.api_copy_note':'Copy this key — it will <strong style="color:var(--err)">not be shown again</strong>.',
|
|
|
+ 'modal.api_share_note':'Share it with the user to allow direct REST API access via the <code style="font-family:\'IBM Plex Mono\',monospace;background:var(--s3);padding:1px 5px;border-radius:3px;">X-API-Key</code> header.',
|
|
|
+ 'modal.copy_key':'Copy Key','modal.done':'Done',
|
|
|
+ 'modal.key_copied':'Key copied to clipboard','modal.copy_failed':'Copy failed — select manually',
|
|
|
+ 'modal.revoke_confirm':'Revoke API key for "{name}"? They will no longer be able to use the REST API.',
|
|
|
+ 'modal.revoke_failed':'Failed to revoke key','modal.key_revoked':'API key revoked',
|
|
|
+ 'modal.gen_failed':'Failed','network_error':'Network error','submission_failed':'Submission failed',
|
|
|
+ },
|
|
|
+ es: {
|
|
|
+ 'login.subtitle':'Inteligencia de audio','login.email':'Correo electrónico','login.email_ph':'tu@correo.com',
|
|
|
+ 'login.password':'Contraseña','login.sign_in':'Iniciar sesión',
|
|
|
+ 'login.err_fill':'Por favor completa ambos campos.','login.err_invalid':'Correo o contraseña inválidos.',
|
|
|
+ 'login.err_server':'No se pudo conectar al servidor.',
|
|
|
+ 'cp.title':'Nueva contraseña','cp.head':'Seguridad de cuenta','cp.back':'← Volver',
|
|
|
+ 'cp.subtitle_voluntary':'Ingresa tu contraseña actual y elige una nueva.',
|
|
|
+ 'cp.subtitle_forced':'¡Bienvenido/a, <strong>{name}</strong>! Por favor establece una nueva contraseña para activar tu cuenta.',
|
|
|
+ 'cp.fallback_name':'usuario',
|
|
|
+ 'cp.current':'Contraseña actual','cp.current_ph':'Tu contraseña actual',
|
|
|
+ 'cp.new':'Nueva contraseña','cp.new_ph':'Mínimo 8 caracteres',
|
|
|
+ 'cp.confirm':'Confirmar contraseña','cp.confirm_ph':'Repite la nueva contraseña',
|
|
|
+ 'cp.submit':'Establecer contraseña',
|
|
|
+ 'cp.err_short':'La contraseña debe tener al menos 8 caracteres.','cp.err_mismatch':'Las contraseñas no coinciden.',
|
|
|
+ 'cp.err_current_required':'La contraseña actual es requerida.','cp.err_failed':'No se pudo cambiar la contraseña.',
|
|
|
+ 'cp.err_network':'Error de red — inténtalo de nuevo.','cp.success':'Contraseña actualizada correctamente',
|
|
|
+ 'app.admin_panel':'Panel de admin','app.password':'Contraseña','app.sign_out':'Cerrar sesión',
|
|
|
+ 's.upload':'01 — Subir archivo','upload.h3':'Arrastra tu audio o video',
|
|
|
+ 'upload.p':'MP3, MP4, WAV, OGG, M4A, WebM — cualquier formato',
|
|
|
+ 's.settings':'02 — Configuración','settings.language':'Idioma','settings.model':'Modelo',
|
|
|
+ 'settings.prompt':'Prompt inicial','settings.prompt_optional':'(opcional)','settings.prompt_expand':'▼ Mostrar','settings.prompt_collapse':'▲ Ocultar',
|
|
|
+ 'settings.prompt_ph':'Ej: nombres propios, términos técnicos o contexto que ayude a Whisper…',
|
|
|
+ 'settings.prompt_hint':'Nombres propios, términos técnicos o contexto que ayude a Whisper a transcribir con mayor precisión.',
|
|
|
+ 'settings.formats':'Formatos de salida','fmt.txt_spk':'Texto con hablantes',
|
|
|
+ 'fmt.srt_spk':'Subtítulos con hablantes','fmt.txt_only':'Solo texto','fmt.srt_only':'Solo subtítulos',
|
|
|
+ 'submit.transcribe':'Transcribir','submit.select_fmt':'Selecciona al menos un formato de salida.',
|
|
|
+ 's.processing':'03 — Procesando','step.transcribe':'Transcribir','step.diarize':'Identificar',
|
|
|
+ 'step.combine':'Combinar','step.done':'Listo','status.initializing':'Inicializando…',
|
|
|
+ 'status.pending':'En espera','status.converting':'Convirtiendo','status.transcribing':'Transcribiendo',
|
|
|
+ 'status.diarizing':'Identificando hablantes','status.combining':'Combinando',
|
|
|
+ 'status.completed':'Completado','status.failed':'Fallido',
|
|
|
+ 'status.pending_desc':'En cola — esperando inicio…','status.converting_desc':'Convirtiendo video a audio…',
|
|
|
+ 'status.transcribing_desc':'<strong>Paso 1</strong> — Ejecutando reconocimiento de voz Whisper…',
|
|
|
+ 'status.diarizing_desc':'<strong>Paso 2</strong> — Ejecutando diarización de hablantes PyAnnote…',
|
|
|
+ 'status.combining_desc':'<strong>Paso 3</strong> — Alineando palabras con turnos de hablantes…',
|
|
|
+ 'status.completed_desc':'¡Listo! Tu transcripción está disponible.','status.failed_desc':'Ocurrió un error.',
|
|
|
+ 's.results':'04 — Resultados','s.preview':'Vista previa — primeros segmentos',
|
|
|
+ 'results.more':'+ {n} segmentos más','results.downloaded':'Descargado {fmt}',
|
|
|
+ 'results.dl_failed':'Error al descargar','results.err':'Error: {msg}',
|
|
|
+ 'fmtlabel.txt':'TXT con hablantes','fmtlabel.srt':'SRT con hablantes',
|
|
|
+ 'fmtlabel.txt_nh':'TXT texto plano','fmtlabel.srt_nh':'SRT texto plano',
|
|
|
+ 'fmt.no_spk':'(sin hbl)',
|
|
|
+ 'history.title':'Historial de trabajos','history.refresh':'↻ Actualizar',
|
|
|
+ 'history.th_file':'Archivo','history.th_status':'Estado','history.th_language':'Idioma',
|
|
|
+ 'history.th_segments':'Segmentos','history.th_time':'Tiempo','history.th_actions':'Acciones',
|
|
|
+ 'history.loading':'Cargando…','history.empty':'Sin trabajos aún','history.job_is':'El trabajo está {status}',
|
|
|
+ 'admin.title':'Admin','admin.tab_users':'Usuarios','admin.tab_history':'Historia','admin.tab_metrics':'Métricas','admin.back_app':'← Aplicación',
|
|
|
+ 'admin.history_title':'Historial global de trabajos','admin.th_user':'Usuario',
|
|
|
+ 'admin.users_title':'Cuentas de usuario','admin.create_user':'+ Crear usuario',
|
|
|
+ 'admin.th_name':'Nombre','admin.th_email':'Correo','admin.th_role':'Rol',
|
|
|
+ 'admin.th_password':'Contraseña','admin.th_api_key':'Clave API','admin.th_created':'Creado',
|
|
|
+ 'admin.th_actions':'Acciones','admin.loading':'Cargando…','admin.no_users':'Sin usuarios aún',
|
|
|
+ 'admin.pwd_default':'Por defecto','admin.pwd_set':'Establecida','admin.api_yes':'Sí','admin.api_no':'No',
|
|
|
+ 'admin.revoke_key':'Revocar','admin.gen_key':'Gen. clave','admin.reset_pwd':'Restablecer',
|
|
|
+ 'admin.delete':'Eliminar','admin.load_failed':'Error al cargar usuarios',
|
|
|
+ 'metrics.overview':'Resumen','metrics.loading':'Cargando métricas…',
|
|
|
+ 'metrics.failed':'Error al cargar métricas','metrics.total_jobs':'Total de trabajos',
|
|
|
+ 'metrics.completed':'Completados','metrics.failed_label':'Fallidos',
|
|
|
+ 'metrics.avg_transcription':'Transcripción prom.','metrics.by_model':'Por modelo',
|
|
|
+ 'metrics.by_language':'Por idioma','metrics.by_user':'Por usuario','metrics.no_data':'Sin datos',
|
|
|
+ 'modal.create_user':'Crear usuario','modal.email':'Correo electrónico','modal.email_ph':'usuario@ejemplo.com',
|
|
|
+ 'modal.full_name':'Nombre completo','modal.full_name_ph':'Juan Pérez',
|
|
|
+ 'modal.default_password':'Contraseña por defecto',
|
|
|
+ 'modal.default_password_ph':'Contraseña temporal (mín. 8 caracteres)',
|
|
|
+ 'modal.default_password_note':'El usuario deberá cambiarla en el primer inicio de sesión.',
|
|
|
+ 'modal.role':'Rol','modal.role_user':'Usuario','modal.role_user_desc':'Acceso estándar',
|
|
|
+ 'modal.role_admin':'Admin','modal.role_admin_desc':'Acceso completo',
|
|
|
+ 'modal.cancel':'Cancelar','modal.create':'Crear',
|
|
|
+ 'modal.all_required':'Todos los campos son requeridos.',
|
|
|
+ 'modal.pwd_short':'La contraseña debe tener al menos 8 caracteres.',
|
|
|
+ 'modal.user_created':'Usuario {name} creado','modal.user_create_failed':'No se pudo crear el usuario.',
|
|
|
+ 'modal.network_error':'Error de red.',
|
|
|
+ 'modal.reset_pwd_title':'Restablecer contraseña',
|
|
|
+ 'modal.reset_pwd_desc':'Establece una nueva contraseña por defecto para <strong style="color:var(--text)">{name}</strong>. Deberá cambiarla en el siguiente inicio de sesión.',
|
|
|
+ 'modal.new_default_pwd':'Nueva contraseña por defecto','modal.min_8_ph':'Mínimo 8 caracteres',
|
|
|
+ 'modal.reset':'Restablecer','modal.reset_failed':'Error.',
|
|
|
+ 'modal.pwd_reset_success':'Contraseña restablecida — el usuario debe cambiarla al siguiente inicio',
|
|
|
+ 'modal.confirm_delete':'¿Eliminar usuario "{name}"? Esta acción no se puede deshacer.',
|
|
|
+ 'modal.user_deleted':'Usuario {name} eliminado','modal.delete_failed':'Error al eliminar usuario',
|
|
|
+ 'modal.api_generated_title':'Clave API generada',
|
|
|
+ 'modal.api_copy_note':'Copia esta clave — <strong style="color:var(--err)">no se mostrará de nuevo</strong>.',
|
|
|
+ 'modal.api_share_note':'Compártela con el usuario para acceso REST API directo mediante el encabezado <code style="font-family:\'IBM Plex Mono\',monospace;background:var(--s3);padding:1px 5px;border-radius:3px;">X-API-Key</code>.',
|
|
|
+ 'modal.copy_key':'Copiar clave','modal.done':'Hecho',
|
|
|
+ 'modal.key_copied':'Clave copiada al portapapeles','modal.copy_failed':'Error al copiar — selecciona manualmente',
|
|
|
+ 'modal.revoke_confirm':'¿Revocar la clave API de "{name}"? Ya no podrán usar la REST API.',
|
|
|
+ 'modal.revoke_failed':'Error al revocar la clave','modal.key_revoked':'Clave API revocada',
|
|
|
+ 'modal.gen_failed':'Error','network_error':'Error de red','submission_failed':'Error al enviar',
|
|
|
+ },
|
|
|
+};
|
|
|
+
|
|
|
+function t(key, vars) {
|
|
|
+ let str = (T[currentLang]?.[key]) ?? (T['en']?.[key]) ?? key;
|
|
|
+ if (vars) for (const [k, v] of Object.entries(vars)) str = str.replaceAll('{' + k + '}', v);
|
|
|
+ return str;
|
|
|
+}
|
|
|
+
|
|
|
+function applyTranslations() {
|
|
|
+ document.documentElement.lang = currentLang;
|
|
|
+ document.querySelectorAll('[data-i18n]').forEach(el => { el.textContent = t(el.dataset.i18n); });
|
|
|
+ document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { el.placeholder = t(el.dataset.i18nPlaceholder); });
|
|
|
+ document.querySelectorAll('.lang-toggle-btn').forEach(btn => { btn.textContent = currentLang === 'es' ? 'EN' : 'ES'; });
|
|
|
+}
|
|
|
+
|
|
|
+function toggleLanguage() {
|
|
|
+ currentLang = currentLang === 'es' ? 'en' : 'es';
|
|
|
+ localStorage.setItem('tx_lang', currentLang);
|
|
|
+ applyTranslations();
|
|
|
+ // re-render visible dynamic content
|
|
|
+ if (!document.getElementById('app-screen').classList.contains('hidden')) renderHistory();
|
|
|
+ if (!document.getElementById('admin-screen').classList.contains('hidden')) {
|
|
|
+ if (currentAdminTab === 'users') renderAdminUsers();
|
|
|
+ else if (cachedMetrics) renderMetrics(cachedMetrics);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ─── STATE ────────────────────────────────────────────────────
|
|
|
+let currentUser = null; // { email, name, role, session_token }
|
|
|
+let currentJob = null;
|
|
|
+let pollTimer = null;
|
|
|
+let jobHistory = [];
|
|
|
+let changePwForced = false;
|
|
|
+let changePwReturnTo = 'app';
|
|
|
+let adminUsers = [];
|
|
|
+let currentAdminTab = 'users';
|
|
|
+let cachedMetrics = null;
|
|
|
+
|
|
|
+// ─── INIT ─────────────────────────────────────────────────────
|
|
|
+window.addEventListener('DOMContentLoaded', () => {
|
|
|
+ applyTranslations();
|
|
|
+ setupDragDrop();
|
|
|
+
|
|
|
+ const saved = localStorage.getItem('tx_session');
|
|
|
+ if (saved) {
|
|
|
+ try {
|
|
|
+ currentUser = JSON.parse(saved);
|
|
|
+ verifyAndShow();
|
|
|
+ } catch { showLogin(); }
|
|
|
+ }
|
|
|
+
|
|
|
+ document.getElementById('login-password').addEventListener('keydown', e => {
|
|
|
+ if (e.key === 'Enter') login();
|
|
|
+ });
|
|
|
+ document.getElementById('login-email').addEventListener('keydown', e => {
|
|
|
+ if (e.key === 'Enter') document.getElementById('login-password').focus();
|
|
|
+ });
|
|
|
+ document.getElementById('toggle-password').addEventListener('click', () => {
|
|
|
+ const inp = document.getElementById('login-password');
|
|
|
+ const show = document.getElementById('eye-show');
|
|
|
+ const hide = document.getElementById('eye-hide');
|
|
|
+ if (inp.type === 'password') {
|
|
|
+ inp.type = 'text'; show.style.display = 'none'; hide.style.display = '';
|
|
|
+ } else {
|
|
|
+ inp.type = 'password'; show.style.display = ''; hide.style.display = 'none';
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ document.getElementById('cp-new').addEventListener('keydown', e => {
|
|
|
+ if (e.key === 'Enter') document.getElementById('cp-confirm').focus();
|
|
|
+ });
|
|
|
+ document.getElementById('cp-confirm').addEventListener('keydown', e => {
|
|
|
+ if (e.key === 'Enter') submitChangePassword();
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+// ─── AUTH ─────────────────────────────────────────────────────
|
|
|
+async function verifyAndShow() {
|
|
|
+ try {
|
|
|
+ const res = await apiFetch('/auth/verify');
|
|
|
+ if (res.ok) {
|
|
|
+ const data = await res.json();
|
|
|
+ currentUser = { ...currentUser, ...data };
|
|
|
+ localStorage.setItem('tx_session', JSON.stringify(currentUser));
|
|
|
+ if (data.is_default_password) {
|
|
|
+ showChangePassword(true, 'app');
|
|
|
+ } else {
|
|
|
+ showApp();
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ logout();
|
|
|
+ }
|
|
|
+ } catch { showLogin(); }
|
|
|
+}
|
|
|
+
|
|
|
+async function login() {
|
|
|
+ const email = document.getElementById('login-email').value.trim();
|
|
|
+ const password = document.getElementById('login-password').value;
|
|
|
+ if (!email || !password) { showLoginError(t('login.err_fill')); return; }
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await fetch('/auth/login', {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({ email, password }),
|
|
|
+ });
|
|
|
+ const data = await res.json();
|
|
|
+ if (!res.ok) { showLoginError(data.detail || t('login.err_invalid')); return; }
|
|
|
+
|
|
|
+ currentUser = {
|
|
|
+ email: data.user.email,
|
|
|
+ name: data.user.name,
|
|
|
+ role: data.user.role,
|
|
|
+ session_token: data.session_token,
|
|
|
+ };
|
|
|
+ localStorage.setItem('tx_session', JSON.stringify(currentUser));
|
|
|
+
|
|
|
+ if (data.is_default_password) {
|
|
|
+ showChangePassword(true, 'app');
|
|
|
+ } else {
|
|
|
+ showApp();
|
|
|
+ }
|
|
|
+ } catch { showLoginError(t('login.err_server')); }
|
|
|
+}
|
|
|
+
|
|
|
+async function logout() {
|
|
|
+ if (currentUser?.session_token) {
|
|
|
+ try { await apiFetch('/auth/logout', { method: 'POST' }); } catch {}
|
|
|
+ }
|
|
|
+ localStorage.removeItem('tx_session');
|
|
|
+ currentUser = null;
|
|
|
+ if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
|
|
+ showLogin();
|
|
|
+}
|
|
|
+
|
|
|
+function showLoginError(msg) {
|
|
|
+ const el = document.getElementById('login-error');
|
|
|
+ el.textContent = msg;
|
|
|
+ el.classList.remove('hidden');
|
|
|
+ el.style.animation = 'none';
|
|
|
+ requestAnimationFrame(() => { el.style.animation = ''; });
|
|
|
+}
|
|
|
+
|
|
|
+// ─── CHANGE PASSWORD ──────────────────────────────────────────
|
|
|
+function showChangePassword(forced, returnTo) {
|
|
|
+ changePwForced = forced;
|
|
|
+ changePwReturnTo = returnTo || 'app';
|
|
|
+
|
|
|
+ document.getElementById('cp-new').value = '';
|
|
|
+ document.getElementById('cp-confirm').value = '';
|
|
|
+ document.getElementById('cp-current').value = '';
|
|
|
+ document.getElementById('cp-error').classList.add('hidden');
|
|
|
+
|
|
|
+ if (forced) {
|
|
|
+ document.getElementById('cp-back-btn').classList.add('hidden');
|
|
|
+ document.getElementById('cp-current-field').classList.add('hidden');
|
|
|
+ const name = currentUser?.name ? currentUser.name.split(' ')[0] : t('cp.fallback_name');
|
|
|
+ document.getElementById('cp-subtitle').innerHTML = t('cp.subtitle_forced', { name: escHtml(name) });
|
|
|
+ } else {
|
|
|
+ document.getElementById('cp-back-btn').classList.remove('hidden');
|
|
|
+ document.getElementById('cp-current-field').classList.remove('hidden');
|
|
|
+ document.getElementById('cp-subtitle').textContent = t('cp.subtitle_voluntary');
|
|
|
+ }
|
|
|
+
|
|
|
+ showScreen('change-password');
|
|
|
+}
|
|
|
+
|
|
|
+function cancelChangePassword() {
|
|
|
+ showScreen(changePwReturnTo);
|
|
|
+}
|
|
|
+
|
|
|
+function startChangePassword() {
|
|
|
+ showChangePassword(false, currentAdminTab === 'users' && document.getElementById('admin-screen').classList.contains('hidden') === false ? 'admin' : 'app');
|
|
|
+}
|
|
|
+
|
|
|
+async function submitChangePassword() {
|
|
|
+ const newPw = document.getElementById('cp-new').value;
|
|
|
+ const confirm = document.getElementById('cp-confirm').value;
|
|
|
+ const currPw = document.getElementById('cp-current').value;
|
|
|
+ const errEl = document.getElementById('cp-error');
|
|
|
+
|
|
|
+ errEl.classList.add('hidden');
|
|
|
+
|
|
|
+ if (newPw.length < 8) { showCpError(t('cp.err_short')); return; }
|
|
|
+ if (newPw !== confirm) { showCpError(t('cp.err_mismatch')); return; }
|
|
|
+ if (!changePwForced && !currPw) { showCpError(t('cp.err_current_required')); return; }
|
|
|
+
|
|
|
+ try {
|
|
|
+ const body = { new_password: newPw };
|
|
|
+ if (!changePwForced) body.current_password = currPw;
|
|
|
+
|
|
|
+ const res = await apiFetch('/auth/change-password', {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify(body),
|
|
|
+ });
|
|
|
+ const data = await res.json();
|
|
|
+ if (!res.ok) { showCpError(data.detail || t('cp.err_failed')); return; }
|
|
|
+
|
|
|
+ toast(t('cp.success'));
|
|
|
+ showScreen(changePwReturnTo);
|
|
|
+ } catch { showCpError(t('cp.err_network')); }
|
|
|
+}
|
|
|
+
|
|
|
+function showCpError(msg) {
|
|
|
+ const el = document.getElementById('cp-error');
|
|
|
+ el.textContent = msg;
|
|
|
+ el.classList.remove('hidden');
|
|
|
+ el.style.animation = 'none';
|
|
|
+ requestAnimationFrame(() => { el.style.animation = ''; });
|
|
|
+}
|
|
|
+
|
|
|
+// ─── NAVIGATION ───────────────────────────────────────────────
|
|
|
+function showScreen(name) {
|
|
|
+ ['login', 'change-password', 'app', 'admin'].forEach(s => {
|
|
|
+ const el = document.getElementById(s + '-screen');
|
|
|
+ if (el) el.classList.toggle('hidden', s !== name);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function showLogin() {
|
|
|
+ document.getElementById('login-error').classList.add('hidden');
|
|
|
+ document.getElementById('login-password').value = '';
|
|
|
+ showScreen('login');
|
|
|
+}
|
|
|
+
|
|
|
+function showApp() {
|
|
|
+ updateUserBadges();
|
|
|
+ const adminBtn = document.getElementById('admin-btn');
|
|
|
+ if (adminBtn) adminBtn.classList.toggle('hidden', currentUser?.role !== 'admin');
|
|
|
+ showScreen('app');
|
|
|
+ loadHistory();
|
|
|
+}
|
|
|
+
|
|
|
+function showAdmin() {
|
|
|
+ updateUserBadges();
|
|
|
+ showScreen('admin');
|
|
|
+ switchAdminTab(currentAdminTab);
|
|
|
+}
|
|
|
+
|
|
|
+function updateUserBadges() {
|
|
|
+ const initial = (currentUser?.name || currentUser?.email || '?')[0].toUpperCase();
|
|
|
+ document.querySelectorAll('.user-avatar-el').forEach(el => el.textContent = initial);
|
|
|
+ document.querySelectorAll('.user-name-el').forEach(el => el.textContent = currentUser?.name || '—');
|
|
|
+ document.querySelectorAll('.user-email-el').forEach(el => el.textContent = currentUser?.email || '—');
|
|
|
+}
|
|
|
+
|
|
|
+// ─── API HELPER ───────────────────────────────────────────────
|
|
|
+function apiFetch(path, opts = {}) {
|
|
|
+ opts.headers = {
|
|
|
+ ...(opts.headers || {}),
|
|
|
+ 'X-Session-Token': currentUser?.session_token || '',
|
|
|
+ };
|
|
|
+ return fetch(path, opts);
|
|
|
+}
|
|
|
+
|
|
|
+// ─── FILE UPLOAD ──────────────────────────────────────────────
|
|
|
+function setupDragDrop() {
|
|
|
+ const zone = document.getElementById('upload-zone');
|
|
|
+ zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('drag-over'); });
|
|
|
+ zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'));
|
|
|
+ zone.addEventListener('drop', e => {
|
|
|
+ e.preventDefault();
|
|
|
+ zone.classList.remove('drag-over');
|
|
|
+ const f = e.dataTransfer.files[0];
|
|
|
+ if (f) setFile(f);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function onFileSelect(e) {
|
|
|
+ const f = e.target.files[0];
|
|
|
+ if (f) setFile(f);
|
|
|
+}
|
|
|
+
|
|
|
+function setFile(f) {
|
|
|
+ const dt = new DataTransfer();
|
|
|
+ dt.items.add(f);
|
|
|
+ document.getElementById('file-input').files = dt.files;
|
|
|
+ document.getElementById('file-name-display').textContent = f.name;
|
|
|
+ document.getElementById('file-chip').classList.remove('hidden');
|
|
|
+ document.getElementById('submit-btn').disabled = false;
|
|
|
+ document.getElementById('submit-hint').textContent = `${(f.size / 1024 / 1024).toFixed(1)} MB`;
|
|
|
+}
|
|
|
+
|
|
|
+function clearFile() {
|
|
|
+ document.getElementById('file-input').value = '';
|
|
|
+ document.getElementById('file-chip').classList.add('hidden');
|
|
|
+ document.getElementById('submit-btn').disabled = true;
|
|
|
+ document.getElementById('submit-hint').textContent = '';
|
|
|
+}
|
|
|
+
|
|
|
+function togglePromptField() {
|
|
|
+ const field = document.getElementById('prompt-field');
|
|
|
+ const btn = document.getElementById('prompt-toggle-btn');
|
|
|
+ const hidden = field.classList.toggle('hidden');
|
|
|
+ btn.setAttribute('data-i18n', hidden ? 'settings.prompt_expand' : 'settings.prompt_collapse');
|
|
|
+ btn.textContent = hidden ? (t('settings.prompt_expand') || '▼ Mostrar') : (t('settings.prompt_collapse') || '▲ Ocultar');
|
|
|
+}
|
|
|
+
|
|
|
+// ─── SUBMIT ───────────────────────────────────────────────────
|
|
|
+async function submitJob() {
|
|
|
+ const fileInput = document.getElementById('file-input');
|
|
|
+ if (!fileInput.files[0]) return;
|
|
|
+
|
|
|
+ const fmts = ['txt', 'srt', 'txt_nh', 'srt_nh'];
|
|
|
+ const anyFmt = fmts.some(f => document.getElementById('fmt-' + f).checked);
|
|
|
+ if (!anyFmt) { toast(t('submit.select_fmt')); return; }
|
|
|
+
|
|
|
+ const fd = new FormData();
|
|
|
+ fd.append('file', fileInput.files[0]);
|
|
|
+ fd.append('language', document.getElementById('lang-select').value);
|
|
|
+ fd.append('model', document.getElementById('model-select').value);
|
|
|
+ fd.append('initial_prompt', document.getElementById('initial-prompt').value.trim());
|
|
|
+ fmts.forEach(f => fd.append(f, document.getElementById('fmt-' + f).checked));
|
|
|
+
|
|
|
+ document.getElementById('submit-btn').disabled = true;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await apiFetch('/transcribe', { method: 'POST', body: fd });
|
|
|
+ const data = await res.json();
|
|
|
+ if (!res.ok) { toast(data.detail || t('submission_failed')); document.getElementById('submit-btn').disabled = false; return; }
|
|
|
+
|
|
|
+ currentJob = data;
|
|
|
+ showJobSection(fileInput.files[0].name, data.job_id);
|
|
|
+ startPolling(data.job_id);
|
|
|
+ } catch(e) {
|
|
|
+ toast(t('network_error') + ': ' + e.message);
|
|
|
+ document.getElementById('submit-btn').disabled = false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ─── JOB SECTION ─────────────────────────────────────────────
|
|
|
+function showJobSection(filename) {
|
|
|
+ document.getElementById('job-section').classList.remove('hidden');
|
|
|
+ document.getElementById('results-section').classList.add('hidden');
|
|
|
+ document.getElementById('job-filename').textContent = filename;
|
|
|
+ setStatus('pending');
|
|
|
+ document.getElementById('job-section').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
|
+}
|
|
|
+
|
|
|
+function getStatusMap() {
|
|
|
+ return {
|
|
|
+ pending: { label: t('status.pending'), cls: 'status-pending', desc: t('status.pending_desc'), step: null },
|
|
|
+ converting: { label: t('status.converting'), cls: 'status-active', desc: t('status.converting_desc'), step: 'transcribing' },
|
|
|
+ transcribing: { label: t('status.transcribing'), cls: 'status-active', desc: t('status.transcribing_desc'), step: 'transcribing' },
|
|
|
+ diarizing: { label: t('status.diarizing'), cls: 'status-active', desc: t('status.diarizing_desc'), step: 'diarizing' },
|
|
|
+ combining: { label: t('status.combining'), cls: 'status-active', desc: t('status.combining_desc'), step: 'combining' },
|
|
|
+ completed: { label: t('status.completed'), cls: 'status-completed', desc: t('status.completed_desc'), step: 'completed' },
|
|
|
+ failed: { label: t('status.failed'), cls: 'status-failed', desc: t('status.failed_desc'), step: null },
|
|
|
+ };
|
|
|
+}
|
|
|
+const STEP_ORDER = ['transcribing', 'diarizing', 'combining', 'completed'];
|
|
|
+
|
|
|
+function setStatus(status, job) {
|
|
|
+ const sm = getStatusMap();
|
|
|
+ const info = sm[status] || sm['pending'];
|
|
|
+ const pill = document.getElementById('status-pill');
|
|
|
+ pill.className = 'status-pill ' + info.cls;
|
|
|
+ document.getElementById('status-text-pill').textContent = info.label;
|
|
|
+ document.getElementById('job-status-desc').innerHTML = info.desc;
|
|
|
+ document.getElementById('wave-anim').style.display = info.cls === 'status-active' ? '' : 'none';
|
|
|
+
|
|
|
+ STEP_ORDER.forEach(s => {
|
|
|
+ const el = document.getElementById('step-' + s);
|
|
|
+ if (el) el.classList.remove('active', 'done');
|
|
|
+ });
|
|
|
+ if (info.step) {
|
|
|
+ const idx = STEP_ORDER.indexOf(info.step);
|
|
|
+ STEP_ORDER.forEach((s, i) => {
|
|
|
+ const el = document.getElementById('step-' + s);
|
|
|
+ if (!el) return;
|
|
|
+ if (i < idx) el.classList.add('done');
|
|
|
+ if (i === idx) el.classList.add('active');
|
|
|
+ });
|
|
|
+ }
|
|
|
+ if (status === 'failed' && job?.error) {
|
|
|
+ document.getElementById('job-status-desc').innerHTML = `<span style="color:var(--err)">${escHtml(job.error)}</span>`;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ─── POLLING ──────────────────────────────────────────────────
|
|
|
+function startPolling(jobId) {
|
|
|
+ if (pollTimer) clearInterval(pollTimer);
|
|
|
+ pollTimer = setInterval(async () => {
|
|
|
+ try {
|
|
|
+ const res = await apiFetch(`/jobs/${jobId}`);
|
|
|
+ const job = await res.json();
|
|
|
+ setStatus(job.status, job);
|
|
|
+ if (job.status === 'completed' || job.status === 'failed') {
|
|
|
+ clearInterval(pollTimer);
|
|
|
+ pollTimer = null;
|
|
|
+ if (job.status === 'completed') showResults(job);
|
|
|
+ loadHistory();
|
|
|
+ document.getElementById('submit-btn').disabled = false;
|
|
|
+ }
|
|
|
+ } catch {}
|
|
|
+ }, 5000);
|
|
|
+}
|
|
|
+
|
|
|
+// ─── RESULTS ─────────────────────────────────────────────────
|
|
|
+function getFmtMeta() {
|
|
|
+ return {
|
|
|
+ txt: { icon: '📄', label: t('fmtlabel.txt') },
|
|
|
+ srt: { icon: '🎬', label: t('fmtlabel.srt') },
|
|
|
+ txt_nh: { icon: '📄', label: t('fmtlabel.txt_nh') },
|
|
|
+ srt_nh: { icon: '🎬', label: t('fmtlabel.srt_nh') },
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function showResults(job) {
|
|
|
+ document.getElementById('results-section').classList.remove('hidden');
|
|
|
+
|
|
|
+ const grid = document.getElementById('results-grid');
|
|
|
+ grid.innerHTML = '';
|
|
|
+ Object.keys(job.results).forEach(fmt => {
|
|
|
+ const m = getFmtMeta()[fmt] || { icon: '📁', label: fmt };
|
|
|
+ const btn = document.createElement('button');
|
|
|
+ btn.className = 'result-btn';
|
|
|
+ btn.innerHTML = `<span class="icon">${m.icon}</span><span class="format">.${fmt.split('_')[0]}</span><span class="label">${m.label}</span>`;
|
|
|
+ btn.onclick = () => downloadFmt(job.job_id, fmt);
|
|
|
+ grid.appendChild(btn);
|
|
|
+ });
|
|
|
+
|
|
|
+ const preview = document.getElementById('segments-preview');
|
|
|
+ preview.innerHTML = '';
|
|
|
+ const segs = (job.segments || []).slice(0, 5);
|
|
|
+ segs.forEach(s => {
|
|
|
+ const row = document.createElement('div');
|
|
|
+ row.className = 'segment-row';
|
|
|
+ const t = srtTime(s.start) + ' – ' + srtTime(s.end);
|
|
|
+ const spk = s.speaker ? `<span class="seg-speaker">${escHtml(s.speaker)}</span>` : '<span style="color:var(--text3)">—</span>';
|
|
|
+ row.innerHTML = `<span class="seg-time">${t}</span>${spk}<span class="seg-text">${escHtml(s.text)}</span>`;
|
|
|
+ preview.appendChild(row);
|
|
|
+ });
|
|
|
+ const total = (job.segments || []).length;
|
|
|
+ if (total > 5) {
|
|
|
+ const more = document.createElement('div');
|
|
|
+ more.className = 'preview-more';
|
|
|
+ more.textContent = t('results.more', { n: total - 5 });
|
|
|
+ preview.appendChild(more);
|
|
|
+ }
|
|
|
+
|
|
|
+ document.getElementById('results-section').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
|
+}
|
|
|
+
|
|
|
+async function downloadFmt(jobId, fmt) {
|
|
|
+ try {
|
|
|
+ const res = await apiFetch(`/jobs/${jobId}/download/${fmt}`);
|
|
|
+ if (!res.ok) { toast(t('results.dl_failed')); return; }
|
|
|
+ const blob = await res.blob();
|
|
|
+ const url = URL.createObjectURL(blob);
|
|
|
+ const a = document.createElement('a');
|
|
|
+ a.href = url;
|
|
|
+ const ext = fmt.includes('srt') ? 'srt' : 'txt';
|
|
|
+ a.download = `transcription_${fmt}.${ext}`;
|
|
|
+ document.body.appendChild(a);
|
|
|
+ a.click();
|
|
|
+ document.body.removeChild(a);
|
|
|
+ URL.revokeObjectURL(url);
|
|
|
+ toast(t('results.downloaded', { fmt }));
|
|
|
+ } catch(e) { toast(t('results.err', { msg: e.message })); }
|
|
|
+}
|
|
|
+
|
|
|
+// ─── HISTORY ─────────────────────────────────────────────────
|
|
|
+async function loadHistory() {
|
|
|
+ try {
|
|
|
+ const res = await apiFetch('/jobs');
|
|
|
+ const list = await res.json();
|
|
|
+ jobHistory = list.sort((a, b) => (b.job_id > a.job_id ? 1 : -1));
|
|
|
+ renderHistory();
|
|
|
+ } catch {}
|
|
|
+}
|
|
|
+
|
|
|
+async function openHistoryJob(jobId) {
|
|
|
+ try {
|
|
|
+ const res = await apiFetch(`/jobs/${jobId}`);
|
|
|
+ const job = await res.json();
|
|
|
+ if (job.status !== 'completed') { toast(t('history.job_is', { status: t('status.' + job.status) || job.status })); return; }
|
|
|
+ document.getElementById('job-section').classList.remove('hidden');
|
|
|
+ document.getElementById('job-filename').textContent = job.filename || jobId;
|
|
|
+ setStatus('completed', job);
|
|
|
+ showResults(job);
|
|
|
+ } catch(e) { toast('Could not load job: ' + e.message); }
|
|
|
+}
|
|
|
+
|
|
|
+function renderHistory() {
|
|
|
+ const tbody = document.getElementById('history-tbody');
|
|
|
+ document.getElementById('history-count').textContent = jobHistory.length;
|
|
|
+
|
|
|
+ if (!jobHistory.length) {
|
|
|
+ tbody.innerHTML = `<tr><td colspan="6" class="history-empty">${t('history.empty')}</td></tr>`;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ tbody.innerHTML = jobHistory.map(j => {
|
|
|
+ const statusCls = j.status === 'completed' ? 'status-completed' : j.status === 'failed' ? 'status-failed' : 'status-active';
|
|
|
+ const fmts = j.status === 'completed' ? (j.formats || []) : [];
|
|
|
+ const dlBtns = fmts.map(fmt => {
|
|
|
+ const ext = fmt.includes('srt') ? 'srt' : 'txt';
|
|
|
+ return `<button class="btn btn-ghost btn-sm" style="padding:3px 8px;font-size:10px;" onclick="event.stopPropagation();downloadFmt('${j.job_id}','${fmt}')">.${ext}${fmt.includes('_nh') ? ' ' + t('fmt.no_spk') : ''}</button>`;
|
|
|
+ }).join(' ');
|
|
|
+ const rowClick = j.status === 'completed' ? `onclick="openHistoryJob('${j.job_id}')" style="cursor:pointer;"` : '';
|
|
|
+ return `<tr ${rowClick}>
|
|
|
+ <td class="mono" style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escHtml(j.filename || '')}">${escHtml(j.filename || '—')}</td>
|
|
|
+ <td><span class="status-pill ${statusCls}" style="font-size:10px;padding:3px 9px;"><span class="status-dot" style="width:5px;height:5px;"></span>${t('status.' + j.status) || j.status}</span></td>
|
|
|
+ <td class="mono" style="color:var(--text2)">${j.language || '—'}</td>
|
|
|
+ <td class="mono" style="color:var(--text2)">${j.segments ? j.segments.length : '—'}</td>
|
|
|
+ <td class="mono" style="color:var(--text2)">${j.transcription_time != null ? j.transcription_time + 's' : '—'}</td>
|
|
|
+ <td style="white-space:nowrap;">${dlBtns || '<span style="color:var(--text3);font-size:11px;">—</span>'}</td>
|
|
|
+ </tr>`;
|
|
|
+ }).join('');
|
|
|
+}
|
|
|
+
|
|
|
+// ═══════════════════════════════════════════
|
|
|
+// ADMIN PANEL
|
|
|
+// ═══════════════════════════════════════════
|
|
|
+
|
|
|
+function switchAdminTab(tab) {
|
|
|
+ currentAdminTab = tab;
|
|
|
+ document.getElementById('admin-tab-users').classList.toggle('hidden', tab !== 'users');
|
|
|
+ document.getElementById('admin-tab-history').classList.toggle('hidden', tab !== 'history');
|
|
|
+ document.getElementById('admin-tab-metrics').classList.toggle('hidden', tab !== 'metrics');
|
|
|
+ document.getElementById('tab-btn-users').classList.toggle('active', tab === 'users');
|
|
|
+ document.getElementById('tab-btn-history').classList.toggle('active', tab === 'history');
|
|
|
+ document.getElementById('tab-btn-metrics').classList.toggle('active', tab === 'metrics');
|
|
|
+
|
|
|
+ if (tab === 'users') loadAdminUsers();
|
|
|
+ if (tab === 'history') loadAdminJobs();
|
|
|
+ if (tab === 'metrics') loadMetrics();
|
|
|
+}
|
|
|
+
|
|
|
+async function loadAdminJobs() {
|
|
|
+ try {
|
|
|
+ const res = await apiFetch('/admin/jobs');
|
|
|
+ const jobs = await res.json();
|
|
|
+ renderAdminJobs(jobs);
|
|
|
+ } catch(e) {
|
|
|
+ document.getElementById('admin-history-tbody').innerHTML = `<tr><td colspan="7" class="history-empty" style="color:var(--err)">Error: ${escHtml(e.message)}</td></tr>`;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function renderAdminJobs(jobs) {
|
|
|
+ const tbody = document.getElementById('admin-history-tbody');
|
|
|
+ if (!jobs.length) {
|
|
|
+ tbody.innerHTML = `<tr><td colspan="7" class="history-empty">${t('history.empty')}</td></tr>`;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ tbody.innerHTML = jobs.map(j => {
|
|
|
+ const statusCls = j.status === 'completed' ? 'status-completed' : j.status === 'failed' ? 'status-failed' : 'status-active';
|
|
|
+ const created = j.created_at ? new Date(j.created_at).toLocaleString() : '—';
|
|
|
+ return `<tr>
|
|
|
+ <td class="mono" style="max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escHtml(j.filename || '')}">${escHtml(j.filename || '—')}</td>
|
|
|
+ <td class="mono" style="color:var(--text2);font-size:11px;">${escHtml(j.submitted_by || '—')}</td>
|
|
|
+ <td><span class="status-pill ${statusCls}" style="font-size:10px;padding:3px 9px;"><span class="status-dot" style="width:5px;height:5px;"></span>${t('status.' + j.status) || j.status}</span></td>
|
|
|
+ <td class="mono" style="color:var(--text2)">${j.language || '—'}</td>
|
|
|
+ <td class="mono" style="color:var(--text2)">${j.model || '—'}</td>
|
|
|
+ <td class="mono" style="color:var(--text2)">${j.transcription_time != null ? j.transcription_time + 's' : '—'}</td>
|
|
|
+ <td class="mono" style="color:var(--text3);font-size:11px;">${created}</td>
|
|
|
+ </tr>`;
|
|
|
+ }).join('');
|
|
|
+}
|
|
|
+
|
|
|
+// ─── USERS ────────────────────────────────────────────────────
|
|
|
+async function loadAdminUsers() {
|
|
|
+ try {
|
|
|
+ const res = await apiFetch('/admin/users');
|
|
|
+ if (!res.ok) { toast(t('admin.load_failed')); return; }
|
|
|
+ adminUsers = await res.json();
|
|
|
+ renderAdminUsers();
|
|
|
+ } catch(e) { toast('Error: ' + e.message); }
|
|
|
+}
|
|
|
+
|
|
|
+function renderAdminUsers() {
|
|
|
+ const tbody = document.getElementById('admin-users-tbody');
|
|
|
+ if (!adminUsers.length) {
|
|
|
+ tbody.innerHTML = `<tr><td colspan="7" class="history-empty">${t('admin.no_users')}</td></tr>`;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ tbody.innerHTML = adminUsers.map(u => {
|
|
|
+ const roleCls = u.role === 'admin' ? 'badge-admin' : 'badge-user';
|
|
|
+ const pwdCls = u.is_default_password ? 'badge-default' : 'badge-set';
|
|
|
+ const pwdLbl = u.is_default_password ? t('admin.pwd_default') : t('admin.pwd_set');
|
|
|
+ const apiCls = u.has_api_key ? 'badge-yes' : 'badge-no';
|
|
|
+ const apiLbl = u.has_api_key ? t('admin.api_yes') : t('admin.api_no');
|
|
|
+ const created = u.created_at ? u.created_at.split('T')[0] : '—';
|
|
|
+ const isSelf = u.email === currentUser?.email;
|
|
|
+ const apiBtn = u.has_api_key
|
|
|
+ ? `<button class="btn btn-danger btn-sm" style="font-size:10px;padding:3px 10px;" onclick="revokeApiKey('${u.id}','${escAttr(u.name)}')">${t('admin.revoke_key')}</button>`
|
|
|
+ : `<button class="btn btn-success btn-sm" style="font-size:10px;padding:3px 10px;" onclick="generateApiKey('${u.id}')">${t('admin.gen_key')}</button>`;
|
|
|
+ const delBtn = isSelf ? '' : `<button class="btn btn-danger btn-sm" style="font-size:10px;padding:3px 10px;" onclick="deleteUser('${u.id}','${escAttr(u.name)}')">${t('admin.delete')}</button>`;
|
|
|
+ return `<tr>
|
|
|
+ <td style="font-weight:600;">${escHtml(u.name)}</td>
|
|
|
+ <td class="mono" style="font-size:11px;color:var(--text2);">${escHtml(u.email)}</td>
|
|
|
+ <td><span class="badge ${roleCls}">${u.role}</span></td>
|
|
|
+ <td><span class="badge ${pwdCls}">${pwdLbl}</span></td>
|
|
|
+ <td><span class="badge ${apiCls}">${apiLbl}</span></td>
|
|
|
+ <td class="mono" style="color:var(--text3);font-size:11px;">${created}</td>
|
|
|
+ <td style="white-space:nowrap;display:flex;gap:6px;flex-wrap:wrap;">
|
|
|
+ <button class="btn btn-ghost btn-sm" style="font-size:10px;padding:3px 10px;" onclick="openResetPasswordModal('${u.id}','${escAttr(u.name)}')">${t('admin.reset_pwd')}</button>
|
|
|
+ ${apiBtn}
|
|
|
+ ${delBtn}
|
|
|
+ </td>
|
|
|
+ </tr>`;
|
|
|
+ }).join('');
|
|
|
+}
|
|
|
+
|
|
|
+// Create user modal
|
|
|
+function openCreateUserModal() {
|
|
|
+ document.getElementById('modal-title').textContent = t('modal.create_user');
|
|
|
+ document.getElementById('modal-body').innerHTML = `
|
|
|
+ <div class="modal-field">
|
|
|
+ <label>${t('modal.email')}</label>
|
|
|
+ <input type="email" id="mu-email" placeholder="${escHtml(t('modal.email_ph'))}">
|
|
|
+ </div>
|
|
|
+ <div class="modal-field">
|
|
|
+ <label>${t('modal.full_name')}</label>
|
|
|
+ <input type="text" id="mu-name" placeholder="${escHtml(t('modal.full_name_ph'))}">
|
|
|
+ </div>
|
|
|
+ <div class="modal-field">
|
|
|
+ <label>${t('modal.default_password')}</label>
|
|
|
+ <input type="text" id="mu-password" placeholder="${escHtml(t('modal.default_password_ph'))}">
|
|
|
+ <p class="modal-note">${t('modal.default_password_note')}</p>
|
|
|
+ </div>
|
|
|
+ <div class="modal-field">
|
|
|
+ <label>${t('modal.role')}</label>
|
|
|
+ <div class="role-toggle">
|
|
|
+ <div class="role-option">
|
|
|
+ <input type="radio" name="mu-role" id="mu-role-user" value="user" checked>
|
|
|
+ <label for="mu-role-user">${t('modal.role_user')}<br><span style="font-size:10px;color:var(--text3)">${t('modal.role_user_desc')}</span></label>
|
|
|
+ </div>
|
|
|
+ <div class="role-option">
|
|
|
+ <input type="radio" name="mu-role" id="mu-role-admin" value="admin">
|
|
|
+ <label for="mu-role-admin">${t('modal.role_admin')}<br><span style="font-size:10px;color:var(--text3)">${t('modal.role_admin_desc')}</span></label>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div id="mu-error" class="modal-error hidden"></div>
|
|
|
+ `;
|
|
|
+ document.getElementById('modal-actions').innerHTML = `
|
|
|
+ <button class="btn btn-ghost" onclick="closeModal()">${t('modal.cancel')}</button>
|
|
|
+ <button class="btn btn-primary" onclick="createUser()">${t('modal.create')}</button>
|
|
|
+ `;
|
|
|
+ document.getElementById('modal-overlay').classList.remove('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+async function createUser() {
|
|
|
+ const email = document.getElementById('mu-email').value.trim();
|
|
|
+ const name = document.getElementById('mu-name').value.trim();
|
|
|
+ const password = document.getElementById('mu-password').value;
|
|
|
+ const role = document.querySelector('input[name="mu-role"]:checked').value;
|
|
|
+ const errEl = document.getElementById('mu-error');
|
|
|
+ errEl.classList.add('hidden');
|
|
|
+
|
|
|
+ if (!email || !name || !password) { errEl.textContent = t('modal.all_required'); errEl.classList.remove('hidden'); return; }
|
|
|
+ if (password.length < 8) { errEl.textContent = t('modal.pwd_short'); errEl.classList.remove('hidden'); return; }
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await apiFetch('/admin/users', {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({ email, name, password, role }),
|
|
|
+ });
|
|
|
+ const data = await res.json();
|
|
|
+ if (!res.ok) { errEl.textContent = data.detail || t('modal.user_create_failed'); errEl.classList.remove('hidden'); return; }
|
|
|
+ closeModal();
|
|
|
+ toast(t('modal.user_created', { name }));
|
|
|
+ loadAdminUsers();
|
|
|
+ } catch(e) { errEl.textContent = t('modal.network_error'); errEl.classList.remove('hidden'); }
|
|
|
+}
|
|
|
+
|
|
|
+// Reset password modal
|
|
|
+function openResetPasswordModal(userId, userName) {
|
|
|
+ document.getElementById('modal-title').textContent = t('modal.reset_pwd_title');
|
|
|
+ document.getElementById('modal-body').innerHTML = `
|
|
|
+ <p style="font-size:13px;color:var(--text2);margin-bottom:16px;">
|
|
|
+ ${t('modal.reset_pwd_desc', { name: escHtml(userName) })}
|
|
|
+ </p>
|
|
|
+ <div class="modal-field">
|
|
|
+ <label>${t('modal.new_default_pwd')}</label>
|
|
|
+ <input type="text" id="rp-password" placeholder="${escHtml(t('modal.min_8_ph'))}">
|
|
|
+ </div>
|
|
|
+ <div id="rp-error" class="modal-error hidden"></div>
|
|
|
+ `;
|
|
|
+ document.getElementById('modal-actions').innerHTML = `
|
|
|
+ <button class="btn btn-ghost" onclick="closeModal()">${t('modal.cancel')}</button>
|
|
|
+ <button class="btn btn-primary" onclick="resetPassword('${userId}')">${t('modal.reset')}</button>
|
|
|
+ `;
|
|
|
+ document.getElementById('modal-overlay').classList.remove('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+async function resetPassword(userId) {
|
|
|
+ const password = document.getElementById('rp-password').value;
|
|
|
+ const errEl = document.getElementById('rp-error');
|
|
|
+ errEl.classList.add('hidden');
|
|
|
+
|
|
|
+ if (password.length < 8) { errEl.textContent = t('modal.pwd_short'); errEl.classList.remove('hidden'); return; }
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await apiFetch(`/admin/users/${userId}/reset-password`, {
|
|
|
+ method: 'PATCH',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({ new_password: password }),
|
|
|
+ });
|
|
|
+ if (!res.ok) { const d = await res.json(); errEl.textContent = d.detail || t('modal.reset_failed'); errEl.classList.remove('hidden'); return; }
|
|
|
+ closeModal();
|
|
|
+ toast(t('modal.pwd_reset_success'));
|
|
|
+ loadAdminUsers();
|
|
|
+ } catch { errEl.textContent = t('modal.network_error'); errEl.classList.remove('hidden'); }
|
|
|
+}
|
|
|
+
|
|
|
+// Delete user
|
|
|
+async function deleteUser(userId, userName) {
|
|
|
+ if (!confirm(t('modal.confirm_delete', { name: userName }))) return;
|
|
|
+ try {
|
|
|
+ const res = await apiFetch(`/admin/users/${userId}`, { method: 'DELETE' });
|
|
|
+ if (res.status === 400) { const d = await res.json(); toast(d.detail); return; }
|
|
|
+ if (!res.ok) { toast(t('modal.delete_failed')); return; }
|
|
|
+ toast(t('modal.user_deleted', { name: userName }));
|
|
|
+ loadAdminUsers();
|
|
|
+ } catch { toast(t('network_error')); }
|
|
|
+}
|
|
|
+
|
|
|
+// Generate API key
|
|
|
+async function generateApiKey(userId) {
|
|
|
+ try {
|
|
|
+ const res = await apiFetch(`/admin/users/${userId}/api-key`, { method: 'POST' });
|
|
|
+ const data = await res.json();
|
|
|
+ if (!res.ok) { toast(data.detail || t('modal.gen_failed')); return; }
|
|
|
+ loadAdminUsers();
|
|
|
+ showApiKeyModal(data.api_key);
|
|
|
+ } catch { toast('Network error'); }
|
|
|
+}
|
|
|
+
|
|
|
+function showApiKeyModal(key) {
|
|
|
+ document.getElementById('modal-title').textContent = t('modal.api_generated_title');
|
|
|
+ document.getElementById('modal-body').innerHTML = `
|
|
|
+ <p style="font-size:13px;color:var(--text2);margin-bottom:4px;">${t('modal.api_copy_note')}</p>
|
|
|
+ <div class="key-display" id="api-key-display">${escHtml(key)}</div>
|
|
|
+ <p class="modal-note">${t('modal.api_share_note')}</p>
|
|
|
+ `;
|
|
|
+ document.getElementById('modal-actions').innerHTML = `
|
|
|
+ <button class="btn btn-ghost" onclick="copyApiKey('${escAttr(key)}')">${t('modal.copy_key')}</button>
|
|
|
+ <button class="btn btn-primary" onclick="closeModal()">${t('modal.done')}</button>
|
|
|
+ `;
|
|
|
+ document.getElementById('modal-overlay').classList.remove('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+function copyApiKey(key) {
|
|
|
+ navigator.clipboard.writeText(key).then(() => toast(t('modal.key_copied'))).catch(() => toast(t('modal.copy_failed')));
|
|
|
+}
|
|
|
+
|
|
|
+// Revoke API key
|
|
|
+async function revokeApiKey(userId, userName) {
|
|
|
+ if (!confirm(t('modal.revoke_confirm', { name: userName }))) return;
|
|
|
+ try {
|
|
|
+ const res = await apiFetch(`/admin/users/${userId}/api-key`, { method: 'DELETE' });
|
|
|
+ if (!res.ok) { toast(t('modal.revoke_failed')); return; }
|
|
|
+ toast(t('modal.key_revoked'));
|
|
|
+ loadAdminUsers();
|
|
|
+ } catch { toast(t('network_error')); }
|
|
|
+}
|
|
|
+
|
|
|
+// ─── METRICS ──────────────────────────────────────────────────
|
|
|
+async function loadMetrics() {
|
|
|
+ const container = document.getElementById('metrics-content');
|
|
|
+ container.innerHTML = `<div class="history-empty">${t('metrics.loading')}</div>`;
|
|
|
+ try {
|
|
|
+ const res = await apiFetch('/admin/metrics');
|
|
|
+ const data = await res.json();
|
|
|
+ renderMetrics(data);
|
|
|
+ } catch(e) {
|
|
|
+ container.innerHTML = `<div class="history-empty" style="color:var(--err)">${t('metrics.failed')}</div>`;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function renderMetrics(d) {
|
|
|
+ cachedMetrics = d;
|
|
|
+ const completed = d.by_status?.completed || 0;
|
|
|
+ const failed = d.by_status?.failed || 0;
|
|
|
+
|
|
|
+ const cards = `
|
|
|
+ <div class="metrics-grid">
|
|
|
+ <div class="metric-card accent">
|
|
|
+ <div class="metric-value">${d.total_jobs}</div>
|
|
|
+ <div class="metric-label">${t('metrics.total_jobs')}</div>
|
|
|
+ </div>
|
|
|
+ <div class="metric-card ok">
|
|
|
+ <div class="metric-value">${completed}</div>
|
|
|
+ <div class="metric-label">${t('metrics.completed')}</div>
|
|
|
+ </div>
|
|
|
+ <div class="metric-card err">
|
|
|
+ <div class="metric-value">${failed}</div>
|
|
|
+ <div class="metric-label">${t('metrics.failed_label')}</div>
|
|
|
+ </div>
|
|
|
+ <div class="metric-card">
|
|
|
+ <div class="metric-value">${d.avg_transcription_time != null ? d.avg_transcription_time + 's' : '—'}</div>
|
|
|
+ <div class="metric-label">${t('metrics.avg_transcription')}</div>
|
|
|
+ </div>
|
|
|
+ </div>`;
|
|
|
+
|
|
|
+ const noData = `<div style="color:var(--text3);font-size:12px;">${t('metrics.no_data')}</div>`;
|
|
|
+ const modelBars = barList(d.by_model);
|
|
|
+ const langBars = barList(d.by_language);
|
|
|
+ const userBars = barList(d.by_user);
|
|
|
+
|
|
|
+ const cols = `
|
|
|
+ <div class="metrics-cols">
|
|
|
+ <div class="card" style="margin-bottom:0;">
|
|
|
+ <div class="section-label" style="margin-bottom:12px;">${t('metrics.by_model')}</div>
|
|
|
+ ${modelBars || noData}
|
|
|
+ </div>
|
|
|
+ <div class="card" style="margin-bottom:0;">
|
|
|
+ <div class="section-label" style="margin-bottom:12px;">${t('metrics.by_language')}</div>
|
|
|
+ ${langBars || noData}
|
|
|
+ </div>
|
|
|
+ <div class="card" style="margin-bottom:0;">
|
|
|
+ <div class="section-label" style="margin-bottom:12px;">${t('metrics.by_user')}</div>
|
|
|
+ ${userBars || noData}
|
|
|
+ </div>
|
|
|
+ </div>`;
|
|
|
+
|
|
|
+ document.getElementById('metrics-content').innerHTML = cards + cols;
|
|
|
+}
|
|
|
+
|
|
|
+function barList(obj) {
|
|
|
+ if (!obj || !Object.keys(obj).length) return '';
|
|
|
+ const entries = Object.entries(obj).sort((a, b) => b[1] - a[1]).slice(0, 8);
|
|
|
+ const max = entries[0][1];
|
|
|
+ return `<div class="bar-list">${entries.map(([label, count]) => `
|
|
|
+ <div class="bar-item">
|
|
|
+ <div class="bar-label" title="${escHtml(label)}">${escHtml(label)}</div>
|
|
|
+ <div class="bar-track"><div class="bar-fill" style="width:${Math.round(count/max*100)}%"></div></div>
|
|
|
+ <div class="bar-count">${count}</div>
|
|
|
+ </div>`).join('')}</div>`;
|
|
|
+}
|
|
|
+
|
|
|
+// ═══════════════════════════════════════════
|
|
|
+// MODAL
|
|
|
+// ═══════════════════════════════════════════
|
|
|
+function closeModal() {
|
|
|
+ document.getElementById('modal-overlay').classList.add('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+// ─── HELPERS ─────────────────────────────────────────────────
|
|
|
+function srtTime(sec) {
|
|
|
+ const t = Math.floor(sec);
|
|
|
+ const h = Math.floor(t / 3600);
|
|
|
+ const m = Math.floor((t % 3600) / 60);
|
|
|
+ const s = t % 60;
|
|
|
+ return `${pad(h)}:${pad(m)}:${pad(s)}`;
|
|
|
+}
|
|
|
+function pad(n) { return String(n).padStart(2, '0'); }
|
|
|
+
|
|
|
+function escHtml(str) {
|
|
|
+ return String(str)
|
|
|
+ .replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
|
+}
|
|
|
+
|
|
|
+function escAttr(str) {
|
|
|
+ return String(str).replace(/'/g, "\\'").replace(/"/g, '"');
|
|
|
+}
|
|
|
+
|
|
|
+let toastTimer = null;
|
|
|
+function toast(msg) {
|
|
|
+ const el = document.getElementById('toast');
|
|
|
+ el.textContent = msg;
|
|
|
+ el.classList.add('show');
|
|
|
+ if (toastTimer) clearTimeout(toastTimer);
|
|
|
+ toastTimer = setTimeout(() => el.classList.remove('show'), 2800);
|
|
|
+}
|
|
|
+</script>
|
|
|
+</body>
|
|
|
+</html>
|