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

1132 lines
64 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">
<title>VoiceScript — STT & OCR</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;
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
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}
.login-box{width:380px;padding:48px 40px;background:var(--surf);border:1px solid var(--border2);border-radius:6px}
.login-logo{display:flex;align-items:center;gap:12px;margin-bottom:36px}
.login-mark{width:28px;height:28px;background:var(--accent);clip-path:polygon(0 20%,100% 0,100% 80%,0 100%)}
.login-title{font-family:var(--mono);font-size:1rem;font-weight:600;letter-spacing:.08em}
.login-title span{color:var(--accent)}
.field{margin-bottom:16px}
.field label{display:block;font-family:var(--mono);font-size:.65rem;letter-spacing:.12em;color:var(--muted);text-transform:uppercase;margin-bottom:6px}
.field input{width:100%;padding:10px 12px;background:var(--bg);border:1px solid var(--border2);border-radius:3px;color:var(--text);font-family:var(--mono);font-size:.85rem;outline:none;transition:border-color .15s}
.field input:focus{border-color:var(--accent)}
#btn-login{width:100%;margin-top:8px;padding:12px;background:var(--accent);color:#000;border:none;border-radius:3px;font-family:var(--mono);font-size:.82rem;font-weight:600;letter-spacing:.1em;cursor:pointer;transition:all .15s;text-transform:uppercase}
#btn-login:hover{background:#00ffb3}
#login-err{display:none;margin-top:12px;padding:10px 12px;background:rgba(255,107,53,.08);border:1px solid rgba(255,107,53,.3);border-radius:3px;font-family:var(--mono);font-size:.72rem;color:var(--warn)}
/* HEADER */
header{border-bottom:1px solid var(--border);padding:12px 24px;display:flex;align-items:center;gap:12px;position:sticky;top:0;background:rgba(8,8,10,.94);backdrop-filter:blur(12px);z-index:100}
.logo-mark{width:26px;height:26px;background:var(--accent);clip-path:polygon(0 20%,100% 0,100% 80%,0 100%)}
header h1{font-family:var(--mono);font-size:.95rem;font-weight:600;letter-spacing:.08em}
header h1 span{color:var(--accent)}
#user-info{margin-left:auto;display:flex;align-items:center;gap:10px;font-family:var(--mono);font-size:.68rem;color:var(--muted)}
#user-badge{padding:3px 8px;border-radius:2px;font-size:.6rem;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:4px 10px;border-radius:2px;font-family:var(--mono);font-size:.65rem;cursor:pointer;letter-spacing:.08em;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);padding:0 24px;background:var(--surf)}
.nav-tab{font-family:var(--mono);font-size:.7rem;letter-spacing:.1em;text-transform:uppercase;padding:13px 18px;background:none;border:none;color:var(--muted);cursor:pointer;border-bottom:2px solid transparent;transition:all .15s;display:flex;align-items:center;gap:7px}
.nav-tab.active{color:var(--accent);border-bottom-color:var(--accent)}
.nav-tab:hover:not(.active){color:var(--text)}
.nav-tab.admin-tab{color:var(--orange)}
.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)}
/* PAGE / PANEL */
.page{display:none;flex:1}
.page.active{display:flex}
.two-panel{display:grid;grid-template-columns:1fr 1fr;width:100%;max-width:1360px;margin:0 auto}
.panel{padding:28px 32px;border-right:1px solid var(--border);min-height:calc(100vh - 110px)}
.panel:last-child{border-right:none}
.panel-title{font-family:var(--mono);font-size:.63rem;letter-spacing:.15em;color:var(--muted);text-transform:uppercase;margin-bottom:20px;display:flex;align-items:center;gap:10px}
.panel-title::after{content:'';flex:1;height:1px;background:var(--border)}
/* DROPZONE */
.dropzone{border:1px dashed var(--border2);border-radius:4px;padding:40px 24px;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:2rem;margin-bottom:10px;display:block;opacity:.35}
.drop-label{font-size:.88rem;color:var(--muted);line-height:1.7}
.drop-label strong{color:var(--text);font-weight:500}
.drop-formats{margin-top:8px;font-family:var(--mono);font-size:.6rem;color:var(--muted);letter-spacing:.05em}
.file-info{display:none;margin-top:12px;padding:10px 12px;background:var(--surf);border:1px solid var(--border2);border-radius:3px;font-family:var(--mono);font-size:.73rem}
.file-info .fname{color:var(--accent);margin-bottom:2px;word-break:break-all}
.file-info .fsize{color:var(--muted)}
/* SECTION LABEL */
.sec-label{font-family:var(--mono);font-size:.62rem;letter-spacing:.1em;color:var(--muted);text-transform:uppercase;margin-bottom:7px;margin-top:16px}
/* ENGINE SELECTOR */
.engine-btns{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.engine-btn{padding:11px 8px;background:var(--surf);border:1px solid var(--border2);color:var(--muted);border-radius:4px;font-family:var(--mono);font-size:.7rem;letter-spacing:.05em;cursor:pointer;transition:all .18s;text-align:center;display:flex;flex-direction:column;align-items:center;gap:4px}
.engine-btn .e-icon{font-size:1.3rem;opacity:.5;transition:opacity .18s}
.engine-btn .e-name{font-weight:600}
.engine-btn .e-desc{font-size:.58rem;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{opacity:1}
.engine-btn.active[data-engine="ollama"],.engine-btn.active[data-engine="whisper+ollama"]{background:rgba(167,139,250,.07);border-color:#7c6cd4;color:var(--purple)}
.engine-btn.active[data-engine="ollama"] .e-icon,.engine-btn.active[data-engine="whisper+ollama"] .e-icon{opacity:1}
.engine-btn.active[data-engine="paddle"]{background:rgba(0,229,160,.07);border-color:var(--accent2);color:var(--accent)}
.engine-btn.active[data-engine="paddle"] .e-icon{opacity:1}
/* OLLAMA OPTIONS */
.ollama-opts{display:none;margin-top:12px;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:8px 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}
.cprompt-toggle:hover{color:var(--text)}
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:.72rem;line-height:1.6;resize:vertical;min-height:64px;outline:none;margin-top:6px;display:none}
textarea.cprompt:focus{border-color:#7c6cd4}
/* MODE TOGGLE */
.mode-btns{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:7px}
.mode-btn{padding:9px;background:var(--surf);border:1px solid var(--border2);color:var(--muted);border-radius:3px;font-family:var(--mono);font-size:.68rem;letter-spacing:.07em;cursor:pointer;transition:all .15s;text-align:center;text-transform:uppercase}
.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:12px;border:none;border-radius:3px;font-family:var(--mono);font-size:.8rem;font-weight:600;letter-spacing:.1em;cursor:pointer;transition:all .15s;text-transform:uppercase}
.btn-start.green{background:var(--accent);color:#000}
.btn-start.green:hover:not(:disabled){background:#00ffb3;transform:translateY(-1px)}
.btn-start.purple{background:var(--purple);color:#fff}
.btn-start.purple:hover:not(:disabled){background:#c4b5fd;transform:translateY(-1px)}
.btn-start:disabled{background:var(--border2);color:var(--muted);cursor:not-allowed;transform:none}
/* 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:2px;background:var(--border);border-radius:1px;overflow:hidden}
.prog-fill{height:100%;transition:width .4s ease;width:0%;border-radius:1px;background:var(--accent)}
.waveform{display:flex;align-items:center;justify-content:center;gap:3px;margin-top:14px;height:26px}
.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:8px}.wave-bar:nth-child(2){animation-delay:.1s;height:14px}
.wave-bar:nth-child(3){animation-delay:.2s;height:22px}.wave-bar:nth-child(4){animation-delay:.3s;height:18px}
.wave-bar:nth-child(5){animation-delay:.4s;height:26px}.wave-bar:nth-child(6){animation-delay:.3s;height:18px}
.wave-bar:nth-child(7){animation-delay:.2s;height:22px}.wave-bar:nth-child(8){animation-delay:.1s;height:14px}
.wave-bar:nth-child(9){animation-delay:0s;height:8px}
@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:3px;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:8px;margin-bottom:14px}
.meta-chip{font-family:var(--mono);font-size:.63rem;padding:4px 9px;border:1px solid var(--border2);border-radius:2px;color:var(--muted);letter-spacing:.04em}
.meta-chip span{color:var(--accent)}
.result-tabs{display:none;border-bottom:1px solid var(--border);margin-bottom:12px}
.tab-btn{font-family:var(--mono);font-size:.66rem;letter-spacing:.1em;padding:8px 14px;background:none;border:none;color:var(--muted);cursor:pointer;border-bottom:2px solid transparent;transition:all .15s;text-transform:uppercase}
.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:340px;background:var(--surf);border:1px solid var(--border);color:var(--text);padding:14px;border-radius:3px;font-family:var(--mono);font-size:.77rem;line-height:1.8;resize:vertical;outline:none;white-space:pre-wrap}
.segments-list,.lines-list{flex:1;min-height:340px;overflow-y:auto;background:var(--surf);border:1px solid var(--border);border-radius:3px}
.seg-item{display:grid;grid-template-columns:110px 1fr;border-bottom:1px solid var(--border)}
.seg-item:last-child{border-bottom:none}
.seg-item:hover{background:rgba(255,255,255,.015)}
.seg-time{padding:10px 12px;font-family:var(--mono);font-size:.63rem;color:var(--muted);border-right:1px solid var(--border);white-space:nowrap;line-height:1.6}
.seg-text{padding:10px 14px;font-size:.8rem;line-height:1.6}
.line-item{display:grid;grid-template-columns:55px 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:3px}
.ocr-table{width:100%;border-collapse:collapse;font-size:.77rem;font-family:var(--mono)}
.ocr-table th{background:#1a1a2e;color:var(--accent);padding:8px 12px;text-align:left;border:1px solid var(--border2);font-weight:500}
.ocr-table td{padding:8px 12px;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:.66rem;color:var(--muted);letter-spacing:.08em;padding:9px 12px;background:var(--surf2);border-bottom:1px solid var(--border);text-transform:uppercase}
.result-actions{display:none;gap:8px;margin-top:10px}
.btn-act{flex:1;padding:8px;background:none;border:1px solid var(--border2);color:var(--text);border-radius:3px;font-family:var(--mono);font-size:.66rem;letter-spacing:.08em;cursor:pointer;transition:all .15s;text-transform:uppercase}
.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:50px 0}
.empty-icon{font-size:2rem;opacity:.18}
.empty-text{font-family:var(--mono);font-size:.66rem;letter-spacing:.1em;text-align:center;line-height:1.9;text-transform:uppercase}
/* ══ SETTINGS PAGE ══ */
#page-settings{display:none;flex-direction:column}
#page-settings.active{display:flex}
.settings-wrap{max-width:680px;margin:0 auto;padding:36px 32px;width:100%}
.settings-section{background:var(--surf);border:1px solid var(--border2);border-radius:6px;padding:24px;margin-bottom:20px}
.settings-section h3{font-family:var(--mono);font-size:.72rem;letter-spacing:.12em;color:var(--muted);text-transform:uppercase;margin-bottom:16px;padding-bottom:12px;border-bottom:1px solid var(--border)}
.settings-row{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:14px}
.settings-row:last-child{margin-bottom:0}
.settings-label{font-family:var(--mono);font-size:.75rem;color:var(--text);flex:1}
.settings-label small{display:block;color:var(--muted);font-size:.62rem;margin-top:2px}
.settings-select{flex:1;max-width:280px;background:var(--surf2);border:1px solid var(--border2);color:var(--text);padding:8px 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}
.settings-select:focus{border-color:var(--blue)}
.btn-settings{padding:9px 18px;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}
.btn-settings.blue{background:var(--blue);color:#fff}
.btn-settings.blue:hover{background:#6db8ff}
.btn-settings.outline{background:none;border:1px solid var(--border2);color:var(--muted)}
.btn-settings.outline:hover{border-color:var(--text);color:var(--text)}
.ollama-status{font-family:var(--mono);font-size:.65rem;padding:4px 10px;border-radius:2px;margin-left:8px}
.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 ══ */
#page-admin{display:none;flex-direction:column}
#page-admin.active{display:flex}
.admin-wrap{max-width:860px;margin:0 auto;padding:36px 32px;width:100%}
.admin-section{background:var(--surf);border:1px solid var(--border2);border-radius:6px;margin-bottom:20px;overflow:hidden}
.admin-section-head{padding:16px 20px;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:.72rem;letter-spacing:.12em;color:var(--muted);text-transform:uppercase}
.user-table{width:100%;border-collapse:collapse}
.user-table th{font-family:var(--mono);font-size:.62rem;letter-spacing:.1em;color:var(--muted);text-transform:uppercase;padding:10px 16px;border-bottom:1px solid var(--border);text-align:left;background:var(--surf2)}
.user-table td{padding:12px 16px;border-bottom:1px solid var(--border);font-size:.82rem;vertical-align:middle}
.user-table tr:last-child td{border-bottom:none}
.user-table tr:hover td{background:rgba(255,255,255,.015)}
.perm-badge{font-family:var(--mono);font-size:.58rem;padding:3px 7px;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:.58rem;padding:3px 7px;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 10px;border:1px solid var(--border2);background:none;color:var(--muted);border-radius:2px;font-family:var(--mono);font-size:.62rem;cursor:pointer;transition:all .12s;margin-left:4px}
.btn-sm:hover{border-color:var(--accent);color:var(--accent)}
.btn-sm.danger:hover{border-color:var(--warn);color:var(--warn)}
.add-user-form{padding:20px}
.add-form-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:14px}
.form-group{display:flex;flex-direction:column;gap:5px}
.form-group label{font-family:var(--mono);font-size:.62rem;letter-spacing:.1em;color:var(--muted);text-transform:uppercase}
.form-input{background:var(--surf2);border:1px solid var(--border2);color:var(--text);padding:8px 10px;border-radius:3px;font-family:var(--mono);font-size:.78rem;outline:none}
.form-input:focus{border-color:var(--accent)}
.perm-checks{display:flex;gap:16px;align-items:center;margin-top:4px}
.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:14px;height:14px;cursor:pointer}
.perm-check:hover{color:var(--text)}
.btn-add{padding:9px 20px;background:var(--accent);color:#000;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}
.btn-add:hover{background:#00ffb3}
.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)}
/* EDIT MODAL */
.modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:200;align-items:center;justify-content:center}
.modal-overlay.visible{display:flex}
.modal-box{background:var(--surf);border:1px solid var(--border2);border-radius:6px;padding:28px;width:380px;max-width:90vw}
.modal-title{font-family:var(--mono);font-size:.8rem;font-weight:600;letter-spacing:.08em;margin-bottom:20px}
.modal-actions{display:flex;gap:8px;margin-top:20px;justify-content:flex-end}
/* IMAGE PREVIEW */
#ocr-preview-wrap{display:none;margin-top:12px}
#ocr-preview{max-width:100%;max-height:180px;border:1px solid var(--border);border-radius:3px;object-fit:contain}
@media(max-width:900px){.two-panel{grid-template-columns:1fr}.panel{border-right:none;border-bottom:1px solid var(--border);min-height:auto;padding:22px 16px}.panel:last-child{border-bottom:none}.add-form-grid{grid-template-columns:1fr}}
</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"></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 id="user-info">
<span id="user-name" style="color:var(--text);font-size:.75rem"></span>
<span id="user-badge"></span>
<button id="btn-logout">로그아웃</button>
</div>
</header>
<!-- NAV -->
<div class="nav-tabs" id="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 settings-tab" data-page="settings">⚙️ 설정</button>
<button class="nav-tab admin-tab" data-page="admin" id="admin-tab" style="display:none">👤 관리자</button>
</div>
<!-- ══════════════════ STT PAGE ══════════════════ -->
<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 · aac · 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 style="font-family:var(--mono);font-size:.6rem;color:var(--muted);margin-top:5px">
설정 페이지에서 기본 STT 모델을 지정하세요
</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" id="stt-wave">
<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 PAGE ══════════════════ -->
<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:460px"></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>
<!-- ══════════════════ SETTINGS PAGE ══════════════════ -->
<div class="page" id="page-settings">
<div class="settings-wrap">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:24px">
<h2 style="font-family:var(--mono);font-size:.9rem;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">🔄 모델 새로고침</button>
</div>
<div class="settings-section">
<h3>🎙 STT Ollama 후처리 기본 모델</h3>
<div class="settings-row">
<div class="settings-label">
Whisper 변환 후 Ollama로 교정할 때 사용할 기본 모델
<small>STT 페이지에서 모델 미선택 시 이 모델이 사용됩니다</small>
</div>
</div>
<select class="settings-select" id="setting-stt-model" style="width:100%;max-width:100%">
<option value="">(없음 — Ollama 후처리 비활성)</option>
</select>
</div>
<div class="settings-section">
<h3>🔍 OCR Ollama 기본 모델</h3>
<div class="settings-row">
<div class="settings-label">
OCR에서 Ollama Vision 엔진 선택 시 사용할 기본 모델
<small>OCR 페이지에서 모델 미선택 시 이 모델이 사용됩니다</small>
</div>
</div>
<select class="settings-select" id="setting-ocr-model" style="width:100%;max-width:100%">
<option value="">(없음)</option>
</select>
</div>
<div style="display:flex;gap:10px;justify-content:flex-end">
<div id="settings-msg" style="font-family:var(--mono);font-size:.7rem;color:var(--accent);display:none;align-items:center">✓ 저장되었습니다</div>
<button class="btn-settings blue" id="btn-save-settings">설정 저장</button>
</div>
</div>
</div>
<!-- ══════════════════ ADMIN PAGE ══════════════════ -->
<div class="page" id="page-admin">
<div class="admin-wrap">
<h2 style="font-family:var(--mono);font-size:.9rem;font-weight:600;letter-spacing:.06em;margin-bottom:24px">👤 사용자 관리</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" id="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">
</div>
<div class="form-group">
<label>비밀번호</label>
<input type="password" class="form-input" id="new-password" placeholder="password">
</div>
</div>
<div class="form-group">
<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 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>새 비밀번호 (변경 시에만 입력)</label>
<input type="password" class="form-input" id="edit-password" placeholder="비워두면 변경 안 함" style="width:100%;margin-top:5px">
</div>
<div class="form-group" style="margin-top:14px">
<label>사용 권한</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 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 = localStorage.getItem('vs_token') || null;
let currentUser = null; // {username, role, permissions}
let ollamaModels = []; // 전체 Ollama 모델 목록
let appSettings = {}; // {stt_ollama_model, ocr_ollama_model}
// STT
let sttFile=null, sttOutputFile=null, sttEngine='whisper';
// OCR
let ocrFile=null, ocrOutputTxt=null, ocrOutputXlsx=null, ocrEngine='paddle', ocrMode='text';
// Admin
let editTarget=null;
// ══════════════════════════════════════════════════════════════
// API 헬퍼
// ══════════════════════════════════════════════════════════════
const api = (method, url, body) => {
const opts = {method, headers: {Authorization: 'Bearer '+(token||'')}};
if(body) opts.body = body;
return fetch(url, opts);
};
const apiFD = (url, fd) => api('POST', url, fd);
// ══════════════════════════════════════════════════════════════
// AUTH
// ══════════════════════════════════════════════════════════════
async function checkAuth(){
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();
} else showLogin();
} catch{showLogin()}
}
function applyUserUI(){
document.getElementById('user-name').textContent = currentUser.username;
const badge = document.getElementById('user-badge');
badge.textContent = currentUser.role === 'admin' ? 'ADMIN' : 'USER';
badge.className = 'user-badge ' + currentUser.role;
// 관리자 탭 표시
document.getElementById('admin-tab').style.display =
currentUser.role === 'admin' ? 'flex' : 'none';
// 권한 없는 탭 비활성화
document.querySelectorAll('.nav-tab[data-page="stt"]').forEach(t => {
t.style.opacity = currentUser.permissions?.stt ? '1' : '0.35';
t.style.pointerEvents = currentUser.permissions?.stt ? '' : 'none';
});
document.querySelectorAll('.nav-tab[data-page="ocr"]').forEach(t => {
t.style.opacity = currentUser.permissions?.ocr ? '1' : '0.35';
t.style.pointerEvents = currentUser.permissions?.ocr ? '' : 'none';
});
}
function showLogin(){ document.getElementById('login-overlay').style.display='flex' }
function 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();
const 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='';
});
// ══════════════════════════════════════════════════════════════
// 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(e){ console.error('모델 로드 실패', e) }
}
function populateModelSelects(){
const makeOptions = (sel, defaultVal, prefix='') => {
const cur = sel.value || defaultVal || '';
sel.innerHTML = `<option value="">${prefix || '(없음)'}</option>`;
ollamaModels.forEach(m => {
const opt = document.createElement('option');
opt.value = m; opt.textContent = m;
if(m === cur) opt.selected = true;
sel.appendChild(opt);
});
};
// STT 후처리 모델 드롭다운
makeOptions(document.getElementById('stt-ollama-model'),
appSettings.stt_ollama_model, '설정 기본 모델 사용');
// OCR Ollama 모델 드롭다운
makeOptions(document.getElementById('ocr-ollama-model'),
appSettings.ocr_ollama_model, '설정 기본 모델 사용');
// 설정 페이지 드롭다운
makeOptions(document.getElementById('setting-stt-model'),
appSettings.stt_ollama_model, '(없음 — Ollama 후처리 비활성)');
makeOptions(document.getElementById('setting-ocr-model'),
appSettings.ocr_ollama_model, '(없음)');
}
// ══════════════════════════════════════════════════════════════
// 설정 로드/저장
// ══════════════════════════════════════════════════════════════
async function loadSettings(){
try{
const r = await api('GET','/api/settings');
appSettings = await r.json();
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);
try{
const r = await api('POST','/api/settings',fd);
const d = await r.json();
if(r.ok){
appSettings = d.settings;
const msg = document.getElementById('settings-msg');
msg.style.display = 'flex'; setTimeout(()=>msg.style.display='none', 2000);
}
} catch{}
});
document.getElementById('btn-refresh-models')?.addEventListener('click', ()=>loadOllamaModels());
// ══════════════════════════════════════════════════════════════
// NAV TABS
// ══════════════════════════════════════════════════════════════
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 page = document.getElementById('page-'+btn.dataset.page);
if(page) page.classList.add('active');
if(btn.dataset.page==='admin') loadUsers();
if(btn.dataset.page==='settings') loadSettings();
});
});
// ══════════════════════════════════════════════════════════════
// 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';
}
// STT 엔진 버튼
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;
const ollamaOpts=document.getElementById('stt-ollama-opts');
ollamaOpts.classList.toggle('visible', sttEngine==='whisper+ollama');
document.getElementById('stt-btn').className =
'btn-start ' + (sttEngine==='whisper+ollama'?'purple':'green');
if(sttFile) document.getElementById('stt-btn').disabled=false;
});
});
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);
const useOllama = sttEngine==='whisper+ollama';
fd.append('use_ollama', useOllama?'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=>{
document.getElementById('stt-err').style.display='block';
document.getElementById('stt-err').textContent='⚠ '+e;
setSttLoading(false);
});
} catch(e){document.getElementById('stt-err').style.display='block';document.getElementById('stt-err').textContent='⚠ '+e.message;setSttLoading(false)}
});
function setSttLoading(on){
document.getElementById('stt-btn').disabled=on;
document.getElementById('stt-prog').style.display=on?'block':'none';
document.getElementById('stt-wave').style.display=on?'flex':'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||'Ollama';
} 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',resetStt);
function resetStt(){
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';
}
// OCR 엔진 버튼
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;
const ollamaOpts=document.getElementById('ocr-ollama-opts');
ollamaOpts.classList.toggle('visible', ocrEngine==='ollama');
document.getElementById('ocr-btn').className='btn-start '+(ocrEngine==='ollama'?'purple':'green');
if(ocrFile) document.getElementById('ocr-btn').disabled=false;
});
});
// 커스텀 프롬프트 토글
document.getElementById('cprompt-toggle').addEventListener('click',()=>{
const ta=document.getElementById('custom-prompt');
const arrow=document.getElementById('cprompt-toggle');
const open=ta.style.display!=='block';
ta.style.display=open?'block':'none';
arrow.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=>{
document.getElementById('ocr-err').style.display='block';
document.getElementById('ocr-err').textContent='⚠ '+e;
setOcrLoading(false);
});
} catch(e){document.getElementById('ocr-err').style.display='block';document.getElementById('ocr-err').textContent='⚠ '+e.message;setOcrLoading(false)}
});
function setOcrLoading(on){
const isOllama=ocrEngine==='ollama';
const color=isOllama?'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=color;
document.getElementById('ocr-ppct').style.color=color;
document.querySelectorAll('#ocr-wave .wave-bar').forEach(b=>b.style.background=color);
if(on)setProg('ocr',0,'준비 중...');
}
function showOcrResult(d){
ocrOutputTxt=d.txt_file||null; ocrOutputXlsx=d.xlsx_file||null;
const isOllama=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=isOllama?`Ollama·${d.ollama_model||''}`:'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 conf=line.confidence||0, cls=conf>=.9?'high':conf>=.7?'mid':'low';
const row=document.createElement('div'); row.className='line-item';
row.innerHTML=`<div class="line-conf ${cls}">${isOllama?'AI':Math.round(conf*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',resetOcr);
function resetOcr(){
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');
}
// ══════════════════════════════════════════════════════════════
// ADMIN
// ══════════════════════════════════════════════════════════════
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 perms=info.permissions||{};
const isAdmin=info.role==='admin';
tr.innerHTML=`
<td style="font-family:var(--mono);font-size:.8rem">${esc(name)}</td>
<td><span class="role-badge ${info.role}">${info.role}</span></td>
<td><span class="perm-badge ${perms.stt?'on':'off'}">${perms.stt?'허용':'차단'}</span></td>
<td><span class="perm-badge ${perms.ocr?'on':'off'}">${perms.ocr?'허용':'차단'}</span></td>
<td>
${isAdmin?'<span style="font-family:var(--mono);font-size:.62rem;color:var(--muted)">기본 관리자</span>':`
<button class="btn-sm" onclick="openEditModal('${esc(name)}',${perms.stt},${perms.ocr})">편집</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 username=document.getElementById('new-username').value.trim();
const password=document.getElementById('new-password').value;
const stt=document.getElementById('new-perm-stt').checked;
const ocr=document.getElementById('new-perm-ocr').checked;
const msg=document.getElementById('add-msg');
if(!username||!password){showAdminMsg(msg,'아이디와 비밀번호를 입력하세요','err');return}
const fd=new FormData();
fd.append('username',username);fd.append('password',password);
fd.append('perm_stt',stt?'true':'false');fd.append('perm_ocr',ocr?'true':'false');
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;
loadUsers();
} else showAdminMsg(msg,d.detail||'실패','err');
} catch{showAdminMsg(msg,'서버 오류','err')}
});
function openEditModal(username, stt, ocr){
editTarget=username;
document.getElementById('edit-modal-username').textContent=username;
document.getElementById('edit-perm-stt').checked=stt;
document.getElementById('edit-perm-ocr').checked=ocr;
document.getElementById('edit-password').value='';
document.getElementById('edit-msg').style.display='none';
document.getElementById('edit-modal').classList.add('visible');
}
document.getElementById('btn-modal-cancel').addEventListener('click',()=>{
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');
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||'')}});
const d=await r.json();
if(r.ok) loadUsers();
else 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);
}
// ══════════════════════════════════════════════════════════════
// RESULT TABS
// ══════════════════════════════════════════════════════════════
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));
const p=t.closest('.panel');
p.querySelectorAll('.tab-content').forEach((c,i)=>c.classList.toggle('active',i===0));
}
// ══════════════════════════════════════════════════════════════
// POLLING / 공통 유틸
// ══════════════════════════════════════════════════════════════
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(prefix,pct,msg){
document.getElementById(prefix+'-pfill').style.width=pct+'%';
document.getElementById(prefix+'-pmsg').textContent=msg;
document.getElementById(prefix+'-ppct').textContent=pct+'%';
}
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<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{}}
// ══════════════════════════════════════════════════════════════
checkAuth();
</script>
</body>
</html>