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