1074 lines
84 KiB
HTML
1074 lines
84 KiB
HTML
<!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)}
|
||
|
||
/* ── 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">
|
||
<span class="drop-icon">🎵</span>
|
||
<div class="drop-label"><strong>탭하거나 드래그하여 선택</strong><br>음성 또는 영상 파일</div>
|
||
<div class="drop-formats">mp3 · wav · m4a · ogg · flac · mp4 · webm</div>
|
||
</div>
|
||
<div class="file-info" id="stt-info"><div class="fname" id="stt-fname"></div><div class="fsize" id="stt-fsize"></div></div>
|
||
<div 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>
|
||
<button class="btn-start green" id="stt-btn" disabled>변환 시작</button>
|
||
<div class="prog-box" id="stt-prog">
|
||
<div class="prog-header"><span class="prog-msg" id="stt-pmsg">처리 중...</span><span class="prog-pct" id="stt-ppct">0%</span></div>
|
||
<div class="prog-track"><div class="prog-fill" id="stt-pfill"></div></div>
|
||
<div class="waveform"><div class="wave-bar"></div><div class="wave-bar"></div><div class="wave-bar"></div><div class="wave-bar"></div><div class="wave-bar"></div><div class="wave-bar"></div><div class="wave-bar"></div><div class="wave-bar"></div><div class="wave-bar"></div></div>
|
||
</div>
|
||
<div class="err-box" id="stt-err"></div>
|
||
</section>
|
||
<section class="panel">
|
||
<div class="panel-title">변환 결과</div>
|
||
<div class="result-meta" id="stt-meta">
|
||
<div class="meta-chip">언어 <span id="stt-mlang">—</span></div>
|
||
<div class="meta-chip">길이 <span id="stt-mdur">—</span></div>
|
||
<div class="meta-chip">세그먼트 <span id="stt-msegs">—</span></div>
|
||
<div class="meta-chip" id="stt-mollama-chip" style="display:none">교정 <span id="stt-mollama">—</span></div>
|
||
</div>
|
||
<div class="result-tabs" id="stt-tabs">
|
||
<button class="tab-btn active" data-tab="stt-text">전체 텍스트</button>
|
||
<button class="tab-btn" data-tab="stt-segs">타임스탬프</button>
|
||
</div>
|
||
<div class="tab-content active" id="stt-text">
|
||
<div class="empty-state" id="stt-empty"><div class="empty-icon">📝</div><div class="empty-text">파일 업로드 후<br>변환을 시작하면<br>결과가 표시됩니다</div></div>
|
||
<textarea class="result-textarea" id="stt-result" style="display:none" readonly></textarea>
|
||
</div>
|
||
<div class="tab-content" id="stt-segs"><div class="segments-list" id="stt-seglist"></div></div>
|
||
<div class="result-actions" id="stt-actions">
|
||
<button class="btn-act" id="stt-copy">복사</button>
|
||
<button class="btn-act primary" id="stt-dl">TXT 저장</button>
|
||
<button class="btn-act" id="stt-new">새 파일</button>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ══ OCR ══ -->
|
||
<div class="page" id="page-ocr">
|
||
<div class="two-panel">
|
||
<section class="panel">
|
||
<div class="panel-title">이미지 업로드</div>
|
||
<div class="dropzone" id="ocr-drop">
|
||
<input type="file" id="ocr-input" accept=".jpg,.jpeg,.png,.bmp,.tiff,.tif,.webp,.gif">
|
||
<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 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,'(없음)');
|
||
}
|
||
|
||
// ══ 설정 ══
|
||
async function loadSettings(){
|
||
try{const r=await api('GET','/api/settings');appSettings=await r.json();
|
||
const th=appSettings.cpu_threads||0;cpuSlider.value=th;cpuDisplay.textContent=th===0?'0 (자동)':th+' 스레드';
|
||
document.getElementById('stt-timeout').value=appSettings.stt_timeout||0;
|
||
document.getElementById('ollama-timeout').value=appSettings.ollama_timeout||600;
|
||
populateModelSelects()}catch{}
|
||
}
|
||
document.getElementById('btn-save-settings').addEventListener('click',async()=>{
|
||
const fd=new FormData();
|
||
fd.append('stt_ollama_model',document.getElementById('setting-stt-model').value);
|
||
fd.append('ocr_ollama_model',document.getElementById('setting-ocr-model').value);
|
||
fd.append('cpu_threads',cpuSlider.value);
|
||
fd.append('stt_timeout',document.getElementById('stt-timeout').value||'0');
|
||
fd.append('ollama_timeout',document.getElementById('ollama-timeout').value||'600');
|
||
try{const r=await api('POST','/api/settings',fd);if(r.ok){appSettings=(await r.json()).settings;const msg=document.getElementById('settings-msg');msg.style.display='block';setTimeout(()=>msg.style.display='none',3500)}}catch{}
|
||
});
|
||
document.getElementById('btn-refresh-models').addEventListener('click',loadOllamaModels);
|
||
|
||
// ══ NAV ══
|
||
document.querySelectorAll('.nav-tab').forEach(btn=>{
|
||
btn.addEventListener('click',()=>{
|
||
document.querySelectorAll('.nav-tab').forEach(b=>b.classList.remove('active'));
|
||
document.querySelectorAll('.page').forEach(p=>p.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
const p=document.getElementById('page-'+btn.dataset.page);if(p)p.classList.add('active');
|
||
if(btn.dataset.page==='admin')loadUsers();
|
||
if(btn.dataset.page==='settings'){loadSettings();fetchSysInfo()}
|
||
if(btn.dataset.page==='history'){histPage=1;loadHistory()}
|
||
});
|
||
});
|
||
|
||
// ══ STT ══
|
||
const sttDrop=document.getElementById('stt-drop'),sttInput=document.getElementById('stt-input');
|
||
sttInput.addEventListener('change',()=>setSttFile(sttInput.files[0]));
|
||
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');setSttFile(e.dataTransfer.files[0])});
|
||
function setSttFile(f){if(!f)return;sttFile=f;showFileInfo('stt',f);document.getElementById('stt-btn').disabled=false;document.getElementById('stt-err').style.display='none'}
|
||
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-btn').className='btn-start '+(sttEngine==='whisper+ollama'?'purple':'green')})});
|
||
document.getElementById('stt-btn').addEventListener('click',async()=>{
|
||
if(!sttFile)return;document.getElementById('stt-err').style.display='none';setSttLoading(true);
|
||
const fd=new FormData();fd.append('file',sttFile);fd.append('use_ollama',sttEngine==='whisper+ollama'?'true':'false');fd.append('ollama_model',document.getElementById('stt-ollama-model').value||'');
|
||
try{const r=await api('POST','/api/transcribe',fd);const d=await r.json();if(!r.ok)throw new Error(d.detail||'업로드 실패');pollTask(d.task_id,dt=>setProg('stt',dt.progress||0,dt.message||'처리 중...'),showSttResult,e=>{showErr('stt-err',e);setSttLoading(false)})}
|
||
catch(e){showErr('stt-err',e.message);setSttLoading(false)}
|
||
});
|
||
function setSttLoading(on){document.getElementById('stt-btn').disabled=on;document.getElementById('stt-prog').style.display=on?'block':'none';if(on)setProg('stt',0,'준비 중...')}
|
||
function showSttResult(d){
|
||
sttOutputFile=d.output_file;
|
||
document.getElementById('stt-mlang').textContent=(d.language||'').toUpperCase();
|
||
document.getElementById('stt-mdur').textContent=fmtDur(d.duration);
|
||
document.getElementById('stt-msegs').textContent=(d.segments||[]).length+'개';
|
||
const chip=document.getElementById('stt-mollama-chip');if(d.ollama_used){chip.style.display='block';document.getElementById('stt-mollama').textContent=d.ollama_model}else chip.style.display='none';
|
||
document.getElementById('stt-meta').style.display='flex';document.getElementById('stt-tabs').style.display='flex';
|
||
document.getElementById('stt-empty').style.display='none';document.getElementById('stt-result').style.display='block';document.getElementById('stt-result').value=d.text||'';
|
||
const sl=document.getElementById('stt-seglist');sl.innerHTML='';
|
||
(d.segments||[]).forEach(s=>{const row=document.createElement('div');row.className='seg-item';row.innerHTML=`<div class="seg-time">${fmtTime(s.start)}<br>→${fmtTime(s.end)}</div><div class="seg-text">${esc(s.text)}</div>`;sl.appendChild(row)});
|
||
document.getElementById('stt-actions').style.display='flex';setSttLoading(false);
|
||
}
|
||
document.getElementById('stt-copy').addEventListener('click',()=>copyText(document.getElementById('stt-result').value,document.getElementById('stt-copy')));
|
||
document.getElementById('stt-dl').addEventListener('click',()=>dlFile(sttOutputFile));
|
||
document.getElementById('stt-new').addEventListener('click',()=>{sttFile=null;sttInput.value='';sttOutputFile=null;['stt-info','stt-prog','stt-err','stt-meta','stt-tabs','stt-actions'].forEach(id=>document.getElementById(id).style.display='none');document.getElementById('stt-empty').style.display='flex';document.getElementById('stt-result').style.display='none';document.getElementById('stt-result').value='';document.getElementById('stt-seglist').innerHTML='';document.getElementById('stt-btn').disabled=true;resetTabs('stt-tabs')});
|
||
|
||
// ══ OCR ══
|
||
const ocrDrop=document.getElementById('ocr-drop'),ocrInput=document.getElementById('ocr-input');
|
||
ocrInput.addEventListener('change',()=>setOcrFile(ocrInput.files[0]));
|
||
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');setOcrFile(e.dataTransfer.files[0])});
|
||
function setOcrFile(f){if(!f)return;ocrFile=f;showFileInfo('ocr',f);document.getElementById('ocr-btn').disabled=false;document.getElementById('ocr-err').style.display='none';const p=document.getElementById('ocr-preview'),w=document.getElementById('ocr-preview-wrap');p.src=URL.createObjectURL(f);w.style.display='block'}
|
||
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-btn').className='btn-start '+(ocrEngine==='ollama'?'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()=>{
|
||
if(!ocrFile)return;document.getElementById('ocr-err').style.display='none';setOcrLoading(true);
|
||
const fd=new FormData();fd.append('file',ocrFile);fd.append('mode',ocrMode);fd.append('backend',ocrEngine);fd.append('ollama_model',document.getElementById('ocr-ollama-model').value||'');fd.append('custom_prompt',document.getElementById('custom-prompt').value||'');
|
||
try{const r=await api('POST','/api/ocr',fd);const d=await r.json();if(!r.ok)throw new Error(d.detail||'업로드 실패');pollTask(d.task_id,dt=>setProg('ocr',dt.progress||0,dt.message||'처리 중...'),showOcrResult,e=>{showErr('ocr-err',e);setOcrLoading(false)})}
|
||
catch(e){showErr('ocr-err',e.message);setOcrLoading(false)}
|
||
});
|
||
function setOcrLoading(on){const io=ocrEngine==='ollama',c=io?'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==='ollama';
|
||
document.getElementById('ocr-mlines').textContent=(d.line_count||0)+'줄';document.getElementById('ocr-mmode').textContent=d.mode==='structure'?'구조분석':'텍스트';document.getElementById('ocr-mbackend').textContent=io?`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',()=>{ocrFile=null;ocrInput.value='';ocrOutputTxt=null;ocrOutputXlsx=null;['ocr-info','ocr-preview-wrap','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&¤tUser?.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,'&').replace(/</g,'<').replace(/>/g,'>')}
|
||
async function copyText(text,btn){try{await navigator.clipboard.writeText(text);const o=btn.textContent;btn.textContent='복사됨 ✓';setTimeout(()=>btn.textContent=o,1500)}catch{}}
|
||
|
||
checkAuth();
|
||
</script>
|
||
</body>
|
||
</html>
|