Files
whisper-stt/app/static/index.html

2043 lines
143 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<title>VoiceScript</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans+KR:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
:root{
--bg:#08080a;--surf:#0f0f14;--surf2:#141419;--border:#1c1c24;--border2:#272730;
--accent:#00e5a0;--accent2:#00b37a;--blue:#4da6ff;--purple:#a78bfa;
--orange:#fb923c;--warn:#ff6b35;--text:#e4e4f0;--muted:#52526a;
--mono:'IBM Plex Mono',monospace;--sans:'IBM Plex Sans KR',sans-serif;
--hdr:56px;
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html{-webkit-text-size-adjust:100%}
body{background:var(--bg);color:var(--text);font-family:var(--sans);min-height:100vh;display:flex;flex-direction:column}
/* ── LOGIN ── */
#login-overlay{position:fixed;inset:0;background:var(--bg);display:flex;align-items:center;justify-content:center;z-index:999;padding:16px}
.login-box{width:100%;max-width:380px;padding:36px 28px;background:var(--surf);border:1px solid var(--border2);border-radius:8px}
.login-logo{display:flex;align-items:center;gap:12px;margin-bottom:28px}
.login-mark{width:26px;height:26px;background:var(--accent);clip-path:polygon(0 20%,100% 0,100% 80%,0 100%);flex-shrink:0}
.login-title{font-family:var(--mono);font-size:.95rem;font-weight:600;letter-spacing:.08em}
.login-title span{color:var(--accent)}
.field{margin-bottom:14px}
.field label{display:block;font-family:var(--mono);font-size:.63rem;letter-spacing:.12em;color:var(--muted);text-transform:uppercase;margin-bottom:5px}
.field input{width:100%;padding:11px 12px;background:var(--bg);border:1px solid var(--border2);border-radius:4px;color:var(--text);font-family:var(--mono);font-size:.9rem;outline:none;transition:border-color .15s;-webkit-appearance:none}
.field input:focus{border-color:var(--accent)}
#btn-login{width:100%;margin-top:6px;padding:13px;background:var(--accent);color:#000;border:none;border-radius:4px;font-family:var(--mono);font-size:.85rem;font-weight:600;letter-spacing:.1em;cursor:pointer;transition:all .15s;text-transform:uppercase;-webkit-appearance:none}
#btn-login:active{background:#00c98a}
#login-err{display:none;margin-top:10px;padding:10px 12px;background:rgba(255,107,53,.08);border:1px solid rgba(255,107,53,.3);border-radius:4px;font-family:var(--mono);font-size:.72rem;color:var(--warn)}
/* ── HEADER ── */
header{height:var(--hdr);border-bottom:1px solid var(--border);padding:0 16px;display:flex;align-items:center;gap:10px;position:sticky;top:0;background:rgba(8,8,10,.96);backdrop-filter:blur(12px);z-index:100}
.logo-mark{width:24px;height:24px;background:var(--accent);clip-path:polygon(0 20%,100% 0,100% 80%,0 100%);flex-shrink:0}
header h1{font-family:var(--mono);font-size:.88rem;font-weight:600;letter-spacing:.08em}
header h1 span{color:var(--accent)}
.ram-widget{display:none;align-items:center;gap:8px;padding:5px 10px;background:var(--surf);border:1px solid var(--border2);border-radius:3px;font-family:var(--mono);font-size:.6rem}
.ram-label{color:var(--muted)}
.ram-bar-wrap{width:52px;height:4px;background:var(--border);border-radius:2px;overflow:hidden}
.ram-bar{height:100%;border-radius:2px;transition:width .6s,background .3s}
.ram-text{color:var(--text);white-space:nowrap}
.cpu-text{color:var(--muted);white-space:nowrap}
#user-info{margin-left:auto;display:flex;align-items:center;gap:8px}
#user-badge{padding:2px 7px;border-radius:2px;font-family:var(--mono);font-size:.58rem;font-weight:600;letter-spacing:.08em;text-transform:uppercase}
#user-badge.admin{background:rgba(251,146,60,.12);color:var(--orange);border:1px solid rgba(251,146,60,.3)}
#user-badge.user{background:rgba(0,229,160,.08);color:var(--accent);border:1px solid rgba(0,229,160,.2)}
#btn-logout{background:none;border:1px solid var(--border2);color:var(--muted);padding:5px 10px;border-radius:2px;font-family:var(--mono);font-size:.62rem;cursor:pointer;transition:all .15s;text-transform:uppercase}
#btn-logout:hover{border-color:var(--warn);color:var(--warn)}
/* ── NAV ── */
.nav-tabs{display:flex;border-bottom:1px solid var(--border);background:var(--surf);overflow-x:auto;-webkit-overflow-scrolling:touch;scrollbar-width:none}
.nav-tabs::-webkit-scrollbar{display:none}
.nav-tab{font-family:var(--mono);font-size:.68rem;letter-spacing:.08em;text-transform:uppercase;padding:12px 14px;background:none;border:none;color:var(--muted);cursor:pointer;border-bottom:2px solid transparent;transition:all .15s;white-space:nowrap;flex-shrink:0}
.nav-tab.active{color:var(--accent);border-bottom-color:var(--accent)}
.nav-tab:hover:not(.active){color:var(--text)}
.nav-tab.admin-tab.active{color:var(--orange);border-bottom-color:var(--orange)}
.nav-tab.settings-tab.active{color:var(--blue);border-bottom-color:var(--blue)}
.nav-tab.history-tab.active{color:var(--purple);border-bottom-color:var(--purple)}
/* ── PAGE ── */
.page{display:none;flex:1;flex-direction:column}
.page.active{display:flex}
/* ── TWO PANEL ── */
.two-panel{display:flex;flex-direction:column;width:100%;max-width:1360px;margin:0 auto}
.panel{padding:20px 16px;border-bottom:1px solid var(--border)}
.panel:last-child{border-bottom:none}
.panel-title{font-family:var(--mono);font-size:.6rem;letter-spacing:.15em;color:var(--muted);text-transform:uppercase;margin-bottom:16px;display:flex;align-items:center;gap:8px}
.panel-title::after{content:'';flex:1;height:1px;background:var(--border)}
/* ── DROPZONE ── */
.dropzone{border:1px dashed var(--border2);border-radius:6px;padding:28px 16px;text-align:center;cursor:pointer;transition:all .2s;position:relative;background:var(--surf)}
.dropzone:hover,.dropzone.dragover{border-color:var(--accent);background:rgba(0,229,160,.04)}
.dropzone input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer}
.drop-icon{font-size:1.8rem;margin-bottom:8px;display:block;opacity:.35}
.drop-label{font-size:.88rem;color:var(--muted);line-height:1.6}
.drop-label strong{color:var(--text);font-weight:500}
.drop-formats{margin-top:6px;font-family:var(--mono);font-size:.6rem;color:var(--muted);letter-spacing:.04em}
.file-info{display:none;margin-top:10px;padding:10px 12px;background:var(--surf);border:1px solid var(--border2);border-radius:4px;font-family:var(--mono);font-size:.72rem}
.file-info .fname{color:var(--accent);margin-bottom:2px;word-break:break-all}
.file-info .fsize{color:var(--muted)}
.sec-label{font-family:var(--mono);font-size:.6rem;letter-spacing:.1em;color:var(--muted);text-transform:uppercase;margin-bottom:6px;margin-top:14px}
/* ── ENGINE ── */
.engine-btns{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.engine-btn{padding:10px 6px;background:var(--surf);border:1px solid var(--border2);color:var(--muted);border-radius:4px;font-family:var(--mono);font-size:.68rem;cursor:pointer;transition:all .18s;text-align:center;display:flex;flex-direction:column;align-items:center;gap:3px;-webkit-tap-highlight-color:transparent}
.engine-btn .e-icon{font-size:1.2rem;opacity:.5;transition:opacity .18s}
.engine-btn .e-name{font-weight:600;font-size:.68rem}
.engine-btn .e-desc{font-size:.56rem;color:var(--muted);line-height:1.4}
.engine-btn.active[data-engine="whisper"]{background:rgba(0,229,160,.07);border-color:var(--accent2);color:var(--accent)}
.engine-btn.active[data-engine="whisper"] .e-icon,.engine-btn.active[data-engine="paddle"] .e-icon{opacity:1}
.engine-btn.active[data-engine="whisper+ollama"],.engine-btn.active[data-engine="ollama"]{background:rgba(167,139,250,.07);border-color:#7c6cd4;color:var(--purple)}
.engine-btn.active[data-engine="whisper+ollama"] .e-icon,.engine-btn.active[data-engine="ollama"] .e-icon{opacity:1}
.engine-btn.active[data-engine="paddle"]{background:rgba(0,229,160,.07);border-color:var(--accent2);color:var(--accent)}
.ollama-opts{display:none;margin-top:10px;padding:12px;background:var(--surf2);border:1px solid #272040;border-radius:4px}
.ollama-opts.visible{display:block}
.model-select{width:100%;background:var(--surf);border:1px solid var(--border2);color:var(--text);padding:9px 10px;border-radius:3px;font-family:var(--mono);font-size:.75rem;outline:none;cursor:pointer;appearance:none;-webkit-appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%2352526a'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center;margin-top:6px}
.model-select:focus{border-color:#7c6cd4}
.cprompt-toggle{display:inline-flex;align-items:center;gap:6px;font-family:var(--mono);font-size:.63rem;color:var(--muted);cursor:pointer;margin-top:10px}
textarea.cprompt{width:100%;background:var(--surf);border:1px solid var(--border2);color:var(--text);padding:10px 12px;border-radius:3px;font-family:var(--mono);font-size:.75rem;line-height:1.6;resize:vertical;min-height:60px;outline:none;margin-top:6px;display:none;-webkit-appearance:none}
.mode-btns{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:7px}
.mode-btn{padding:10px;background:var(--surf);border:1px solid var(--border2);color:var(--muted);border-radius:3px;font-family:var(--mono);font-size:.68rem;cursor:pointer;transition:all .15s;text-align:center;text-transform:uppercase;-webkit-tap-highlight-color:transparent}
.mode-btn.active{background:rgba(0,229,160,.07);border-color:var(--accent2);color:var(--accent)}
/* ── BUTTONS ── */
.btn-start{margin-top:14px;width:100%;padding:14px;border:none;border-radius:4px;font-family:var(--mono);font-size:.82rem;font-weight:600;letter-spacing:.1em;cursor:pointer;transition:all .15s;text-transform:uppercase;-webkit-appearance:none}
.btn-start.green{background:var(--accent);color:#000}
.btn-start.green:active{background:#00c98a}
.btn-start.purple{background:var(--purple);color:#fff}
.btn-start.purple:active{background:#9f7aea}
.btn-start:disabled{background:var(--border2);color:var(--muted);cursor:not-allowed}
/* ── PROGRESS ── */
.prog-box{display:none;margin-top:14px}
.prog-header{display:flex;justify-content:space-between;margin-bottom:6px}
.prog-msg{font-family:var(--mono);font-size:.7rem;color:var(--muted)}
.prog-pct{font-family:var(--mono);font-size:.7rem;color:var(--accent)}
.prog-track{height:3px;background:var(--border);border-radius:2px;overflow:hidden}
.prog-fill{height:100%;transition:width .4s ease;width:0%;border-radius:2px;background:var(--accent)}
.waveform{display:flex;align-items:center;justify-content:center;gap:3px;margin-top:14px;height:24px}
.wave-bar{width:3px;border-radius:2px;opacity:.6;animation:wave 1s ease-in-out infinite;background:var(--accent)}
.wave-bar:nth-child(1){animation-delay:0s;height:7px}.wave-bar:nth-child(2){animation-delay:.1s;height:13px}
.wave-bar:nth-child(3){animation-delay:.2s;height:20px}.wave-bar:nth-child(4){animation-delay:.3s;height:16px}
.wave-bar:nth-child(5){animation-delay:.4s;height:24px}.wave-bar:nth-child(6){animation-delay:.3s;height:16px}
.wave-bar:nth-child(7){animation-delay:.2s;height:20px}.wave-bar:nth-child(8){animation-delay:.1s;height:13px}
.wave-bar:nth-child(9){animation-delay:0s;height:7px}
@keyframes wave{0%,100%{transform:scaleY(.4);opacity:.3}50%{transform:scaleY(1.2);opacity:.9}}
.err-box{display:none;margin-top:10px;padding:10px 12px;background:rgba(255,107,53,.08);border:1px solid rgba(255,107,53,.3);border-radius:4px;font-family:var(--mono);font-size:.7rem;color:var(--warn);white-space:pre-wrap;line-height:1.6}
/* ── RESULT ── */
.result-meta{display:none;flex-wrap:wrap;gap:6px;margin-bottom:12px}
.meta-chip{font-family:var(--mono);font-size:.62rem;padding:4px 8px;border:1px solid var(--border2);border-radius:2px;color:var(--muted)}
.meta-chip span{color:var(--accent)}
.result-tabs{display:none;border-bottom:1px solid var(--border);margin-bottom:12px;overflow-x:auto;-webkit-overflow-scrolling:touch;scrollbar-width:none}
.result-tabs::-webkit-scrollbar{display:none}
.tab-btn{font-family:var(--mono);font-size:.65rem;letter-spacing:.08em;padding:8px 12px;background:none;border:none;color:var(--muted);cursor:pointer;border-bottom:2px solid transparent;transition:all .15s;text-transform:uppercase;white-space:nowrap}
.tab-btn.active{color:var(--accent);border-bottom-color:var(--accent)}
.tab-btn:hover:not(.active){color:var(--text)}
.tab-content{display:none;flex-direction:column;flex:1}
.tab-content.active{display:flex}
.result-textarea{flex:1;min-height:260px;background:var(--surf);border:1px solid var(--border);color:var(--text);padding:14px;border-radius:4px;font-family:var(--mono);font-size:.78rem;line-height:1.8;resize:vertical;outline:none;white-space:pre-wrap;-webkit-appearance:none}
.segments-list,.lines-list{flex:1;min-height:260px;overflow-y:auto;background:var(--surf);border:1px solid var(--border);border-radius:4px;-webkit-overflow-scrolling:touch}
.seg-item{display:grid;grid-template-columns:90px 1fr;border-bottom:1px solid var(--border)}
.seg-item:last-child{border-bottom:none}
.seg-time{padding:10px 10px;font-family:var(--mono);font-size:.6rem;color:var(--muted);border-right:1px solid var(--border);white-space:nowrap;line-height:1.6}
.seg-text{padding:10px 12px;font-size:.8rem;line-height:1.6}
.line-item{display:grid;grid-template-columns:50px 1fr;border-bottom:1px solid var(--border)}
.line-item:last-child{border-bottom:none}
.line-conf{padding:9px 8px;font-family:var(--mono);font-size:.6rem;border-right:1px solid var(--border);text-align:center;display:flex;align-items:center;justify-content:center}
.line-conf.high{color:var(--accent)}.line-conf.mid{color:#f0b42a}.line-conf.low{color:var(--warn)}
.line-text{padding:9px 12px;font-size:.8rem;line-height:1.5}
.table-wrapper{overflow-x:auto;margin-bottom:12px;border:1px solid var(--border);border-radius:4px;-webkit-overflow-scrolling:touch}
.ocr-table{width:100%;border-collapse:collapse;font-size:.75rem;font-family:var(--mono)}
.ocr-table th{background:#1a1a2e;color:var(--accent);padding:8px 10px;text-align:left;border:1px solid var(--border2);font-weight:500}
.ocr-table td{padding:8px 10px;border:1px solid var(--border);line-height:1.5}
.ocr-table tr:nth-child(even) td{background:rgba(255,255,255,.015)}
.table-title{font-family:var(--mono);font-size:.64rem;color:var(--muted);letter-spacing:.08em;padding:8px 12px;background:var(--surf2);border-bottom:1px solid var(--border);text-transform:uppercase}
.result-actions{display:none;gap:6px;margin-top:10px;flex-wrap:wrap}
.btn-act{flex:1;min-width:80px;padding:9px 6px;background:none;border:1px solid var(--border2);color:var(--text);border-radius:3px;font-family:var(--mono);font-size:.64rem;cursor:pointer;transition:all .15s;text-transform:uppercase;-webkit-tap-highlight-color:transparent}
.btn-act:hover{border-color:var(--accent);color:var(--accent)}
.btn-act.primary{background:rgba(0,229,160,.07);border-color:var(--accent2);color:var(--accent)}
.btn-act.excel{background:rgba(77,166,255,.07);border-color:#3a7cc4;color:var(--blue)}
.empty-state{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;color:var(--muted);padding:40px 0}
.empty-icon{font-size:1.8rem;opacity:.18}
.empty-text{font-family:var(--mono);font-size:.65rem;letter-spacing:.1em;text-align:center;line-height:1.9;text-transform:uppercase}
#ocr-preview-wrap{display:none;margin-top:10px}
#ocr-preview{max-width:100%;max-height:160px;border:1px solid var(--border);border-radius:4px;object-fit:contain}
/* ── HISTORY ── */
#page-history{display:none;flex-direction:column}
#page-history.active{display:flex}
.history-wrap{padding:16px;width:100%;max-width:960px;margin:0 auto}
.history-header{display:flex;align-items:center;gap:8px;margin-bottom:16px;flex-wrap:wrap}
.history-header h2{font-family:var(--mono);font-size:.85rem;font-weight:600;letter-spacing:.06em}
.hist-filter{display:flex;gap:5px;flex-wrap:wrap}
.hist-filter-btn{font-family:var(--mono);font-size:.63rem;padding:5px 10px;border:1px solid var(--border2);background:none;color:var(--muted);border-radius:2px;cursor:pointer;transition:all .15s;text-transform:uppercase}
.hist-filter-btn.active{border-color:var(--purple);color:var(--purple);background:rgba(167,139,250,.07)}
.btn-hist-clear{font-family:var(--mono);font-size:.63rem;padding:5px 10px;border:1px solid rgba(255,107,53,.3);background:none;color:var(--warn);border-radius:2px;cursor:pointer}
.history-list{display:flex;flex-direction:column;gap:8px}
.hist-card{background:var(--surf);border:1px solid var(--border2);border-radius:6px;overflow:hidden}
.hist-card-head{display:flex;align-items:center;gap:8px;padding:12px 14px;cursor:pointer;user-select:none;flex-wrap:wrap}
.hist-card-head:active{background:rgba(255,255,255,.02)}
.hist-type-badge{font-family:var(--mono);font-size:.58rem;font-weight:600;padding:2px 7px;border-radius:2px;text-transform:uppercase;flex-shrink:0}
.hist-type-badge.stt{background:rgba(0,229,160,.1);color:var(--accent);border:1px solid rgba(0,229,160,.2)}
.hist-type-badge.ocr{background:rgba(77,166,255,.1);color:var(--blue);border:1px solid rgba(77,166,255,.2)}
.hist-status-badge{font-family:var(--mono);font-size:.57rem;padding:2px 6px;border-radius:2px;flex-shrink:0}
.hist-status-badge.success{background:rgba(0,229,160,.07);color:var(--accent2);border:1px solid rgba(0,229,160,.15)}
.hist-status-badge.processing{background:rgba(251,146,60,.07);color:var(--orange);border:1px solid rgba(251,146,60,.15)}
.hist-status-badge.failed{background:rgba(255,107,53,.07);color:var(--warn);border:1px solid rgba(255,107,53,.15)}
.hist-filename{font-family:var(--mono);font-size:.75rem;color:var(--text);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:80px}
.hist-time{font-family:var(--mono);font-size:.6rem;color:var(--muted);flex-shrink:0}
.hist-user{font-family:var(--mono);font-size:.6rem;color:var(--muted);flex-shrink:0;padding:2px 5px;border:1px solid var(--border);border-radius:2px}
.hist-expand{font-size:.7rem;color:var(--muted);flex-shrink:0;transition:transform .2s;margin-left:auto}
.hist-expand.open{transform:rotate(180deg)}
.hist-del{font-family:var(--mono);font-size:.6rem;padding:4px 8px;border:1px solid var(--border2);background:none;color:var(--muted);border-radius:2px;cursor:pointer;flex-shrink:0;-webkit-tap-highlight-color:transparent}
.hist-del:active{border-color:var(--warn);color:var(--warn)}
.hist-detail{display:none;border-top:1px solid var(--border);padding:14px;background:var(--surf2)}
.hist-detail.open{display:grid;grid-template-columns:1fr;gap:14px}
.hist-section-title{font-family:var(--mono);font-size:.59rem;letter-spacing:.12em;color:var(--muted);text-transform:uppercase;margin-bottom:8px;padding-bottom:5px;border-bottom:1px solid var(--border)}
.hist-kv{display:flex;flex-direction:column;gap:5px}
.hist-kv-row{display:flex;gap:8px;align-items:baseline}
.hist-k{font-family:var(--mono);font-size:.6rem;color:var(--muted);min-width:72px;flex-shrink:0}
.hist-v{font-family:var(--mono);font-size:.7rem;color:var(--text);word-break:break-all}
.hist-preview-text{background:var(--surf);border:1px solid var(--border);border-radius:3px;padding:10px;font-family:var(--mono);font-size:.7rem;line-height:1.7;color:var(--muted);white-space:pre-wrap;max-height:100px;overflow-y:auto;margin-top:6px}
.hist-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:4px}
.hist-btn{font-family:var(--mono);font-size:.63rem;padding:7px 12px;border:1px solid var(--border2);background:none;color:var(--text);border-radius:3px;cursor:pointer;text-transform:uppercase;-webkit-tap-highlight-color:transparent}
.hist-btn:active{border-color:var(--accent);color:var(--accent)}
.hist-btn.blue{border-color:#3a7cc4;color:var(--blue)}
.pagination{display:flex;gap:5px;margin-top:14px;justify-content:center;flex-wrap:wrap}
.pg-btn{font-family:var(--mono);font-size:.66rem;padding:7px 12px;border:1px solid var(--border2);background:none;color:var(--muted);border-radius:2px;cursor:pointer;-webkit-tap-highlight-color:transparent}
.pg-btn.active{border-color:var(--purple);color:var(--purple);background:rgba(167,139,250,.07)}
.pg-btn:disabled{opacity:.3;cursor:not-allowed}
.hist-empty{text-align:center;padding:50px 16px;font-family:var(--mono);font-size:.7rem;color:var(--muted);letter-spacing:.1em;text-transform:uppercase}
/* ── SETTINGS ── */
#page-settings{display:none;flex-direction:column}
#page-settings.active{display:flex}
.settings-wrap{padding:16px;width:100%;max-width:680px;margin:0 auto}
.settings-section{background:var(--surf);border:1px solid var(--border2);border-radius:6px;padding:18px;margin-bottom:14px}
.settings-section h3{font-family:var(--mono);font-size:.68rem;letter-spacing:.12em;color:var(--muted);text-transform:uppercase;margin-bottom:14px;padding-bottom:10px;border-bottom:1px solid var(--border)}
.settings-select{width:100%;background:var(--surf2);border:1px solid var(--border2);color:var(--text);padding:9px 10px;border-radius:3px;font-family:var(--mono);font-size:.78rem;outline:none;cursor:pointer;appearance:none;-webkit-appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%2352526a'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center;margin-top:6px}
.settings-label{font-family:var(--mono);font-size:.73rem;color:var(--text);margin-bottom:4px;display:block}
.settings-label small{display:block;color:var(--muted);font-size:.6rem;margin-top:2px;font-weight:normal}
.cpu-slider-wrap{display:flex;align-items:center;gap:10px;margin-top:8px}
.cpu-slider{flex:1;-webkit-appearance:none;appearance:none;height:4px;border-radius:2px;background:var(--border2);outline:none;cursor:pointer}
.cpu-slider::-webkit-slider-thumb{-webkit-appearance:none;width:18px;height:18px;border-radius:50%;background:var(--blue);cursor:pointer}
.cpu-val{font-family:var(--mono);font-size:.8rem;font-weight:600;color:var(--blue);min-width:76px;text-align:right}
/* 타임아웃 입력 */
.timeout-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:8px}
.timeout-item label{display:block;font-family:var(--mono);font-size:.6rem;letter-spacing:.1em;color:var(--muted);text-transform:uppercase;margin-bottom:5px}
.timeout-input{width:100%;background:var(--surf2);border:1px solid var(--border2);color:var(--text);padding:9px 10px;border-radius:3px;font-family:var(--mono);font-size:.82rem;outline:none;-webkit-appearance:none}
.timeout-input:focus{border-color:var(--blue)}
.timeout-hint{font-family:var(--mono);font-size:.6rem;color:var(--muted);margin-top:4px}
.sys-monitor{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:10px}
.sys-card{background:var(--surf2);border:1px solid var(--border);border-radius:4px;padding:12px}
.sys-card-title{font-family:var(--mono);font-size:.58rem;letter-spacing:.1em;color:var(--muted);text-transform:uppercase;margin-bottom:7px}
.sys-bar-wrap{height:5px;background:var(--border);border-radius:3px;overflow:hidden;margin-bottom:5px}
.sys-bar{height:100%;border-radius:3px;transition:width .6s,background .3s}
.sys-val{font-family:var(--mono);font-size:.78rem;font-weight:600;color:var(--text)}
.sys-sub{font-family:var(--mono);font-size:.6rem;color:var(--muted);margin-top:2px}
.btn-settings{padding:10px 16px;border:none;border-radius:3px;font-family:var(--mono);font-size:.72rem;font-weight:600;letter-spacing:.08em;cursor:pointer;transition:all .15s;text-transform:uppercase;-webkit-appearance:none}
.btn-settings.blue{background:var(--blue);color:#fff}
.btn-settings.blue:active{background:#3d8ce6}
.btn-settings.outline{background:none;border:1px solid var(--border2);color:var(--muted)}
.ollama-status{font-family:var(--mono);font-size:.63rem;padding:4px 9px;border-radius:2px}
.ollama-status.ok{background:rgba(0,229,160,.1);color:var(--accent);border:1px solid rgba(0,229,160,.2)}
.ollama-status.fail{background:rgba(255,107,53,.1);color:var(--warn);border:1px solid rgba(255,107,53,.2)}
/* ── 활성 작업 배너 ── */
#active-tasks-banner{background:rgba(77,166,255,.08);border-bottom:1px solid rgba(77,166,255,.2);padding:8px 20px;display:none;font-family:var(--mono);font-size:.68rem;color:var(--blue);flex-wrap:wrap;gap:6px;align-items:center}
/* ── 배치 큐 ── */
.batch-queue{margin-top:12px;display:flex;flex-direction:column;gap:5px;max-height:260px;overflow-y:auto}
.batch-item{display:grid;grid-template-columns:1fr auto auto;align-items:center;gap:8px;padding:8px 12px;background:var(--surf);border:1px solid var(--border2);border-radius:4px}
.batch-item.running{border-color:var(--accent2)}.batch-item.done{border-color:rgba(0,229,160,.3)}.batch-item.failed{border-color:rgba(255,107,53,.3)}.batch-item.cancelled{border-color:rgba(255,107,53,.2);opacity:.6}
.bi-name{font-family:var(--mono);font-size:.72rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.bi-status{font-family:var(--mono);font-size:.6rem;padding:3px 7px;border-radius:2px;white-space:nowrap}
.bi-status.waiting{background:rgba(255,255,255,.04);color:var(--muted);border:1px solid var(--border)}.bi-status.running{background:rgba(0,229,160,.07);color:var(--accent);border:1px solid rgba(0,229,160,.2)}.bi-status.done{background:rgba(0,229,160,.07);color:var(--accent2);border:1px solid rgba(0,229,160,.2)}.bi-status.failed,.bi-status.cancelled{background:rgba(255,107,53,.07);color:var(--warn);border:1px solid rgba(255,107,53,.2)}
.bi-dl{font-family:var(--mono);font-size:.6rem;padding:3px 8px;border:1px solid var(--border2);background:none;color:var(--text);border-radius:2px;cursor:pointer}.bi-dl:hover{border-color:var(--accent);color:var(--accent)}
.bi-prog{height:2px;background:var(--accent);border-radius:1px;transition:width .4s;margin-top:3px}
.batch-summary{font-family:var(--mono);font-size:.66rem;color:var(--muted);margin-top:6px}.batch-summary span{color:var(--text)}
.batch-actions{display:flex;gap:8px;margin-top:8px}
.batch-add-btn{flex:1;padding:7px;background:none;border:1px dashed var(--border2);color:var(--muted);border-radius:3px;font-family:var(--mono);font-size:.68rem;cursor:pointer;transition:all .15s}.batch-add-btn:hover{border-color:var(--accent);color:var(--accent)}
.batch-clear-btn{padding:7px 12px;background:none;border:1px solid var(--border2);color:var(--muted);border-radius:3px;font-family:var(--mono);font-size:.68rem;cursor:pointer}.batch-clear-btn:hover{border-color:var(--warn);color:var(--warn)}
/* ── 자막 페이지 ── */
#page-subtitle{display:none;flex-direction:column}#page-subtitle.active{display:flex}
.subtitle-wrap{max-width:860px;margin:0 auto;padding:28px 16px;width:100%}
.sub-card{background:var(--surf);border:1px solid var(--border2);border-radius:6px;padding:20px;margin-bottom:14px}
.sub-card h3{font-family:var(--mono);font-size:.68rem;letter-spacing:.1em;color:var(--muted);text-transform:uppercase;margin-bottom:14px;padding-bottom:10px;border-bottom:1px solid var(--border)}
.sub-select{width:100%;background:var(--surf2);border:1px solid var(--border2);color:var(--text);padding:9px 10px;border-radius:3px;font-family:var(--mono);font-size:.78rem;outline:none;cursor:pointer;appearance:none;-webkit-appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%2352526a'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center;margin-top:4px}
.sub-select:focus{border-color:var(--blue)}
.lang-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.fmt-row{display:flex;gap:8px;margin-top:6px}
.fmt-btn{flex:1;padding:9px;background:var(--surf);border:1px solid var(--border2);color:var(--muted);border-radius:3px;font-family:var(--mono);font-size:.68rem;cursor:pointer;text-align:center;transition:all .15s;text-transform:uppercase}
.fmt-btn.active{background:rgba(77,166,255,.08);border-color:#3a7cc4;color:var(--blue)}
.step-indicator{display:flex;align-items:center;gap:0;margin-bottom:6px}
.step-dot{width:30px;height:30px;border-radius:50%;border:2px solid var(--border2);background:var(--surf);display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:.7rem;font-weight:600;color:var(--muted);transition:all .3s;flex-shrink:0}
.step-dot.active{border-color:var(--blue);background:rgba(77,166,255,.1);color:var(--blue)}.step-dot.done{border-color:var(--accent);background:rgba(0,229,160,.1);color:var(--accent)}
.step-line{flex:1;height:2px;background:var(--border);transition:background .3s}.step-line.done{background:var(--accent)}
.step-labels{display:flex;justify-content:space-between;margin-top:5px;margin-bottom:18px}
.step-label{font-family:var(--mono);font-size:.58rem;color:var(--muted);text-align:center;flex:1;letter-spacing:.05em;text-transform:uppercase}
.step-label.active{color:var(--blue)}.step-label.done{color:var(--accent)}
.sub-prog-box{background:var(--surf2);border:1px solid var(--border2);border-radius:6px;padding:18px;margin-bottom:14px;display:none}
.sub-step-row{display:flex;align-items:flex-start;gap:12px;margin-bottom:10px}
.sub-step-icon{width:26px;height:26px;border-radius:50%;border:2px solid var(--border2);display:flex;align-items:center;justify-content:center;font-size:.72rem;flex-shrink:0;transition:all .3s}
.sub-step-icon.waiting{border-color:var(--border2);color:var(--muted)}.sub-step-icon.running{border-color:var(--blue);background:rgba(77,166,255,.1);color:var(--blue)}.sub-step-icon.done{border-color:var(--accent);background:rgba(0,229,160,.1);color:var(--accent)}.sub-step-icon.failed{border-color:var(--warn);background:rgba(255,107,53,.1);color:var(--warn)}
.sub-step-name{font-family:var(--mono);font-size:.72rem;color:var(--text);font-weight:600}
.sub-step-msg{font-family:var(--mono);font-size:.62rem;color:var(--muted);margin-top:3px;word-break:break-all}
.sub-prog-bar-wrap{height:3px;background:var(--border);border-radius:2px;overflow:hidden;margin-top:12px}
.sub-prog-bar{height:100%;background:var(--blue);border-radius:2px;transition:width .5s ease;width:0%}
.sub-result-card{background:var(--surf);border:1px solid rgba(0,229,160,.2);border-radius:6px;padding:20px;display:none}
.sub-info-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:8px;margin-bottom:14px}
.sub-info-item{background:var(--surf2);border:1px solid var(--border);border-radius:3px;padding:10px 12px}
.sub-info-label{font-family:var(--mono);font-size:.58rem;color:var(--muted);text-transform:uppercase;letter-spacing:.08em;margin-bottom:3px}
.sub-info-val{font-family:var(--mono);font-size:.82rem;color:var(--text);font-weight:600}
.sub-dl-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.sub-dl-btn{padding:11px;background:none;border:1px solid var(--border2);color:var(--text);border-radius:4px;font-family:var(--mono);font-size:.68rem;cursor:pointer;transition:all .15s;text-align:center;display:flex;flex-direction:column;align-items:center;gap:4px}
.sub-dl-btn:hover{border-color:var(--accent);color:var(--accent)}
.sub-dl-btn.trans{border-color:#3a7cc4;color:var(--blue)}.sub-dl-btn.trans:hover{background:rgba(77,166,255,.07)}
.btn-cancel{padding:8px 16px;background:rgba(255,107,53,.08);border:1px solid rgba(255,107,53,.3);color:var(--warn);border-radius:3px;font-family:var(--mono);font-size:.7rem;cursor:pointer;transition:all .15s}
.btn-cancel:hover{background:rgba(255,107,53,.15)}
/* 이력 자막 뱃지 */
.hist-type-badge.subtitle{background:rgba(77,166,255,.1);color:var(--blue);border:1px solid rgba(77,166,255,.2)}
@media(min-width:768px){.sub-info-grid{grid-template-columns:repeat(4,1fr)}.sub-dl-grid{grid-template-columns:repeat(4,1fr)}}
/* ── ADMIN ── */
#page-admin{display:none;flex-direction:column}
#page-admin.active{display:flex}
.admin-wrap{padding:16px;width:100%;max-width:860px;margin:0 auto}
.admin-section{background:var(--surf);border:1px solid var(--border2);border-radius:6px;margin-bottom:14px;overflow:hidden}
.admin-section-head{padding:14px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
.admin-section-head h3{font-family:var(--mono);font-size:.68rem;letter-spacing:.12em;color:var(--muted);text-transform:uppercase}
.user-table{width:100%;border-collapse:collapse;display:block;overflow-x:auto;-webkit-overflow-scrolling:touch}
.user-table thead,.user-table tbody{display:table;width:100%}
.user-table th{font-family:var(--mono);font-size:.6rem;letter-spacing:.1em;color:var(--muted);text-transform:uppercase;padding:9px 12px;border-bottom:1px solid var(--border);text-align:left;background:var(--surf2);white-space:nowrap}
.user-table td{padding:11px 12px;border-bottom:1px solid var(--border);font-size:.8rem;vertical-align:middle;white-space:nowrap}
.user-table tr:last-child td{border-bottom:none}
.perm-badge{font-family:var(--mono);font-size:.57rem;padding:2px 6px;border-radius:2px;margin-right:4px}
.perm-badge.on{background:rgba(0,229,160,.1);color:var(--accent);border:1px solid rgba(0,229,160,.2)}
.perm-badge.off{background:rgba(255,255,255,.04);color:var(--muted);border:1px solid var(--border)}
.role-badge{font-family:var(--mono);font-size:.57rem;padding:2px 6px;border-radius:2px}
.role-badge.admin{background:rgba(251,146,60,.1);color:var(--orange);border:1px solid rgba(251,146,60,.2)}
.role-badge.user{background:rgba(255,255,255,.04);color:var(--muted);border:1px solid var(--border)}
.btn-sm{padding:5px 9px;border:1px solid var(--border2);background:none;color:var(--muted);border-radius:2px;font-family:var(--mono);font-size:.6rem;cursor:pointer;margin-left:4px;-webkit-tap-highlight-color:transparent}
.btn-sm:active{border-color:var(--accent);color:var(--accent)}
.btn-sm.danger:active{border-color:var(--warn);color:var(--warn)}
.add-user-form{padding:16px}
.add-form-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:12px}
.form-group{display:flex;flex-direction:column;gap:4px}
.form-group label{font-family:var(--mono);font-size:.6rem;letter-spacing:.1em;color:var(--muted);text-transform:uppercase}
.form-input{background:var(--surf2);border:1px solid var(--border2);color:var(--text);padding:9px 10px;border-radius:3px;font-family:var(--mono);font-size:.8rem;outline:none;-webkit-appearance:none}
.form-input:focus{border-color:var(--accent)}
.perm-checks{display:flex;gap:14px;align-items:center;margin-top:4px;flex-wrap:wrap}
.perm-check{display:flex;align-items:center;gap:6px;cursor:pointer;font-family:var(--mono);font-size:.72rem;color:var(--muted)}
.perm-check input{accent-color:var(--accent);width:15px;height:15px;cursor:pointer}
.btn-add{padding:10px 20px;background:var(--accent);color:#000;border:none;border-radius:3px;font-family:var(--mono);font-size:.72rem;font-weight:600;cursor:pointer;text-transform:uppercase;-webkit-appearance:none}
.btn-add:active{background:#00c98a}
.admin-msg{font-family:var(--mono);font-size:.7rem;padding:8px 12px;border-radius:3px;margin-top:10px;display:none}
.admin-msg.ok{background:rgba(0,229,160,.08);border:1px solid rgba(0,229,160,.2);color:var(--accent)}
.admin-msg.err{background:rgba(255,107,53,.08);border:1px solid rgba(255,107,53,.2);color:var(--warn)}
.modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:200;align-items:flex-end;justify-content:center;padding:0}
.modal-overlay.visible{display:flex}
.modal-box{background:var(--surf);border:1px solid var(--border2);border-radius:12px 12px 0 0;padding:24px 20px 32px;width:100%;max-width:480px}
.modal-title{font-family:var(--mono);font-size:.8rem;font-weight:600;letter-spacing:.08em;margin-bottom:18px}
.modal-actions{display:flex;gap:8px;margin-top:18px;justify-content:flex-end}
/* ── 데스크탑 확장 ── */
@media(min-width:768px){
:root{--hdr:60px}
header{padding:0 24px;gap:12px}
.ram-widget{display:flex}
#user-info{gap:10px}
.two-panel{flex-direction:row}
.panel{padding:28px 32px;border-bottom:none;border-right:1px solid var(--border);flex:1}
.panel:last-child{border-right:none}
.nav-tab{font-size:.7rem;padding:13px 18px}
.hist-detail.open{grid-template-columns:1fr 1fr 1fr}
.add-form-grid{grid-template-columns:1fr 1fr}
.sys-monitor{grid-template-columns:1fr 1fr}
.modal-overlay{align-items:center;padding:16px}
.modal-box{border-radius:8px;max-width:400px}
.timeout-grid{grid-template-columns:1fr 1fr}
.settings-wrap,.history-wrap,.admin-wrap{padding:32px}
}
@media(min-width:1024px){
header{padding:0 28px}
.panel{padding:32px 36px}
}
</style>
</head>
<body>
<!-- LOGIN -->
<div id="login-overlay">
<div class="login-box">
<div class="login-logo"><div class="login-mark"></div><div class="login-title">Voice<span>Script</span></div></div>
<div class="field"><label>아이디</label><input type="text" id="inp-user" placeholder="username" autocomplete="username" inputmode="text"></div>
<div class="field"><label>비밀번호</label><input type="password" id="inp-pass" placeholder="password" autocomplete="current-password"></div>
<button id="btn-login">로그인</button>
<div id="login-err"></div>
</div>
</div>
<!-- HEADER -->
<header>
<div class="logo-mark"></div>
<h1>Voice<span>Script</span></h1>
<div class="ram-widget" id="ram-widget">
<span class="ram-label">RAM</span>
<div class="ram-bar-wrap"><div class="ram-bar" id="ram-bar"></div></div>
<span class="ram-text" id="ram-text"></span>
<span class="cpu-text" id="cpu-text"></span>
</div>
<div id="user-info">
<span id="user-name" style="font-family:var(--mono);font-size:.72rem;color:var(--text)"></span>
<span id="user-badge"></span>
<button id="btn-logout">나가기</button>
</div>
</header>
<!-- NAV -->
<div class="nav-tabs">
<button class="nav-tab active" data-page="stt">🎙 STT</button>
<button class="nav-tab" data-page="ocr">🔍 OCR</button>
<button class="nav-tab history-tab" data-page="history">📋 이력</button>
<button class="nav-tab" data-page="subtitle">🎬 자막</button>
<button class="nav-tab settings-tab" data-page="settings">⚙️ 설정</button>
<button class="nav-tab admin-tab" data-page="admin" id="admin-tab" style="display:none">👤 관리자</button>
</div>
<!-- ══ STT ══ -->
<div class="page active" id="page-stt">
<div class="two-panel">
<section class="panel">
<div class="panel-title">파일 업로드</div>
<div class="dropzone" id="stt-drop">
<input type="file" id="stt-input" accept=".mp3,.mp4,.wav,.m4a,.ogg,.flac,.aac,.wma,.webm,.mkv,.avi,.mov,.ts,.mts,.h264,.h265" multiple>
<span class="drop-icon">🎵</span>
<div class="drop-label"><strong>탭하거나 드래그하여 선택</strong><br>음성·영상 복수 선택 가능</div>
<div class="drop-formats">mp3 · wav · m4a · ogg · flac · mp4 · webm</div>
</div>
<div class="file-info" id="stt-info"><div class="fname" id="stt-fname"></div><div class="fsize" id="stt-fsize"></div></div>
<div id="stt-queue" style="display:none">
<div class="batch-queue" id="stt-queue-list"></div>
<div class="batch-summary" id="stt-queue-summary"></div>
<div class="batch-actions">
<button class="batch-add-btn" onclick="document.getElementById('stt-input').click()">+ 더 추가</button>
<button class="batch-clear-btn" id="stt-queue-clear">초기화</button>
</div>
</div>
<div class="sec-label">STT 엔진</div>
<div class="engine-btns">
<button class="engine-btn active" data-engine="whisper"><span class="e-icon"></span><span class="e-name">faster-whisper</span><span class="e-desc">로컬 CPU 변환<br>빠르고 안정적</span></button>
<button class="engine-btn" data-engine="whisper+ollama"><span class="e-icon">🦙</span><span class="e-name">+ Ollama 교정</span><span class="e-desc">Whisper 후<br>Ollama 교정</span></button>
<button class="engine-btn" data-engine="whisper+openrouter"><span class="e-icon">🌐</span><span class="e-name">+ OpenRouter</span><span class="e-desc">외부 AI<br>문장 교정</span></button>
</div>
<div class="ollama-opts" id="stt-ollama-opts">
<div class="sec-label" style="margin-top:0">후처리 모델</div>
<select class="model-select" id="stt-ollama-model"><option value="">설정 기본 모델 사용</option></select>
</div>
<div class="ollama-opts" id="stt-or-opts">
<div class="sec-label" style="margin-top:0">OpenRouter 교정 모델</div>
<select class="model-select" id="stt-or-model"><option value="">설정 기본 모델 사용</option></select>
</div>
<button class="btn-start green" id="stt-btn" disabled>변환 시작</button>
<div class="prog-box" id="stt-prog">
<div class="prog-header"><span class="prog-msg" id="stt-pmsg">처리 중...</span><span class="prog-pct" id="stt-ppct">0%</span></div>
<div class="prog-track"><div class="prog-fill" id="stt-pfill"></div></div>
<div class="waveform"><div class="wave-bar"></div><div class="wave-bar"></div><div class="wave-bar"></div><div class="wave-bar"></div><div class="wave-bar"></div><div class="wave-bar"></div><div class="wave-bar"></div><div class="wave-bar"></div><div class="wave-bar"></div></div>
</div>
<div class="err-box" id="stt-err"></div>
</section>
<section class="panel">
<div class="panel-title">변환 결과</div>
<div class="result-meta" id="stt-meta">
<div class="meta-chip">언어 <span id="stt-mlang"></span></div>
<div class="meta-chip">길이 <span id="stt-mdur"></span></div>
<div class="meta-chip">세그먼트 <span id="stt-msegs"></span></div>
<div class="meta-chip" id="stt-mollama-chip" style="display:none">교정 <span id="stt-mollama"></span></div>
</div>
<div class="result-tabs" id="stt-tabs">
<button class="tab-btn active" data-tab="stt-text">전체 텍스트</button>
<button class="tab-btn" data-tab="stt-segs">타임스탬프</button>
</div>
<div class="tab-content active" id="stt-text">
<div class="empty-state" id="stt-empty"><div class="empty-icon">📝</div><div class="empty-text">파일 업로드 후<br>변환을 시작하면<br>결과가 표시됩니다</div></div>
<textarea class="result-textarea" id="stt-result" style="display:none" readonly></textarea>
</div>
<div class="tab-content" id="stt-segs"><div class="segments-list" id="stt-seglist"></div></div>
<div class="result-actions" id="stt-actions">
<button class="btn-act" id="stt-copy">복사</button>
<button class="btn-act primary" id="stt-dl">TXT 저장</button>
<button class="btn-act" id="stt-new">새 파일</button>
</div>
</section>
</div>
</div>
<!-- ══ OCR ══ -->
<div class="page" id="page-ocr">
<div class="two-panel">
<section class="panel">
<div class="panel-title">이미지 업로드</div>
<div class="dropzone" id="ocr-drop">
<input type="file" id="ocr-input" accept=".jpg,.jpeg,.png,.bmp,.tiff,.tif,.webp,.gif" multiple>
<span class="drop-icon">🖼</span>
<div class="drop-label"><strong>탭하거나 드래그하여 선택</strong><br>복수 선택 가능</div>
<div class="drop-formats">jpg · png · bmp · tiff · webp · gif</div>
</div>
<div class="file-info" id="ocr-info"><div class="fname" id="ocr-fname"></div><div class="fsize" id="ocr-fsize"></div></div>
<div id="ocr-preview-wrap"><img id="ocr-preview"></div>
<div id="ocr-queue" style="display:none">
<div class="batch-queue" id="ocr-queue-list"></div>
<div class="batch-summary" id="ocr-queue-summary"></div>
<div class="batch-actions">
<button class="batch-add-btn" onclick="document.getElementById('ocr-input').click()">+ 더 추가</button>
<button class="batch-clear-btn" id="ocr-queue-clear">초기화</button>
</div>
</div>
<div class="sec-label">OCR 엔진</div>
<div class="engine-btns">
<button class="engine-btn active" data-engine="paddle"><span class="e-icon">🐾</span><span class="e-name">PaddleOCR</span><span class="e-desc">로컬 실행<br>표 구조 분석</span></button>
<button class="engine-btn" data-engine="ollama"><span class="e-icon">🦙</span><span class="e-name">Ollama Vision</span><span class="e-desc">자연어 지시<br>커스텀 프롬프트</span></button>
<button class="engine-btn" data-engine="openrouter"><span class="e-icon">🌐</span><span class="e-name">OpenRouter Vision</span><span class="e-desc">Claude/GPT-4o<br>외부 Vision AI</span></button>
</div>
<div class="ollama-opts" id="ocr-ollama-opts">
<div class="sec-label" style="margin-top:0">Vision 모델</div>
<select class="model-select" id="ocr-ollama-model"><option value="">설정 기본 모델 사용</option></select>
<span class="cprompt-toggle" id="cprompt-toggle">▶ 커스텀 프롬프트</span>
<textarea class="cprompt" id="custom-prompt" placeholder="예: 이 영수증의 품목과 금액을 JSON으로 추출해줘"></textarea>
</div>
<div class="sec-label">인식 모드</div>
<div class="mode-btns">
<button class="mode-btn active" data-mode="text">📄 텍스트 추출</button>
<button class="mode-btn" data-mode="structure">📊 표 구조 분석</button>
</div>
<div id="mode-desc" style="margin-top:6px;font-family:var(--mono);font-size:.62rem;color:var(--muted);line-height:1.6">일반 텍스트와 글자를 인식합니다</div>
<button class="btn-start green" id="ocr-btn" disabled>인식 시작</button>
<div class="prog-box" id="ocr-prog">
<div class="prog-header"><span class="prog-msg" id="ocr-pmsg">처리 중...</span><span class="prog-pct" id="ocr-ppct">0%</span></div>
<div class="prog-track"><div class="prog-fill" id="ocr-pfill"></div></div>
<div class="waveform" id="ocr-wave" style="display:none"><div class="wave-bar"></div><div class="wave-bar"></div><div class="wave-bar"></div><div class="wave-bar"></div><div class="wave-bar"></div><div class="wave-bar"></div><div class="wave-bar"></div><div class="wave-bar"></div><div class="wave-bar"></div></div>
</div>
<div class="err-box" id="ocr-err"></div>
</section>
<section class="panel">
<div class="panel-title">인식 결과</div>
<div class="result-meta" id="ocr-meta">
<div class="meta-chip"><span id="ocr-mlines"></span></div>
<div class="meta-chip">모드 <span id="ocr-mmode"></span></div>
<div class="meta-chip">엔진 <span id="ocr-mbackend"></span></div>
<div class="meta-chip"><span id="ocr-mtables"></span></div>
</div>
<div class="result-tabs" id="ocr-tabs">
<button class="tab-btn active" data-tab="ocr-text">전체 텍스트</button>
<button class="tab-btn" data-tab="ocr-lines">줄별</button>
<button class="tab-btn" data-tab="ocr-tables">표 뷰어</button>
</div>
<div class="tab-content active" id="ocr-text">
<div class="empty-state" id="ocr-empty"><div class="empty-icon">🔍</div><div class="empty-text">이미지 업로드 후<br>인식을 시작하면<br>결과가 표시됩니다</div></div>
<textarea class="result-textarea" id="ocr-result" style="display:none" readonly></textarea>
</div>
<div class="tab-content" id="ocr-lines"><div class="lines-list" id="ocr-linelist"></div></div>
<div class="tab-content" id="ocr-tables">
<div id="ocr-tablelist" style="overflow-y:auto;max-height:400px"></div>
<div class="empty-state" id="ocr-tableempty"><div class="empty-icon">📊</div><div class="empty-text">표 구조 분석 모드를<br>선택하면 표를<br>추출할 수 있습니다</div></div>
</div>
<div class="result-actions" id="ocr-actions">
<button class="btn-act" id="ocr-copy">복사</button>
<button class="btn-act primary" id="ocr-dl-txt">TXT</button>
<button class="btn-act excel" id="ocr-dl-xlsx" style="display:none">Excel</button>
<button class="btn-act" id="ocr-new">새 파일</button>
</div>
</section>
</div>
</div>
<!-- ══ HISTORY ══ -->
<div class="page" id="page-history">
<div class="history-wrap">
<div class="history-header">
<h2>📋 변환 이력</h2>
<div class="hist-filter">
<button class="hist-filter-btn active" data-type="">전체</button>
<button class="hist-filter-btn" data-type="stt">STT</button>
<button class="hist-filter-btn" data-type="ocr">OCR</button>
<button class="hist-filter-btn" data-type="subtitle">🎬 자막</button>
</div>
<button class="btn-hist-clear" id="btn-hist-refresh">🔄</button>
<button class="btn-hist-clear" id="btn-hist-clear" style="display:none">🗑 전체삭제</button>
</div>
<div class="history-list" id="history-list"><div class="hist-empty">불러오는 중...</div></div>
<div class="pagination" id="pagination"></div>
</div>
</div>
<!-- ══ SETTINGS ══ -->
<div class="page" id="page-settings">
<div class="settings-wrap">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:18px;flex-wrap:wrap">
<h2 style="font-family:var(--mono);font-size:.88rem;font-weight:600;letter-spacing:.06em">⚙️ 설정</h2>
<span id="ollama-status-badge"></span>
<button class="btn-settings outline" id="btn-refresh-models" style="margin-left:auto;font-size:.65rem;padding:6px 12px">🔄 모델갱신</button>
</div>
<!-- 시스템 리소스 -->
<div class="settings-section">
<h3>📊 시스템 리소스</h3>
<div class="sys-monitor">
<div class="sys-card"><div class="sys-card-title">RAM 사용량</div><div class="sys-bar-wrap"><div class="sys-bar" id="sys-ram-bar"></div></div><div class="sys-val" id="sys-ram-val"></div><div class="sys-sub" id="sys-ram-sub">로딩 중</div></div>
<div class="sys-card"><div class="sys-card-title">CPU 사용률</div><div class="sys-bar-wrap"><div class="sys-bar" id="sys-cpu-bar"></div></div><div class="sys-val" id="sys-cpu-val"></div><div class="sys-sub" id="sys-cpu-sub">로딩 중</div></div>
<div class="sys-card"><div class="sys-card-title">Swap</div><div class="sys-bar-wrap"><div class="sys-bar" id="sys-swap-bar" style="background:var(--orange)"></div></div><div class="sys-val" id="sys-swap-val"></div><div class="sys-sub" id="sys-swap-sub"></div></div>
<div class="sys-card"><div class="sys-card-title">CPU 스레드</div><div class="sys-val" id="sys-threads-val" style="margin-top:8px"></div><div class="sys-sub" id="sys-threads-sub">worker 재시작 후 반영</div></div>
</div>
</div>
<!-- CPU 스레드 -->
<div class="settings-section">
<h3>🖥️ CPU 스레드</h3>
<label class="settings-label">Whisper STT에 사용할 CPU 스레드 수<small>0 = 자동 (전체 코어 사용)</small></label>
<div class="cpu-slider-wrap">
<input type="range" class="cpu-slider" id="cpu-slider" min="0" max="16" step="1" value="0">
<span class="cpu-val" id="cpu-val-display">0 (자동)</span>
</div>
</div>
<!-- 타임아웃 -->
<div class="settings-section">
<h3>⏱️ 변환 타임아웃</h3>
<div class="timeout-grid">
<div class="timeout-item">
<label>STT 변환 타임아웃 (초)</label>
<input type="number" class="timeout-input" id="stt-timeout" min="0" max="7200" placeholder="0">
<div class="timeout-hint">0 = 무제한 · 대용량 파일은 크게 설정</div>
</div>
<div class="timeout-item">
<label>Ollama 응답 타임아웃 (초)</label>
<input type="number" class="timeout-input" id="ollama-timeout" min="30" max="3600" placeholder="600">
<div class="timeout-hint">11b 이상 모델은 300+ 권장</div>
</div>
<div class="timeout-item">
<label>자막 LLM 타임아웃 (초)</label>
<input type="number" class="timeout-input" id="subtitle-timeout" min="30" max="7200" placeholder="600">
<div class="timeout-hint">번역·교정 시 응답 대기 시간</div>
</div>
</div>
</div>
<!-- STT Ollama 모델 -->
<div class="settings-section">
<h3>🎙 STT Ollama 후처리 기본 모델</h3>
<label class="settings-label">Whisper 변환 후 교정에 사용할 모델<small>STT 페이지에서 미선택 시 이 모델이 사용됩니다</small></label>
<select class="settings-select" id="setting-stt-model" style="margin-top:8px"><option value="">(없음)</option></select>
</div>
<!-- OCR Ollama 모델 -->
<div class="settings-section">
<h3>🔍 OCR Ollama 기본 모델</h3>
<label class="settings-label">OCR Vision 엔진 선택 시 사용할 모델<small>OCR 페이지에서 미선택 시 이 모델이 사용됩니다</small></label>
<select class="settings-select" id="setting-ocr-model" style="margin-top:8px"><option value="">(없음)</option></select>
</div>
<!-- OpenRouter -->
<div class="settings-section">
<h3>🌐 OpenRouter 외부 AI 연동</h3>
<label class="settings-label">API 키</label>
<div style="display:flex;gap:6px;margin-top:6px">
<input type="password" id="or-api-key" placeholder="sk-or-v1-..." autocomplete="off" style="flex:1;background:var(--surf2);border:1px solid var(--border2);color:var(--text);padding:9px 10px;border-radius:3px;font-family:var(--mono);font-size:.75rem;outline:none">
<button class="btn-settings" id="btn-or-test" style="white-space:nowrap">연결 테스트</button>
</div>
<div id="or-test-result" style="font-family:var(--mono);font-size:.68rem;margin-top:6px;display:none"></div>
<label class="settings-label" style="margin-top:10px">API URL</label>
<input type="text" id="or-url" value="https://openrouter.ai/api/v1" style="width:100%;background:var(--surf2);border:1px solid var(--border2);color:var(--text);padding:9px 10px;border-radius:3px;font-family:var(--mono);font-size:.75rem;outline:none;margin-top:6px">
<div id="or-models-wrap" style="display:none;margin-top:12px">
<div id="or-connected-badge" style="font-family:var(--mono);font-size:.68rem;color:var(--accent);margin-bottom:8px"></div>
<div style="display:flex;gap:6px;margin-bottom:8px">
<button class="or-model-tab active" data-filter="vision">Vision</button>
<button class="or-model-tab" data-filter="text">텍스트</button>
<button class="or-model-tab" data-filter="all">전체</button>
</div>
<label class="settings-label">STT/자막 기본 모델</label>
<select class="settings-select" id="setting-or-stt-model" style="margin-top:4px"><option value="">(없음)</option></select>
<label class="settings-label" style="margin-top:8px">OCR 기본 Vision 모델</label>
<select class="settings-select" id="setting-or-ocr-model" style="margin-top:4px"><option value="">(없음)</option></select>
</div>
</div>
<!-- Groq / OpenAI -->
<div class="settings-section">
<h3>🔑 STT 엔진 API 키</h3>
<label class="settings-label">Groq API 키 <small>groq.com에서 무료 발급</small></label>
<input type="password" id="groq-api-key" placeholder="gsk_..." autocomplete="off" style="width:100%;background:var(--surf2);border:1px solid var(--border2);color:var(--text);padding:9px 10px;border-radius:3px;font-family:var(--mono);font-size:.75rem;outline:none;margin-top:6px">
<label class="settings-label" style="margin-top:10px">OpenAI API 키</label>
<input type="password" id="openai-api-key" placeholder="sk-..." autocomplete="off" style="width:100%;background:var(--surf2);border:1px solid var(--border2);color:var(--text);padding:9px 10px;border-radius:3px;font-family:var(--mono);font-size:.75rem;outline:none;margin-top:6px">
<label class="settings-label" style="margin-top:10px">기본 STT 엔진</label>
<select class="settings-select" id="default-stt-engine" style="margin-top:4px">
<option value="local">⚡ faster-whisper (로컬)</option>
<option value="groq">🟠 Groq API</option>
<option value="openai">🔵 OpenAI API</option>
</select>
</div>
<div style="display:flex;gap:10px;justify-content:flex-end;align-items:center">
<div id="settings-msg" style="font-family:var(--mono);font-size:.68rem;color:var(--accent);display:none">✓ 저장됨 (CPU·타임아웃: worker 재시작 후 반영)</div>
<button class="btn-settings blue" id="btn-save-settings">저장</button>
</div>
</div>
</div>
<!-- ══ 자막 PAGE ══ -->
<div class="page" id="page-subtitle">
<div class="subtitle-wrap">
<h2 style="font-family:var(--mono);font-size:.9rem;font-weight:600;letter-spacing:.06em;margin-bottom:20px">🎬 자막 생성</h2>
<div class="step-indicator">
<div class="step-dot" id="sdot-1">1</div><div class="step-line" id="sline-1"></div>
<div class="step-dot" id="sdot-2">2</div><div class="step-line" id="sline-2"></div>
<div class="step-dot" id="sdot-3">3</div>
</div>
<div class="step-labels">
<span class="step-label" id="slabel-1">오디오 추출</span>
<span class="step-label" id="slabel-2">음성 인식·교정</span>
<span class="step-label" id="slabel-3">번역 (선택)</span>
</div>
<div class="sub-card" id="sub-config-card">
<h3>📁 영상 / 오디오 파일</h3>
<div class="dropzone" id="sub-drop" style="padding:22px 16px">
<input type="file" id="sub-input" accept=".mp4,.mkv,.avi,.mov,.webm,.ts,.mts,.wmv,.flv,.h264,.h265,.mp3,.wav,.m4a,.ogg,.flac">
<span class="drop-icon" style="font-size:1.6rem">🎬</span>
<div class="drop-label"><strong>탭하거나 드래그하여 선택</strong><br>mp4 · mkv · h.264/h.265 · mp3 · wav 등</div>
</div>
<div class="file-info" id="sub-info" style="display:none;margin-top:10px">
<div class="fname" id="sub-fname"></div><div class="fsize" id="sub-fsize"></div>
</div>
</div>
<div class="sub-card">
<h3>⚙️ 자막 설정</h3>
<div class="lang-grid">
<div>
<div class="sec-label">음성 언어 (원어)</div>
<select class="sub-select" id="sub-src-lang">
<option value="">🔍 자동 감지 (권장)</option>
<option value="ko">🇰🇷 한국어</option><option value="en">🇺🇸 English</option>
<option value="ja">🇯🇵 日本語</option><option value="zh">🇨🇳 中文(简体)</option>
<option value="zh-tw">🇹🇼 中文(繁體)</option><option value="fr">🇫🇷 Français</option>
<option value="de">🇩🇪 Deutsch</option><option value="es">🇪🇸 Español</option>
<option value="it">🇮🇹 Italiano</option><option value="pt">🇵🇹 Português</option>
<option value="ru">🇷🇺 Русский</option><option value="ar">🇸🇦 العربية</option>
<option value="vi">🇻🇳 Tiếng Việt</option><option value="th">🇹🇭 ไทย</option>
<option value="id">🇮🇩 Bahasa Indonesia</option><option value="nl">🇳🇱 Nederlands</option>
<option value="pl">🇵🇱 Polski</option><option value="tr">🇹🇷 Türkçe</option>
<option value="sv">🇸🇪 Svenska</option><option value="hi">🇮🇳 हिन्दी</option>
</select>
</div>
<div>
<div class="sec-label">자막 포맷</div>
<div class="fmt-row">
<button class="fmt-btn active" data-fmt="srt">SRT</button>
<button class="fmt-btn" data-fmt="vtt">VTT</button>
<button class="fmt-btn" data-fmt="both">둘 다</button>
</div>
</div>
</div>
<div style="margin-top:12px">
<div class="sec-label">STT 엔진</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-top:6px">
<button class="fmt-btn active" data-stt-eng="local" id="sub-eng-local">⚡ faster-whisper</button>
<button class="fmt-btn" data-stt-eng="groq" id="sub-eng-groq">🟠 Groq API</button>
<button class="fmt-btn" data-stt-eng="openai" id="sub-eng-openai">🔵 OpenAI API</button>
</div>
</div>
</div>
<div class="sub-card">
<h3>🧠 Step 2 — LLM 교정 (선택)</h3>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-family:var(--mono);font-size:.75rem;color:var(--text)">
<input type="checkbox" id="sub-refine-enable" style="accent-color:var(--purple);width:15px;height:15px"> LLM 교정 사용
</label>
<div id="sub-refine-opts" style="display:none;margin-top:12px">
<div class="lang-grid">
<div>
<div class="sec-label">교정 엔진</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:6px">
<button class="fmt-btn active" data-refine-via="ollama">🦙 Ollama</button>
<button class="fmt-btn" data-refine-via="openrouter">🌐 OpenRouter</button>
</div>
</div>
<div>
<div class="sec-label">교정 모델</div>
<select class="sub-select" id="sub-refine-model"><option value="">설정 기본 사용</option></select>
</div>
</div>
</div>
</div>
<div class="sub-card">
<h3>🌐 Step 3 — 번역 (선택)</h3>
<div style="font-family:var(--mono);font-size:.65rem;color:var(--muted);margin-bottom:10px">비워두면 원어 자막만 생성됩니다</div>
<div class="lang-grid">
<div>
<div class="sec-label">번역 대상 언어</div>
<select class="sub-select" id="sub-trans-lang">
<option value="">번역 안 함</option>
<option value="ko">🇰🇷 한국어</option><option value="en">🇺🇸 English</option>
<option value="ja">🇯🇵 日本語</option><option value="zh">🇨🇳 中文(简体)</option>
<option value="zh-tw">🇹🇼 中文(繁體)</option><option value="fr">🇫🇷 Français</option>
<option value="de">🇩🇪 Deutsch</option><option value="es">🇪🇸 Español</option>
<option value="it">🇮🇹 Italiano</option><option value="pt">🇵🇹 Português</option>
<option value="ru">🇷🇺 Русский</option><option value="ar">🇸🇦 العربية</option>
<option value="vi">🇻🇳 Tiếng Việt</option><option value="th">🇹🇭 ไทย</option>
<option value="id">🇮🇩 Bahasa Indonesia</option><option value="nl">🇳🇱 Nederlands</option>
<option value="pl">🇵🇱 Polski</option><option value="tr">🇹🇷 Türkçe</option>
<option value="sv">🇸🇪 Svenska</option><option value="hi">🇮🇳 हिन्दी</option>
</select>
</div>
<div id="sub-trans-engine-wrap" style="display:none">
<div class="sec-label">번역 엔진</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:6px">
<button class="fmt-btn active" data-trans-via="ollama">🦙 Ollama</button>
<button class="fmt-btn" data-trans-via="openrouter">🌐 OpenRouter</button>
</div>
<div class="sec-label">번역 모델</div>
<select class="sub-select" id="sub-trans-model"><option value="">설정 기본 사용</option></select>
</div>
</div>
</div>
<div style="display:flex;gap:10px;margin-bottom:14px">
<button class="btn-start blue" id="sub-btn" disabled style="flex:1">자막 생성 시작</button>
<button class="btn-cancel" id="sub-cancel-btn" style="display:none">⏹ 취소</button>
</div>
<div class="sub-prog-box" id="sub-prog-box">
<div class="sub-step-row"><div class="sub-step-icon waiting" id="sub-sicon-1"></div><div><div class="sub-step-name">Step 1 — 오디오 추출</div><div class="sub-step-msg" id="sub-smsg-1">ffmpeg 오디오 추출 대기</div></div></div>
<div class="sub-step-row"><div class="sub-step-icon waiting" id="sub-sicon-2"></div><div><div class="sub-step-name">Step 2 — 음성 인식 · LLM 교정</div><div class="sub-step-msg" id="sub-smsg-2">Whisper 인식 대기</div></div></div>
<div class="sub-step-row"><div class="sub-step-icon waiting" id="sub-sicon-3"></div><div><div class="sub-step-name">Step 3 — 번역</div><div class="sub-step-msg" id="sub-smsg-3">번역 대기</div></div></div>
<div class="sub-prog-bar-wrap"><div class="sub-prog-bar" id="sub-prog-bar"></div></div>
</div>
<div class="err-box" id="sub-err"></div>
<div class="sub-result-card" id="sub-result-card">
<div style="font-family:var(--mono);font-size:.72rem;letter-spacing:.1em;color:var(--accent);text-transform:uppercase;margin-bottom:14px">✓ 자막 생성 완료</div>
<div class="sub-info-grid">
<div class="sub-info-item"><div class="sub-info-label">감지 언어</div><div class="sub-info-val" id="sub-res-lang"></div></div>
<div class="sub-info-item"><div class="sub-info-label">재생 시간</div><div class="sub-info-val" id="sub-res-dur"></div></div>
<div class="sub-info-item"><div class="sub-info-label">자막 수</div><div class="sub-info-val" id="sub-res-segs"></div></div>
<div class="sub-info-item"><div class="sub-info-label">번역</div><div class="sub-info-val" id="sub-res-trans"></div></div>
</div>
<div class="sub-dl-grid" id="sub-dl-grid"></div>
<button class="btn-act" id="sub-new" style="margin-top:12px;width:100%">새 파일</button>
</div>
</div>
</div>
<!-- ══ ADMIN ══ -->
<div class="page" id="page-admin">
<div class="admin-wrap">
<h2 style="font-family:var(--mono);font-size:.88rem;font-weight:600;letter-spacing:.06em;margin-bottom:18px">👤 사용자 관리</h2>
<div class="admin-section">
<div class="admin-section-head"><h3>사용자 목록</h3><button class="btn-sm" id="btn-reload-users">새로고침</button></div>
<table class="user-table"><thead><tr><th>사용자명</th><th>역할</th><th>STT</th><th>OCR</th><th>관리</th></tr></thead><tbody id="user-tbody"></tbody></table>
</div>
<div class="admin-section">
<div class="admin-section-head"><h3>신규 사용자 추가</h3></div>
<div class="add-user-form">
<div class="add-form-grid">
<div class="form-group"><label>아이디</label><input type="text" class="form-input" id="new-username" placeholder="username" autocomplete="off"></div>
<div class="form-group"><label>비밀번호</label><input type="password" class="form-input" id="new-password" placeholder="password" autocomplete="new-password"></div>
</div>
<div class="form-group" style="margin-bottom:12px">
<label>기능 권한</label>
<div class="perm-checks">
<label class="perm-check"><input type="checkbox" id="new-perm-stt"> STT 음성변환</label>
<label class="perm-check"><input type="checkbox" id="new-perm-ocr"> OCR 이미지인식</label>
<label class="perm-check"><input type="checkbox" id="new-perm-subtitle"> 🎬 자막생성</label>
</div>
</div>
<div id="new-stt-models-wrap" style="margin-bottom:12px;display:none">
<label style="font-family:var(--mono);font-size:.62rem;letter-spacing:.1em;color:var(--muted);text-transform:uppercase">
STT Whisper 모델 제한
<span style="color:var(--muted);font-size:.6rem;text-transform:none;margin-left:4px">· 선택 없음 = 모두 허용</span>
</label>
<div id="new-stt-model-checks" style="margin-top:8px;display:flex;flex-wrap:wrap;gap:8px"></div>
</div>
<div id="new-ocr-models-wrap" style="margin-bottom:12px;display:none">
<label style="font-family:var(--mono);font-size:.62rem;letter-spacing:.1em;color:var(--muted);text-transform:uppercase">
OCR Ollama 모델 제한
<span style="color:var(--muted);font-size:.6rem;text-transform:none;margin-left:4px">· 선택 없음 = 모두 허용</span>
</label>
<div id="new-ocr-model-checks" style="margin-top:8px;display:flex;flex-wrap:wrap;gap:8px"></div>
</div>
<div style="margin-top:14px"><button class="btn-add" id="btn-add-user">사용자 추가</button></div>
<div class="admin-msg" id="add-msg"></div>
</div>
</div>
</div>
</div>
<!-- 편집 모달 -->
<div class="modal-overlay" id="edit-modal">
<div class="modal-box">
<div class="modal-title">권한 편집 — <span id="edit-modal-username"></span></div>
<div class="form-group"><label style="font-family:var(--mono);font-size:.62rem;letter-spacing:.1em;color:var(--muted);text-transform:uppercase">새 비밀번호 (변경 시에만)</label><input type="password" class="form-input" id="edit-password" placeholder="비워두면 변경 안 함" style="width:100%;margin-top:5px" autocomplete="new-password"></div>
<div class="form-group" style="margin-top:14px">
<label style="font-family:var(--mono);font-size:.62rem;letter-spacing:.1em;color:var(--muted);text-transform:uppercase">기능 권한</label>
<div class="perm-checks" style="margin-top:6px">
<label class="perm-check"><input type="checkbox" id="edit-perm-stt"> STT 음성변환</label>
<label class="perm-check"><input type="checkbox" id="edit-perm-ocr"> OCR 이미지인식</label>
<label class="perm-check"><input type="checkbox" id="edit-perm-subtitle"> 🎬 자막생성</label>
</div>
</div>
<div id="edit-stt-models-wrap" style="margin-top:12px;display:none">
<label style="font-family:var(--mono);font-size:.62rem;letter-spacing:.1em;color:var(--muted);text-transform:uppercase">
STT 모델 제한 <span style="color:var(--muted);font-size:.6rem;text-transform:none;margin-left:4px">· 선택 없음 = 모두 허용</span>
</label>
<div id="edit-stt-model-checks" style="margin-top:8px;display:flex;flex-wrap:wrap;gap:8px"></div>
</div>
<div id="edit-ocr-models-wrap" style="margin-top:12px;display:none">
<label style="font-family:var(--mono);font-size:.62rem;letter-spacing:.1em;color:var(--muted);text-transform:uppercase">
OCR 모델 제한 <span style="color:var(--muted);font-size:.6rem;text-transform:none;margin-left:4px">· 선택 없음 = 모두 허용</span>
</label>
<div id="edit-ocr-model-checks" style="margin-top:8px;display:flex;flex-wrap:wrap;gap:8px"></div>
</div>
<div class="modal-actions"><button class="btn-sm" id="btn-modal-cancel">취소</button><button class="btn-add" id="btn-modal-save">저장</button></div>
<div class="admin-msg" id="edit-msg"></div>
</div>
</div>
<script>
// ══ STATE ══
let token=null,currentUser=null,ollamaModels=[],appSettings={};
let sttFile=null,sttOutputFile=null,sttEngine='whisper';
let ocrFile=null,ocrOutputTxt=null,ocrOutputXlsx=null,ocrEngine='paddle',ocrMode='text';
let editTarget=null,sysTimer=null;
let histPage=1,histType='',histTotal=0;
const HIST_PER=15;
const api=(method,url,body)=>{const o={method,headers:{Authorization:'Bearer '+(token||'')}};if(body)o.body=body;return fetch(url,o)};
// ══ AUTH ══
async function checkAuth(){
token=localStorage.getItem('vs_token');
if(!token){showLogin();return}
try{const r=await api('GET','/api/me');if(r.ok){currentUser=await r.json();applyUserUI();await Promise.all([loadOllamaModels(),loadSettings()]);hideLogin();startSysMonitor()}else showLogin()}
catch{showLogin()}
}
function applyUserUI(){
document.getElementById('user-name').textContent=currentUser.username;
const b=document.getElementById('user-badge');b.textContent=currentUser.role==='admin'?'ADMIN':'USER';b.className='user-badge '+currentUser.role;
document.getElementById('admin-tab').style.display=currentUser.role==='admin'?'flex':'none';
document.getElementById('btn-hist-clear').style.display=currentUser.role==='admin'?'block':'none';
}
const showLogin=()=>{document.getElementById('login-overlay').style.display='flex';stopSysMonitor()};
const hideLogin=()=>document.getElementById('login-overlay').style.display='none';
document.getElementById('btn-login').addEventListener('click',doLogin);
document.getElementById('inp-pass').addEventListener('keydown',e=>{if(e.key==='Enter')doLogin()});
async function doLogin(){
const u=document.getElementById('inp-user').value.trim(),p=document.getElementById('inp-pass').value;
const err=document.getElementById('login-err');err.style.display='none';
if(!u||!p){err.style.display='block';err.textContent='아이디와 비밀번호를 입력하세요';return}
const fd=new FormData();fd.append('username',u);fd.append('password',p);
try{const r=await fetch('/api/login',{method:'POST',body:fd});const d=await r.json();if(!r.ok){err.style.display='block';err.textContent=d.detail||'로그인 실패';return}token=d.access_token;localStorage.setItem('vs_token',token);await checkAuth()}
catch{err.style.display='block';err.textContent='서버 연결 실패'}
}
document.getElementById('btn-logout').addEventListener('click',()=>{token=null;currentUser=null;localStorage.removeItem('vs_token');showLogin();document.getElementById('inp-pass').value=''});
// ══ SYS MONITOR ══
function startSysMonitor(){fetchSysInfo();sysTimer=setInterval(fetchSysInfo,6000)}
function stopSysMonitor(){if(sysTimer){clearInterval(sysTimer);sysTimer=null}}
async function fetchSysInfo(){
try{const r=await api('GET','/api/system');if(!r.ok)return;const d=await r.json();
const p=d.ram_percent||0;const bar=document.getElementById('ram-bar');
bar.style.width=p+'%';bar.style.background=p>85?'var(--warn)':p>65?'#f0b42a':'var(--accent)';
document.getElementById('ram-text').textContent=`${d.ram_avail_gb}G여유`;
document.getElementById('cpu-text').textContent=`CPU ${d.cpu_percent}%`;
updateSC('ram',d.ram_percent,`${d.ram_used_gb}GB / ${d.ram_total_gb}GB`,`여유 ${d.ram_avail_gb}GB`,'var(--accent)');
updateSC('cpu',d.cpu_percent,`${d.cpu_percent}%`,`물리 ${d.cpu_physical}코어 / 논리 ${d.cpu_logical}스레드`,'var(--blue)');
const sp=d.swap_total_gb>0?Math.round(d.swap_used_gb/d.swap_total_gb*100):0;
updateSC('swap',sp,`${d.swap_used_gb}GB / ${d.swap_total_gb}GB`,`사용률 ${sp}%`,'var(--orange)');
const th=d.cpu_threads_setting;document.getElementById('sys-threads-val').textContent=th===0?`자동 (${d.cpu_logical}스레드)`:`${th} 스레드`;
const sl=document.getElementById('cpu-slider');if(sl.max<d.cpu_logical)sl.max=d.cpu_logical;
// 타임아웃 현재값 반영
if(d.stt_timeout!==undefined&&!document.getElementById('stt-timeout').value) document.getElementById('stt-timeout').value=d.stt_timeout;
if(d.ollama_timeout!==undefined&&!document.getElementById('ollama-timeout').value) document.getElementById('ollama-timeout').value=d.ollama_timeout;
}catch{}
}
function updateSC(id,pct,val,sub,color){
const b=document.getElementById(`sys-${id}-bar`);if(!b)return;
b.style.width=Math.min(pct||0,100)+'%';b.style.background=color;
document.getElementById(`sys-${id}-val`).textContent=val;
document.getElementById(`sys-${id}-sub`).textContent=sub;
}
// ══ CPU 슬라이더 ══
const cpuSlider=document.getElementById('cpu-slider'),cpuDisplay=document.getElementById('cpu-val-display');
cpuSlider.addEventListener('input',()=>{const v=parseInt(cpuSlider.value);cpuDisplay.textContent=v===0?'0 (자동)':v+' 스레드'});
// ══ OLLAMA 모델 ══
async function loadOllamaModels(){
try{const r=await api('GET','/api/ollama/models');const d=await r.json();ollamaModels=d.models||[];
const badge=document.getElementById('ollama-status-badge');
if(badge){badge.className='ollama-status '+(d.connected?'ok':'fail');badge.textContent=d.connected?`✓ Ollama(${ollamaModels.length})`:'✗ Ollama 연결실패'}
populateModelSelects()}catch{}
}
function populateModelSelects(){
const fill=(sel,def,ph)=>{const cur=sel.value||def||'';sel.innerHTML=`<option value="">${ph}</option>`;ollamaModels.forEach(m=>{const o=document.createElement('option');o.value=m;o.textContent=m;if(m===cur)o.selected=true;sel.appendChild(o)})};
fill(document.getElementById('stt-ollama-model'),appSettings.stt_ollama_model,'설정 기본 모델 사용');
fill(document.getElementById('ocr-ollama-model'),appSettings.ocr_ollama_model,'설정 기본 모델 사용');
fill(document.getElementById('setting-stt-model'),appSettings.stt_ollama_model,'(없음)');
fill(document.getElementById('setting-ocr-model'),appSettings.ocr_ollama_model,'(없음)');
}
// ══ 설정 ══
async function loadSettings(){
try{const r=await api('GET','/api/settings');appSettings=await r.json();
const th=appSettings.cpu_threads||0;cpuSlider.value=th;cpuDisplay.textContent=th===0?'0 (자동)':th+' 스레드';
document.getElementById('stt-timeout').value=appSettings.stt_timeout||0;
document.getElementById('ollama-timeout').value=appSettings.ollama_timeout||600;
populateModelSelects()}catch{}
}
document.getElementById('btn-save-settings').addEventListener('click',async()=>{
const fd=new FormData();
fd.append('stt_ollama_model',document.getElementById('setting-stt-model').value);
fd.append('ocr_ollama_model',document.getElementById('setting-ocr-model').value);
fd.append('cpu_threads',cpuSlider.value);
fd.append('stt_timeout',document.getElementById('stt-timeout').value||'0');
fd.append('ollama_timeout',document.getElementById('ollama-timeout').value||'600');
try{const r=await api('POST','/api/settings',fd);if(r.ok){appSettings=(await r.json()).settings;const msg=document.getElementById('settings-msg');msg.style.display='block';setTimeout(()=>msg.style.display='none',3500)}}catch{}
});
document.getElementById('btn-refresh-models').addEventListener('click',loadOllamaModels);
// ══ NAV ══
document.querySelectorAll('.nav-tab').forEach(btn=>{
btn.addEventListener('click',()=>{
document.querySelectorAll('.nav-tab').forEach(b=>b.classList.remove('active'));
document.querySelectorAll('.page').forEach(p=>p.classList.remove('active'));
btn.classList.add('active');
const p=document.getElementById('page-'+btn.dataset.page);if(p)p.classList.add('active');
if(btn.dataset.page==='admin')loadUsers();
if(btn.dataset.page==='settings'){loadSettings();fetchSysInfo()}
if(btn.dataset.page==='history'){histPage=1;loadHistory()}
});
});
// ══ STT ══
const sttDrop=document.getElementById('stt-drop'),sttInput=document.getElementById('stt-input');
let sttQueue=[], sttCurrentTaskId=null;
const AUDIO_EXTS=['mp3','mp4','wav','m4a','ogg','flac','aac','wma','webm','mkv','avi','mov','ts','mts','h264','h265'];
function addSttFiles(fl){
const files=Array.from(fl).filter(f=>AUDIO_EXTS.includes(f.name.split('.').pop().toLowerCase()));
if(!files.length)return;
files.forEach(f=>sttQueue.push({file:f,taskId:null,outputFile:null,status:'waiting'}));
renderSttQueue(); document.getElementById('stt-btn').disabled=false;
}
sttInput.addEventListener('change',()=>addSttFiles(sttInput.files));
sttDrop.addEventListener('dragover',e=>{e.preventDefault();sttDrop.classList.add('dragover')});
sttDrop.addEventListener('dragleave',()=>sttDrop.classList.remove('dragover'));
sttDrop.addEventListener('drop',e=>{e.preventDefault();sttDrop.classList.remove('dragover');addSttFiles(e.dataTransfer.files)});
function renderSttQueue(){
const qEl=document.getElementById('stt-queue'),list=document.getElementById('stt-queue-list'),sum=document.getElementById('stt-queue-summary');
if(!sttQueue.length){if(qEl)qEl.style.display='none';return}
if(qEl)qEl.style.display='block'; list.innerHTML='';
sttQueue.forEach((item,i)=>{
const div=document.createElement('div'); div.className='batch-item '+item.status;
div.innerHTML=`<div><div class="bi-name">${esc(item.file.name)}</div><div class="bi-prog" id="sbi-${i}" style="width:0%;display:${item.status==='running'?'block':'none'}"></div></div>
<span class="bi-status ${item.status}">${{waiting:'대기',running:'변환중',done:'완료',failed:'실패',cancelled:'취소'}[item.status]||item.status}</span>
<span>${item.status==='done'&&item.outputFile?`<button class="bi-dl" onclick="dlFile('${esc(item.outputFile)}')">📥 TXT</button>`:''}</span>`;
list.appendChild(div);
});
const done=sttQueue.filter(i=>i.status==='done').length,failed=sttQueue.filter(i=>i.status==='failed').length,running=sttQueue.filter(i=>i.status==='running').length;
if(sum)sum.innerHTML=`전체 <span>${sttQueue.length}</span> · 완료 <span>${done}</span> · 실패 <span>${failed}</span>${running?` · 진행중 <span>${running}</span>`:''}`;
}
document.getElementById('stt-queue-clear')?.addEventListener('click',()=>{sttQueue=[];renderSttQueue();document.getElementById('stt-btn').disabled=true});
document.querySelectorAll('#page-stt .engine-btn').forEach(btn=>{
btn.addEventListener('click',()=>{
document.querySelectorAll('#page-stt .engine-btn').forEach(b=>b.classList.remove('active'));
btn.classList.add('active'); sttEngine=btn.dataset.engine;
document.getElementById('stt-ollama-opts')?.classList.toggle('visible',sttEngine==='whisper+ollama');
document.getElementById('stt-or-opts')?.classList.toggle('visible',sttEngine==='whisper+openrouter');
document.getElementById('stt-btn').className='btn-start '+(sttEngine==='whisper'?'green':'purple');
});
});
document.getElementById('stt-btn').addEventListener('click',async()=>{
const pending=sttQueue.filter(i=>i.status==='waiting');
if(!pending.length){showErr('stt-err','변환할 파일이 없습니다');return}
document.getElementById('stt-err').style.display='none';
document.getElementById('stt-btn').disabled=true;
document.getElementById('stt-prog').style.display='block';
setProg('stt',0,`${pending.length}개 파일 업로드 중...`);
const fd=new FormData();
pending.forEach(item=>fd.append('files',item.file));
fd.append('use_ollama',sttEngine==='whisper+ollama'?'true':'false');
fd.append('ollama_model',document.getElementById('stt-ollama-model')?.value||'');
fd.append('use_openrouter',sttEngine==='whisper+openrouter'?'true':'false');
fd.append('openrouter_model',document.getElementById('stt-or-model')?.value||'');
fd.append('stt_engine',appSettings.default_stt_engine||'local');
try{
const url=pending.length===1?'/api/transcribe':'/api/transcribe/batch';
const r=await api('POST',url,fd); const d=await r.json();
if(!r.ok)throw new Error(d.detail||'업로드 실패');
const items=pending.length===1?[d]:(d.items||[]);
let pi=0;
sttQueue.forEach((qItem,qi)=>{
if(qItem.status!=='waiting')return;
const ti=items[pi++]; if(!ti)return;
if(ti.error){qItem.status='failed';return}
qItem.status='running'; qItem.taskId=ti.task_id; renderSttQueue();
addActiveTask(ti.task_id,{type:'STT',filename:qItem.file.name,startedAt:Date.now()});
renderActiveTasksBanner();
const t=setInterval(async()=>{
try{
const r2=await api('GET','/api/status/'+ti.task_id); if(r2.status===401){clearInterval(t);showLogin();return}
const d2=await r2.json();
const bp=document.getElementById('sbi-'+qi); if(bp){bp.style.display='block';bp.style.width=(d2.progress||0)+'%';}
if(d2.state==='success'){
clearInterval(t); removeActiveTask(ti.task_id); renderActiveTasksBanner();
qItem.outputFile=d2.output_file||null; qItem.status='done'; renderSttQueue();
if(sttQueue.filter(i=>i.status==='done').length===1&&!sttQueue.some(i=>i.status==='running')) showSttResult(d2);
checkSttDone();
} else if(['failure','cancelled'].includes(d2.state)){
clearInterval(t); removeActiveTask(ti.task_id); renderActiveTasksBanner();
qItem.status=d2.state==='cancelled'?'cancelled':'failed'; renderSttQueue(); checkSttDone();
} else {
const done=sttQueue.filter(i=>i.status==='done').length;
setProg('stt',20+Math.round((done/sttQueue.length)*75),d2.message||'처리 중...');
}
}catch{}
},2000);
});
}catch(e){showErr('stt-err',e.message);document.getElementById('stt-btn').disabled=false;document.getElementById('stt-prog').style.display='none'}
});
function checkSttDone(){
if(sttQueue.every(i=>['done','failed','waiting','cancelled'].includes(i.status))){
const done=sttQueue.filter(i=>i.status==='done').length;
setProg('stt',100,`완료 ${done}/${sttQueue.length}`);
setTimeout(()=>document.getElementById('stt-prog').style.display='none',2500);
document.getElementById('stt-btn').disabled=false;
}
}
function setSttLoading(on){document.getElementById('stt-btn').disabled=on;document.getElementById('stt-prog').style.display=on?'block':'none';if(on)setProg('stt',0,'준비 중...')}
function showSttResult(d){
sttOutputFile=d.output_file;
document.getElementById('stt-mlang').textContent=(d.language||'').toUpperCase();
document.getElementById('stt-mdur').textContent=fmtDur(d.duration);
document.getElementById('stt-msegs').textContent=(d.segments||[]).length+'개';
const chip=document.getElementById('stt-mollama-chip');if(d.ollama_used){chip.style.display='block';document.getElementById('stt-mollama').textContent=d.ollama_model}else chip.style.display='none';
document.getElementById('stt-meta').style.display='flex';document.getElementById('stt-tabs').style.display='flex';
document.getElementById('stt-empty').style.display='none';document.getElementById('stt-result').style.display='block';document.getElementById('stt-result').value=d.text||'';
const sl=document.getElementById('stt-seglist');sl.innerHTML='';
(d.segments||[]).forEach(s=>{const row=document.createElement('div');row.className='seg-item';row.innerHTML=`<div class="seg-time">${fmtTime(s.start)}<br>→${fmtTime(s.end)}</div><div class="seg-text">${esc(s.text)}</div>`;sl.appendChild(row)});
document.getElementById('stt-actions').style.display='flex';setSttLoading(false);
}
document.getElementById('stt-copy').addEventListener('click',()=>copyText(document.getElementById('stt-result').value,document.getElementById('stt-copy')));
document.getElementById('stt-dl').addEventListener('click',()=>dlFile(sttOutputFile));
document.getElementById('stt-new').addEventListener('click',()=>{sttQueue=[];sttInput.value='';sttOutputFile=null;renderSttQueue();['stt-prog','stt-err','stt-meta','stt-tabs','stt-actions'].forEach(id=>document.getElementById(id).style.display='none');document.getElementById('stt-empty').style.display='flex';document.getElementById('stt-result').style.display='none';document.getElementById('stt-result').value='';document.getElementById('stt-seglist').innerHTML='';document.getElementById('stt-btn').disabled=true;resetTabs('stt-tabs')});
// ══ OCR ══
const ocrDrop=document.getElementById('ocr-drop'),ocrInput=document.getElementById('ocr-input');
let ocrQueue=[];
const IMG_EXTS=['jpg','jpeg','png','bmp','tiff','tif','webp','gif'];
function addOcrFiles(fl){
const files=Array.from(fl).filter(f=>IMG_EXTS.includes(f.name.split('.').pop().toLowerCase()));
if(!files.length)return;
files.forEach(f=>ocrQueue.push({file:f,taskId:null,txtFile:null,xlsxFile:null,status:'waiting'}));
renderOcrQueue(); document.getElementById('ocr-btn').disabled=false;
}
ocrInput.addEventListener('change',()=>addOcrFiles(ocrInput.files));
ocrDrop.addEventListener('dragover',e=>{e.preventDefault();ocrDrop.classList.add('dragover')});
ocrDrop.addEventListener('dragleave',()=>ocrDrop.classList.remove('dragover'));
ocrDrop.addEventListener('drop',e=>{e.preventDefault();ocrDrop.classList.remove('dragover');addOcrFiles(e.dataTransfer.files)});
function renderOcrQueue(){
const qEl=document.getElementById('ocr-queue'),list=document.getElementById('ocr-queue-list'),sum=document.getElementById('ocr-queue-summary');
if(!ocrQueue.length){if(qEl)qEl.style.display='none';return}
if(qEl)qEl.style.display='block'; list.innerHTML='';
ocrQueue.forEach((item,i)=>{
const div=document.createElement('div');div.className='batch-item '+item.status;
div.innerHTML=`<div><div class="bi-name">${esc(item.file.name)}</div></div>
<span class="bi-status ${item.status}">${{waiting:'대기',running:'인식중',done:'완료',failed:'실패'}[item.status]||item.status}</span>
<span style="display:flex;gap:3px">
${item.status==='done'&&item.txtFile?`<button class="bi-dl" onclick="dlFile('${esc(item.txtFile)}')">TXT</button>`:''}
${item.status==='done'&&item.xlsxFile?`<button class="bi-dl" onclick="dlFile('${esc(item.xlsxFile)}')">XLS</button>`:''}
</span>`;
list.appendChild(div);
});
const done=ocrQueue.filter(i=>i.status==='done').length,failed=ocrQueue.filter(i=>i.status==='failed').length;
if(sum)sum.innerHTML=`전체 <span>${ocrQueue.length}</span> · 완료 <span>${done}</span> · 실패 <span>${failed}</span>`;
}
document.getElementById('ocr-queue-clear')?.addEventListener('click',()=>{ocrQueue=[];renderOcrQueue();document.getElementById('ocr-btn').disabled=true});
document.querySelectorAll('#page-ocr .engine-btn').forEach(btn=>{
btn.addEventListener('click',()=>{
document.querySelectorAll('#page-ocr .engine-btn').forEach(b=>b.classList.remove('active'));
btn.classList.add('active'); ocrEngine=btn.dataset.engine;
document.getElementById('ocr-ollama-opts')?.classList.toggle('visible',ocrEngine==='ollama');
document.getElementById('ocr-or-opts')?.classList.toggle('visible',ocrEngine==='openrouter');
document.getElementById('ocr-btn').className='btn-start '+(ocrEngine==='paddle'?'green':'purple');
});
});
document.getElementById('cprompt-toggle')?.addEventListener('click',()=>{const ta=document.getElementById('custom-prompt');const open=ta.style.display!=='block';ta.style.display=open?'block':'none';document.getElementById('cprompt-toggle').textContent=(open?'▼':'▶')+' 커스텀 프롬프트'});
document.querySelectorAll('.mode-btn').forEach(btn=>{btn.addEventListener('click',()=>{document.querySelectorAll('.mode-btn').forEach(b=>b.classList.remove('active'));btn.classList.add('active');ocrMode=btn.dataset.mode;document.getElementById('mode-desc').textContent=ocrMode==='structure'?'표 구조를 감지하고 Excel로 저장합니다':'일반 텍스트와 글자를 인식합니다'})});
document.getElementById('ocr-btn').addEventListener('click',async()=>{
const pending=ocrQueue.filter(i=>i.status==='waiting');
if(!pending.length){showErr('ocr-err','인식할 파일이 없습니다');return}
document.getElementById('ocr-err').style.display='none';
document.getElementById('ocr-btn').disabled=true;
document.getElementById('ocr-prog').style.display='block';
setProg('ocr',0,`${pending.length}개 업로드 중...`);
const fd=new FormData();
pending.forEach(item=>fd.append('files',item.file));
fd.append('mode',ocrMode); fd.append('backend',ocrEngine);
fd.append('ollama_model',ocrEngine==='ollama'?(document.getElementById('ocr-ollama-model')?.value||''):'');
fd.append('openrouter_model',ocrEngine==='openrouter'?(document.getElementById('ocr-or-model')?.value||''):'');
const cp=ocrEngine==='openrouter'?(document.getElementById('custom-prompt-or')?.value||''):(document.getElementById('custom-prompt')?.value||'');
fd.append('custom_prompt',cp);
try{
const url=pending.length===1?'/api/ocr':'/api/ocr/batch';
const r=await api('POST',url,fd); const d=await r.json();
if(!r.ok)throw new Error(d.detail||'업로드 실패');
const items=pending.length===1?[d]:(d.items||[]);
let pi=0;
ocrQueue.forEach((qItem,qi)=>{
if(qItem.status!=='waiting')return;
const ti=items[pi++]; if(!ti)return;
if(ti.error){qItem.status='failed';return}
qItem.status='running'; qItem.taskId=ti.task_id; renderOcrQueue();
const t=setInterval(async()=>{
try{
const r2=await api('GET','/api/status/'+ti.task_id); if(r2.status===401){clearInterval(t);showLogin();return}
const d2=await r2.json();
if(d2.state==='success'){
clearInterval(t); qItem.txtFile=d2.txt_file||null; qItem.xlsxFile=d2.xlsx_file||null; qItem.status='done'; renderOcrQueue();
if(ocrQueue.filter(i=>i.status==='done').length===1&&!ocrQueue.some(i=>i.status==='running')) showOcrResult(d2);
if(ocrQueue.every(i=>['done','failed','waiting'].includes(i.status))){const done=ocrQueue.filter(i=>i.status==='done').length;setProg('ocr',100,`완료 ${done}/${ocrQueue.length}`);setTimeout(()=>document.getElementById('ocr-prog').style.display='none',2500);document.getElementById('ocr-btn').disabled=false;}
} else if(d2.state==='failure'){clearInterval(t);qItem.status='failed';renderOcrQueue();}
else{const done=ocrQueue.filter(i=>i.status==='done').length;setProg('ocr',20+Math.round((done/ocrQueue.length)*75),d2.message||'처리 중...');}
}catch{}
},2000);
});
}catch(e){showErr('ocr-err',e.message);document.getElementById('ocr-btn').disabled=false;document.getElementById('ocr-prog').style.display='none'}
});
function setOcrLoading(on){const isAI=ocrEngine!=='paddle',c=isAI?'var(--purple)':'var(--accent)';document.getElementById('ocr-btn').disabled=on;document.getElementById('ocr-prog').style.display=on?'block':'none';document.getElementById('ocr-wave').style.display=on?'flex':'none';document.getElementById('ocr-pfill').style.background=c;document.getElementById('ocr-ppct').style.color=c;document.querySelectorAll('#ocr-wave .wave-bar').forEach(b=>b.style.background=c);if(on)setProg('ocr',0,'준비 중...')}
function showOcrResult(d){
ocrOutputTxt=d.txt_file||null;ocrOutputXlsx=d.xlsx_file||null;const io=d.backend!=='paddle';
document.getElementById('ocr-mlines').textContent=(d.line_count||0)+'줄';document.getElementById('ocr-mmode').textContent=d.mode==='structure'?'구조분석':'텍스트';document.getElementById('ocr-mbackend').textContent=d.backend==='openrouter'?'OpenRouter':d.backend==='ollama'?'Ollama':'Paddle';document.getElementById('ocr-mtables').textContent=(d.tables||[]).length+'개';
document.getElementById('ocr-meta').style.display='flex';document.getElementById('ocr-tabs').style.display='flex';document.getElementById('ocr-empty').style.display='none';document.getElementById('ocr-result').style.display='block';document.getElementById('ocr-result').value=d.full_text||'';
const ll=document.getElementById('ocr-linelist');ll.innerHTML='';(d.lines||[]).forEach(line=>{const c=line.confidence||0,cls=c>=.9?'high':c>=.7?'mid':'low';const row=document.createElement('div');row.className='line-item';row.innerHTML=`<div class="line-conf ${cls}">${io?'AI':Math.round(c*100)+'%'}</div><div class="line-text">${esc(line.text)}</div>`;ll.appendChild(row)});
const tl=document.getElementById('ocr-tablelist'),te=document.getElementById('ocr-tableempty');tl.innerHTML='';const tables=d.tables||[];te.style.display=tables.length?'none':'flex';tables.forEach((t,i)=>{const w=document.createElement('div');w.innerHTML=`<div class="table-title">표 ${i+1}${t.rows||0}× ${t.cols||0}열</div><div class="table-wrapper">${(t.html||'').replace(/<table/g,'<table class="ocr-table"')}</div>`;tl.appendChild(w)});
document.getElementById('ocr-actions').style.display='flex';document.getElementById('ocr-dl-xlsx').style.display=ocrOutputXlsx?'inline-flex':'none';setOcrLoading(false);document.getElementById('ocr-prog').style.display='none';document.getElementById('ocr-wave').style.display='none';
}
document.getElementById('ocr-copy').addEventListener('click',()=>copyText(document.getElementById('ocr-result').value,document.getElementById('ocr-copy')));
document.getElementById('ocr-dl-txt').addEventListener('click',()=>dlFile(ocrOutputTxt));
document.getElementById('ocr-dl-xlsx').addEventListener('click',()=>dlFile(ocrOutputXlsx));
document.getElementById('ocr-new').addEventListener('click',()=>{ocrQueue=[];ocrInput.value='';ocrOutputTxt=null;ocrOutputXlsx=null;renderOcrQueue();['ocr-prog','ocr-err','ocr-meta','ocr-tabs','ocr-actions'].forEach(id=>document.getElementById(id).style.display='none');document.getElementById('ocr-empty').style.display='flex';document.getElementById('ocr-result').style.display='none';document.getElementById('ocr-result').value='';document.getElementById('ocr-linelist').innerHTML='';document.getElementById('ocr-tablelist').innerHTML='';document.getElementById('ocr-btn').disabled=true;resetTabs('ocr-tabs')});
// ══ HISTORY ══
document.querySelectorAll('.hist-filter-btn').forEach(btn=>{btn.addEventListener('click',()=>{document.querySelectorAll('.hist-filter-btn').forEach(b=>b.classList.remove('active'));btn.classList.add('active');histType=btn.dataset.type;histPage=1;loadHistory()})});
document.getElementById('btn-hist-refresh').addEventListener('click',()=>loadHistory());
document.getElementById('btn-hist-clear').addEventListener('click',async()=>{if(!confirm('전체 이력을 삭제하시겠습니까?'))return;await api('DELETE','/api/history');loadHistory()});
async function loadHistory(){
const list=document.getElementById('history-list');list.innerHTML='<div class="hist-empty">불러오는 중...</div>';
try{const r=await api('GET',`/api/history?page=${histPage}&per_page=${HIST_PER}&type_=${histType}`);const d=await r.json();histTotal=d.total||0;renderHistoryList(d.items||[]);renderPagination()}
catch{list.innerHTML='<div class="hist-empty">이력을 불러올 수 없습니다</div>'}
}
function renderHistoryList(items){
const list=document.getElementById('history-list');
if(!items.length){list.innerHTML='<div class="hist-empty">📋 이력이 없습니다</div>';return}
list.innerHTML='';
items.forEach(h=>{
const card=document.createElement('div');card.className='hist-card';
const inp=h.input||{},set=h.settings||{},out=h.output||{};
const isStt=h.type==='stt',isSub=h.type==='subtitle',isOcr=h.type==='ocr';
const typeBadge={stt:'🎙 STT',ocr:'🔍 OCR',subtitle:'🎬 자막'}[h.type]||h.type;
const statusLabel={success:'완료',processing:'처리중',failed:'실패',cancelled:'취소'}[h.status]||h.status;
const ENG={local:'faster-whisper',groq:'Groq',openai:'OpenAI'};
let settingsHtml='';
if(isStt){
settingsHtml=`<div class="hist-kv-row"><span class="hist-k">STT 엔진</span><span class="hist-v">${ENG[set.stt_engine||'local']}</span></div>
<div class="hist-kv-row"><span class="hist-k">모델</span><span class="hist-v">${esc(set.model||'—')}</span></div>
<div class="hist-kv-row"><span class="hist-k">언어</span><span class="hist-v">${esc(set.language||'auto')}</span></div>
${set.use_ollama?`<div class="hist-kv-row"><span class="hist-k">LLM 교정</span><span class="hist-v">${esc(set.ollama_model||'기본')}</span></div>`:''}
${set.use_openrouter?`<div class="hist-kv-row"><span class="hist-k">OR 교정</span><span class="hist-v">${esc(set.openrouter_model||'기본')}</span></div>`:''}`;
} else if(isSub){
settingsHtml=`<div class="hist-kv-row"><span class="hist-k">STT 엔진</span><span class="hist-v">${ENG[set.stt_engine||'local']}</span></div>
<div class="hist-kv-row"><span class="hist-k">원어</span><span class="hist-v">${esc(set.src_language||'자동')}</span></div>
<div class="hist-kv-row"><span class="hist-k">포맷</span><span class="hist-v">${(set.subtitle_fmt||'srt').toUpperCase()}</span></div>
${set.refine_model?`<div class="hist-kv-row"><span class="hist-k">교정</span><span class="hist-v">${esc(set.refine_model)}</span></div>`:''}
${set.translate_to?`<div class="hist-kv-row"><span class="hist-k">번역</span><span class="hist-v">${esc(set.translate_to)} / ${esc(set.trans_model||'기본')}</span></div>`:''}`;
} else {
settingsHtml=`<div class="hist-kv-row"><span class="hist-k">엔진</span><span class="hist-v">${esc(set.backend||'—')}</span></div>
<div class="hist-kv-row"><span class="hist-k">모드</span><span class="hist-v">${esc(set.mode||'—')}</span></div>
${set.ollama_model?`<div class="hist-kv-row"><span class="hist-k">모델</span><span class="hist-v">${esc(set.ollama_model)}</span></div>`:''}
${set.openrouter_model?`<div class="hist-kv-row"><span class="hist-k">OR 모델</span><span class="hist-v">${esc(set.openrouter_model)}</span></div>`:''}`;
}
let resultHtml='';
if(h.status==='failed'||h.status==='cancelled'){
resultHtml=`<div class="hist-kv-row"><span class="hist-k" style="color:var(--warn)">${h.status==='cancelled'?'취소':'오류'}</span><span class="hist-v" style="color:var(--warn)">${esc(out.error||'—')}</span></div>`;
} else if(h.status==='processing'){
resultHtml=`<div class="hist-kv-row"><span class="hist-k">상태</span><span class="hist-v" style="color:var(--orange)">처리 중...</span></div>`;
} else if(h.status==='success'){
if(isStt) resultHtml=`<div class="hist-kv-row"><span class="hist-k">언어</span><span class="hist-v">${esc(out.language||'—')}</span></div><div class="hist-kv-row"><span class="hist-k">재생시간</span><span class="hist-v">${fmtDur(out.duration_s)}</span></div><div class="hist-kv-row"><span class="hist-k">세그먼트</span><span class="hist-v">${out.segments||0}개</span></div>`;
else if(isSub) resultHtml=`<div class="hist-kv-row"><span class="hist-k">감지 언어</span><span class="hist-v">${esc(out.detected_language||'—')}</span></div><div class="hist-kv-row"><span class="hist-k">재생시간</span><span class="hist-v">${fmtDur(out.duration_s)}</span></div><div class="hist-kv-row"><span class="hist-k">자막 수</span><span class="hist-v">${out.segment_count||0}개</span></div>${out.translated?`<div class="hist-kv-row"><span class="hist-k">번역</span><span class="hist-v">${esc(out.translate_to||'—')}</span></div>`:''}`;
else resultHtml=`<div class="hist-kv-row"><span class="hist-k">줄 수</span><span class="hist-v">${out.line_count||0}줄</span></div><div class="hist-kv-row"><span class="hist-k">표</span><span class="hist-v">${out.table_count||0}개</span></div>`;
}
const previewHtml=h.status==='success'&&out.text_preview?`<div><div class="hist-section-title">📄 미리보기</div><div class="hist-preview-text">${esc(out.text_preview)}</div></div>`:'';
let dlHtml='';
if(h.status==='success'){
const btns=[];
if(isStt&&out.filename) btns.push(`<button class="hist-btn" onclick="dlFile('${esc(out.filename)}')">📥 TXT</button>`);
if(isSub){
if(out.srt_orig) btns.push(`<button class="hist-btn" onclick="dlFile('${esc(out.srt_orig)}')">📄 원어 SRT</button>`);
if(out.vtt_orig) btns.push(`<button class="hist-btn" onclick="dlFile('${esc(out.vtt_orig)}')">📄 원어 VTT</button>`);
if(out.srt_trans) btns.push(`<button class="hist-btn blue" onclick="dlFile('${esc(out.srt_trans)}')">🌐 번역 SRT</button>`);
if(out.vtt_trans) btns.push(`<button class="hist-btn blue" onclick="dlFile('${esc(out.vtt_trans)}')">🌐 번역 VTT</button>`);
}
if(isOcr){
if(out.txt_file) btns.push(`<button class="hist-btn" onclick="dlFile('${esc(out.txt_file)}')">📥 TXT</button>`);
if(out.xlsx_file) btns.push(`<button class="hist-btn blue" onclick="dlFile('${esc(out.xlsx_file)}')">📊 Excel</button>`);
}
if(btns.length) dlHtml=`<div class="hist-actions">${btns.join('')}</div>`;
}
card.innerHTML=`
<div class="hist-card-head" onclick="toggleHistDetail(this)">
<span class="hist-type-badge ${h.type}">${typeBadge}</span>
<span class="hist-status-badge ${h.status}">${statusLabel}</span>
<span class="hist-filename">${esc(inp.filename||'—')}</span>
${h.username&&currentUser?.role==='admin'?`<span class="hist-user">${esc(h.username)}</span>`:''}
<span class="hist-time">${esc((h.timestamp||'').replace(' ','<br>'))}</span>
<button class="hist-del" onclick="event.stopPropagation();deleteHist('${h.id}',this.closest('.hist-card'))">✕</button>
<span class="hist-expand">▼</span>
</div>
<div class="hist-detail" id="hd-${h.id}">
<div><div class="hist-section-title">📥 입력</div><div class="hist-kv">
<div class="hist-kv-row"><span class="hist-k">파일명</span><span class="hist-v">${esc(inp.filename||'—')}</span></div>
<div class="hist-kv-row"><span class="hist-k">크기</span><span class="hist-v">${fmtBytes(inp.size_bytes||0)}</span></div>
<div class="hist-kv-row"><span class="hist-k">형식</span><span class="hist-v">${esc(inp.format||'—')}</span></div>
</div></div>
<div><div class="hist-section-title">⚙️ 설정</div><div class="hist-kv">${settingsHtml}</div></div>
<div><div class="hist-section-title">📤 결과</div><div class="hist-kv">${resultHtml}</div></div>
${previewHtml}${dlHtml}
</div>`;
list.appendChild(card);
});
}
function toggleHistDetail(head){const id=head.parentElement.querySelector('.hist-detail').id.replace('hd-','');const det=document.getElementById('hd-'+id);const exp=head.querySelector('.hist-expand');const open=det.classList.contains('open');det.classList.toggle('open',!open);exp.classList.toggle('open',!open)}
async function deleteHist(id,card){if(!confirm('이 이력을 삭제하시겠습니까?'))return;try{const r=await api('DELETE','/api/history/'+id);if(r.ok){card.style.opacity='0';card.style.transition='opacity .3s';setTimeout(()=>{card.remove();if(!document.querySelectorAll('.hist-card').length)loadHistory()},300)}}catch{}}
function renderPagination(){
const pg=document.getElementById('pagination');pg.innerHTML='';
const tp=Math.ceil(histTotal/HIST_PER);if(tp<=1)return;
const btn=(label,page,active)=>{const b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=label;b.disabled=page<1||page>tp;b.addEventListener('click',()=>{histPage=page;loadHistory()});return b};
pg.appendChild(btn('◀',histPage-1,false));
for(let i=Math.max(1,histPage-2);i<=Math.min(tp,histPage+2);i++)pg.appendChild(btn(i,i,i===histPage));
pg.appendChild(btn('▶',histPage+1,false));
const info=document.createElement('span');info.style.cssText='font-family:var(--mono);font-size:.63rem;color:var(--muted);padding:0 8px';info.textContent=`${histTotal}`;pg.appendChild(info);
}
// ══ 자막 ══
const subDrop=document.getElementById('sub-drop'),subInput=document.getElementById('sub-input');
let subFile=null, subTaskId=null, subFmt='srt', subTransVia='ollama', subRefineVia='ollama', subSttEng='local';
subInput.addEventListener('change',()=>setSubFile(subInput.files[0]));
subDrop.addEventListener('dragover',e=>{e.preventDefault();subDrop.classList.add('dragover')});
subDrop.addEventListener('dragleave',()=>subDrop.classList.remove('dragover'));
subDrop.addEventListener('drop',e=>{e.preventDefault();subDrop.classList.remove('dragover');setSubFile(e.dataTransfer.files[0])});
function setSubFile(f){
if(!f)return; subFile=f;
document.getElementById('sub-info').style.display='block';
document.getElementById('sub-fname').textContent=f.name;
document.getElementById('sub-fsize').textContent=fmtBytes(f.size);
document.getElementById('sub-btn').disabled=false;
document.getElementById('sub-err').style.display='none';
}
// 포맷 버튼
document.querySelectorAll('#page-subtitle .fmt-btn[data-fmt]').forEach(btn=>{
btn.addEventListener('click',()=>{document.querySelectorAll('#page-subtitle .fmt-btn[data-fmt]').forEach(b=>b.classList.remove('active'));btn.classList.add('active');subFmt=btn.dataset.fmt;});
});
// STT 엔진 버튼
document.querySelectorAll('button[data-stt-eng]').forEach(btn=>{
btn.addEventListener('click',()=>{document.querySelectorAll('button[data-stt-eng]').forEach(b=>b.classList.remove('active'));btn.classList.add('active');subSttEng=btn.dataset.sttEng;});
});
// 교정 엔진 버튼
document.querySelectorAll('button[data-refine-via]').forEach(btn=>{
btn.addEventListener('click',()=>{document.querySelectorAll('button[data-refine-via]').forEach(b=>b.classList.remove('active'));btn.classList.add('active');subRefineVia=btn.dataset.refineVia;fillSubModels();});
});
// 번역 엔진 버튼
document.querySelectorAll('button[data-trans-via]').forEach(btn=>{
btn.addEventListener('click',()=>{document.querySelectorAll('button[data-trans-via]').forEach(b=>b.classList.remove('active'));btn.classList.add('active');subTransVia=btn.dataset.transVia;fillSubModels();});
});
document.getElementById('sub-refine-enable')?.addEventListener('change',function(){
document.getElementById('sub-refine-opts').style.display=this.checked?'block':'none';
if(this.checked)fillSubModels();
});
document.getElementById('sub-trans-lang')?.addEventListener('change',function(){
document.getElementById('sub-trans-engine-wrap').style.display=this.value?'block':'none';
if(this.value)fillSubModels();
});
function fillSubModels(){
const refSel=document.getElementById('sub-refine-model');
const transSel=document.getElementById('sub-trans-model');
const refList=subRefineVia==='openrouter'?orModels:ollamaModels;
const transList=subTransVia==='openrouter'?orModels:ollamaModels;
const _fill=(sel,list,def)=>{if(!sel)return;const cur=sel.value||def||'';sel.innerHTML='<option value="">설정 기본 사용</option>';list.forEach(m=>{const o=document.createElement('option');o.value=m;o.textContent=m;if(m===cur)o.selected=true;sel.appendChild(o);});};
_fill(refSel,refList,appSettings.stt_ollama_model);
_fill(transSel,transList,appSettings.stt_ollama_model);
}
function setSubStep(step,status){
const icon=document.getElementById('sub-sicon-'+step);if(!icon)return;
icon.className='sub-step-icon '+status;
icon.textContent={waiting:'⏳',running:'⚡',done:'✓',failed:'✗'}[status]||'⏳';
const dot=document.getElementById('sdot-'+step);if(dot)dot.className='step-dot '+(status==='done'?'done':status==='running'?'active':'');
if(step>1){const ln=document.getElementById('sline-'+(step-1));if(ln)ln.className='step-line '+(status!=='waiting'?'done':'');}
const lbl=document.getElementById('slabel-'+step);if(lbl)lbl.className='step-label '+(status==='done'?'done':status==='running'?'active':'');
if(status!=='waiting'){const ln=document.getElementById('sline-'+step);if(ln)ln.className='step-line '+(status==='done'?'done':'');}
}
document.getElementById('sub-btn').addEventListener('click',async()=>{
if(!subFile)return;
const transLang=document.getElementById('sub-trans-lang').value;
const useRefine=document.getElementById('sub-refine-enable').checked;
const fd=new FormData();
fd.append('file',subFile);
fd.append('src_language',document.getElementById('sub-src-lang').value||'');
fd.append('subtitle_fmt',subFmt);
fd.append('stt_engine',subSttEng);
fd.append('refine_model',useRefine?(document.getElementById('sub-refine-model')?.value||''):'');
fd.append('refine_via',subRefineVia);
fd.append('translate_to',transLang);
fd.append('trans_model',transLang?(document.getElementById('sub-trans-model')?.value||''):'');
fd.append('trans_via',subTransVia);
document.getElementById('sub-btn').disabled=true;
document.getElementById('sub-cancel-btn').style.display='block';
document.getElementById('sub-err').style.display='none';
document.getElementById('sub-prog-box').style.display='block';
document.getElementById('sub-result-card').style.display='none';
document.getElementById('sub-prog-bar').style.width='0%';
[1,2,3].forEach(s=>setSubStep(s,'waiting')); setSubStep(1,'running');
try{
const r=await api('POST','/api/subtitle',fd); const d=await r.json();
if(!r.ok)throw new Error(d.detail||'업로드 실패');
subTaskId=d.task_id;
addActiveTask(d.task_id,{type:'자막',filename:subFile.name,startedAt:Date.now()});
renderActiveTasksBanner();
pollSubtitle(d.task_id,!!transLang);
}catch(e){
showErr('sub-err',e.message);
document.getElementById('sub-btn').disabled=false;
document.getElementById('sub-cancel-btn').style.display='none';
document.getElementById('sub-prog-box').style.display='none';
}
});
document.getElementById('sub-cancel-btn')?.addEventListener('click',async()=>{
if(!subTaskId||!confirm('자막 생성을 취소하시겠습니까?'))return;
try{
await api('POST','/api/cancel/'+subTaskId);
removeActiveTask(subTaskId); renderActiveTasksBanner();
document.getElementById('sub-cancel-btn').style.display='none';
document.getElementById('sub-btn').disabled=false;
showErr('sub-err','작업이 취소되었습니다');
document.getElementById('sub-prog-box').style.display='none';
[1,2,3].forEach(s=>setSubStep(s,'waiting'));
subTaskId=null;
}catch{}
});
const LANG_NAMES_MAP={ko:'한국어',en:'English',ja:'日本語',zh:'中文(简体)','zh-tw':'中文(繁體)',fr:'Français',de:'Deutsch',es:'Español',it:'Italiano',pt:'Português',ru:'Русский',ar:'العربية',vi:'Tiếng Việt',th:'ไทย',id:'Bahasa Indonesia',nl:'Nederlands',pl:'Polski',tr:'Türkçe',sv:'Svenska',hi:'हिन्दी'};
function langName(code){return LANG_NAMES_MAP[code]||code||'알 수 없음'}
function pollSubtitle(taskId,hasTranslation){
let prevStep=0;
const t=setInterval(async()=>{
try{
const r=await api('GET','/api/status/'+taskId);if(r.status===401){clearInterval(t);showLogin();return}
const d=await r.json();
if(['progress','success'].includes(d.state)){
const step=d.step||1; const prog=d.progress||0;
document.getElementById('sub-prog-bar').style.width=prog+'%';
if(step!==prevStep){
if(prevStep>0&&prevStep<step)setSubStep(prevStep,'done');
if(step<=3)setSubStep(step,'running');
if(!hasTranslation&&step===2){document.getElementById('sub-smsg-3').textContent='번역 미선택 — 건너뜀';}
prevStep=step;
}
if(d.step_msg){ const el=document.getElementById('sub-smsg-'+step); if(el)el.textContent=d.step_msg; }
}
if(d.state==='success'){
clearInterval(t); removeActiveTask(taskId); renderActiveTasksBanner();
[1,2,3].forEach(s=>setSubStep(s,'done'));
document.getElementById('sub-prog-bar').style.width='100%';
document.getElementById('sub-cancel-btn').style.display='none';
setTimeout(()=>showSubResult(d),400);
} else if(['failure','cancelled'].includes(d.state)){
clearInterval(t); removeActiveTask(taskId); renderActiveTasksBanner();
if(prevStep>0)setSubStep(prevStep,'failed');
document.getElementById('sub-cancel-btn').style.display='none';
document.getElementById('sub-btn').disabled=false;
showErr('sub-err',d.message||(d.state==='cancelled'?'취소됨':'자막 생성 실패'));
}
}catch{}
},2000);
}
function showSubResult(d){
document.getElementById('sub-prog-box').style.display='none';
const rc=document.getElementById('sub-result-card');rc.style.display='block';
document.getElementById('sub-res-lang').textContent=langName(d.detected_language);
document.getElementById('sub-res-dur').textContent=fmtDur(d.duration);
document.getElementById('sub-res-segs').textContent=(d.segment_count||0)+'개';
document.getElementById('sub-res-trans').textContent=d.translated?langName(d.translate_to):'없음';
const grid=document.getElementById('sub-dl-grid');grid.innerHTML='';
const addBtn=(label,lang,file,cls='')=>{
if(!file)return;
const ext=file.split('.').pop().toUpperCase();
const btn=document.createElement('button');btn.className='sub-dl-btn '+cls;
btn.innerHTML=`<span style="font-size:1.1rem">📄</span><span style="font-weight:600">${ext} ${label}</span><span style="font-size:.58rem;color:var(--muted)">${langName(lang)}</span>`;
btn.onclick=()=>dlFile(file);grid.appendChild(btn);
};
addBtn('원어',d.detected_language,d.srt_orig);
addBtn('원어',d.detected_language,d.vtt_orig);
addBtn('번역',d.translate_to,d.srt_trans,'trans');
addBtn('번역',d.translate_to,d.vtt_trans,'trans');
document.getElementById('sub-btn').disabled=false;
}
document.getElementById('sub-new')?.addEventListener('click',()=>{
subFile=null;subInput.value='';subTaskId=null;
document.getElementById('sub-info').style.display='none';
document.getElementById('sub-prog-box').style.display='none';
document.getElementById('sub-result-card').style.display='none';
document.getElementById('sub-err').style.display='none';
document.getElementById('sub-cancel-btn').style.display='none';
document.getElementById('sub-btn').disabled=true;
document.getElementById('sub-prog-bar').style.width='0%';
document.getElementById('sub-refine-enable').checked=false;
document.getElementById('sub-refine-opts').style.display='none';
[1,2,3].forEach(s=>setSubStep(s,'waiting'));
});
// ══ ADMIN ══
// 모델 체크박스 렌더링 헬퍼
function renderModelChecks(container, models, selected=[]) {
container.innerHTML = '';
if (!models.length) {
container.innerHTML = '<span style="font-family:var(--mono);font-size:.65rem;color:var(--muted)">연결된 Ollama 모델 없음</span>';
return;
}
models.forEach(m => {
const lbl = document.createElement('label');
lbl.className = 'perm-check';
lbl.innerHTML = `<input type="checkbox" value="${esc(m)}"${selected.includes(m)?' checked':''}> <span style="font-size:.7rem">${esc(m)}</span>`;
container.appendChild(lbl);
});
}
// 체크된 모델 목록 수집
function getCheckedModels(container) {
return Array.from(container.querySelectorAll('input[type=checkbox]:checked')).map(cb => cb.value);
}
// STT 체크박스 토글 시 모델 섹션 표시
document.getElementById('new-perm-stt').addEventListener('change', function() {
const wrap = document.getElementById('new-stt-models-wrap');
wrap.style.display = this.checked ? 'block' : 'none';
if (this.checked) renderModelChecks(document.getElementById('new-stt-model-checks'), whisperModels, []);
});
document.getElementById('new-perm-ocr').addEventListener('change', function() {
const wrap = document.getElementById('new-ocr-models-wrap');
wrap.style.display = this.checked ? 'block' : 'none';
if (this.checked) renderModelChecks(document.getElementById('new-ocr-model-checks'), ollamaModels, []);
});
// Whisper 모델 목록 (하드코딩 + 환경 설정 기반)
const whisperModels = ['tiny', 'base', 'small', 'medium', 'large-v2', 'large-v3'];
async function loadUsers() {
const tbody = document.getElementById('user-tbody'); tbody.innerHTML = '';
try {
const r = await api('GET', '/api/admin/users'); const d = await r.json();
Object.entries(d.users || {}).forEach(([name, info]) => {
const tr = document.createElement('tr');
const p = info.permissions || {}; const isAdmin = info.role === 'admin';
const sttModels = (p.allowed_stt_models||[]).length ? p.allowed_stt_models.join(', ') : '전체';
const ocrModels = (p.allowed_ocr_models||[]).length ? p.allowed_ocr_models.join(', ') : '전체';
tr.innerHTML = `
<td style="font-family:var(--mono);font-size:.78rem">${esc(name)}</td>
<td><span class="role-badge ${info.role}">${info.role}</span></td>
<td>
<span class="perm-badge ${p.stt?'on':'off'}">${p.stt?'허용':'차단'}</span>
${p.stt?`<span style="font-family:var(--mono);font-size:.58rem;color:var(--muted)">${sttModels}</span>`:''}
</td>
<td>
<span class="perm-badge ${p.ocr?'on':'off'}">${p.ocr?'허용':'차단'}</span>
${p.ocr?`<span style="font-family:var(--mono);font-size:.58rem;color:var(--muted)">${ocrModels}</span>`:''}
</td>
<td>${isAdmin
? '<span style="font-family:var(--mono);font-size:.6rem;color:var(--muted)">기본</span>'
: `<button class="btn-sm" onclick="openEditModal('${esc(name)}',${JSON.stringify(p)})">편집</button>
<button class="btn-sm danger" onclick="doDeleteUser('${esc(name)}')">삭제</button>`
}</td>`;
tbody.appendChild(tr);
});
} catch {}
}
document.getElementById('btn-reload-users').addEventListener('click', loadUsers);
document.getElementById('btn-add-user').addEventListener('click', async () => {
const u = document.getElementById('new-username').value.trim();
const p = document.getElementById('new-password').value;
const msg = document.getElementById('add-msg');
if (!u || !p) { showAdminMsg(msg, '아이디와 비밀번호를 입력하세요', 'err'); return; }
const fd = new FormData();
fd.append('username', u); fd.append('password', p);
fd.append('perm_stt', document.getElementById('new-perm-stt').checked ? 'true' : 'false');
fd.append('perm_ocr', document.getElementById('new-perm-ocr').checked ? 'true' : 'false');
fd.append('perm_subtitle', document.getElementById('new-perm-subtitle')?.checked ? 'true' : 'false');
fd.append('allowed_stt_models', getCheckedModels(document.getElementById('new-stt-model-checks')).join(','));
fd.append('allowed_ocr_models', getCheckedModels(document.getElementById('new-ocr-model-checks')).join(','));
try {
const r = await api('POST', '/api/admin/users', fd); const d = await r.json();
if (r.ok) {
showAdminMsg(msg, d.message, 'ok');
document.getElementById('new-username').value = '';
document.getElementById('new-password').value = '';
document.getElementById('new-perm-stt').checked = false;
document.getElementById('new-perm-ocr').checked = false;
document.getElementById('new-stt-models-wrap').style.display = 'none';
document.getElementById('new-ocr-models-wrap').style.display = 'none';
loadUsers();
} else showAdminMsg(msg, d.detail || '실패', 'err');
} catch { showAdminMsg(msg, '서버 오류', 'err'); }
});
function openEditModal(name, perms) {
editTarget = name;
document.getElementById('edit-modal-username').textContent = name;
document.getElementById('edit-perm-stt').checked = perms.stt || false;
document.getElementById('edit-perm-ocr').checked = perms.ocr || false;
document.getElementById('edit-password').value = '';
document.getElementById('edit-msg').style.display = 'none';
// STT 모델
const sttWrap = document.getElementById('edit-stt-models-wrap');
sttWrap.style.display = perms.stt ? 'block' : 'none';
renderModelChecks(document.getElementById('edit-stt-model-checks'), whisperModels, perms.allowed_stt_models || []);
// OCR 모델
const ocrWrap = document.getElementById('edit-ocr-models-wrap');
ocrWrap.style.display = perms.ocr ? 'block' : 'none';
renderModelChecks(document.getElementById('edit-ocr-model-checks'), ollamaModels, perms.allowed_ocr_models || []);
document.getElementById('edit-modal').classList.add('visible');
}
// 편집 모달 내 권한 체크박스 토글
document.getElementById('edit-perm-stt').addEventListener('change', function() {
document.getElementById('edit-stt-models-wrap').style.display = this.checked ? 'block' : 'none';
});
document.getElementById('edit-perm-ocr').addEventListener('change', function() {
document.getElementById('edit-ocr-models-wrap').style.display = this.checked ? 'block' : 'none';
});
document.getElementById('btn-modal-cancel').addEventListener('click', () => document.getElementById('edit-modal').classList.remove('visible'));
document.getElementById('edit-modal').addEventListener('click', e => { if (e.target === document.getElementById('edit-modal')) document.getElementById('edit-modal').classList.remove('visible') });
document.getElementById('btn-modal-save').addEventListener('click', async () => {
if (!editTarget) return;
const fd = new FormData();
fd.append('perm_stt', document.getElementById('edit-perm-stt').checked ? 'true' : 'false');
fd.append('perm_ocr', document.getElementById('edit-perm-ocr').checked ? 'true' : 'false');
fd.append('perm_subtitle', document.getElementById('edit-perm-subtitle')?.checked ? 'true' : 'false');
fd.append('allowed_stt_models', getCheckedModels(document.getElementById('edit-stt-model-checks')).join(','));
fd.append('allowed_ocr_models', getCheckedModels(document.getElementById('edit-ocr-model-checks')).join(','));
const pw = document.getElementById('edit-password').value;
if (pw) fd.append('password', pw);
try {
const r = await fetch(`/api/admin/users/${editTarget}`, { method: 'PUT', headers: { Authorization: 'Bearer ' + (token || '') }, body: fd });
const d = await r.json(); const msg = document.getElementById('edit-msg');
if (r.ok) { showAdminMsg(msg, d.message, 'ok'); setTimeout(() => { document.getElementById('edit-modal').classList.remove('visible'); loadUsers(); }, 800); }
else showAdminMsg(msg, d.detail || '실패', 'err');
} catch { showAdminMsg(document.getElementById('edit-msg'), '서버 오류', 'err'); }
});
async function doDeleteUser(username) {
if (!confirm(`"${username}" 사용자를 삭제하시겠습니까?`)) return;
try {
const r = await fetch(`/api/admin/users/${username}`, { method: 'DELETE', headers: { Authorization: 'Bearer ' + (token || '') } });
if (r.ok) loadUsers(); else { const d = await r.json(); alert(d.detail || '삭제 실패'); }
} catch { alert('서버 오류'); }
}
function showAdminMsg(el, msg, type) { el.style.display = 'block'; el.className = 'admin-msg ' + type; el.textContent = msg; setTimeout(() => el.style.display = 'none', 3000); }
// ══ 공통 ══
document.addEventListener('click',e=>{if(!e.target.classList.contains('tab-btn'))return;const parent=e.target.closest('.result-tabs');parent.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));e.target.classList.add('active');const panel=parent.closest('.panel');panel.querySelectorAll('.tab-content').forEach(c=>c.classList.remove('active'));const t=document.getElementById(e.target.dataset.tab);if(t)t.classList.add('active')});
function resetTabs(id){const t=document.getElementById(id);if(!t)return;t.querySelectorAll('.tab-btn').forEach((b,i)=>b.classList.toggle('active',i===0));t.closest('.panel').querySelectorAll('.tab-content').forEach((c,i)=>c.classList.toggle('active',i===0))}
function pollTask(taskId,onProgress,onSuccess,onError){const t=setInterval(async()=>{try{const r=await api('GET','/api/status/'+taskId);if(r.status===401){clearInterval(t);showLogin();return}const d=await r.json();onProgress(d);if(d.state==='success'){clearInterval(t);onSuccess(d)}else if(d.state==='failure'){clearInterval(t);onError(d.message||'실패')}}catch{}},1500)}
async function dlFile(fn){if(!fn)return;try{const r=await api('GET','/api/download/'+fn);if(!r.ok)return;const b=await r.blob();const u=URL.createObjectURL(b);const a=document.createElement('a');a.href=u;a.download=fn;a.click();URL.revokeObjectURL(u)}catch{}}
function setProg(p,pct,msg){document.getElementById(p+'-pfill').style.width=pct+'%';document.getElementById(p+'-pmsg').textContent=msg;document.getElementById(p+'-ppct').textContent=pct+'%'}
function showErr(id,msg){const el=document.getElementById(id);el.style.display='block';el.textContent='⚠ '+msg}
function showFileInfo(p,f){document.getElementById(p+'-info').style.display='block';document.getElementById(p+'-fname').textContent=f.name;document.getElementById(p+'-fsize').textContent=fmtBytes(f.size)}
function fmtBytes(b){if(!b)return '0 B';if(b<1024)return b+' B';if(b<1048576)return(b/1024).toFixed(1)+' KB';return(b/1048576).toFixed(1)+' MB'}
function fmtDur(s){if(!s)return '—';return Math.floor(s/60)+'분 '+Math.floor(s%60)+'초'}
function fmtTime(s){const m=Math.floor(s/60),ss=Math.floor(s%60);return String(m).padStart(2,'0')+':'+String(ss).padStart(2,'0')}
function esc(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
async function copyText(text,btn){try{await navigator.clipboard.writeText(text);const o=btn.textContent;btn.textContent='복사됨 ✓';setTimeout(()=>btn.textContent=o,1500)}catch{}}
// ══ 상태 ══
// ══ 활성 작업 추적 (페이지 닫혀도 유지) ══
const ACTIVE_TASKS_KEY='vs_active_tasks'; // {taskId: {type, filename, startedAt}}
function saveActiveTasks(tasks){ localStorage.setItem(ACTIVE_TASKS_KEY,JSON.stringify(tasks)); }
function loadActiveTasks(){ try{ return JSON.parse(localStorage.getItem(ACTIVE_TASKS_KEY)||'{}'); } catch{ return {}; } }
function addActiveTask(taskId, meta){ const t=loadActiveTasks(); t[taskId]=meta; saveActiveTasks(t); }
function removeActiveTask(taskId){ const t=loadActiveTasks(); delete t[taskId]; saveActiveTasks(t); }
// 활성 작업 배너 (헤더 아래)
function renderActiveTasksBanner(){
let banner=document.getElementById('active-tasks-banner');
if(!banner){
banner=document.createElement('div');
banner.id='active-tasks-banner';
banner.style.cssText='background:rgba(77,166,255,.08);border-bottom:1px solid rgba(77,166,255,.2);padding:8px 20px;display:none;font-family:var(--mono);font-size:.68rem;color:var(--blue)';
document.querySelector('.nav-tabs').insertAdjacentElement('beforebegin',banner);
}
const tasks=loadActiveTasks();
const ids=Object.keys(tasks);
if(!ids.length){ banner.style.display='none'; return; }
banner.style.display='block';
banner.innerHTML=`⚡ 진행 중인 작업 ${ids.length}개: `+ids.map(id=>{
const m=tasks[id];
return `<span style="margin-left:8px;padding:2px 8px;background:rgba(77,166,255,.12);border-radius:2px">
${m.type||'?'}: ${esc(m.filename||'')}
<button onclick="cancelTaskFromBanner('${id}')" style="margin-left:6px;background:rgba(255,107,53,.2);border:none;color:var(--warn);border-radius:2px;cursor:pointer;padding:1px 5px;font-family:var(--mono);font-size:.6rem">✕ 취소</button>
</span>`;
}).join('');
}
async function cancelTaskFromBanner(taskId){
if(!confirm('이 작업을 취소하시겠습니까?')) return;
try{
await api('POST','/api/cancel/'+taskId);
removeActiveTask(taskId);
renderActiveTasksBanner();
}catch{}
}
// 시작 시 활성 작업 재폴링
async function resumeActiveTasks(){
const tasks=loadActiveTasks();
const ids=Object.keys(tasks);
if(!ids.length) return;
renderActiveTasksBanner();
for(const id of ids){
pollResumed(id);
}
}
function pollResumed(taskId){
const t=setInterval(async()=>{
try{
const r=await api('GET','/api/status/'+taskId);
if(r.status===401){ clearInterval(t); return; }
const d=await r.json();
if(['success','failure','cancelled'].includes(d.state)){
clearInterval(t);
removeActiveTask(taskId);
renderActiveTasksBanner();
// 자막 탭이 열려있으면 결과 표시
if(d.state==='success' && d.srt_orig){
showSubResult(d);
}
}
}catch{}
},3000);
}
// ══ API ══
// ══ AUTH ══
async function checkAuth(){
token=localStorage.getItem('vs_token');
if(!token){showLogin();return}
try{
const r=await api('GET','/api/me');
if(r.ok){
currentUser=await r.json();
applyUserUI();
await Promise.all([loadOllamaModels(),loadSettings()]);
hideLogin();
startSysMonitor();
await resumeActiveTasks();
} else showLogin();
}catch{showLogin()}
}
function applyUserUI(){
document.getElementById('user-name').textContent=currentUser.username;
const b=document.getElementById('user-badge');
b.textContent=currentUser.role==='admin'?'ADMIN':'USER';
b.className='user-badge '+currentUser.role;
document.getElementById('admin-tab').style.display=currentUser.role==='admin'?'flex':'none';
document.getElementById('btn-hist-clear').style.display=currentUser.role==='admin'?'block':'none';
// 권한에 따라 탭 표시
const perms=currentUser.permissions||{};
document.querySelectorAll('.nav-tab').forEach(t=>{
const p=t.dataset.page;
if(p==='stt') { t.style.opacity=perms.stt?'1':'0.35'; t.style.pointerEvents=perms.stt?'':'none'; }
if(p==='ocr') { t.style.opacity=perms.ocr?'1':'0.35'; t.style.pointerEvents=perms.ocr?'':'none'; }
if(p==='subtitle'){ t.style.opacity=perms.subtitle?'1':'0.35'; t.style.pointerEvents=perms.subtitle?'':'none'; }
});
}
const showLogin=()=>{ document.getElementById('login-overlay').style.display='flex'; stopSysMonitor(); };
const hideLogin=()=>{ document.getElementById('login-overlay').style.display='none'; };
document.getElementById('btn-login').addEventListener('click',doLogin);
document.getElementById('inp-pass').addEventListener('keydown',e=>{ if(e.key==='Enter')doLogin(); });
async function doLogin(){
const u=document.getElementById('inp-user').value.trim(), p=document.getElementById('inp-pass').value;
const err=document.getElementById('login-err'); err.style.display='none';
if(!u||!p){ err.style.display='block'; err.textContent='아이디와 비밀번호를 입력하세요'; return; }
const fd=new FormData(); fd.append('username',u); fd.append('password',p);
try{
const r=await fetch('/api/login',{method:'POST',body:fd});
const d=await r.json();
if(!r.ok){ err.style.display='block'; err.textContent=d.detail||'로그인 실패'; return; }
token=d.access_token; localStorage.setItem('vs_token',token);
await checkAuth();
}catch{ err.style.display='block'; err.textContent='서버 연결 실패'; }
}
document.getElementById('btn-logout').addEventListener('click',()=>{
token=null; currentUser=null; localStorage.removeItem('vs_token');
showLogin(); document.getElementById('inp-pass').value='';
document.getElementById('ram-widget').style.display='none';
});
// ══ 시스템 모니터 ══
function startSysMonitor(){ fetchSysInfo(); sysTimer=setInterval(fetchSysInfo,6000); }
function stopSysMonitor(){ if(sysTimer){ clearInterval(sysTimer); sysTimer=null; } }
async function fetchSysInfo(){
try{
const r=await api('GET','/api/system'); if(!r.ok)return; const d=await r.json();
const p=d.ram_percent||0;
const bar=document.getElementById('ram-bar');
bar.style.width=p+'%'; bar.style.background=p>85?'var(--warn)':p>65?'#f0b42a':'var(--accent)';
document.getElementById('ram-text').textContent=`${d.ram_avail_gb}G여유`;
document.getElementById('cpu-text').textContent=`CPU ${d.cpu_percent}%`;
document.getElementById('ram-widget').style.display='flex';
updateSC('ram',d.ram_percent,`${d.ram_used_gb}GB / ${d.ram_total_gb}GB`,`여유 ${d.ram_avail_gb}GB`,'var(--accent)');
updateSC('cpu',d.cpu_percent,`${d.cpu_percent}%`,`물리 ${d.cpu_physical}코어 / 논리 ${d.cpu_logical}스레드`,'var(--blue)');
const sp=d.swap_total_gb>0?Math.round(d.swap_used_gb/d.swap_total_gb*100):0;
updateSC('swap',sp,`${d.swap_used_gb}GB / ${d.swap_total_gb}GB`,`사용률 ${sp}%`,'var(--orange)');
const th=d.cpu_threads_setting;
document.getElementById('sys-threads-val').textContent=th===0?`자동 (${d.cpu_logical}스레드)`:`${th} 스레드`;
const sl=document.getElementById('cpu-slider'); if(sl&&sl.max<d.cpu_logical)sl.max=d.cpu_logical;
// 타임아웃 표시
if(d.subtitle_timeout!==undefined){
const sel=document.getElementById('subtitle-timeout'); if(sel&&!sel.value) sel.value=d.subtitle_timeout;
}
}catch{}
}
function updateSC(id,pct,val,sub,color){
const b=document.getElementById(`sys-${id}-bar`); if(!b)return;
b.style.width=Math.min(pct||0,100)+'%'; b.style.background=color;
document.getElementById(`sys-${id}-val`).textContent=val;
document.getElementById(`sys-${id}-sub`).textContent=sub;
}
// CPU 슬라이더
const cpuSlider=document.getElementById('cpu-slider'), cpuDisplay=document.getElementById('cpu-val-display');
if(cpuSlider) cpuSlider.addEventListener('input',()=>{
const v=parseInt(cpuSlider.value);
cpuDisplay.textContent=v===0?'0 (자동)':v+' 스레드';
});
// ══ Ollama 모델 ══
async function loadOllamaModels(){
try{
const r=await api('GET','/api/ollama/models'); const d=await r.json();
ollamaModels=d.models||[];
const badge=document.getElementById('ollama-status-badge');
if(badge){ badge.className='ollama-status '+(d.connected?'ok':'fail'); badge.textContent=d.connected?`✓ Ollama(${ollamaModels.length})`:'✗ Ollama 연결실패'; }
populateModelSelects();
loadSttEngineStatus();
}catch{}
}
// ══ OpenRouter 모델 ══
async function loadOrModels(){
try{
const r=await api('GET','/api/openrouter/models'); const d=await r.json();
if(d.connected){
orModels=d.models||[]; orVisionModels=d.vision_models||[]; orTextModels=d.text_models||[];
const wrap=document.getElementById('or-models-wrap');
if(wrap){ wrap.style.display='block';
const badge=document.getElementById('or-connected-badge');
if(badge) badge.textContent=`✓ 연결됨 — Vision ${orVisionModels.length}개 / 전체 ${orModels.length}`; }
}
populateOrSelects();
}catch{}
}
let orFilter='vision';
document.querySelectorAll('.or-model-tab')?.forEach(btn=>{
btn.addEventListener('click',()=>{
document.querySelectorAll('.or-model-tab').forEach(b=>b.classList.remove('active'));
btn.classList.add('active'); orFilter=btn.dataset.filter; populateOrSelects(orFilter);
});
});
function populateOrSelects(filter){
filter=filter||orFilter;
const allList=filter==='vision'?orVisionModels:filter==='text'?orTextModels:orModels;
const _fill=(sel,def,list)=>{
if(!sel)return; const cur=sel.value||def||'';
sel.innerHTML='<option value="">(없음)</option>';
list.forEach(m=>{ const o=document.createElement('option'); o.value=m; o.textContent=m; if(m===cur)o.selected=true; sel.appendChild(o); });
};
// 설정 탭
_fill(document.getElementById('setting-or-stt-model'),appSettings.openrouter_stt_model,orModels);
const ocrSel=document.getElementById('setting-or-ocr-model');
if(ocrSel){ const cur=ocrSel.value||appSettings.openrouter_ocr_model||''; ocrSel.innerHTML='<option value="">(없음)</option>'; orVisionModels.forEach(m=>{const o=document.createElement('option');o.value=m;o.textContent=m;if(m===cur)o.selected=true;ocrSel.appendChild(o);}); }
// STT 탭 OR 모델
_fill(document.getElementById('stt-or-model'),appSettings.openrouter_stt_model,orModels);
// OCR 탭 OR 모델
const ocrPageSel=document.getElementById('ocr-or-model');
if(ocrPageSel){ const cur=ocrPageSel.value||appSettings.openrouter_ocr_model||''; ocrPageSel.innerHTML='<option value="">설정 기본 모델 사용</option>'; orVisionModels.forEach(m=>{const o=document.createElement('option');o.value=m;o.textContent=m;if(m===cur)o.selected=true;ocrPageSel.appendChild(o);}); }
// 자막 탭 OR 모델 (번역/교정)
_fill(document.getElementById('sub-trans-model'),appSettings.openrouter_stt_model,orModels);
_fill(document.getElementById('sub-refine-model'),appSettings.openrouter_stt_model,orModels);
}
function populateModelSelects(){
const fill=(sel,def,ph)=>{ if(!sel)return; const cur=sel.value||def||''; sel.innerHTML=`<option value="">${ph}</option>`; ollamaModels.forEach(m=>{const o=document.createElement('option');o.value=m;o.textContent=m;if(m===cur)o.selected=true;sel.appendChild(o);}); };
fill(document.getElementById('stt-ollama-model'),appSettings.stt_ollama_model,'설정 기본 모델 사용');
fill(document.getElementById('ocr-ollama-model'),appSettings.ocr_ollama_model,'설정 기본 모델 사용');
fill(document.getElementById('setting-stt-model'),appSettings.stt_ollama_model,'(없음)');
fill(document.getElementById('setting-ocr-model'),appSettings.ocr_ollama_model,'(없음)');
fill(document.getElementById('sub-trans-model-ollama'),appSettings.stt_ollama_model,'설정 기본 모델 사용');
fill(document.getElementById('sub-refine-model-ollama'),appSettings.stt_ollama_model,'설정 기본 모델 사용');
populateOrSelects();
}
async function loadSttEngineStatus(){
try{
const r=await api('GET','/api/stt-engines'); if(!r.ok)return;
const d=await r.json();
sttEngineStatus={local:true,groq:d.groq?.key_set||false,openai:d.openai?.key_set||false};
}catch{}
}
// ══ 설정 로드 ══
async function loadSettings(){
try{
const r=await api('GET','/api/settings'); appSettings=await r.json();
const th=appSettings.cpu_threads||0;
if(cpuSlider){ cpuSlider.value=th; if(cpuDisplay)cpuDisplay.textContent=th===0?'0 (자동)':th+' 스레드'; }
const _set=(id,v)=>{ const el=document.getElementById(id); if(el)el.value=v; };
_set('stt-timeout', appSettings.stt_timeout||0);
_set('ollama-timeout',appSettings.ollama_timeout||600);
_set('subtitle-timeout',appSettings.subtitle_timeout||600);
if(appSettings.openrouter_url){ _set('or-url',appSettings.openrouter_url); }
if(appSettings.openrouter_api_key_masked){ const el=document.getElementById('or-api-key'); if(el)el.placeholder='저장됨: '+appSettings.openrouter_api_key_masked; }
if(appSettings.groq_api_key_masked){ const el=document.getElementById('groq-api-key'); if(el)el.placeholder='저장됨: '+appSettings.groq_api_key_masked; }
if(appSettings.openai_api_key_masked){ const el=document.getElementById('openai-api-key'); if(el)el.placeholder='저장됨: '+appSettings.openai_api_key_masked; }
const defEng=document.getElementById('default-stt-engine'); if(defEng)defEng.value=appSettings.default_stt_engine||'local';
populateModelSelects();
if(appSettings.openrouter_api_key_masked) loadOrModels();
loadSttEngineStatus();
}catch{}
}
// ══ 설정 저장 ══
document.getElementById('btn-save-settings')?.addEventListener('click',async()=>{
const fd=new FormData();
const _get=(id,def='')=>document.getElementById(id)?.value||def;
fd.append('stt_ollama_model', _get('setting-stt-model'));
fd.append('ocr_ollama_model', _get('setting-ocr-model'));
fd.append('cpu_threads', cpuSlider?.value||'0');
fd.append('stt_timeout', _get('stt-timeout','0'));
fd.append('ollama_timeout', _get('ollama-timeout','600'));
fd.append('subtitle_timeout', _get('subtitle-timeout','600'));
fd.append('openrouter_url', _get('or-url','https://openrouter.ai/api/v1'));
fd.append('openrouter_stt_model',_get('setting-or-stt-model'));
fd.append('openrouter_ocr_model',_get('setting-or-ocr-model'));
fd.append('default_stt_engine', _get('default-stt-engine','local'));
const orKey=document.getElementById('or-api-key')?.value?.trim(); if(orKey) fd.append('openrouter_api_key', orKey);
const groqKey=document.getElementById('groq-api-key')?.value?.trim(); if(groqKey) fd.append('groq_api_key', groqKey);
const oaKey=document.getElementById('openai-api-key')?.value?.trim(); if(oaKey) fd.append('openai_api_key', oaKey);
try{
const r=await api('POST','/api/settings',fd); const d=await r.json();
if(r.ok){
appSettings=d.settings||appSettings;
['or-api-key','groq-api-key','openai-api-key'].forEach(id=>{ const el=document.getElementById(id); if(el)el.value=''; });
await loadSttEngineStatus();
if(orKey||appSettings.openrouter_api_key_masked) loadOrModels();
const msg=document.getElementById('settings-msg'); if(msg){ msg.style.display='block'; setTimeout(()=>msg.style.display='none',3500); }
}
}catch{}
});
document.getElementById('btn-refresh-models')?.addEventListener('click',()=>{ loadOllamaModels(); loadOrModels(); });
document.getElementById('btn-or-test')?.addEventListener('click',async()=>{
const key=document.getElementById('or-api-key')?.value?.trim();
const url=document.getElementById('or-url')?.value?.trim()||'https://openrouter.ai/api/v1';
const result=document.getElementById('or-test-result');
if(!key){ if(result){result.style.display='block';result.style.color='var(--warn)';result.textContent='API 키를 입력하세요';} return; }
if(result){result.style.display='block';result.style.color='var(--muted)';result.textContent='연결 중...';}
try{
const fd=new FormData(); fd.append('api_key',key); fd.append('base_url',url);
const r=await api('POST','/api/openrouter/test',fd); const d=await r.json();
if(result){result.style.color=d.ok?'var(--accent)':'var(--warn)';result.textContent=d.message;}
if(d.ok) loadOrModels();
}catch{ if(result){result.style.color='var(--warn)';result.textContent='요청 실패';} }
});
// ══ NAV ══
document.querySelectorAll('.nav-tab').forEach(btn=>{
btn.addEventListener('click',()=>{
document.querySelectorAll('.nav-tab').forEach(b=>b.classList.remove('active'));
document.querySelectorAll('.page').forEach(p=>p.classList.remove('active'));
btn.classList.add('active');
const p=document.getElementById('page-'+btn.dataset.page); if(p)p.classList.add('active');
if(btn.dataset.page==='admin') loadUsers();
if(btn.dataset.page==='settings'){ loadSettings(); fetchSysInfo(); }
if(btn.dataset.page==='history'){ histPage=1; loadHistory(); }
if(btn.dataset.page==='subtitle') fillSubModels();
});
});
checkAuth();
</script>
</body>
</html>