index.html 98 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427
  1. <!DOCTYPE html>
  2. <html lang="es">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>Transcriptor</title>
  7. <link rel="preconnect" href="https://fonts.googleapis.com">
  8. <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  9. <link href="https://fonts.googleapis.com/css2?family=Syne:wght@600;700;800&family=IBM+Plex+Mono:wght@400;500&family=Manrope:wght@300;400;500;600&display=swap" rel="stylesheet">
  10. <style>
  11. :root {
  12. --bg: #080808;
  13. --s1: #111111;
  14. --s2: #181818;
  15. --s3: #222222;
  16. --border: #2a2a2a;
  17. --border2: #383838;
  18. --accent: #e8a000;
  19. --accent-lo: rgba(232,160,0,0.12);
  20. --accent-md: rgba(232,160,0,0.25);
  21. --text: #e4dfd6;
  22. --text2: #7a756e;
  23. --text3: #3d3a36;
  24. --ok: #4caf74;
  25. --err: #e05252;
  26. --radius: 6px;
  27. }
  28. *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  29. html, body {
  30. height: 100%;
  31. background: var(--bg);
  32. color: var(--text);
  33. font-family: 'Manrope', sans-serif;
  34. font-size: 14px;
  35. line-height: 1.6;
  36. -webkit-font-smoothing: antialiased;
  37. }
  38. body::before {
  39. content: '';
  40. position: fixed;
  41. inset: 0;
  42. background-image: radial-gradient(circle, #282420 1px, transparent 1px);
  43. background-size: 28px 28px;
  44. opacity: 0.55;
  45. pointer-events: none;
  46. z-index: 0;
  47. }
  48. ::-webkit-scrollbar { width: 6px; }
  49. ::-webkit-scrollbar-track { background: var(--s1); }
  50. ::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 3px; }
  51. .mono { font-family: 'IBM Plex Mono', monospace; }
  52. .hidden { display: none !important; }
  53. /* ─── INPUT ─── */
  54. input, select {
  55. width: 100%;
  56. background: var(--s2);
  57. border: 1px solid var(--border);
  58. border-radius: var(--radius);
  59. color: var(--text);
  60. font-family: 'Manrope', sans-serif;
  61. font-size: 14px;
  62. padding: 10px 14px;
  63. outline: none;
  64. transition: border-color .2s, box-shadow .2s;
  65. }
  66. input:focus, select:focus {
  67. border-color: var(--accent);
  68. box-shadow: 0 0 0 3px var(--accent-lo);
  69. }
  70. select option { background: var(--s2); }
  71. input::placeholder { color: var(--text3); }
  72. /* ─── BUTTON ─── */
  73. .btn {
  74. display: inline-flex;
  75. align-items: center;
  76. justify-content: center;
  77. gap: 8px;
  78. padding: 11px 22px;
  79. border: none;
  80. border-radius: var(--radius);
  81. font-family: 'Syne', sans-serif;
  82. font-size: 13px;
  83. font-weight: 700;
  84. letter-spacing: .06em;
  85. text-transform: uppercase;
  86. cursor: pointer;
  87. transition: transform .15s, opacity .15s, box-shadow .2s;
  88. }
  89. .btn:active { transform: scale(.97); }
  90. .btn-primary {
  91. background: var(--accent);
  92. color: #000;
  93. }
  94. .btn-primary:hover { box-shadow: 0 0 24px rgba(232,160,0,.35); }
  95. .btn-primary:disabled { opacity: .35; cursor: not-allowed; transform: none; box-shadow: none; }
  96. .btn-ghost {
  97. background: var(--s2);
  98. border: 1px solid var(--border);
  99. color: var(--text2);
  100. font-size: 12px;
  101. padding: 7px 14px;
  102. }
  103. .btn-ghost:hover { border-color: var(--border2); color: var(--text); }
  104. .btn-sm { padding: 7px 14px; font-size: 11px; }
  105. .btn-danger {
  106. background: rgba(224,82,82,.1);
  107. border: 1px solid rgba(224,82,82,.25);
  108. color: var(--err);
  109. font-size: 12px;
  110. padding: 7px 14px;
  111. }
  112. .btn-danger:hover { background: rgba(224,82,82,.18); border-color: rgba(224,82,82,.5); }
  113. .btn-success {
  114. background: rgba(76,175,116,.1);
  115. border: 1px solid rgba(76,175,116,.25);
  116. color: var(--ok);
  117. font-size: 12px;
  118. padding: 7px 14px;
  119. }
  120. .btn-success:hover { background: rgba(76,175,116,.18); border-color: rgba(76,175,116,.5); }
  121. /* ═══════════════════════════════════════════
  122. LOGIN / CHANGE-PASSWORD SCREENS
  123. ═══════════════════════════════════════════ */
  124. #login-screen, #change-password-screen {
  125. position: fixed;
  126. inset: 0;
  127. z-index: 10;
  128. display: flex;
  129. align-items: center;
  130. justify-content: center;
  131. animation: fadeIn .4s ease;
  132. }
  133. .login-card {
  134. position: relative;
  135. width: 100%;
  136. max-width: 420px;
  137. margin: 0 16px;
  138. background: var(--s1);
  139. border: 1px solid var(--border);
  140. border-top: 2px solid var(--accent);
  141. border-radius: var(--radius);
  142. padding: 40px 36px 36px;
  143. box-shadow: 0 32px 80px rgba(0,0,0,.6);
  144. }
  145. .login-logo {
  146. display: flex;
  147. align-items: center;
  148. gap: 12px;
  149. margin-bottom: 28px;
  150. }
  151. .login-logo .waveform {
  152. display: flex;
  153. align-items: center;
  154. gap: 3px;
  155. height: 28px;
  156. }
  157. .login-logo .waveform span {
  158. display: block;
  159. width: 3px;
  160. border-radius: 2px;
  161. background: var(--accent);
  162. }
  163. .login-logo h1 {
  164. font-family: 'Syne', sans-serif;
  165. font-size: 22px;
  166. font-weight: 800;
  167. letter-spacing: .08em;
  168. text-transform: uppercase;
  169. }
  170. .login-logo small {
  171. display: block;
  172. color: var(--text2);
  173. font-size: 10px;
  174. letter-spacing: .12em;
  175. text-transform: uppercase;
  176. margin-top: 2px;
  177. }
  178. .login-card .field { margin-bottom: 16px; }
  179. .login-card label {
  180. display: block;
  181. font-size: 11px;
  182. font-weight: 600;
  183. letter-spacing: .1em;
  184. text-transform: uppercase;
  185. color: var(--text2);
  186. margin-bottom: 7px;
  187. }
  188. .input-wrap { position: relative; }
  189. .input-wrap input { padding-right: 44px; }
  190. .input-wrap .eye {
  191. position: absolute;
  192. right: 12px;
  193. top: 50%;
  194. transform: translateY(-50%);
  195. background: none;
  196. border: none;
  197. color: var(--text3);
  198. cursor: pointer;
  199. padding: 4px;
  200. line-height: 1;
  201. transition: color .2s;
  202. }
  203. .input-wrap .eye:hover { color: var(--text2); }
  204. .login-card .btn-primary { width: 100%; margin-top: 8px; }
  205. .login-error {
  206. margin-top: 14px;
  207. padding: 10px 14px;
  208. background: rgba(224,82,82,.1);
  209. border: 1px solid rgba(224,82,82,.3);
  210. border-radius: var(--radius);
  211. color: var(--err);
  212. font-size: 12px;
  213. text-align: center;
  214. animation: shake .3s ease;
  215. }
  216. .login-subtitle {
  217. font-size: 13px;
  218. color: var(--text2);
  219. margin-bottom: 24px;
  220. line-height: 1.5;
  221. }
  222. .login-subtitle strong { color: var(--accent); }
  223. .cp-back-link {
  224. display: inline-block;
  225. margin-bottom: 20px;
  226. font-size: 12px;
  227. color: var(--text2);
  228. cursor: pointer;
  229. background: none;
  230. border: none;
  231. padding: 0;
  232. text-decoration: underline;
  233. text-underline-offset: 3px;
  234. }
  235. .cp-back-link:hover { color: var(--text); }
  236. /* ═══════════════════════════════════════════
  237. APP / ADMIN SCREENS (shared layout)
  238. ═══════════════════════════════════════════ */
  239. #app-screen, #admin-screen {
  240. position: relative;
  241. z-index: 1;
  242. min-height: 100vh;
  243. display: flex;
  244. flex-direction: column;
  245. }
  246. .app-header {
  247. display: flex;
  248. align-items: center;
  249. justify-content: space-between;
  250. padding: 14px 28px;
  251. border-bottom: 1px solid var(--border);
  252. background: rgba(8,8,8,.85);
  253. backdrop-filter: blur(8px);
  254. position: sticky;
  255. top: 0;
  256. z-index: 5;
  257. }
  258. .header-logo {
  259. display: flex;
  260. align-items: center;
  261. gap: 10px;
  262. font-family: 'Syne', sans-serif;
  263. font-size: 17px;
  264. font-weight: 800;
  265. letter-spacing: .1em;
  266. text-transform: uppercase;
  267. }
  268. .header-logo .dot {
  269. width: 8px;
  270. height: 8px;
  271. border-radius: 50%;
  272. background: var(--accent);
  273. box-shadow: 0 0 8px var(--accent);
  274. }
  275. .header-left {
  276. display: flex;
  277. align-items: center;
  278. gap: 20px;
  279. }
  280. .header-right {
  281. display: flex;
  282. align-items: center;
  283. gap: 10px;
  284. }
  285. .user-badge {
  286. display: flex;
  287. align-items: center;
  288. gap: 10px;
  289. padding: 6px 12px 6px 8px;
  290. background: var(--s2);
  291. border: 1px solid var(--border);
  292. border-radius: 20px;
  293. }
  294. .user-badge .avatar {
  295. width: 26px;
  296. height: 26px;
  297. border-radius: 50%;
  298. background: var(--accent-lo);
  299. border: 1px solid var(--accent-md);
  300. display: flex;
  301. align-items: center;
  302. justify-content: center;
  303. font-family: 'Syne', sans-serif;
  304. font-size: 11px;
  305. font-weight: 700;
  306. color: var(--accent);
  307. }
  308. .user-badge .user-info { line-height: 1.3; }
  309. .user-badge .user-name { font-size: 12px; font-weight: 600; }
  310. .user-badge .user-email { font-size: 10px; color: var(--text2); font-family: 'IBM Plex Mono', monospace; }
  311. .app-main {
  312. flex: 1;
  313. max-width: 720px;
  314. width: 100%;
  315. margin: 0 auto;
  316. padding: 36px 20px 60px;
  317. }
  318. .app-main-wide {
  319. flex: 1;
  320. max-width: 1000px;
  321. width: 100%;
  322. margin: 0 auto;
  323. padding: 36px 20px 60px;
  324. }
  325. /* ─── SECTION HEADING ─── */
  326. .section-label {
  327. font-size: 10px;
  328. font-weight: 700;
  329. letter-spacing: .18em;
  330. text-transform: uppercase;
  331. color: var(--text2);
  332. display: flex;
  333. align-items: center;
  334. gap: 10px;
  335. margin-bottom: 14px;
  336. }
  337. .section-label::after {
  338. content: '';
  339. flex: 1;
  340. height: 1px;
  341. background: var(--border);
  342. }
  343. /* ─── CARD ─── */
  344. .card {
  345. background: var(--s1);
  346. border: 1px solid var(--border);
  347. border-radius: var(--radius);
  348. padding: 22px;
  349. margin-bottom: 16px;
  350. }
  351. /* ─── UPLOAD ZONE ─── */
  352. .upload-zone {
  353. border: 1.5px dashed var(--border2);
  354. border-radius: var(--radius);
  355. padding: 44px 24px;
  356. text-align: center;
  357. cursor: pointer;
  358. transition: border-color .2s, background .2s;
  359. position: relative;
  360. overflow: hidden;
  361. }
  362. .upload-zone:hover, .upload-zone.drag-over {
  363. border-color: var(--accent);
  364. background: var(--accent-lo);
  365. }
  366. .upload-zone input[type=file] {
  367. position: absolute;
  368. inset: 0;
  369. opacity: 0;
  370. cursor: pointer;
  371. width: 100%;
  372. height: 100%;
  373. }
  374. .upload-icon {
  375. margin: 0 auto 16px;
  376. width: 48px;
  377. height: 48px;
  378. border: 1.5px solid var(--border2);
  379. border-radius: 12px;
  380. display: flex;
  381. align-items: center;
  382. justify-content: center;
  383. transition: border-color .2s;
  384. }
  385. .upload-zone:hover .upload-icon { border-color: var(--accent); }
  386. .upload-zone h3 {
  387. font-family: 'Syne', sans-serif;
  388. font-size: 15px;
  389. font-weight: 700;
  390. margin-bottom: 6px;
  391. }
  392. .upload-zone p { color: var(--text2); font-size: 12px; }
  393. .file-chip {
  394. display: inline-flex;
  395. align-items: center;
  396. gap: 8px;
  397. padding: 8px 14px;
  398. background: var(--s3);
  399. border: 1px solid var(--border2);
  400. border-radius: 20px;
  401. font-size: 12px;
  402. font-family: 'IBM Plex Mono', monospace;
  403. color: var(--text);
  404. margin-top: 12px;
  405. }
  406. .file-chip .remove {
  407. background: none;
  408. border: none;
  409. color: var(--text2);
  410. cursor: pointer;
  411. line-height: 1;
  412. padding: 0 2px;
  413. font-size: 16px;
  414. transition: color .15s;
  415. }
  416. .file-chip .remove:hover { color: var(--err); }
  417. /* ─── SETTINGS GRID ─── */
  418. .settings-grid {
  419. display: grid;
  420. grid-template-columns: 1fr 1fr;
  421. gap: 12px;
  422. }
  423. .field label {
  424. display: block;
  425. font-size: 11px;
  426. font-weight: 600;
  427. letter-spacing: .1em;
  428. text-transform: uppercase;
  429. color: var(--text2);
  430. margin-bottom: 7px;
  431. }
  432. /* ─── FORMAT CHECKBOXES ─── */
  433. .formats-grid {
  434. display: grid;
  435. grid-template-columns: repeat(4, 1fr);
  436. gap: 10px;
  437. margin-top: 4px;
  438. }
  439. .fmt-check { position: relative; }
  440. .fmt-check input[type=checkbox] { position: absolute; opacity: 0; width: 0; height: 0; }
  441. .fmt-check label {
  442. display: flex;
  443. flex-direction: column;
  444. align-items: center;
  445. gap: 6px;
  446. padding: 14px 8px;
  447. background: var(--s2);
  448. border: 1.5px solid var(--border);
  449. border-radius: var(--radius);
  450. cursor: pointer;
  451. transition: border-color .2s, background .2s;
  452. text-transform: none;
  453. letter-spacing: 0;
  454. color: var(--text2);
  455. font-size: 12px;
  456. font-weight: 500;
  457. }
  458. .fmt-check label .fmt-tag {
  459. font-family: 'IBM Plex Mono', monospace;
  460. font-size: 13px;
  461. font-weight: 500;
  462. color: var(--text);
  463. }
  464. .fmt-check label .fmt-desc { font-size: 10px; color: var(--text3); text-align: center; line-height: 1.4; }
  465. .fmt-check input:checked + label { border-color: var(--accent); background: var(--accent-lo); color: var(--text); }
  466. .fmt-check input:checked + label .fmt-tag { color: var(--accent); }
  467. .fmt-check input:checked + label .fmt-desc { color: var(--text2); }
  468. .fmt-check label:hover { border-color: var(--border2); }
  469. .submit-row {
  470. display: flex;
  471. align-items: center;
  472. justify-content: flex-end;
  473. gap: 12px;
  474. margin-top: 20px;
  475. }
  476. .submit-row .btn-primary { min-width: 160px; }
  477. /* ─── JOB STATUS ─── */
  478. .job-card {
  479. background: var(--s1);
  480. border: 1px solid var(--border);
  481. border-radius: var(--radius);
  482. overflow: hidden;
  483. margin-bottom: 16px;
  484. animation: slideDown .3s ease;
  485. }
  486. .job-card-top {
  487. display: flex;
  488. align-items: center;
  489. justify-content: space-between;
  490. padding: 16px 20px;
  491. border-bottom: 1px solid var(--border);
  492. }
  493. .job-card-top .filename {
  494. font-family: 'IBM Plex Mono', monospace;
  495. font-size: 13px;
  496. color: var(--text);
  497. max-width: 60%;
  498. overflow: hidden;
  499. text-overflow: ellipsis;
  500. white-space: nowrap;
  501. }
  502. .status-pill {
  503. display: inline-flex;
  504. align-items: center;
  505. gap: 7px;
  506. padding: 4px 11px;
  507. border-radius: 20px;
  508. font-size: 11px;
  509. font-weight: 700;
  510. letter-spacing: .08em;
  511. text-transform: uppercase;
  512. }
  513. .status-dot { width: 7px; height: 7px; border-radius: 50%; }
  514. .status-pending { background: rgba(100,100,100,.15); color: var(--text2); }
  515. .status-pending .status-dot { background: var(--text2); }
  516. .status-active { background: rgba(232,160,0,.12); color: var(--accent); }
  517. .status-active .status-dot { background: var(--accent); animation: pulse 1.2s ease-in-out infinite; }
  518. .status-completed { background: rgba(76,175,116,.12); color: var(--ok); }
  519. .status-completed .status-dot { background: var(--ok); }
  520. .status-failed { background: rgba(224,82,82,.12); color: var(--err); }
  521. .status-failed .status-dot { background: var(--err); }
  522. .job-card-body { padding: 20px; }
  523. .waveform-anim {
  524. display: flex;
  525. align-items: center;
  526. gap: 4px;
  527. height: 48px;
  528. justify-content: center;
  529. margin-bottom: 16px;
  530. }
  531. .waveform-anim .bar {
  532. width: 4px;
  533. border-radius: 2px;
  534. background: var(--accent);
  535. transform-origin: bottom center;
  536. animation: wave 1.4s ease-in-out infinite;
  537. }
  538. .waveform-anim .bar:nth-child(1) { height: 12px; animation-delay: 0.00s; }
  539. .waveform-anim .bar:nth-child(2) { height: 22px; animation-delay: 0.07s; }
  540. .waveform-anim .bar:nth-child(3) { height: 34px; animation-delay: 0.14s; }
  541. .waveform-anim .bar:nth-child(4) { height: 28px; animation-delay: 0.21s; }
  542. .waveform-anim .bar:nth-child(5) { height: 40px; animation-delay: 0.28s; }
  543. .waveform-anim .bar:nth-child(6) { height: 32px; animation-delay: 0.35s; }
  544. .waveform-anim .bar:nth-child(7) { height: 44px; animation-delay: 0.42s; }
  545. .waveform-anim .bar:nth-child(8) { height: 36px; animation-delay: 0.49s; }
  546. .waveform-anim .bar:nth-child(9) { height: 48px; animation-delay: 0.56s; }
  547. .waveform-anim .bar:nth-child(10) { height: 40px; animation-delay: 0.63s; }
  548. .waveform-anim .bar:nth-child(11) { height: 48px; animation-delay: 0.70s; }
  549. .waveform-anim .bar:nth-child(12) { height: 36px; animation-delay: 0.77s; }
  550. .waveform-anim .bar:nth-child(13) { height: 44px; animation-delay: 0.84s; }
  551. .waveform-anim .bar:nth-child(14) { height: 28px; animation-delay: 0.91s; }
  552. .waveform-anim .bar:nth-child(15) { height: 38px; animation-delay: 0.98s; }
  553. .waveform-anim .bar:nth-child(16) { height: 22px; animation-delay: 1.05s; }
  554. .waveform-anim .bar:nth-child(17) { height: 32px; animation-delay: 1.12s; }
  555. .waveform-anim .bar:nth-child(18) { height: 16px; animation-delay: 1.19s; }
  556. .waveform-anim .bar:nth-child(19) { height: 24px; animation-delay: 1.26s; }
  557. .waveform-anim .bar:nth-child(20) { height: 12px; animation-delay: 1.33s; }
  558. .status-text { text-align: center; font-size: 13px; color: var(--text2); }
  559. .status-text strong { color: var(--accent); }
  560. .pipeline-steps {
  561. display: flex;
  562. align-items: center;
  563. gap: 0;
  564. margin-top: 18px;
  565. }
  566. .pipeline-step {
  567. flex: 1;
  568. display: flex;
  569. flex-direction: column;
  570. align-items: center;
  571. gap: 6px;
  572. font-size: 10px;
  573. letter-spacing: .08em;
  574. text-transform: uppercase;
  575. color: var(--text3);
  576. position: relative;
  577. }
  578. .pipeline-step::after {
  579. content: '';
  580. position: absolute;
  581. top: 13px;
  582. left: calc(50% + 14px);
  583. right: calc(-50% + 14px);
  584. height: 1px;
  585. background: var(--border);
  586. }
  587. .pipeline-step:last-child::after { display: none; }
  588. .step-node {
  589. width: 28px;
  590. height: 28px;
  591. border-radius: 50%;
  592. border: 1.5px solid var(--border2);
  593. display: flex;
  594. align-items: center;
  595. justify-content: center;
  596. font-size: 10px;
  597. transition: all .3s;
  598. }
  599. .pipeline-step.active .step-node { border-color: var(--accent); background: var(--accent-lo); color: var(--accent); box-shadow: 0 0 12px var(--accent-lo); }
  600. .pipeline-step.done .step-node { border-color: var(--ok); background: rgba(76,175,116,.12); color: var(--ok); }
  601. .pipeline-step.active { color: var(--accent); }
  602. .pipeline-step.done { color: var(--ok); }
  603. /* ─── RESULTS ─── */
  604. .results-grid {
  605. display: grid;
  606. grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
  607. gap: 10px;
  608. margin-bottom: 20px;
  609. }
  610. .result-btn {
  611. display: flex;
  612. flex-direction: column;
  613. align-items: center;
  614. gap: 8px;
  615. padding: 16px 12px;
  616. background: var(--s2);
  617. border: 1.5px solid var(--border);
  618. border-radius: var(--radius);
  619. cursor: pointer;
  620. transition: border-color .2s, background .2s, transform .15s;
  621. font-family: 'IBM Plex Mono', monospace;
  622. font-size: 13px;
  623. color: var(--text);
  624. }
  625. .result-btn:hover { border-color: var(--accent); background: var(--accent-lo); transform: translateY(-2px); }
  626. .result-btn .icon { font-size: 22px; }
  627. .result-btn .format { font-weight: 500; color: var(--accent); }
  628. .result-btn .label { font-size: 10px; color: var(--text2); font-family: 'Manrope', sans-serif; text-align: center; }
  629. .segments-preview { margin-top: 4px; }
  630. .segment-row {
  631. display: grid;
  632. grid-template-columns: 110px 90px 1fr;
  633. gap: 12px;
  634. padding: 10px 0;
  635. border-bottom: 1px solid var(--border);
  636. font-size: 12px;
  637. align-items: start;
  638. }
  639. .segment-row:last-child { border-bottom: none; }
  640. .seg-time { font-family: 'IBM Plex Mono', monospace; color: var(--text2); font-size: 11px; padding-top: 1px; }
  641. .seg-speaker {
  642. font-family: 'IBM Plex Mono', monospace;
  643. font-size: 11px;
  644. padding: 2px 8px;
  645. border-radius: 3px;
  646. background: var(--accent-lo);
  647. color: var(--accent);
  648. text-align: center;
  649. align-self: start;
  650. width: fit-content;
  651. }
  652. .seg-text { color: var(--text); line-height: 1.5; }
  653. .preview-more { text-align: center; color: var(--text3); font-size: 11px; padding-top: 10px; font-family: 'IBM Plex Mono', monospace; }
  654. /* ─── JOBS HISTORY ─── */
  655. .history-table {
  656. width: 100%;
  657. border-collapse: collapse;
  658. font-size: 12px;
  659. }
  660. .history-table th {
  661. text-align: left;
  662. padding: 8px 12px;
  663. color: var(--text2);
  664. font-size: 10px;
  665. font-weight: 700;
  666. letter-spacing: .12em;
  667. text-transform: uppercase;
  668. border-bottom: 1px solid var(--border);
  669. }
  670. .history-table td {
  671. padding: 10px 12px;
  672. border-bottom: 1px solid var(--border);
  673. vertical-align: middle;
  674. }
  675. .history-table tr:last-child td { border-bottom: none; }
  676. .history-table .mono { color: var(--text2); }
  677. .history-empty { text-align: center; color: var(--text3); font-size: 12px; padding: 24px; }
  678. /* ═══════════════════════════════════════════
  679. ADMIN PANEL
  680. ═══════════════════════════════════════════ */
  681. .tab-nav {
  682. display: flex;
  683. gap: 2px;
  684. align-items: center;
  685. }
  686. .tab-btn {
  687. padding: 6px 16px;
  688. border: none;
  689. background: none;
  690. color: var(--text2);
  691. font-family: 'Syne', sans-serif;
  692. font-weight: 700;
  693. font-size: 11px;
  694. letter-spacing: .08em;
  695. text-transform: uppercase;
  696. cursor: pointer;
  697. border-radius: var(--radius);
  698. transition: all .15s;
  699. }
  700. .tab-btn.active { background: var(--accent-lo); color: var(--accent); }
  701. .tab-btn:hover:not(.active) { background: var(--s2); color: var(--text); }
  702. /* Role + status badges */
  703. .badge {
  704. display: inline-block;
  705. padding: 2px 8px;
  706. border-radius: 3px;
  707. font-size: 10px;
  708. font-weight: 700;
  709. font-family: 'IBM Plex Mono', monospace;
  710. letter-spacing: .06em;
  711. text-transform: uppercase;
  712. }
  713. .badge-admin { background: rgba(232,160,0,.15); color: var(--accent); border: 1px solid rgba(232,160,0,.3); }
  714. .badge-user { background: var(--s3); color: var(--text2); border: 1px solid var(--border); }
  715. .badge-default { background: rgba(224,82,82,.1); color: var(--err); border: 1px solid rgba(224,82,82,.25); }
  716. .badge-set { background: rgba(76,175,116,.1); color: var(--ok); border: 1px solid rgba(76,175,116,.25); }
  717. .badge-yes { background: rgba(76,175,116,.1); color: var(--ok); border: 1px solid rgba(76,175,116,.25); }
  718. .badge-no { background: var(--s3); color: var(--text3); border: 1px solid var(--border); }
  719. /* Metrics */
  720. .metrics-grid {
  721. display: grid;
  722. grid-template-columns: repeat(4, 1fr);
  723. gap: 12px;
  724. margin-bottom: 20px;
  725. }
  726. .metric-card {
  727. background: var(--s1);
  728. border: 1px solid var(--border);
  729. border-radius: var(--radius);
  730. padding: 20px 16px 16px;
  731. text-align: center;
  732. }
  733. .metric-value {
  734. font-family: 'Syne', sans-serif;
  735. font-size: 32px;
  736. font-weight: 800;
  737. color: var(--text);
  738. line-height: 1;
  739. }
  740. .metric-label {
  741. font-size: 10px;
  742. color: var(--text2);
  743. text-transform: uppercase;
  744. letter-spacing: .12em;
  745. margin-top: 6px;
  746. }
  747. .metric-card.accent .metric-value { color: var(--accent); }
  748. .metric-card.ok .metric-value { color: var(--ok); }
  749. .metric-card.err .metric-value { color: var(--err); }
  750. .metrics-cols {
  751. display: grid;
  752. grid-template-columns: 1fr 1fr 1fr;
  753. gap: 14px;
  754. margin-bottom: 14px;
  755. }
  756. .bar-list { display: flex; flex-direction: column; gap: 10px; }
  757. .bar-item { display: flex; align-items: center; gap: 10px; font-size: 12px; }
  758. .bar-label {
  759. width: 90px;
  760. font-family: 'IBM Plex Mono', monospace;
  761. color: var(--text2);
  762. overflow: hidden;
  763. text-overflow: ellipsis;
  764. white-space: nowrap;
  765. font-size: 11px;
  766. }
  767. .bar-track {
  768. flex: 1;
  769. height: 5px;
  770. background: var(--s3);
  771. border-radius: 3px;
  772. overflow: hidden;
  773. }
  774. .bar-fill { height: 100%; background: var(--accent); border-radius: 3px; transition: width .6s ease; }
  775. .bar-count { width: 28px; text-align: right; color: var(--text2); font-family: 'IBM Plex Mono', monospace; font-size: 11px; }
  776. /* ═══════════════════════════════════════════
  777. MODAL
  778. ═══════════════════════════════════════════ */
  779. #modal-overlay {
  780. position: fixed;
  781. inset: 0;
  782. background: rgba(0,0,0,.72);
  783. z-index: 50;
  784. display: flex;
  785. align-items: center;
  786. justify-content: center;
  787. animation: fadeIn .2s ease;
  788. }
  789. .modal {
  790. background: var(--s1);
  791. border: 1px solid var(--border);
  792. border-top: 2px solid var(--accent);
  793. border-radius: var(--radius);
  794. padding: 28px 32px 32px;
  795. width: 100%;
  796. max-width: 440px;
  797. margin: 0 16px;
  798. box-shadow: 0 24px 60px rgba(0,0,0,.6);
  799. }
  800. .modal-title {
  801. font-family: 'Syne', sans-serif;
  802. font-size: 16px;
  803. font-weight: 700;
  804. letter-spacing: .04em;
  805. margin-bottom: 20px;
  806. }
  807. .modal-field { margin-bottom: 14px; }
  808. .modal-field label {
  809. display: block;
  810. font-size: 11px;
  811. font-weight: 600;
  812. letter-spacing: .1em;
  813. text-transform: uppercase;
  814. color: var(--text2);
  815. margin-bottom: 7px;
  816. }
  817. .modal-actions {
  818. display: flex;
  819. gap: 10px;
  820. justify-content: flex-end;
  821. margin-top: 24px;
  822. }
  823. .modal-note {
  824. font-size: 11px;
  825. color: var(--text2);
  826. margin-top: 8px;
  827. line-height: 1.5;
  828. }
  829. .key-display {
  830. font-family: 'IBM Plex Mono', monospace;
  831. font-size: 12px;
  832. background: var(--s2);
  833. border: 1px solid var(--border);
  834. border-radius: var(--radius);
  835. padding: 12px 14px;
  836. word-break: break-all;
  837. color: var(--accent);
  838. margin: 12px 0;
  839. line-height: 1.6;
  840. }
  841. .modal-error {
  842. margin-top: 10px;
  843. padding: 8px 12px;
  844. background: rgba(224,82,82,.1);
  845. border: 1px solid rgba(224,82,82,.3);
  846. border-radius: var(--radius);
  847. color: var(--err);
  848. font-size: 12px;
  849. }
  850. .role-toggle {
  851. display: grid;
  852. grid-template-columns: 1fr 1fr;
  853. gap: 8px;
  854. }
  855. .role-option input[type=radio] { position: absolute; opacity: 0; width: 0; height: 0; }
  856. .role-option {
  857. position: relative;
  858. }
  859. .role-option label {
  860. display: flex;
  861. flex-direction: column;
  862. align-items: center;
  863. gap: 4px;
  864. padding: 12px 8px;
  865. background: var(--s2);
  866. border: 1.5px solid var(--border);
  867. border-radius: var(--radius);
  868. cursor: pointer;
  869. transition: border-color .2s, background .2s;
  870. text-transform: none;
  871. letter-spacing: 0;
  872. color: var(--text2);
  873. font-size: 12px;
  874. }
  875. .role-option input:checked + label {
  876. border-color: var(--accent);
  877. background: var(--accent-lo);
  878. color: var(--text);
  879. }
  880. /* ─── TOAST ─── */
  881. #toast {
  882. position: fixed;
  883. bottom: 24px;
  884. left: 50%;
  885. transform: translateX(-50%) translateY(20px);
  886. background: var(--s3);
  887. border: 1px solid var(--border2);
  888. border-radius: var(--radius);
  889. padding: 10px 20px;
  890. font-size: 13px;
  891. color: var(--text);
  892. opacity: 0;
  893. transition: opacity .25s, transform .25s;
  894. z-index: 100;
  895. pointer-events: none;
  896. white-space: nowrap;
  897. }
  898. #toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
  899. /* ─── ANIMATIONS ─── */
  900. @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
  901. @keyframes slideDown { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }
  902. @keyframes shake { 0%,100% { transform: translateX(0); } 25% { transform: translateX(-6px); } 75% { transform: translateX(6px); } }
  903. @keyframes pulse { 0%,100% { opacity: 1; box-shadow: 0 0 0 0 var(--accent); } 50% { opacity: .6; box-shadow: 0 0 0 4px transparent; } }
  904. @keyframes wave { 0%,100% { transform: scaleY(.25); opacity: .4; } 50% { transform: scaleY(1); opacity: 1; } }
  905. @media (max-width: 600px) {
  906. .settings-grid { grid-template-columns: 1fr; }
  907. .formats-grid { grid-template-columns: repeat(2, 1fr); }
  908. .pipeline-steps { flex-wrap: wrap; gap: 10px; }
  909. .pipeline-step::after { display: none; }
  910. .metrics-grid { grid-template-columns: repeat(2, 1fr); }
  911. .metrics-cols { grid-template-columns: 1fr; }
  912. }
  913. /* ─── LANG TOGGLE ─── */
  914. .lang-toggle-btn {
  915. font-family: 'IBM Plex Mono', monospace;
  916. font-size: 11px;
  917. font-weight: 500;
  918. letter-spacing: .08em;
  919. min-width: 36px;
  920. padding: 6px 10px;
  921. background: var(--s2);
  922. border: 1px solid var(--border);
  923. color: var(--text2);
  924. border-radius: var(--radius);
  925. cursor: pointer;
  926. transition: border-color .2s, color .2s;
  927. }
  928. .lang-toggle-btn:hover { border-color: var(--border2); color: var(--text); }
  929. .card-lang-toggle { position: absolute; top: 14px; right: 16px; }
  930. </style>
  931. </head>
  932. <body>
  933. <!-- ═══════════════════ LOGIN ═══════════════════ -->
  934. <div id="login-screen">
  935. <div class="login-card">
  936. <button class="lang-toggle-btn card-lang-toggle" onclick="toggleLanguage()">EN</button>
  937. <div class="login-logo">
  938. <div class="waveform" aria-hidden="true">
  939. <span style="height:8px"></span>
  940. <span style="height:14px"></span>
  941. <span style="height:22px"></span>
  942. <span style="height:18px"></span>
  943. <span style="height:28px"></span>
  944. <span style="height:20px"></span>
  945. <span style="height:12px"></span>
  946. <span style="height:6px"></span>
  947. </div>
  948. <div>
  949. <h1>Transcriptor</h1>
  950. <small data-i18n="login.subtitle">Inteligencia de audio</small>
  951. </div>
  952. </div>
  953. <div class="field">
  954. <label for="login-email" data-i18n="login.email">Correo electrónico</label>
  955. <input type="email" id="login-email" placeholder="tu@correo.com" data-i18n-placeholder="login.email_ph" autocomplete="email">
  956. </div>
  957. <div class="field">
  958. <label for="login-password" data-i18n="login.password">Contraseña</label>
  959. <div class="input-wrap">
  960. <input type="password" id="login-password" placeholder="••••••••" autocomplete="current-password">
  961. <button class="eye" id="toggle-password" type="button" title="Show/hide password">
  962. <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>
  963. <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>
  964. </button>
  965. </div>
  966. </div>
  967. <button class="btn btn-primary" onclick="login()">
  968. <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>
  969. <span data-i18n="login.sign_in">Iniciar sesión</span>
  970. </button>
  971. <div id="login-error" class="login-error hidden"></div>
  972. </div>
  973. </div>
  974. <!-- ═══════════════════ CHANGE PASSWORD ═══════════════════ -->
  975. <div id="change-password-screen" class="hidden">
  976. <div class="login-card">
  977. <button class="lang-toggle-btn card-lang-toggle" onclick="toggleLanguage()">EN</button>
  978. <button class="cp-back-link hidden" id="cp-back-btn" onclick="cancelChangePassword()" data-i18n="cp.back">← Volver</button>
  979. <div class="login-logo">
  980. <div class="waveform" aria-hidden="true">
  981. <span style="height:8px"></span>
  982. <span style="height:14px"></span>
  983. <span style="height:22px"></span>
  984. <span style="height:18px"></span>
  985. <span style="height:28px"></span>
  986. <span style="height:20px"></span>
  987. <span style="height:12px"></span>
  988. <span style="height:6px"></span>
  989. </div>
  990. <div>
  991. <h1 data-i18n="cp.title">Nueva contraseña</h1>
  992. <small data-i18n="cp.head">Seguridad de cuenta</small>
  993. </div>
  994. </div>
  995. <p class="login-subtitle" id="cp-subtitle"></p>
  996. <div class="field hidden" id="cp-current-field">
  997. <label for="cp-current" data-i18n="cp.current">Contraseña actual</label>
  998. <input type="password" id="cp-current" placeholder="Tu contraseña actual" data-i18n-placeholder="cp.current_ph" autocomplete="current-password">
  999. </div>
  1000. <div class="field">
  1001. <label for="cp-new" data-i18n="cp.new">Nueva contraseña</label>
  1002. <input type="password" id="cp-new" placeholder="Mínimo 8 caracteres" data-i18n-placeholder="cp.new_ph" autocomplete="new-password">
  1003. </div>
  1004. <div class="field">
  1005. <label for="cp-confirm" data-i18n="cp.confirm">Confirmar contraseña</label>
  1006. <input type="password" id="cp-confirm" placeholder="Repite la nueva contraseña" data-i18n-placeholder="cp.confirm_ph" autocomplete="new-password">
  1007. </div>
  1008. <button class="btn btn-primary" onclick="submitChangePassword()" data-i18n="cp.submit">Establecer contraseña</button>
  1009. <div id="cp-error" class="login-error hidden"></div>
  1010. </div>
  1011. </div>
  1012. <!-- ═══════════════════ APP ═══════════════════ -->
  1013. <div id="app-screen" class="hidden">
  1014. <header class="app-header">
  1015. <div class="header-logo">
  1016. <div class="dot"></div>
  1017. Transcriptor
  1018. </div>
  1019. <div class="header-right">
  1020. <div class="user-badge">
  1021. <div class="avatar user-avatar-el">?</div>
  1022. <div class="user-info">
  1023. <div class="user-name user-name-el">—</div>
  1024. <div class="user-email user-email-el">—</div>
  1025. </div>
  1026. </div>
  1027. <button class="lang-toggle-btn" onclick="toggleLanguage()">EN</button>
  1028. <button id="admin-btn" class="btn btn-ghost btn-sm hidden" onclick="showAdmin()" data-i18n="app.admin_panel">Panel de admin</button>
  1029. <button class="btn btn-ghost btn-sm" onclick="startChangePassword()" data-i18n="app.password">Contraseña</button>
  1030. <button class="btn btn-ghost btn-sm" onclick="logout()" data-i18n="app.sign_out">Cerrar sesión</button>
  1031. </div>
  1032. </header>
  1033. <main class="app-main">
  1034. <div class="section-label" data-i18n="s.upload">01 — Subir archivo</div>
  1035. <div class="card" style="padding: 0; overflow: hidden;">
  1036. <div class="upload-zone" id="upload-zone">
  1037. <input type="file" id="file-input" accept="audio/*,video/*,.mp4,.mp3,.wav,.ogg,.m4a,.webm,.mkv" onchange="onFileSelect(event)">
  1038. <div class="upload-icon">
  1039. <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
  1040. <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
  1041. <polyline points="17 8 12 3 7 8"/>
  1042. <line x1="12" y1="3" x2="12" y2="15"/>
  1043. </svg>
  1044. </div>
  1045. <h3 data-i18n="upload.h3">Arrastra tu audio o video</h3>
  1046. <p data-i18n="upload.p">MP3, MP4, WAV, OGG, M4A, WebM — cualquier formato</p>
  1047. <div id="file-chip" class="hidden" style="justify-content:center;display:flex;margin-top:12px;" onclick="event.stopPropagation()">
  1048. <span class="file-chip">
  1049. <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>
  1050. <span id="file-name-display"></span>
  1051. <button class="remove" onclick="clearFile()" title="Remove file">×</button>
  1052. </span>
  1053. </div>
  1054. </div>
  1055. </div>
  1056. <div class="section-label" style="margin-top:24px;" data-i18n="s.settings">02 — Configuración</div>
  1057. <div class="card">
  1058. <div class="settings-grid" style="margin-bottom:20px;">
  1059. <div class="field">
  1060. <label for="lang-select" data-i18n="settings.language">Idioma</label>
  1061. <select id="lang-select">
  1062. <option value="es" selected>Spanish (es)</option>
  1063. <option value="en">English (en)</option>
  1064. <option value="pt">Portuguese (pt)</option>
  1065. <option value="fr">French (fr)</option>
  1066. <option value="de">German (de)</option>
  1067. <option value="it">Italian (it)</option>
  1068. <option value="ja">Japanese (ja)</option>
  1069. <option value="zh">Chinese (zh)</option>
  1070. <option value="auto">Auto-detect</option>
  1071. </select>
  1072. </div>
  1073. <div class="field">
  1074. <label for="model-select" data-i18n="settings.model">Modelo</label>
  1075. <select id="model-select">
  1076. <option value="large-v3" selected>large-v3 (best)</option>
  1077. <option value="large-v2">large-v2</option>
  1078. <option value="medium">medium</option>
  1079. <option value="small">small (fast)</option>
  1080. <option value="base">base (fastest)</option>
  1081. </select>
  1082. </div>
  1083. </div>
  1084. <div class="field">
  1085. <label data-i18n="settings.formats">Formatos de salida</label>
  1086. <div class="formats-grid">
  1087. <div class="fmt-check">
  1088. <input type="checkbox" id="fmt-txt" checked>
  1089. <label for="fmt-txt">
  1090. <span class="fmt-tag">.txt</span>
  1091. <span class="fmt-desc" data-i18n="fmt.txt_spk">Texto con hablantes</span>
  1092. </label>
  1093. </div>
  1094. <div class="fmt-check">
  1095. <input type="checkbox" id="fmt-srt" checked>
  1096. <label for="fmt-srt">
  1097. <span class="fmt-tag">.srt</span>
  1098. <span class="fmt-desc" data-i18n="fmt.srt_spk">Subtítulos con hablantes</span>
  1099. </label>
  1100. </div>
  1101. <div class="fmt-check">
  1102. <input type="checkbox" id="fmt-txt_nh">
  1103. <label for="fmt-txt_nh">
  1104. <span class="fmt-tag">.txt</span>
  1105. <span class="fmt-desc" data-i18n="fmt.txt_only">Solo texto</span>
  1106. </label>
  1107. </div>
  1108. <div class="fmt-check">
  1109. <input type="checkbox" id="fmt-srt_nh">
  1110. <label for="fmt-srt_nh">
  1111. <span class="fmt-tag">.srt</span>
  1112. <span class="fmt-desc" data-i18n="fmt.srt_only">Solo subtítulos</span>
  1113. </label>
  1114. </div>
  1115. </div>
  1116. </div>
  1117. <div class="field" style="margin-top:20px;">
  1118. <label style="display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none;">
  1119. <span data-i18n="settings.prompt">Prompt inicial</span>
  1120. <span style="font-size:10px;color:var(--text3);font-weight:400;" data-i18n="settings.prompt_optional">(opcional)</span>
  1121. <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>
  1122. </label>
  1123. <div id="prompt-field" class="hidden" style="margin-top:8px;">
  1124. <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>
  1125. <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>
  1126. </div>
  1127. </div>
  1128. <div class="submit-row">
  1129. <span id="submit-hint" style="color:var(--text3);font-size:12px;"></span>
  1130. <button class="btn btn-primary" id="submit-btn" onclick="submitJob()" disabled>
  1131. <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>
  1132. <span data-i18n="submit.transcribe">Transcribir</span>
  1133. </button>
  1134. </div>
  1135. </div>
  1136. <div id="job-section" class="hidden">
  1137. <div class="section-label" style="margin-top:24px;" data-i18n="s.processing">03 — Procesando</div>
  1138. <div class="job-card" id="job-card">
  1139. <div class="job-card-top">
  1140. <span class="filename mono" id="job-filename"></span>
  1141. <span class="status-pill" id="status-pill">
  1142. <span class="status-dot"></span>
  1143. <span id="status-text-pill">Pending</span>
  1144. </span>
  1145. </div>
  1146. <div class="job-card-body" id="job-body">
  1147. <div class="waveform-anim" id="wave-anim">
  1148. <div class="bar"></div><div class="bar"></div><div class="bar"></div>
  1149. <div class="bar"></div><div class="bar"></div><div class="bar"></div>
  1150. <div class="bar"></div><div class="bar"></div><div class="bar"></div>
  1151. <div class="bar"></div><div class="bar"></div><div class="bar"></div>
  1152. <div class="bar"></div><div class="bar"></div><div class="bar"></div>
  1153. <div class="bar"></div><div class="bar"></div><div class="bar"></div>
  1154. <div class="bar"></div><div class="bar"></div>
  1155. </div>
  1156. <div class="status-text" id="job-status-desc" data-i18n="status.initializing">Inicializando…</div>
  1157. <div class="pipeline-steps" style="margin-top:22px;">
  1158. <div class="pipeline-step" id="step-transcribing">
  1159. <div class="step-node">1</div><span data-i18n="step.transcribe">Transcribir</span>
  1160. </div>
  1161. <div class="pipeline-step" id="step-diarizing">
  1162. <div class="step-node">2</div><span data-i18n="step.diarize">Identificar</span>
  1163. </div>
  1164. <div class="pipeline-step" id="step-combining">
  1165. <div class="step-node">3</div><span data-i18n="step.combine">Combinar</span>
  1166. </div>
  1167. <div class="pipeline-step" id="step-completed">
  1168. <div class="step-node">✓</div><span data-i18n="step.done">Listo</span>
  1169. </div>
  1170. </div>
  1171. </div>
  1172. </div>
  1173. </div>
  1174. <div id="results-section" class="hidden">
  1175. <div class="section-label" style="margin-top:24px;" data-i18n="s.results">04 — Resultados</div>
  1176. <div class="card">
  1177. <div class="results-grid" id="results-grid"></div>
  1178. <div class="section-label" style="margin-top:4px;margin-bottom:12px;font-size:10px;" data-i18n="s.preview">Vista previa — primeros segmentos</div>
  1179. <div class="segments-preview" id="segments-preview"></div>
  1180. </div>
  1181. </div>
  1182. <div style="margin-top:32px;">
  1183. <div class="section-label" style="justify-content:space-between;">
  1184. <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>
  1185. <button class="btn btn-ghost btn-sm" onclick="loadHistory()" style="font-size:10px;padding:4px 10px;" data-i18n="history.refresh">↻ Actualizar</button>
  1186. </div>
  1187. <div class="card" style="padding:0;overflow:hidden;">
  1188. <table class="history-table">
  1189. <thead>
  1190. <tr>
  1191. <th data-i18n="history.th_file">Archivo</th>
  1192. <th data-i18n="history.th_status">Estado</th>
  1193. <th data-i18n="history.th_language">Idioma</th>
  1194. <th data-i18n="history.th_segments">Segmentos</th>
  1195. <th data-i18n="history.th_time">Tiempo</th>
  1196. <th data-i18n="history.th_actions">Acciones</th>
  1197. </tr>
  1198. </thead>
  1199. <tbody id="history-tbody">
  1200. <tr><td colspan="6" class="history-empty">Loading…</td></tr>
  1201. </tbody>
  1202. </table>
  1203. </div>
  1204. </div>
  1205. </main>
  1206. </div>
  1207. <!-- ═══════════════════ ADMIN PANEL ═══════════════════ -->
  1208. <div id="admin-screen" class="hidden">
  1209. <header class="app-header">
  1210. <div class="header-left">
  1211. <div class="header-logo">
  1212. <div class="dot"></div>
  1213. <span data-i18n="admin.title">Admin</span>
  1214. </div>
  1215. <div class="tab-nav">
  1216. <button class="tab-btn active" id="tab-btn-users" onclick="switchAdminTab('users')" data-i18n="admin.tab_users">Usuarios</button>
  1217. <button class="tab-btn" id="tab-btn-history" onclick="switchAdminTab('history')" data-i18n="admin.tab_history">Historia</button>
  1218. <button class="tab-btn" id="tab-btn-metrics" onclick="switchAdminTab('metrics')" data-i18n="admin.tab_metrics">Métricas</button>
  1219. </div>
  1220. </div>
  1221. <div class="header-right">
  1222. <div class="user-badge">
  1223. <div class="avatar user-avatar-el">?</div>
  1224. <div class="user-info">
  1225. <div class="user-name user-name-el">—</div>
  1226. <div class="user-email user-email-el">—</div>
  1227. </div>
  1228. </div>
  1229. <button class="lang-toggle-btn" onclick="toggleLanguage()">EN</button>
  1230. <button class="btn btn-ghost btn-sm" onclick="showApp()" data-i18n="admin.back_app">← Aplicación</button>
  1231. <button class="btn btn-ghost btn-sm" onclick="logout()" data-i18n="app.sign_out">Cerrar sesión</button>
  1232. </div>
  1233. </header>
  1234. <main class="app-main-wide">
  1235. <!-- Users tab -->
  1236. <div id="admin-tab-users">
  1237. <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
  1238. <div class="section-label" style="margin-bottom:0;flex:1;" data-i18n="admin.users_title">Cuentas de usuario</div>
  1239. <button class="btn btn-primary btn-sm" onclick="openCreateUserModal()" data-i18n="admin.create_user">+ Crear usuario</button>
  1240. </div>
  1241. <div class="card" style="padding:0;overflow:hidden;">
  1242. <table class="history-table">
  1243. <thead>
  1244. <tr>
  1245. <th data-i18n="admin.th_name">Nombre</th>
  1246. <th data-i18n="admin.th_email">Correo</th>
  1247. <th data-i18n="admin.th_role">Rol</th>
  1248. <th data-i18n="admin.th_password">Contraseña</th>
  1249. <th data-i18n="admin.th_api_key">Clave API</th>
  1250. <th data-i18n="admin.th_created">Creado</th>
  1251. <th data-i18n="admin.th_actions">Acciones</th>
  1252. </tr>
  1253. </thead>
  1254. <tbody id="admin-users-tbody">
  1255. <tr><td colspan="7" class="history-empty">Loading…</td></tr>
  1256. </tbody>
  1257. </table>
  1258. </div>
  1259. </div>
  1260. <!-- History tab -->
  1261. <div id="admin-tab-history" class="hidden">
  1262. <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
  1263. <div class="section-label" style="margin-bottom:0;flex:1;" data-i18n="admin.history_title">Historial global de trabajos</div>
  1264. <button class="btn btn-ghost btn-sm" onclick="loadAdminJobs()" style="font-size:10px;padding:4px 10px;" data-i18n="history.refresh">↻ Actualizar</button>
  1265. </div>
  1266. <div class="card" style="padding:0;overflow:hidden;">
  1267. <table class="history-table">
  1268. <thead>
  1269. <tr>
  1270. <th data-i18n="history.th_file">Archivo</th>
  1271. <th data-i18n="admin.th_user">Usuario</th>
  1272. <th data-i18n="history.th_status">Estado</th>
  1273. <th data-i18n="history.th_language">Idioma</th>
  1274. <th data-i18n="settings.model">Modelo</th>
  1275. <th data-i18n="history.th_time">Tiempo</th>
  1276. <th data-i18n="admin.th_created">Creado</th>
  1277. </tr>
  1278. </thead>
  1279. <tbody id="admin-history-tbody">
  1280. <tr><td colspan="7" class="history-empty">Loading…</td></tr>
  1281. </tbody>
  1282. </table>
  1283. </div>
  1284. </div>
  1285. <!-- Metrics tab -->
  1286. <div id="admin-tab-metrics" class="hidden">
  1287. <div class="section-label" data-i18n="metrics.overview">Resumen</div>
  1288. <div id="metrics-content">
  1289. <div class="history-empty">Loading metrics…</div>
  1290. </div>
  1291. </div>
  1292. </main>
  1293. </div>
  1294. <!-- ═══════════════════ MODAL ═══════════════════ -->
  1295. <div id="modal-overlay" class="hidden" onclick="if(event.target===this)closeModal()">
  1296. <div id="modal-box" class="modal">
  1297. <div id="modal-title" class="modal-title"></div>
  1298. <div id="modal-body"></div>
  1299. <div id="modal-actions" class="modal-actions"></div>
  1300. </div>
  1301. </div>
  1302. <!-- Toast -->
  1303. <div id="toast"></div>
  1304. <script>
  1305. // ─── I18N ─────────────────────────────────────────────────────
  1306. let currentLang = localStorage.getItem('tx_lang') || 'es';
  1307. const T = {
  1308. en: {
  1309. 'login.subtitle':'Audio Intelligence','login.email':'Email','login.email_ph':'your@email.com',
  1310. 'login.password':'Password','login.sign_in':'Sign In',
  1311. 'login.err_fill':'Please fill in both fields.','login.err_invalid':'Invalid email or password.',
  1312. 'login.err_server':'Could not reach the server.',
  1313. 'cp.title':'New Password','cp.head':'Account Security','cp.back':'← Back',
  1314. 'cp.subtitle_voluntary':'Enter your current password, then choose a new one.',
  1315. 'cp.subtitle_forced':'Welcome, <strong>{name}</strong>! Please set a new password to activate your account.',
  1316. 'cp.fallback_name':'there',
  1317. 'cp.current':'Current Password','cp.current_ph':'Your current password',
  1318. 'cp.new':'New Password','cp.new_ph':'Minimum 8 characters',
  1319. 'cp.confirm':'Confirm Password','cp.confirm_ph':'Repeat new password',
  1320. 'cp.submit':'Set Password',
  1321. 'cp.err_short':'Password must be at least 8 characters.','cp.err_mismatch':'Passwords do not match.',
  1322. 'cp.err_current_required':'Current password is required.','cp.err_failed':'Could not change password.',
  1323. 'cp.err_network':'Network error — please try again.','cp.success':'Password updated successfully',
  1324. 'app.admin_panel':'Admin Panel','app.password':'Password','app.sign_out':'Sign out',
  1325. 's.upload':'01 — Upload','upload.h3':'Drop your audio or video',
  1326. 'upload.p':'MP3, MP4, WAV, OGG, M4A, WebM — any format',
  1327. 's.settings':'02 — Settings','settings.language':'Language','settings.model':'Model',
  1328. 'settings.prompt':'Initial Prompt','settings.prompt_optional':'(optional)','settings.prompt_expand':'▼ Show','settings.prompt_collapse':'▲ Hide',
  1329. 'settings.prompt_ph':'e.g. proper nouns, technical terms, or context to help Whisper…',
  1330. 'settings.prompt_hint':'Proper nouns, technical terms, or context that helps Whisper transcribe more accurately.',
  1331. 'settings.formats':'Output Formats','fmt.txt_spk':'Text with speakers',
  1332. 'fmt.srt_spk':'Subtitles with speakers','fmt.txt_only':'Text only','fmt.srt_only':'Subtitles only',
  1333. 'submit.transcribe':'Transcribe','submit.select_fmt':'Select at least one output format.',
  1334. 's.processing':'03 — Processing','step.transcribe':'Transcribe','step.diarize':'Diarize',
  1335. 'step.combine':'Combine','step.done':'Done','status.initializing':'Initializing…',
  1336. 'status.pending':'Pending','status.converting':'Converting','status.transcribing':'Transcribing',
  1337. 'status.diarizing':'Identifying speakers','status.combining':'Combining',
  1338. 'status.completed':'Completed','status.failed':'Failed',
  1339. 'status.pending_desc':'Queued — waiting to start…','status.converting_desc':'Converting video to audio…',
  1340. 'status.transcribing_desc':'<strong>Step 1</strong> — Running Whisper speech recognition…',
  1341. 'status.diarizing_desc':'<strong>Step 2</strong> — Running PyAnnote speaker diarization…',
  1342. 'status.combining_desc':'<strong>Step 3</strong> — Aligning words with speaker turns…',
  1343. 'status.completed_desc':'Done! Your transcription is ready.','status.failed_desc':'An error occurred.',
  1344. 's.results':'04 — Results','s.preview':'Preview — first segments',
  1345. 'results.more':'+ {n} more segments','results.downloaded':'Downloaded {fmt}',
  1346. 'results.dl_failed':'Download failed','results.err':'Error: {msg}',
  1347. 'fmtlabel.txt':'TXT with speakers','fmtlabel.srt':'SRT with speakers',
  1348. 'fmtlabel.txt_nh':'TXT plain text','fmtlabel.srt_nh':'SRT plain text',
  1349. 'fmt.no_spk':'(no spk)',
  1350. 'history.title':'Jobs History','history.refresh':'↻ Refresh',
  1351. 'history.th_file':'File','history.th_status':'Status','history.th_language':'Language',
  1352. 'history.th_segments':'Segments','history.th_time':'Time','history.th_actions':'Actions',
  1353. 'history.loading':'Loading…','history.empty':'No jobs yet','history.job_is':'Job is {status}',
  1354. 'admin.title':'Admin','admin.tab_users':'Users','admin.tab_history':'History','admin.tab_metrics':'Metrics','admin.back_app':'← App',
  1355. 'admin.history_title':'Global Job History','admin.th_user':'User',
  1356. 'admin.users_title':'User Accounts','admin.create_user':'+ Create User',
  1357. 'admin.th_name':'Name','admin.th_email':'Email','admin.th_role':'Role',
  1358. 'admin.th_password':'Password','admin.th_api_key':'API Key','admin.th_created':'Created',
  1359. 'admin.th_actions':'Actions','admin.loading':'Loading…','admin.no_users':'No users yet',
  1360. 'admin.pwd_default':'Default','admin.pwd_set':'Set','admin.api_yes':'Yes','admin.api_no':'No',
  1361. 'admin.revoke_key':'Revoke Key','admin.gen_key':'Gen Key','admin.reset_pwd':'Reset Pwd',
  1362. 'admin.delete':'Delete','admin.load_failed':'Failed to load users',
  1363. 'metrics.overview':'Overview','metrics.loading':'Loading metrics…',
  1364. 'metrics.failed':'Failed to load metrics','metrics.total_jobs':'Total Jobs',
  1365. 'metrics.completed':'Completed','metrics.failed_label':'Failed',
  1366. 'metrics.avg_transcription':'Avg Transcription','metrics.by_model':'By Model',
  1367. 'metrics.by_language':'By Language','metrics.by_user':'By User','metrics.no_data':'No data',
  1368. 'modal.create_user':'Create User','modal.email':'Email','modal.email_ph':'user@example.com',
  1369. 'modal.full_name':'Full Name','modal.full_name_ph':'Jane Smith',
  1370. 'modal.default_password':'Default Password',
  1371. 'modal.default_password_ph':'Temporary password (min 8 chars)',
  1372. 'modal.default_password_note':'User will be required to change this on first login.',
  1373. 'modal.role':'Role','modal.role_user':'User','modal.role_user_desc':'Standard access',
  1374. 'modal.role_admin':'Admin','modal.role_admin_desc':'Full access',
  1375. 'modal.cancel':'Cancel','modal.create':'Create',
  1376. 'modal.all_required':'All fields are required.',
  1377. 'modal.pwd_short':'Password must be at least 8 characters.',
  1378. 'modal.user_created':'User {name} created','modal.user_create_failed':'Could not create user.',
  1379. 'modal.network_error':'Network error.',
  1380. 'modal.reset_pwd_title':'Reset Password',
  1381. '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.',
  1382. 'modal.new_default_pwd':'New Default Password','modal.min_8_ph':'Minimum 8 characters',
  1383. 'modal.reset':'Reset','modal.reset_failed':'Failed.',
  1384. 'modal.pwd_reset_success':'Password reset — user must change on next login',
  1385. 'modal.confirm_delete':'Delete user "{name}"? This cannot be undone.',
  1386. 'modal.user_deleted':'User {name} deleted','modal.delete_failed':'Failed to delete user',
  1387. 'modal.api_generated_title':'API Key Generated',
  1388. 'modal.api_copy_note':'Copy this key — it will <strong style="color:var(--err)">not be shown again</strong>.',
  1389. '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.',
  1390. 'modal.copy_key':'Copy Key','modal.done':'Done',
  1391. 'modal.key_copied':'Key copied to clipboard','modal.copy_failed':'Copy failed — select manually',
  1392. 'modal.revoke_confirm':'Revoke API key for "{name}"? They will no longer be able to use the REST API.',
  1393. 'modal.revoke_failed':'Failed to revoke key','modal.key_revoked':'API key revoked',
  1394. 'modal.gen_failed':'Failed','network_error':'Network error','submission_failed':'Submission failed',
  1395. },
  1396. es: {
  1397. 'login.subtitle':'Inteligencia de audio','login.email':'Correo electrónico','login.email_ph':'tu@correo.com',
  1398. 'login.password':'Contraseña','login.sign_in':'Iniciar sesión',
  1399. 'login.err_fill':'Por favor completa ambos campos.','login.err_invalid':'Correo o contraseña inválidos.',
  1400. 'login.err_server':'No se pudo conectar al servidor.',
  1401. 'cp.title':'Nueva contraseña','cp.head':'Seguridad de cuenta','cp.back':'← Volver',
  1402. 'cp.subtitle_voluntary':'Ingresa tu contraseña actual y elige una nueva.',
  1403. 'cp.subtitle_forced':'¡Bienvenido/a, <strong>{name}</strong>! Por favor establece una nueva contraseña para activar tu cuenta.',
  1404. 'cp.fallback_name':'usuario',
  1405. 'cp.current':'Contraseña actual','cp.current_ph':'Tu contraseña actual',
  1406. 'cp.new':'Nueva contraseña','cp.new_ph':'Mínimo 8 caracteres',
  1407. 'cp.confirm':'Confirmar contraseña','cp.confirm_ph':'Repite la nueva contraseña',
  1408. 'cp.submit':'Establecer contraseña',
  1409. 'cp.err_short':'La contraseña debe tener al menos 8 caracteres.','cp.err_mismatch':'Las contraseñas no coinciden.',
  1410. 'cp.err_current_required':'La contraseña actual es requerida.','cp.err_failed':'No se pudo cambiar la contraseña.',
  1411. 'cp.err_network':'Error de red — inténtalo de nuevo.','cp.success':'Contraseña actualizada correctamente',
  1412. 'app.admin_panel':'Panel de admin','app.password':'Contraseña','app.sign_out':'Cerrar sesión',
  1413. 's.upload':'01 — Subir archivo','upload.h3':'Arrastra tu audio o video',
  1414. 'upload.p':'MP3, MP4, WAV, OGG, M4A, WebM — cualquier formato',
  1415. 's.settings':'02 — Configuración','settings.language':'Idioma','settings.model':'Modelo',
  1416. 'settings.prompt':'Prompt inicial','settings.prompt_optional':'(opcional)','settings.prompt_expand':'▼ Mostrar','settings.prompt_collapse':'▲ Ocultar',
  1417. 'settings.prompt_ph':'Ej: nombres propios, términos técnicos o contexto que ayude a Whisper…',
  1418. 'settings.prompt_hint':'Nombres propios, términos técnicos o contexto que ayude a Whisper a transcribir con mayor precisión.',
  1419. 'settings.formats':'Formatos de salida','fmt.txt_spk':'Texto con hablantes',
  1420. 'fmt.srt_spk':'Subtítulos con hablantes','fmt.txt_only':'Solo texto','fmt.srt_only':'Solo subtítulos',
  1421. 'submit.transcribe':'Transcribir','submit.select_fmt':'Selecciona al menos un formato de salida.',
  1422. 's.processing':'03 — Procesando','step.transcribe':'Transcribir','step.diarize':'Identificar',
  1423. 'step.combine':'Combinar','step.done':'Listo','status.initializing':'Inicializando…',
  1424. 'status.pending':'En espera','status.converting':'Convirtiendo','status.transcribing':'Transcribiendo',
  1425. 'status.diarizing':'Identificando hablantes','status.combining':'Combinando',
  1426. 'status.completed':'Completado','status.failed':'Fallido',
  1427. 'status.pending_desc':'En cola — esperando inicio…','status.converting_desc':'Convirtiendo video a audio…',
  1428. 'status.transcribing_desc':'<strong>Paso 1</strong> — Ejecutando reconocimiento de voz Whisper…',
  1429. 'status.diarizing_desc':'<strong>Paso 2</strong> — Ejecutando diarización de hablantes PyAnnote…',
  1430. 'status.combining_desc':'<strong>Paso 3</strong> — Alineando palabras con turnos de hablantes…',
  1431. 'status.completed_desc':'¡Listo! Tu transcripción está disponible.','status.failed_desc':'Ocurrió un error.',
  1432. 's.results':'04 — Resultados','s.preview':'Vista previa — primeros segmentos',
  1433. 'results.more':'+ {n} segmentos más','results.downloaded':'Descargado {fmt}',
  1434. 'results.dl_failed':'Error al descargar','results.err':'Error: {msg}',
  1435. 'fmtlabel.txt':'TXT con hablantes','fmtlabel.srt':'SRT con hablantes',
  1436. 'fmtlabel.txt_nh':'TXT texto plano','fmtlabel.srt_nh':'SRT texto plano',
  1437. 'fmt.no_spk':'(sin hbl)',
  1438. 'history.title':'Historial de trabajos','history.refresh':'↻ Actualizar',
  1439. 'history.th_file':'Archivo','history.th_status':'Estado','history.th_language':'Idioma',
  1440. 'history.th_segments':'Segmentos','history.th_time':'Tiempo','history.th_actions':'Acciones',
  1441. 'history.loading':'Cargando…','history.empty':'Sin trabajos aún','history.job_is':'El trabajo está {status}',
  1442. 'admin.title':'Admin','admin.tab_users':'Usuarios','admin.tab_history':'Historia','admin.tab_metrics':'Métricas','admin.back_app':'← Aplicación',
  1443. 'admin.history_title':'Historial global de trabajos','admin.th_user':'Usuario',
  1444. 'admin.users_title':'Cuentas de usuario','admin.create_user':'+ Crear usuario',
  1445. 'admin.th_name':'Nombre','admin.th_email':'Correo','admin.th_role':'Rol',
  1446. 'admin.th_password':'Contraseña','admin.th_api_key':'Clave API','admin.th_created':'Creado',
  1447. 'admin.th_actions':'Acciones','admin.loading':'Cargando…','admin.no_users':'Sin usuarios aún',
  1448. 'admin.pwd_default':'Por defecto','admin.pwd_set':'Establecida','admin.api_yes':'Sí','admin.api_no':'No',
  1449. 'admin.revoke_key':'Revocar','admin.gen_key':'Gen. clave','admin.reset_pwd':'Restablecer',
  1450. 'admin.delete':'Eliminar','admin.load_failed':'Error al cargar usuarios',
  1451. 'metrics.overview':'Resumen','metrics.loading':'Cargando métricas…',
  1452. 'metrics.failed':'Error al cargar métricas','metrics.total_jobs':'Total de trabajos',
  1453. 'metrics.completed':'Completados','metrics.failed_label':'Fallidos',
  1454. 'metrics.avg_transcription':'Transcripción prom.','metrics.by_model':'Por modelo',
  1455. 'metrics.by_language':'Por idioma','metrics.by_user':'Por usuario','metrics.no_data':'Sin datos',
  1456. 'modal.create_user':'Crear usuario','modal.email':'Correo electrónico','modal.email_ph':'usuario@ejemplo.com',
  1457. 'modal.full_name':'Nombre completo','modal.full_name_ph':'Juan Pérez',
  1458. 'modal.default_password':'Contraseña por defecto',
  1459. 'modal.default_password_ph':'Contraseña temporal (mín. 8 caracteres)',
  1460. 'modal.default_password_note':'El usuario deberá cambiarla en el primer inicio de sesión.',
  1461. 'modal.role':'Rol','modal.role_user':'Usuario','modal.role_user_desc':'Acceso estándar',
  1462. 'modal.role_admin':'Admin','modal.role_admin_desc':'Acceso completo',
  1463. 'modal.cancel':'Cancelar','modal.create':'Crear',
  1464. 'modal.all_required':'Todos los campos son requeridos.',
  1465. 'modal.pwd_short':'La contraseña debe tener al menos 8 caracteres.',
  1466. 'modal.user_created':'Usuario {name} creado','modal.user_create_failed':'No se pudo crear el usuario.',
  1467. 'modal.network_error':'Error de red.',
  1468. 'modal.reset_pwd_title':'Restablecer contraseña',
  1469. '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.',
  1470. 'modal.new_default_pwd':'Nueva contraseña por defecto','modal.min_8_ph':'Mínimo 8 caracteres',
  1471. 'modal.reset':'Restablecer','modal.reset_failed':'Error.',
  1472. 'modal.pwd_reset_success':'Contraseña restablecida — el usuario debe cambiarla al siguiente inicio',
  1473. 'modal.confirm_delete':'¿Eliminar usuario "{name}"? Esta acción no se puede deshacer.',
  1474. 'modal.user_deleted':'Usuario {name} eliminado','modal.delete_failed':'Error al eliminar usuario',
  1475. 'modal.api_generated_title':'Clave API generada',
  1476. 'modal.api_copy_note':'Copia esta clave — <strong style="color:var(--err)">no se mostrará de nuevo</strong>.',
  1477. '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>.',
  1478. 'modal.copy_key':'Copiar clave','modal.done':'Hecho',
  1479. 'modal.key_copied':'Clave copiada al portapapeles','modal.copy_failed':'Error al copiar — selecciona manualmente',
  1480. 'modal.revoke_confirm':'¿Revocar la clave API de "{name}"? Ya no podrán usar la REST API.',
  1481. 'modal.revoke_failed':'Error al revocar la clave','modal.key_revoked':'Clave API revocada',
  1482. 'modal.gen_failed':'Error','network_error':'Error de red','submission_failed':'Error al enviar',
  1483. },
  1484. };
  1485. function t(key, vars) {
  1486. let str = (T[currentLang]?.[key]) ?? (T['en']?.[key]) ?? key;
  1487. if (vars) for (const [k, v] of Object.entries(vars)) str = str.replaceAll('{' + k + '}', v);
  1488. return str;
  1489. }
  1490. function applyTranslations() {
  1491. document.documentElement.lang = currentLang;
  1492. document.querySelectorAll('[data-i18n]').forEach(el => { el.textContent = t(el.dataset.i18n); });
  1493. document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { el.placeholder = t(el.dataset.i18nPlaceholder); });
  1494. document.querySelectorAll('.lang-toggle-btn').forEach(btn => { btn.textContent = currentLang === 'es' ? 'EN' : 'ES'; });
  1495. }
  1496. function toggleLanguage() {
  1497. currentLang = currentLang === 'es' ? 'en' : 'es';
  1498. localStorage.setItem('tx_lang', currentLang);
  1499. applyTranslations();
  1500. // re-render visible dynamic content
  1501. if (!document.getElementById('app-screen').classList.contains('hidden')) renderHistory();
  1502. if (!document.getElementById('admin-screen').classList.contains('hidden')) {
  1503. if (currentAdminTab === 'users') renderAdminUsers();
  1504. else if (cachedMetrics) renderMetrics(cachedMetrics);
  1505. }
  1506. }
  1507. // ─── STATE ────────────────────────────────────────────────────
  1508. let currentUser = null; // { email, name, role, session_token }
  1509. let currentJob = null;
  1510. let pollTimer = null;
  1511. let jobHistory = [];
  1512. let changePwForced = false;
  1513. let changePwReturnTo = 'app';
  1514. let adminUsers = [];
  1515. let currentAdminTab = 'users';
  1516. let cachedMetrics = null;
  1517. // ─── INIT ─────────────────────────────────────────────────────
  1518. window.addEventListener('DOMContentLoaded', () => {
  1519. applyTranslations();
  1520. setupDragDrop();
  1521. const saved = localStorage.getItem('tx_session');
  1522. if (saved) {
  1523. try {
  1524. currentUser = JSON.parse(saved);
  1525. verifyAndShow();
  1526. } catch { showLogin(); }
  1527. }
  1528. document.getElementById('login-password').addEventListener('keydown', e => {
  1529. if (e.key === 'Enter') login();
  1530. });
  1531. document.getElementById('login-email').addEventListener('keydown', e => {
  1532. if (e.key === 'Enter') document.getElementById('login-password').focus();
  1533. });
  1534. document.getElementById('toggle-password').addEventListener('click', () => {
  1535. const inp = document.getElementById('login-password');
  1536. const show = document.getElementById('eye-show');
  1537. const hide = document.getElementById('eye-hide');
  1538. if (inp.type === 'password') {
  1539. inp.type = 'text'; show.style.display = 'none'; hide.style.display = '';
  1540. } else {
  1541. inp.type = 'password'; show.style.display = ''; hide.style.display = 'none';
  1542. }
  1543. });
  1544. document.getElementById('cp-new').addEventListener('keydown', e => {
  1545. if (e.key === 'Enter') document.getElementById('cp-confirm').focus();
  1546. });
  1547. document.getElementById('cp-confirm').addEventListener('keydown', e => {
  1548. if (e.key === 'Enter') submitChangePassword();
  1549. });
  1550. });
  1551. // ─── AUTH ─────────────────────────────────────────────────────
  1552. async function verifyAndShow() {
  1553. try {
  1554. const res = await apiFetch('/auth/verify');
  1555. if (res.ok) {
  1556. const data = await res.json();
  1557. currentUser = { ...currentUser, ...data };
  1558. localStorage.setItem('tx_session', JSON.stringify(currentUser));
  1559. if (data.is_default_password) {
  1560. showChangePassword(true, 'app');
  1561. } else {
  1562. showApp();
  1563. }
  1564. } else {
  1565. logout();
  1566. }
  1567. } catch { showLogin(); }
  1568. }
  1569. async function login() {
  1570. const email = document.getElementById('login-email').value.trim();
  1571. const password = document.getElementById('login-password').value;
  1572. if (!email || !password) { showLoginError(t('login.err_fill')); return; }
  1573. try {
  1574. const res = await fetch('/auth/login', {
  1575. method: 'POST',
  1576. headers: { 'Content-Type': 'application/json' },
  1577. body: JSON.stringify({ email, password }),
  1578. });
  1579. const data = await res.json();
  1580. if (!res.ok) { showLoginError(data.detail || t('login.err_invalid')); return; }
  1581. currentUser = {
  1582. email: data.user.email,
  1583. name: data.user.name,
  1584. role: data.user.role,
  1585. session_token: data.session_token,
  1586. };
  1587. localStorage.setItem('tx_session', JSON.stringify(currentUser));
  1588. if (data.is_default_password) {
  1589. showChangePassword(true, 'app');
  1590. } else {
  1591. showApp();
  1592. }
  1593. } catch { showLoginError(t('login.err_server')); }
  1594. }
  1595. async function logout() {
  1596. if (currentUser?.session_token) {
  1597. try { await apiFetch('/auth/logout', { method: 'POST' }); } catch {}
  1598. }
  1599. localStorage.removeItem('tx_session');
  1600. currentUser = null;
  1601. if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
  1602. showLogin();
  1603. }
  1604. function showLoginError(msg) {
  1605. const el = document.getElementById('login-error');
  1606. el.textContent = msg;
  1607. el.classList.remove('hidden');
  1608. el.style.animation = 'none';
  1609. requestAnimationFrame(() => { el.style.animation = ''; });
  1610. }
  1611. // ─── CHANGE PASSWORD ──────────────────────────────────────────
  1612. function showChangePassword(forced, returnTo) {
  1613. changePwForced = forced;
  1614. changePwReturnTo = returnTo || 'app';
  1615. document.getElementById('cp-new').value = '';
  1616. document.getElementById('cp-confirm').value = '';
  1617. document.getElementById('cp-current').value = '';
  1618. document.getElementById('cp-error').classList.add('hidden');
  1619. if (forced) {
  1620. document.getElementById('cp-back-btn').classList.add('hidden');
  1621. document.getElementById('cp-current-field').classList.add('hidden');
  1622. const name = currentUser?.name ? currentUser.name.split(' ')[0] : t('cp.fallback_name');
  1623. document.getElementById('cp-subtitle').innerHTML = t('cp.subtitle_forced', { name: escHtml(name) });
  1624. } else {
  1625. document.getElementById('cp-back-btn').classList.remove('hidden');
  1626. document.getElementById('cp-current-field').classList.remove('hidden');
  1627. document.getElementById('cp-subtitle').textContent = t('cp.subtitle_voluntary');
  1628. }
  1629. showScreen('change-password');
  1630. }
  1631. function cancelChangePassword() {
  1632. showScreen(changePwReturnTo);
  1633. }
  1634. function startChangePassword() {
  1635. showChangePassword(false, currentAdminTab === 'users' && document.getElementById('admin-screen').classList.contains('hidden') === false ? 'admin' : 'app');
  1636. }
  1637. async function submitChangePassword() {
  1638. const newPw = document.getElementById('cp-new').value;
  1639. const confirm = document.getElementById('cp-confirm').value;
  1640. const currPw = document.getElementById('cp-current').value;
  1641. const errEl = document.getElementById('cp-error');
  1642. errEl.classList.add('hidden');
  1643. if (newPw.length < 8) { showCpError(t('cp.err_short')); return; }
  1644. if (newPw !== confirm) { showCpError(t('cp.err_mismatch')); return; }
  1645. if (!changePwForced && !currPw) { showCpError(t('cp.err_current_required')); return; }
  1646. try {
  1647. const body = { new_password: newPw };
  1648. if (!changePwForced) body.current_password = currPw;
  1649. const res = await apiFetch('/auth/change-password', {
  1650. method: 'POST',
  1651. headers: { 'Content-Type': 'application/json' },
  1652. body: JSON.stringify(body),
  1653. });
  1654. const data = await res.json();
  1655. if (!res.ok) { showCpError(data.detail || t('cp.err_failed')); return; }
  1656. toast(t('cp.success'));
  1657. showScreen(changePwReturnTo);
  1658. } catch { showCpError(t('cp.err_network')); }
  1659. }
  1660. function showCpError(msg) {
  1661. const el = document.getElementById('cp-error');
  1662. el.textContent = msg;
  1663. el.classList.remove('hidden');
  1664. el.style.animation = 'none';
  1665. requestAnimationFrame(() => { el.style.animation = ''; });
  1666. }
  1667. // ─── NAVIGATION ───────────────────────────────────────────────
  1668. function showScreen(name) {
  1669. ['login', 'change-password', 'app', 'admin'].forEach(s => {
  1670. const el = document.getElementById(s + '-screen');
  1671. if (el) el.classList.toggle('hidden', s !== name);
  1672. });
  1673. }
  1674. function showLogin() {
  1675. document.getElementById('login-error').classList.add('hidden');
  1676. document.getElementById('login-password').value = '';
  1677. showScreen('login');
  1678. }
  1679. function showApp() {
  1680. updateUserBadges();
  1681. const adminBtn = document.getElementById('admin-btn');
  1682. if (adminBtn) adminBtn.classList.toggle('hidden', currentUser?.role !== 'admin');
  1683. showScreen('app');
  1684. loadHistory();
  1685. }
  1686. function showAdmin() {
  1687. updateUserBadges();
  1688. showScreen('admin');
  1689. switchAdminTab(currentAdminTab);
  1690. }
  1691. function updateUserBadges() {
  1692. const initial = (currentUser?.name || currentUser?.email || '?')[0].toUpperCase();
  1693. document.querySelectorAll('.user-avatar-el').forEach(el => el.textContent = initial);
  1694. document.querySelectorAll('.user-name-el').forEach(el => el.textContent = currentUser?.name || '—');
  1695. document.querySelectorAll('.user-email-el').forEach(el => el.textContent = currentUser?.email || '—');
  1696. }
  1697. // ─── API HELPER ───────────────────────────────────────────────
  1698. function apiFetch(path, opts = {}) {
  1699. opts.headers = {
  1700. ...(opts.headers || {}),
  1701. 'X-Session-Token': currentUser?.session_token || '',
  1702. };
  1703. return fetch(path, opts);
  1704. }
  1705. // ─── FILE UPLOAD ──────────────────────────────────────────────
  1706. function setupDragDrop() {
  1707. const zone = document.getElementById('upload-zone');
  1708. zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('drag-over'); });
  1709. zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'));
  1710. zone.addEventListener('drop', e => {
  1711. e.preventDefault();
  1712. zone.classList.remove('drag-over');
  1713. const f = e.dataTransfer.files[0];
  1714. if (f) setFile(f);
  1715. });
  1716. }
  1717. function onFileSelect(e) {
  1718. const f = e.target.files[0];
  1719. if (f) setFile(f);
  1720. }
  1721. function setFile(f) {
  1722. const dt = new DataTransfer();
  1723. dt.items.add(f);
  1724. document.getElementById('file-input').files = dt.files;
  1725. document.getElementById('file-name-display').textContent = f.name;
  1726. document.getElementById('file-chip').classList.remove('hidden');
  1727. document.getElementById('submit-btn').disabled = false;
  1728. document.getElementById('submit-hint').textContent = `${(f.size / 1024 / 1024).toFixed(1)} MB`;
  1729. }
  1730. function clearFile() {
  1731. document.getElementById('file-input').value = '';
  1732. document.getElementById('file-chip').classList.add('hidden');
  1733. document.getElementById('submit-btn').disabled = true;
  1734. document.getElementById('submit-hint').textContent = '';
  1735. }
  1736. function togglePromptField() {
  1737. const field = document.getElementById('prompt-field');
  1738. const btn = document.getElementById('prompt-toggle-btn');
  1739. const hidden = field.classList.toggle('hidden');
  1740. btn.setAttribute('data-i18n', hidden ? 'settings.prompt_expand' : 'settings.prompt_collapse');
  1741. btn.textContent = hidden ? (t('settings.prompt_expand') || '▼ Mostrar') : (t('settings.prompt_collapse') || '▲ Ocultar');
  1742. }
  1743. // ─── SUBMIT ───────────────────────────────────────────────────
  1744. async function submitJob() {
  1745. const fileInput = document.getElementById('file-input');
  1746. if (!fileInput.files[0]) return;
  1747. const fmts = ['txt', 'srt', 'txt_nh', 'srt_nh'];
  1748. const anyFmt = fmts.some(f => document.getElementById('fmt-' + f).checked);
  1749. if (!anyFmt) { toast(t('submit.select_fmt')); return; }
  1750. const fd = new FormData();
  1751. fd.append('file', fileInput.files[0]);
  1752. fd.append('language', document.getElementById('lang-select').value);
  1753. fd.append('model', document.getElementById('model-select').value);
  1754. fd.append('initial_prompt', document.getElementById('initial-prompt').value.trim());
  1755. fmts.forEach(f => fd.append(f, document.getElementById('fmt-' + f).checked));
  1756. document.getElementById('submit-btn').disabled = true;
  1757. try {
  1758. const res = await apiFetch('/transcribe', { method: 'POST', body: fd });
  1759. const data = await res.json();
  1760. if (!res.ok) { toast(data.detail || t('submission_failed')); document.getElementById('submit-btn').disabled = false; return; }
  1761. currentJob = data;
  1762. showJobSection(fileInput.files[0].name, data.job_id);
  1763. startPolling(data.job_id);
  1764. } catch(e) {
  1765. toast(t('network_error') + ': ' + e.message);
  1766. document.getElementById('submit-btn').disabled = false;
  1767. }
  1768. }
  1769. // ─── JOB SECTION ─────────────────────────────────────────────
  1770. function showJobSection(filename) {
  1771. document.getElementById('job-section').classList.remove('hidden');
  1772. document.getElementById('results-section').classList.add('hidden');
  1773. document.getElementById('job-filename').textContent = filename;
  1774. setStatus('pending');
  1775. document.getElementById('job-section').scrollIntoView({ behavior: 'smooth', block: 'start' });
  1776. }
  1777. function getStatusMap() {
  1778. return {
  1779. pending: { label: t('status.pending'), cls: 'status-pending', desc: t('status.pending_desc'), step: null },
  1780. converting: { label: t('status.converting'), cls: 'status-active', desc: t('status.converting_desc'), step: 'transcribing' },
  1781. transcribing: { label: t('status.transcribing'), cls: 'status-active', desc: t('status.transcribing_desc'), step: 'transcribing' },
  1782. diarizing: { label: t('status.diarizing'), cls: 'status-active', desc: t('status.diarizing_desc'), step: 'diarizing' },
  1783. combining: { label: t('status.combining'), cls: 'status-active', desc: t('status.combining_desc'), step: 'combining' },
  1784. completed: { label: t('status.completed'), cls: 'status-completed', desc: t('status.completed_desc'), step: 'completed' },
  1785. failed: { label: t('status.failed'), cls: 'status-failed', desc: t('status.failed_desc'), step: null },
  1786. };
  1787. }
  1788. const STEP_ORDER = ['transcribing', 'diarizing', 'combining', 'completed'];
  1789. function setStatus(status, job) {
  1790. const sm = getStatusMap();
  1791. const info = sm[status] || sm['pending'];
  1792. const pill = document.getElementById('status-pill');
  1793. pill.className = 'status-pill ' + info.cls;
  1794. document.getElementById('status-text-pill').textContent = info.label;
  1795. document.getElementById('job-status-desc').innerHTML = info.desc;
  1796. document.getElementById('wave-anim').style.display = info.cls === 'status-active' ? '' : 'none';
  1797. STEP_ORDER.forEach(s => {
  1798. const el = document.getElementById('step-' + s);
  1799. if (el) el.classList.remove('active', 'done');
  1800. });
  1801. if (info.step) {
  1802. const idx = STEP_ORDER.indexOf(info.step);
  1803. STEP_ORDER.forEach((s, i) => {
  1804. const el = document.getElementById('step-' + s);
  1805. if (!el) return;
  1806. if (i < idx) el.classList.add('done');
  1807. if (i === idx) el.classList.add('active');
  1808. });
  1809. }
  1810. if (status === 'failed' && job?.error) {
  1811. document.getElementById('job-status-desc').innerHTML = `<span style="color:var(--err)">${escHtml(job.error)}</span>`;
  1812. }
  1813. }
  1814. // ─── POLLING ──────────────────────────────────────────────────
  1815. function startPolling(jobId) {
  1816. if (pollTimer) clearInterval(pollTimer);
  1817. pollTimer = setInterval(async () => {
  1818. try {
  1819. const res = await apiFetch(`/jobs/${jobId}`);
  1820. const job = await res.json();
  1821. setStatus(job.status, job);
  1822. if (job.status === 'completed' || job.status === 'failed') {
  1823. clearInterval(pollTimer);
  1824. pollTimer = null;
  1825. if (job.status === 'completed') showResults(job);
  1826. loadHistory();
  1827. document.getElementById('submit-btn').disabled = false;
  1828. }
  1829. } catch {}
  1830. }, 5000);
  1831. }
  1832. // ─── RESULTS ─────────────────────────────────────────────────
  1833. function getFmtMeta() {
  1834. return {
  1835. txt: { icon: '📄', label: t('fmtlabel.txt') },
  1836. srt: { icon: '🎬', label: t('fmtlabel.srt') },
  1837. txt_nh: { icon: '📄', label: t('fmtlabel.txt_nh') },
  1838. srt_nh: { icon: '🎬', label: t('fmtlabel.srt_nh') },
  1839. };
  1840. }
  1841. function showResults(job) {
  1842. document.getElementById('results-section').classList.remove('hidden');
  1843. const grid = document.getElementById('results-grid');
  1844. grid.innerHTML = '';
  1845. Object.keys(job.results).forEach(fmt => {
  1846. const m = getFmtMeta()[fmt] || { icon: '📁', label: fmt };
  1847. const btn = document.createElement('button');
  1848. btn.className = 'result-btn';
  1849. btn.innerHTML = `<span class="icon">${m.icon}</span><span class="format">.${fmt.split('_')[0]}</span><span class="label">${m.label}</span>`;
  1850. btn.onclick = () => downloadFmt(job.job_id, fmt);
  1851. grid.appendChild(btn);
  1852. });
  1853. const preview = document.getElementById('segments-preview');
  1854. preview.innerHTML = '';
  1855. const segs = (job.segments || []).slice(0, 5);
  1856. segs.forEach(s => {
  1857. const row = document.createElement('div');
  1858. row.className = 'segment-row';
  1859. const t = srtTime(s.start) + ' – ' + srtTime(s.end);
  1860. const spk = s.speaker ? `<span class="seg-speaker">${escHtml(s.speaker)}</span>` : '<span style="color:var(--text3)">—</span>';
  1861. row.innerHTML = `<span class="seg-time">${t}</span>${spk}<span class="seg-text">${escHtml(s.text)}</span>`;
  1862. preview.appendChild(row);
  1863. });
  1864. const total = (job.segments || []).length;
  1865. if (total > 5) {
  1866. const more = document.createElement('div');
  1867. more.className = 'preview-more';
  1868. more.textContent = t('results.more', { n: total - 5 });
  1869. preview.appendChild(more);
  1870. }
  1871. document.getElementById('results-section').scrollIntoView({ behavior: 'smooth', block: 'start' });
  1872. }
  1873. async function downloadFmt(jobId, fmt) {
  1874. try {
  1875. const res = await apiFetch(`/jobs/${jobId}/download/${fmt}`);
  1876. if (!res.ok) { toast(t('results.dl_failed')); return; }
  1877. const blob = await res.blob();
  1878. const url = URL.createObjectURL(blob);
  1879. const a = document.createElement('a');
  1880. a.href = url;
  1881. const ext = fmt.includes('srt') ? 'srt' : 'txt';
  1882. a.download = `transcription_${fmt}.${ext}`;
  1883. document.body.appendChild(a);
  1884. a.click();
  1885. document.body.removeChild(a);
  1886. URL.revokeObjectURL(url);
  1887. toast(t('results.downloaded', { fmt }));
  1888. } catch(e) { toast(t('results.err', { msg: e.message })); }
  1889. }
  1890. // ─── HISTORY ─────────────────────────────────────────────────
  1891. async function loadHistory() {
  1892. try {
  1893. const res = await apiFetch('/jobs');
  1894. const list = await res.json();
  1895. jobHistory = list.sort((a, b) => (b.job_id > a.job_id ? 1 : -1));
  1896. renderHistory();
  1897. } catch {}
  1898. }
  1899. async function openHistoryJob(jobId) {
  1900. try {
  1901. const res = await apiFetch(`/jobs/${jobId}`);
  1902. const job = await res.json();
  1903. if (job.status !== 'completed') { toast(t('history.job_is', { status: t('status.' + job.status) || job.status })); return; }
  1904. document.getElementById('job-section').classList.remove('hidden');
  1905. document.getElementById('job-filename').textContent = job.filename || jobId;
  1906. setStatus('completed', job);
  1907. showResults(job);
  1908. } catch(e) { toast('Could not load job: ' + e.message); }
  1909. }
  1910. function renderHistory() {
  1911. const tbody = document.getElementById('history-tbody');
  1912. document.getElementById('history-count').textContent = jobHistory.length;
  1913. if (!jobHistory.length) {
  1914. tbody.innerHTML = `<tr><td colspan="6" class="history-empty">${t('history.empty')}</td></tr>`;
  1915. return;
  1916. }
  1917. tbody.innerHTML = jobHistory.map(j => {
  1918. const statusCls = j.status === 'completed' ? 'status-completed' : j.status === 'failed' ? 'status-failed' : 'status-active';
  1919. const fmts = j.status === 'completed' ? (j.formats || []) : [];
  1920. const dlBtns = fmts.map(fmt => {
  1921. const ext = fmt.includes('srt') ? 'srt' : 'txt';
  1922. 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>`;
  1923. }).join(' ');
  1924. const rowClick = j.status === 'completed' ? `onclick="openHistoryJob('${j.job_id}')" style="cursor:pointer;"` : '';
  1925. return `<tr ${rowClick}>
  1926. <td class="mono" style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escHtml(j.filename || '')}">${escHtml(j.filename || '—')}</td>
  1927. <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>
  1928. <td class="mono" style="color:var(--text2)">${j.language || '—'}</td>
  1929. <td class="mono" style="color:var(--text2)">${j.segments ? j.segments.length : '—'}</td>
  1930. <td class="mono" style="color:var(--text2)">${j.transcription_time != null ? j.transcription_time + 's' : '—'}</td>
  1931. <td style="white-space:nowrap;">${dlBtns || '<span style="color:var(--text3);font-size:11px;">—</span>'}</td>
  1932. </tr>`;
  1933. }).join('');
  1934. }
  1935. // ═══════════════════════════════════════════
  1936. // ADMIN PANEL
  1937. // ═══════════════════════════════════════════
  1938. function switchAdminTab(tab) {
  1939. currentAdminTab = tab;
  1940. document.getElementById('admin-tab-users').classList.toggle('hidden', tab !== 'users');
  1941. document.getElementById('admin-tab-history').classList.toggle('hidden', tab !== 'history');
  1942. document.getElementById('admin-tab-metrics').classList.toggle('hidden', tab !== 'metrics');
  1943. document.getElementById('tab-btn-users').classList.toggle('active', tab === 'users');
  1944. document.getElementById('tab-btn-history').classList.toggle('active', tab === 'history');
  1945. document.getElementById('tab-btn-metrics').classList.toggle('active', tab === 'metrics');
  1946. if (tab === 'users') loadAdminUsers();
  1947. if (tab === 'history') loadAdminJobs();
  1948. if (tab === 'metrics') loadMetrics();
  1949. }
  1950. async function loadAdminJobs() {
  1951. try {
  1952. const res = await apiFetch('/admin/jobs');
  1953. const jobs = await res.json();
  1954. renderAdminJobs(jobs);
  1955. } catch(e) {
  1956. document.getElementById('admin-history-tbody').innerHTML = `<tr><td colspan="7" class="history-empty" style="color:var(--err)">Error: ${escHtml(e.message)}</td></tr>`;
  1957. }
  1958. }
  1959. function renderAdminJobs(jobs) {
  1960. const tbody = document.getElementById('admin-history-tbody');
  1961. if (!jobs.length) {
  1962. tbody.innerHTML = `<tr><td colspan="7" class="history-empty">${t('history.empty')}</td></tr>`;
  1963. return;
  1964. }
  1965. tbody.innerHTML = jobs.map(j => {
  1966. const statusCls = j.status === 'completed' ? 'status-completed' : j.status === 'failed' ? 'status-failed' : 'status-active';
  1967. const created = j.created_at ? new Date(j.created_at).toLocaleString() : '—';
  1968. return `<tr>
  1969. <td class="mono" style="max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escHtml(j.filename || '')}">${escHtml(j.filename || '—')}</td>
  1970. <td class="mono" style="color:var(--text2);font-size:11px;">${escHtml(j.submitted_by || '—')}</td>
  1971. <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>
  1972. <td class="mono" style="color:var(--text2)">${j.language || '—'}</td>
  1973. <td class="mono" style="color:var(--text2)">${j.model || '—'}</td>
  1974. <td class="mono" style="color:var(--text2)">${j.transcription_time != null ? j.transcription_time + 's' : '—'}</td>
  1975. <td class="mono" style="color:var(--text3);font-size:11px;">${created}</td>
  1976. </tr>`;
  1977. }).join('');
  1978. }
  1979. // ─── USERS ────────────────────────────────────────────────────
  1980. async function loadAdminUsers() {
  1981. try {
  1982. const res = await apiFetch('/admin/users');
  1983. if (!res.ok) { toast(t('admin.load_failed')); return; }
  1984. adminUsers = await res.json();
  1985. renderAdminUsers();
  1986. } catch(e) { toast('Error: ' + e.message); }
  1987. }
  1988. function renderAdminUsers() {
  1989. const tbody = document.getElementById('admin-users-tbody');
  1990. if (!adminUsers.length) {
  1991. tbody.innerHTML = `<tr><td colspan="7" class="history-empty">${t('admin.no_users')}</td></tr>`;
  1992. return;
  1993. }
  1994. tbody.innerHTML = adminUsers.map(u => {
  1995. const roleCls = u.role === 'admin' ? 'badge-admin' : 'badge-user';
  1996. const pwdCls = u.is_default_password ? 'badge-default' : 'badge-set';
  1997. const pwdLbl = u.is_default_password ? t('admin.pwd_default') : t('admin.pwd_set');
  1998. const apiCls = u.has_api_key ? 'badge-yes' : 'badge-no';
  1999. const apiLbl = u.has_api_key ? t('admin.api_yes') : t('admin.api_no');
  2000. const created = u.created_at ? u.created_at.split('T')[0] : '—';
  2001. const isSelf = u.email === currentUser?.email;
  2002. const apiBtn = u.has_api_key
  2003. ? `<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>`
  2004. : `<button class="btn btn-success btn-sm" style="font-size:10px;padding:3px 10px;" onclick="generateApiKey('${u.id}')">${t('admin.gen_key')}</button>`;
  2005. 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>`;
  2006. return `<tr>
  2007. <td style="font-weight:600;">${escHtml(u.name)}</td>
  2008. <td class="mono" style="font-size:11px;color:var(--text2);">${escHtml(u.email)}</td>
  2009. <td><span class="badge ${roleCls}">${u.role}</span></td>
  2010. <td><span class="badge ${pwdCls}">${pwdLbl}</span></td>
  2011. <td><span class="badge ${apiCls}">${apiLbl}</span></td>
  2012. <td class="mono" style="color:var(--text3);font-size:11px;">${created}</td>
  2013. <td style="white-space:nowrap;display:flex;gap:6px;flex-wrap:wrap;">
  2014. <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>
  2015. ${apiBtn}
  2016. ${delBtn}
  2017. </td>
  2018. </tr>`;
  2019. }).join('');
  2020. }
  2021. // Create user modal
  2022. function openCreateUserModal() {
  2023. document.getElementById('modal-title').textContent = t('modal.create_user');
  2024. document.getElementById('modal-body').innerHTML = `
  2025. <div class="modal-field">
  2026. <label>${t('modal.email')}</label>
  2027. <input type="email" id="mu-email" placeholder="${escHtml(t('modal.email_ph'))}">
  2028. </div>
  2029. <div class="modal-field">
  2030. <label>${t('modal.full_name')}</label>
  2031. <input type="text" id="mu-name" placeholder="${escHtml(t('modal.full_name_ph'))}">
  2032. </div>
  2033. <div class="modal-field">
  2034. <label>${t('modal.default_password')}</label>
  2035. <input type="text" id="mu-password" placeholder="${escHtml(t('modal.default_password_ph'))}">
  2036. <p class="modal-note">${t('modal.default_password_note')}</p>
  2037. </div>
  2038. <div class="modal-field">
  2039. <label>${t('modal.role')}</label>
  2040. <div class="role-toggle">
  2041. <div class="role-option">
  2042. <input type="radio" name="mu-role" id="mu-role-user" value="user" checked>
  2043. <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>
  2044. </div>
  2045. <div class="role-option">
  2046. <input type="radio" name="mu-role" id="mu-role-admin" value="admin">
  2047. <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>
  2048. </div>
  2049. </div>
  2050. </div>
  2051. <div id="mu-error" class="modal-error hidden"></div>
  2052. `;
  2053. document.getElementById('modal-actions').innerHTML = `
  2054. <button class="btn btn-ghost" onclick="closeModal()">${t('modal.cancel')}</button>
  2055. <button class="btn btn-primary" onclick="createUser()">${t('modal.create')}</button>
  2056. `;
  2057. document.getElementById('modal-overlay').classList.remove('hidden');
  2058. }
  2059. async function createUser() {
  2060. const email = document.getElementById('mu-email').value.trim();
  2061. const name = document.getElementById('mu-name').value.trim();
  2062. const password = document.getElementById('mu-password').value;
  2063. const role = document.querySelector('input[name="mu-role"]:checked').value;
  2064. const errEl = document.getElementById('mu-error');
  2065. errEl.classList.add('hidden');
  2066. if (!email || !name || !password) { errEl.textContent = t('modal.all_required'); errEl.classList.remove('hidden'); return; }
  2067. if (password.length < 8) { errEl.textContent = t('modal.pwd_short'); errEl.classList.remove('hidden'); return; }
  2068. try {
  2069. const res = await apiFetch('/admin/users', {
  2070. method: 'POST',
  2071. headers: { 'Content-Type': 'application/json' },
  2072. body: JSON.stringify({ email, name, password, role }),
  2073. });
  2074. const data = await res.json();
  2075. if (!res.ok) { errEl.textContent = data.detail || t('modal.user_create_failed'); errEl.classList.remove('hidden'); return; }
  2076. closeModal();
  2077. toast(t('modal.user_created', { name }));
  2078. loadAdminUsers();
  2079. } catch(e) { errEl.textContent = t('modal.network_error'); errEl.classList.remove('hidden'); }
  2080. }
  2081. // Reset password modal
  2082. function openResetPasswordModal(userId, userName) {
  2083. document.getElementById('modal-title').textContent = t('modal.reset_pwd_title');
  2084. document.getElementById('modal-body').innerHTML = `
  2085. <p style="font-size:13px;color:var(--text2);margin-bottom:16px;">
  2086. ${t('modal.reset_pwd_desc', { name: escHtml(userName) })}
  2087. </p>
  2088. <div class="modal-field">
  2089. <label>${t('modal.new_default_pwd')}</label>
  2090. <input type="text" id="rp-password" placeholder="${escHtml(t('modal.min_8_ph'))}">
  2091. </div>
  2092. <div id="rp-error" class="modal-error hidden"></div>
  2093. `;
  2094. document.getElementById('modal-actions').innerHTML = `
  2095. <button class="btn btn-ghost" onclick="closeModal()">${t('modal.cancel')}</button>
  2096. <button class="btn btn-primary" onclick="resetPassword('${userId}')">${t('modal.reset')}</button>
  2097. `;
  2098. document.getElementById('modal-overlay').classList.remove('hidden');
  2099. }
  2100. async function resetPassword(userId) {
  2101. const password = document.getElementById('rp-password').value;
  2102. const errEl = document.getElementById('rp-error');
  2103. errEl.classList.add('hidden');
  2104. if (password.length < 8) { errEl.textContent = t('modal.pwd_short'); errEl.classList.remove('hidden'); return; }
  2105. try {
  2106. const res = await apiFetch(`/admin/users/${userId}/reset-password`, {
  2107. method: 'PATCH',
  2108. headers: { 'Content-Type': 'application/json' },
  2109. body: JSON.stringify({ new_password: password }),
  2110. });
  2111. if (!res.ok) { const d = await res.json(); errEl.textContent = d.detail || t('modal.reset_failed'); errEl.classList.remove('hidden'); return; }
  2112. closeModal();
  2113. toast(t('modal.pwd_reset_success'));
  2114. loadAdminUsers();
  2115. } catch { errEl.textContent = t('modal.network_error'); errEl.classList.remove('hidden'); }
  2116. }
  2117. // Delete user
  2118. async function deleteUser(userId, userName) {
  2119. if (!confirm(t('modal.confirm_delete', { name: userName }))) return;
  2120. try {
  2121. const res = await apiFetch(`/admin/users/${userId}`, { method: 'DELETE' });
  2122. if (res.status === 400) { const d = await res.json(); toast(d.detail); return; }
  2123. if (!res.ok) { toast(t('modal.delete_failed')); return; }
  2124. toast(t('modal.user_deleted', { name: userName }));
  2125. loadAdminUsers();
  2126. } catch { toast(t('network_error')); }
  2127. }
  2128. // Generate API key
  2129. async function generateApiKey(userId) {
  2130. try {
  2131. const res = await apiFetch(`/admin/users/${userId}/api-key`, { method: 'POST' });
  2132. const data = await res.json();
  2133. if (!res.ok) { toast(data.detail || t('modal.gen_failed')); return; }
  2134. loadAdminUsers();
  2135. showApiKeyModal(data.api_key);
  2136. } catch { toast('Network error'); }
  2137. }
  2138. function showApiKeyModal(key) {
  2139. document.getElementById('modal-title').textContent = t('modal.api_generated_title');
  2140. document.getElementById('modal-body').innerHTML = `
  2141. <p style="font-size:13px;color:var(--text2);margin-bottom:4px;">${t('modal.api_copy_note')}</p>
  2142. <div class="key-display" id="api-key-display">${escHtml(key)}</div>
  2143. <p class="modal-note">${t('modal.api_share_note')}</p>
  2144. `;
  2145. document.getElementById('modal-actions').innerHTML = `
  2146. <button class="btn btn-ghost" onclick="copyApiKey('${escAttr(key)}')">${t('modal.copy_key')}</button>
  2147. <button class="btn btn-primary" onclick="closeModal()">${t('modal.done')}</button>
  2148. `;
  2149. document.getElementById('modal-overlay').classList.remove('hidden');
  2150. }
  2151. function copyApiKey(key) {
  2152. navigator.clipboard.writeText(key).then(() => toast(t('modal.key_copied'))).catch(() => toast(t('modal.copy_failed')));
  2153. }
  2154. // Revoke API key
  2155. async function revokeApiKey(userId, userName) {
  2156. if (!confirm(t('modal.revoke_confirm', { name: userName }))) return;
  2157. try {
  2158. const res = await apiFetch(`/admin/users/${userId}/api-key`, { method: 'DELETE' });
  2159. if (!res.ok) { toast(t('modal.revoke_failed')); return; }
  2160. toast(t('modal.key_revoked'));
  2161. loadAdminUsers();
  2162. } catch { toast(t('network_error')); }
  2163. }
  2164. // ─── METRICS ──────────────────────────────────────────────────
  2165. async function loadMetrics() {
  2166. const container = document.getElementById('metrics-content');
  2167. container.innerHTML = `<div class="history-empty">${t('metrics.loading')}</div>`;
  2168. try {
  2169. const res = await apiFetch('/admin/metrics');
  2170. const data = await res.json();
  2171. renderMetrics(data);
  2172. } catch(e) {
  2173. container.innerHTML = `<div class="history-empty" style="color:var(--err)">${t('metrics.failed')}</div>`;
  2174. }
  2175. }
  2176. function renderMetrics(d) {
  2177. cachedMetrics = d;
  2178. const completed = d.by_status?.completed || 0;
  2179. const failed = d.by_status?.failed || 0;
  2180. const cards = `
  2181. <div class="metrics-grid">
  2182. <div class="metric-card accent">
  2183. <div class="metric-value">${d.total_jobs}</div>
  2184. <div class="metric-label">${t('metrics.total_jobs')}</div>
  2185. </div>
  2186. <div class="metric-card ok">
  2187. <div class="metric-value">${completed}</div>
  2188. <div class="metric-label">${t('metrics.completed')}</div>
  2189. </div>
  2190. <div class="metric-card err">
  2191. <div class="metric-value">${failed}</div>
  2192. <div class="metric-label">${t('metrics.failed_label')}</div>
  2193. </div>
  2194. <div class="metric-card">
  2195. <div class="metric-value">${d.avg_transcription_time != null ? d.avg_transcription_time + 's' : '—'}</div>
  2196. <div class="metric-label">${t('metrics.avg_transcription')}</div>
  2197. </div>
  2198. </div>`;
  2199. const noData = `<div style="color:var(--text3);font-size:12px;">${t('metrics.no_data')}</div>`;
  2200. const modelBars = barList(d.by_model);
  2201. const langBars = barList(d.by_language);
  2202. const userBars = barList(d.by_user);
  2203. const cols = `
  2204. <div class="metrics-cols">
  2205. <div class="card" style="margin-bottom:0;">
  2206. <div class="section-label" style="margin-bottom:12px;">${t('metrics.by_model')}</div>
  2207. ${modelBars || noData}
  2208. </div>
  2209. <div class="card" style="margin-bottom:0;">
  2210. <div class="section-label" style="margin-bottom:12px;">${t('metrics.by_language')}</div>
  2211. ${langBars || noData}
  2212. </div>
  2213. <div class="card" style="margin-bottom:0;">
  2214. <div class="section-label" style="margin-bottom:12px;">${t('metrics.by_user')}</div>
  2215. ${userBars || noData}
  2216. </div>
  2217. </div>`;
  2218. document.getElementById('metrics-content').innerHTML = cards + cols;
  2219. }
  2220. function barList(obj) {
  2221. if (!obj || !Object.keys(obj).length) return '';
  2222. const entries = Object.entries(obj).sort((a, b) => b[1] - a[1]).slice(0, 8);
  2223. const max = entries[0][1];
  2224. return `<div class="bar-list">${entries.map(([label, count]) => `
  2225. <div class="bar-item">
  2226. <div class="bar-label" title="${escHtml(label)}">${escHtml(label)}</div>
  2227. <div class="bar-track"><div class="bar-fill" style="width:${Math.round(count/max*100)}%"></div></div>
  2228. <div class="bar-count">${count}</div>
  2229. </div>`).join('')}</div>`;
  2230. }
  2231. // ═══════════════════════════════════════════
  2232. // MODAL
  2233. // ═══════════════════════════════════════════
  2234. function closeModal() {
  2235. document.getElementById('modal-overlay').classList.add('hidden');
  2236. }
  2237. // ─── HELPERS ─────────────────────────────────────────────────
  2238. function srtTime(sec) {
  2239. const t = Math.floor(sec);
  2240. const h = Math.floor(t / 3600);
  2241. const m = Math.floor((t % 3600) / 60);
  2242. const s = t % 60;
  2243. return `${pad(h)}:${pad(m)}:${pad(s)}`;
  2244. }
  2245. function pad(n) { return String(n).padStart(2, '0'); }
  2246. function escHtml(str) {
  2247. return String(str)
  2248. .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
  2249. }
  2250. function escAttr(str) {
  2251. return String(str).replace(/'/g, "\\'").replace(/"/g, '&quot;');
  2252. }
  2253. let toastTimer = null;
  2254. function toast(msg) {
  2255. const el = document.getElementById('toast');
  2256. el.textContent = msg;
  2257. el.classList.add('show');
  2258. if (toastTimer) clearTimeout(toastTimer);
  2259. toastTimer = setTimeout(() => el.classList.remove('show'), 2800);
  2260. }
  2261. </script>
  2262. </body>
  2263. </html>