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

1425 lines
106 KiB
HTML
Raw 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)}
/* ── 자막 모드 ── */
.sub-section{margin-top:12px;padding:12px;background:var(--surf2);border:1px solid #1c2840;border-radius:4px}
.sub-section-title{font-family:var(--mono);font-size:.6rem;letter-spacing:.1em;color:var(--blue);text-transform:uppercase;margin-bottom:10px;display:flex;align-items:center;gap:6px}
.lang-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:.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}
.lang-select:focus{border-color:var(--blue)}
.fmt-btns{display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;margin-top:6px}
.fmt-btn{padding:7px;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}
.fmt-btn.active{background:rgba(77,166,255,.08);border-color:#3a7cc4;color:var(--blue)}
.sub-dl-btn{flex:1;padding:8px;background:rgba(77,166,255,.07);border:1px solid #3a7cc4;color:var(--blue);border-radius:3px;font-family:var(--mono);font-size:.66rem;cursor:pointer;transition:all .15s;text-transform:uppercase}
.sub-dl-btn:hover{background:rgba(77,166,255,.15)}
/* ── 배치 큐 ── */
.batch-queue{margin-top:14px;display:flex;flex-direction:column;gap:6px;max-height:280px;overflow-y:auto}
.batch-item{display:grid;grid-template-columns:1fr auto auto;align-items:center;gap:8px;padding:9px 12px;background:var(--surf);border:1px solid var(--border2);border-radius:4px;transition:border-color .2s}
.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.waiting{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{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;white-space:nowrap}.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:.68rem;color:var(--muted);margin-top:8px;display:flex;gap:12px;flex-wrap:wrap}.batch-summary span{color:var(--text)}
.batch-add-btn{margin-top:8px;padding:7px 14px;background:none;border:1px dashed var(--border2);color:var(--muted);border-radius:3px;font-family:var(--mono);font-size:.68rem;cursor:pointer;width:100%;transition:all .15s}.batch-add-btn:hover{border-color:var(--accent);color:var(--accent)}
.batch-clear-btn{padding:7px 14px;background:none;border:1px solid var(--border2);color:var(--muted);border-radius:3px;font-family:var(--mono);font-size:.68rem;cursor:pointer;transition:all .15s}.batch-clear-btn:hover{border-color:var(--warn);color:var(--warn)}
.batch-actions{display:flex;gap:8px;margin-top:10px}
/* ── 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 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>영상(mp4/mkv/h265 등) · 음성 복수 선택 가능</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>
</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="sub-section" id="sub-section">
<div class="sub-section-title">🎬 자막 모드 (영상/음성 → 자막 파일)</div>
<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="subtitle-mode" style="accent-color:var(--blue);width:15px;height:15px">
자막 파일 생성 (SRT / VTT)
</label>
<div id="sub-opts" style="display:none;margin-top:10px">
<div class="sec-label">음성 언어 (원어)</div>
<select class="lang-select" id="force-language">
<option value="">자동 감지</option>
</select>
<div class="sec-label">자막 포맷</div>
<div class="fmt-btns">
<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 class="sec-label">번역 (선택 — 빈칸이면 원어 자막)</div>
<select class="lang-select" id="translate-to">
<option value="">번역 안 함 (원어 자막)</option>
</select>
<div id="trans-model-wrap" style="display:none;margin-top:8px">
<div class="sec-label">번역 엔진</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:6px">
<button class="fmt-btn active" data-via="ollama" id="trans-via-ollama">🦙 Ollama</button>
<button class="fmt-btn" data-via="openrouter" id="trans-via-or">🌐 OpenRouter</button>
</div>
<div class="sec-label">번역 모델</div>
<select class="lang-select" id="translate-model">
<option value="">STT 엔진과 같은 모델 사용</option>
</select>
</div>
</div>
</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="sub-dl-btn" id="stt-dl-srt" style="display:none">SRT 저장</button>
<button class="sub-dl-btn" id="stt-dl-vtt" style="display:none">VTT 저장</button>
<button class="sub-dl-btn" id="stt-dl-srt-orig" style="display:none">원어 SRT</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>
</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>
</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>
</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>
<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>
<!-- ══ 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>
</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>
</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,'(없음)');
populateOrSelects();
}
// ══ 설정 ══
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');
fd.append('openrouter_url',document.getElementById('or-url')?.value||'https://openrouter.ai/api/v1');
const orKey=document.getElementById('or-api-key')?.value?.trim();if(orKey)fd.append('openrouter_api_key',orKey);
fd.append('openrouter_stt_model',document.getElementById('setting-or-stt-model')?.value||'');
fd.append('openrouter_ocr_model',document.getElementById('setting-or-ocr-model')?.value||'');
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=[],sttSubFmt='srt',sttTransVia='ollama';
let languages={};
// 언어 목록 로드
async function loadLanguages(){
try{const r=await api('GET','/api/languages');const d=await r.json();languages=d.languages||{};
const sel1=document.getElementById('force-language');
const sel2=document.getElementById('translate-to');
Object.entries(languages).forEach(([code,name])=>{
sel1.appendChild(Object.assign(document.createElement('option'),{value:code,textContent:`${name} (${code})`}));
sel2.appendChild(Object.assign(document.createElement('option'),{value:code,textContent:`${name} (${code})`}));
});
}catch{}
}
// 번역 모델 드롭다운 채우기
function fillTranslateModels(){
const sel=document.getElementById('translate-model');
const cur=sel.value;sel.innerHTML='<option value="">STT 엔진과 같은 모델 사용</option>';
const models=sttTransVia==='openrouter'?orModels:ollamaModels;
models.forEach(m=>{const o=document.createElement('option');o.value=m;o.textContent=m;if(m===cur)o.selected=true;sel.appendChild(o)});
}
// 자막 모드 토글
document.getElementById('subtitle-mode').addEventListener('change',function(){
document.getElementById('sub-opts').style.display=this.checked?'block':'none';
});
// 포맷 버튼
document.querySelectorAll('.fmt-btn[data-fmt]').forEach(btn=>{
btn.addEventListener('click',()=>{document.querySelectorAll('.fmt-btn[data-fmt]').forEach(b=>b.classList.remove('active'));btn.classList.add('active');sttSubFmt=btn.dataset.fmt});
});
// 번역 언어 선택 → 모델 옵션 표시
document.getElementById('translate-to').addEventListener('change',function(){
document.getElementById('trans-model-wrap').style.display=this.value?'block':'none';
if(this.value)fillTranslateModels();
});
// 번역 엔진 선택
document.querySelectorAll('button[data-via]').forEach(btn=>{
btn.addEventListener('click',()=>{
document.querySelectorAll('button[data-via]').forEach(b=>b.classList.remove('active'));
btn.classList.add('active');sttTransVia=btn.dataset.via;fillTranslateModels();
});
});
// 파일 추가
function addSttFiles(fileList){
const AUDIO=['mp3','mp4','wav','m4a','ogg','flac','aac','wma','webm','mkv','avi','mov','ts','mts','h264','h265'];
const files=Array.from(fileList).filter(f=>AUDIO.includes(f.name.split('.').pop().toLowerCase()));
if(!files.length)return;
files.forEach(f=>sttQueue.push({file:f,taskId:null,outputFile:null,srtFile:null,vttFile:null,srtOrigFile:null,status:'waiting',el:null}));
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)});
document.getElementById('stt-queue-clear').addEventListener('click',()=>{sttQueue=[];renderSttQueue();document.getElementById('stt-btn').disabled=true});
function renderSttQueue(){
const qEl=document.getElementById('stt-queue'),list=document.getElementById('stt-queue-list'),sum=document.getElementById('stt-queue-summary');
if(!sttQueue.length){qEl.style.display='none';return}
qEl.style.display='block';list.innerHTML='';
sttQueue.forEach((item,i)=>{
const div=document.createElement('div');div.className='batch-item '+item.status;
const dlBtns=item.status==='done'?[
item.outputFile?`<button class="bi-dl" onclick="dlFile('${esc(item.outputFile)}')">TXT</button>`:'',
item.srtFile?`<button class="bi-dl" onclick="dlFile('${esc(item.srtFile)}')">SRT</button>`:'',
item.vttFile?`<button class="bi-dl" onclick="dlFile('${esc(item.vttFile)}')">VTT</button>`:'',
item.srtOrigFile?`<button class="bi-dl" onclick="dlFile('${esc(item.srtOrigFile)}')">원어SRT</button>`:'',
].filter(Boolean).join(''):''
div.innerHTML=`<div><div class="bi-name">${esc(item.file.name)}</div><div class="bi-prog" id="stt-bp-${i}" style="width:0%;display:${item.status==='running'?'block':'none'}"></div></div><span class="bi-status ${item.status}">${{waiting:'대기',running:'변환중',done:'완료',failed:'실패'}[item.status]}</span><span style="display:flex;gap:3px">${dlBtns}</span>`;
item.el=div;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;
sum.innerHTML=`전체 <span>${sttQueue.length}</span>개 · 완료 <span>${done}</span> · 실패 <span>${failed}</span>${running?` · 진행중 <span>${running}</span>`:''}`;
}
// 엔진 버튼
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'?'purple':'green');
});
});
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 subMode=document.getElementById('subtitle-mode').checked;
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('subtitle_mode',subMode?'true':'false');
fd.append('subtitle_format',sttSubFmt);
fd.append('force_language',document.getElementById('force-language').value||'');
fd.append('translate_to',document.getElementById('translate-to').value||'');
fd.append('translate_model',document.getElementById('translate-model').value||'');
fd.append('translate_via',sttTransVia);
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 taskItem=items[pi++];if(!taskItem)return;
if(taskItem.error){qItem.status='failed';return}
qItem.status='running';qItem.taskId=taskItem.task_id;renderSttQueue();
pollSttItem(qi,taskItem.task_id);
});
setProg('stt',20,`${items.length}개 변환 중...`);
}catch(e){showErr('stt-err',e.message);document.getElementById('stt-btn').disabled=false;document.getElementById('stt-prog').style.display='none'}
});
function pollSttItem(qi,taskId){
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(d.state==='success'){
clearInterval(t);
const item=sttQueue[qi];
item.outputFile=d.output_file||null;item.srtFile=d.srt_file||null;
item.vttFile=d.vtt_file||null;item.srtOrigFile=d.srt_original_file||null;
item.status='done';renderSttQueue();checkSttBatchDone();
if(sttQueue.filter(i=>i.status!=='waiting'&&i.status!=='done'&&i.status!=='failed').length===0&&
sttQueue.filter(i=>i.status==='done').length===1) showSttResult(d);
} else if(d.state==='failure'){
clearInterval(t);sttQueue[qi].status='failed';renderSttQueue();checkSttBatchDone();
} else {
const done=sttQueue.filter(i=>i.status==='done').length;
setProg('stt',20+Math.round((done/sttQueue.length)*75),d.message||'처리 중...');
}
}catch{}
},1800);
}
function checkSttBatchDone(){
if(sttQueue.every(i=>['done','failed','waiting'].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',2000);
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||'')+( d.translated?' → '+d.translate_to:'')).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';
// 자막 다운로드 버튼
const srtBtn=document.getElementById('stt-dl-srt'),vttBtn=document.getElementById('stt-dl-vtt'),origBtn=document.getElementById('stt-dl-srt-orig');
srtBtn.style.display=d.srt_file?'flex':'none';if(d.srt_file)srtBtn.onclick=()=>dlFile(d.srt_file);
vttBtn.style.display=d.vtt_file?'flex':'none';if(d.vtt_file)vttBtn.onclick=()=>dlFile(d.vtt_file);
origBtn.style.display=d.srt_original_file?'flex':'none';if(d.srt_original_file)origBtn.onclick=()=>dlFile(d.srt_original_file);
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');
['stt-dl-srt','stt-dl-vtt','stt-dl-srt-orig'].forEach(id=>document.getElementById(id).style.display='none');
});
// ══ OCR — 배치 ══
const ocrDrop=document.getElementById('ocr-drop'),ocrInput=document.getElementById('ocr-input');
let ocrQueue=[];
function addOcrFiles(fileList){
const IMG=['jpg','jpeg','png','bmp','tiff','tif','webp','gif'];
const files=Array.from(fileList).filter(f=>IMG.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',el:null}));
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)});
document.getElementById('ocr-queue-clear').addEventListener('click',()=>{ocrQueue=[];renderOcrQueue();document.getElementById('ocr-btn').disabled=true});
function renderOcrQueue(){
const qEl=document.getElementById('ocr-queue'),list=document.getElementById('ocr-queue-list'),sum=document.getElementById('ocr-queue-summary');
if(!ocrQueue.length){qEl.style.display='none';return}
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 class="bi-prog" id="ocr-bp-${i}" style="width:0%;display:${item.status==='running'?'block':'none'}"></div></div><span class="bi-status ${item.status}">${{waiting:'대기',running:'인식중',done:'완료',failed:'실패'}[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.xlsxFile?`<button class="bi-dl" onclick="dlFile('${esc(item.xlsxFile)}')">XLS</button>`:''].filter(Boolean).join(''):''}</span>`;
item.el=div;list.appendChild(div);
});
const done=ocrQueue.filter(i=>i.status==='done').length,failed=ocrQueue.filter(i=>i.status==='failed').length,running=ocrQueue.filter(i=>i.status==='running').length;
sum.innerHTML=`전체 <span>${ocrQueue.length}</span>개 · 완료 <span>${done}</span> · 실패 <span>${failed}</span>${running?` · 진행중 <span>${running}</span>`:''}`;
}
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'?'purple':'green');
});
});
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 taskItem=items[pi++];if(!taskItem)return;
if(taskItem.error){qItem.status='failed';return}
qItem.status='running';qItem.taskId=taskItem.task_id;renderOcrQueue();
const t=setInterval(async()=>{
try{
const r2=await api('GET','/api/status/'+taskItem.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.filter(i=>i.status==='running').length===0)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',2000);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{}
},1800);
});
}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||{},isStt=h.type==='stt';
card.innerHTML=`
<div class="hist-card-head" onclick="toggleHistDetail(this)">
<span class="hist-type-badge ${h.type}">${h.type.toUpperCase()}</span>
<span class="hist-status-badge ${h.status}">${{success:'완료',processing:'처리중',failed:'실패'}[h.status]||h.status}</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">
${isStt?`
<div class="hist-kv-row"><span class="hist-k">Whisper</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||'—')}</span></div>
<div class="hist-kv-row"><span class="hist-k">연산</span><span class="hist-v">${esc(set.compute_type||'—')}</span></div>
<div class="hist-kv-row"><span class="hist-k">CPU</span><span class="hist-v">${set.cpu_threads===0?'자동':set.cpu_threads+'스레드'}</span></div>
<div class="hist-kv-row"><span class="hist-k">STT 타임아웃</span><span class="hist-v">${set.stt_timeout===0?'무제한':set.stt_timeout+'초'}</span></div>
<div class="hist-kv-row"><span class="hist-k">Ollama 교정</span><span class="hist-v">${set.use_ollama?(set.ollama_model||'기본모델'):'없음'}</span></div>
`:`
<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>
<div class="hist-kv-row"><span class="hist-k">언어</span><span class="hist-v">${esc(set.ocr_lang||'—')}</span></div>
${set.backend==='ollama'?`<div class="hist-kv-row"><span class="hist-k">모델</span><span class="hist-v">${esc(set.ollama_model||'—')}</span></div><div class="hist-kv-row"><span class="hist-k">타임아웃</span><span class="hist-v">${set.ollama_timeout||600}초</span></div>`:''}
${set.custom_prompt?`<div class="hist-kv-row"><span class="hist-k">프롬프트</span><span class="hist-v">${esc(set.custom_prompt)}</span></div>`:''}
`}
</div></div>
<div><div class="hist-section-title">📤 결과</div><div class="hist-kv">
${h.status==='failed'?`<div class="hist-kv-row"><span class="hist-k" style="color:var(--warn)">오류</span><span class="hist-v" style="color:var(--warn)">${esc(out.error||'알 수 없음')}</span></div>`:''}
${h.status==='processing'?`<div class="hist-kv-row"><span class="hist-k">상태</span><span class="hist-v" style="color:var(--orange)">처리 중...</span></div>`:''}
${h.status==='success'&&isStt?`
<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>
${out.ollama_used?`<div class="hist-kv-row"><span class="hist-k">Ollama</span><span class="hist-v">${esc(out.ollama_model||'')}</span></div>`:''}
`:''}
${h.status==='success'&&!isStt?`
<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>
${out.ollama_model?`<div class="hist-kv-row"><span class="hist-k">모델</span><span class="hist-v">${esc(out.ollama_model)}</span></div>`:''}
`:''}
</div></div>
${h.status==='success'&&out.text_preview?`<div><div class="hist-section-title">📄 미리보기</div><div class="hist-preview-text">${esc(out.text_preview)}</div></div>`:''}
${h.status==='success'?`<div class="hist-actions">
${isStt&&out.filename?`<button class="hist-btn" onclick="dlFile('${esc(out.filename)}')">📥 TXT</button>`:''}
${!isStt&&out.txt_file?`<button class="hist-btn" onclick="dlFile('${esc(out.txt_file)}')">📥 TXT</button>`:''}
${!isStt&&out.xlsx_file?`<button class="hist-btn blue" onclick="dlFile('${esc(out.xlsx_file)}')">📊 Excel</button>`:''}
</div>`:''}
</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);
}
// ══ 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('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('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{}}
// ══ OPENROUTER ══
async function loadOrModels(){
try{const r=await api('GET','/api/openrouter/models');const d=await r.json();
const wrap=document.getElementById('or-models-wrap');
if(d.connected){orModels=d.models||[];orVisionModels=d.vision_models||[];
wrap.style.display='block';document.getElementById('or-connected-badge').textContent=`✓ 연결됨 — Vision ${orVisionModels.length}개 / 전체 ${orModels.length}`;populateOrSelects('vision');}
else wrap.style.display='none';
}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 list=filter==='vision'?orVisionModels:filter==='text'?orTextModels:orModels;
const fillOr=(sel,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)})};
fillOr(document.getElementById('setting-or-stt-model'),appSettings.openrouter_stt_model);
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)})}
fillOr(document.getElementById('stt-or-model'),appSettings.openrouter_stt_model);
const ocrPage=document.getElementById('ocr-or-model');if(ocrPage){const cur=ocrPage.value||appSettings.openrouter_ocr_model||'';ocrPage.innerHTML='<option value="">설정 기본 모델 사용</option>';orVisionModels.forEach(m=>{const o=document.createElement('option');o.value=m;o.textContent=m;if(m===cur)o.selected=true;ocrPage.appendChild(o)})}
}
document.getElementById('btn-or-test')?.addEventListener('click',async()=>{
const key=document.getElementById('or-api-key').value.trim(),url=document.getElementById('or-url').value.trim()||'https://openrouter.ai/api/v1';
const result=document.getElementById('or-test-result');
if(!key){result.style.display='block';result.style.color='var(--warn)';result.textContent='API 키를 입력하세요';return}
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();
result.style.color=d.ok?'var(--accent)':'var(--warn)';result.textContent=d.message;if(d.ok)loadOrModels();}
catch{result.style.color='var(--warn)';result.textContent='요청 실패'}
});
loadLanguages();
checkAuth();
</script>
</body>
</html>