1132 lines
64 KiB
HTML
1132 lines
64 KiB
HTML
<!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,'&').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>
|