Initial commit after re-install
This commit is contained in:
895
app/static/index.html
Normal file
895
app/static/index.html
Normal file
@@ -0,0 +1,895 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Web STT</title>
|
||||
<style>
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0b0d12;
|
||||
--panel: #121624;
|
||||
--muted: #9aa4b2;
|
||||
--text: #e6eaf2;
|
||||
--accent: #6ea8fe;
|
||||
--danger: #ff6b6b;
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
--field-bg: rgba(0, 0, 0, 0.18);
|
||||
--shadow: 0 18px 45px rgba(0, 0, 0, 0.28);
|
||||
--bg-grad-1: radial-gradient(1200px 500px at 10% 10%, rgba(110, 168, 254, 0.18), transparent 60%);
|
||||
--bg-grad-2: radial-gradient(900px 420px at 80% 20%, rgba(130, 231, 171, 0.12), transparent 60%);
|
||||
}
|
||||
html[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #f7f9fc;
|
||||
--panel: #ffffff;
|
||||
--muted: #475467;
|
||||
--text: #101828;
|
||||
--accent: #2563eb;
|
||||
--danger: #b42318;
|
||||
--border: rgba(16, 24, 40, 0.12);
|
||||
--field-bg: #f2f4f7;
|
||||
--shadow: 0 18px 45px rgba(16, 24, 40, 0.08);
|
||||
--bg-grad-1: radial-gradient(1200px 500px at 10% 10%, rgba(37, 99, 235, 0.12), transparent 60%);
|
||||
--bg-grad-2: radial-gradient(900px 420px at 80% 20%, rgba(22, 163, 74, 0.10), transparent 60%);
|
||||
}
|
||||
html[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple SD Gothic Neo",
|
||||
"Noto Sans KR", "Malgun Gothic", sans-serif;
|
||||
background: var(--bg-grad-1), var(--bg-grad-2), var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
.wrap {
|
||||
max-width: 1220px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 18px 40px;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
.tabs {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.btn.tab {
|
||||
padding: 8px 10px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.btn.tab.active {
|
||||
border-color: color-mix(in oklab, var(--accent) 55%, var(--border));
|
||||
background: color-mix(in oklab, var(--accent) 12%, rgba(255, 255, 255, 0.06));
|
||||
}
|
||||
h1 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.sub {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
@media (min-width: 920px) {
|
||||
.grid {
|
||||
grid-template-columns: 360px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin: 10px 0 6px;
|
||||
}
|
||||
input[type="file"],
|
||||
input[type="text"],
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 10px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--field-bg);
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
input[type="checkbox"] {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
.btn {
|
||||
appearance: none;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text);
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn.primary {
|
||||
background: linear-gradient(180deg, rgba(110, 168, 254, 0.28), rgba(110, 168, 254, 0.16));
|
||||
border-color: color-mix(in oklab, var(--accent) 55%, var(--border));
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.hint {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.status {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.progress {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
progress {
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
}
|
||||
.err {
|
||||
color: var(--danger);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 440px;
|
||||
resize: vertical;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--field-bg);
|
||||
color: var(--text);
|
||||
line-height: 1.55;
|
||||
}
|
||||
#out {
|
||||
min-height: 560px;
|
||||
}
|
||||
#segments {
|
||||
min-height: 260px;
|
||||
}
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
.small {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
tbody tr.record-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
tbody tr.record-row:hover {
|
||||
background: color-mix(in oklab, var(--accent) 6%, transparent);
|
||||
}
|
||||
tbody tr.record-row.selected {
|
||||
background: color-mix(in oklab, var(--accent) 10%, transparent);
|
||||
}
|
||||
th,
|
||||
td {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 10px 8px;
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
text-align: left;
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.truncate {
|
||||
max-width: 320px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.view {
|
||||
display: none;
|
||||
}
|
||||
.view.active {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<div>
|
||||
<h1>Web STT</h1>
|
||||
<div class="sub">mp3/m4a 등 음성파일 업로드 → 텍스트 변환</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<div class="tabs">
|
||||
<button class="btn tab active" id="tab-stt" type="button">전사</button>
|
||||
<button class="btn tab" id="tab-admin" type="button">관리</button>
|
||||
</div>
|
||||
<button class="btn" id="theme" type="button">테마</button>
|
||||
<div class="pill" id="health">서버 상태 확인 중…</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="view-stt" class="view active">
|
||||
<div class="grid">
|
||||
<section class="card">
|
||||
<label>음성 파일</label>
|
||||
<input id="file" type="file" accept="audio/*,.m4a,.mp3,.wav,.mp4,.aac,.ogg,.flac,.webm" />
|
||||
|
||||
<label>작성자 이메일(author_id)</label>
|
||||
<input id="author" type="text" value="dosangyoon@gmail.com" placeholder="예: user@example.com" />
|
||||
|
||||
<div class="row">
|
||||
<div style="flex: 1 1 160px">
|
||||
<label>언어</label>
|
||||
<select id="language">
|
||||
<option value="ko" selected>ko (한국어, 기본)</option>
|
||||
<option value="en">en (English)</option>
|
||||
<option value="ja">ja (日本語)</option>
|
||||
<option value="zh">zh (中文)</option>
|
||||
<option value="auto">자동 감지</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex: 0 0 140px">
|
||||
<label>beam size</label>
|
||||
<select id="beam">
|
||||
<option value="1">1 (빠름)</option>
|
||||
<option value="3">3</option>
|
||||
<option value="5" selected>5 (기본)</option>
|
||||
<option value="8">8</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
<input id="vad" type="checkbox" checked />
|
||||
VAD 필터 (무음 구간 감소)
|
||||
</label>
|
||||
|
||||
<div class="row" style="margin-top: 12px">
|
||||
<button class="btn primary" id="go" disabled>전사(STT) 실행</button>
|
||||
<button class="btn" id="cancel" disabled>취소</button>
|
||||
<button class="btn" id="download" disabled>TXT 다운로드</button>
|
||||
<button class="btn" id="clear">초기화</button>
|
||||
</div>
|
||||
|
||||
<div class="hint">
|
||||
- 허용: mp3, m4a, wav, mp4, aac, ogg, flac, webm<br />
|
||||
- 첫 실행 시 Whisper 모델 다운로드로 시간이 걸릴 수 있습니다.
|
||||
</div>
|
||||
|
||||
<div class="progress">
|
||||
<progress id="prog" max="1" value="0"></progress>
|
||||
<div class="small mono" id="progText">0%</div>
|
||||
</div>
|
||||
<div class="status" id="status"></div>
|
||||
<div class="status err" id="error"></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="row" style="justify-content: space-between">
|
||||
<div class="small" id="meta">결과 대기 중</div>
|
||||
<div class="small mono" id="timing"></div>
|
||||
</div>
|
||||
<label>전사 결과</label>
|
||||
<textarea id="out" class="mono" placeholder="여기에 결과가 표시됩니다." spellcheck="false"></textarea>
|
||||
<label>세그먼트(JSON)</label>
|
||||
<textarea id="segments" class="mono" placeholder="세그먼트가 여기에 표시됩니다." spellcheck="false"></textarea>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="view-admin" class="view">
|
||||
<div class="grid">
|
||||
<section class="card">
|
||||
<div class="row" style="justify-content: space-between">
|
||||
<div style="flex: 1 1 200px">
|
||||
<label>필터(파일명/텍스트)</label>
|
||||
<input id="admin-q" type="text" placeholder="검색어" />
|
||||
</div>
|
||||
<div style="flex: 1 1 200px">
|
||||
<label>author_id</label>
|
||||
<input id="admin-author" type="text" value="dosangyoon@gmail.com" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" style="margin-top: 10px">
|
||||
<button class="btn primary" id="admin-refresh" type="button">목록 새로고침</button>
|
||||
<button class="btn" id="admin-clear-filter" type="button">필터 초기화</button>
|
||||
</div>
|
||||
<div class="status" id="admin-status"></div>
|
||||
<div style="margin-top: 10px; overflow: auto; max-height: 520px">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="nowrap">id</th>
|
||||
<th>파일</th>
|
||||
<th class="nowrap">상태</th>
|
||||
<th class="nowrap">작성자</th>
|
||||
<th class="nowrap">생성</th>
|
||||
<th class="nowrap">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="admin-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="row" style="justify-content: space-between">
|
||||
<div class="small" id="admin-meta">레코드 선택 없음</div>
|
||||
<div class="row">
|
||||
<button class="btn primary" id="admin-save" type="button" disabled>저장(수정)</button>
|
||||
<button class="btn" id="admin-delete" type="button" disabled>삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label>author_id</label>
|
||||
<input id="admin-edit-author" type="text" />
|
||||
|
||||
<label>status</label>
|
||||
<select id="admin-edit-status">
|
||||
<option value="completed">completed</option>
|
||||
<option value="cancelled">cancelled</option>
|
||||
<option value="failed">failed</option>
|
||||
<option value="running">running</option>
|
||||
<option value="queued">queued</option>
|
||||
</select>
|
||||
|
||||
<label>text</label>
|
||||
<textarea id="admin-edit-text" class="mono" style="min-height: 460px" spellcheck="false"></textarea>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const themeEl = $("theme");
|
||||
const tabSttEl = $("tab-stt");
|
||||
const tabAdminEl = $("tab-admin");
|
||||
const viewSttEl = $("view-stt");
|
||||
const viewAdminEl = $("view-admin");
|
||||
const fileEl = $("file");
|
||||
const authorEl = $("author");
|
||||
const goEl = $("go");
|
||||
const cancelEl = $("cancel");
|
||||
const outEl = $("out");
|
||||
const segEl = $("segments");
|
||||
const errEl = $("error");
|
||||
const statusEl = $("status");
|
||||
const progEl = $("prog");
|
||||
const progTextEl = $("progText");
|
||||
const downloadEl = $("download");
|
||||
const clearEl = $("clear");
|
||||
const healthEl = $("health");
|
||||
const metaEl = $("meta");
|
||||
const timingEl = $("timing");
|
||||
|
||||
const allowedExt = [".mp3", ".m4a", ".wav", ".mp4", ".aac", ".ogg", ".flac", ".webm"];
|
||||
let currentJobId = null;
|
||||
let pollTimer = null;
|
||||
let startedAt = null;
|
||||
let uploadController = null;
|
||||
let lastSegCount = 0;
|
||||
let lastTextLen = 0;
|
||||
|
||||
// Tabs
|
||||
function setTab(which) {
|
||||
const isAdmin = which === "admin";
|
||||
viewSttEl.classList.toggle("active", !isAdmin);
|
||||
viewAdminEl.classList.toggle("active", isAdmin);
|
||||
tabSttEl.classList.toggle("active", !isAdmin);
|
||||
tabAdminEl.classList.toggle("active", isAdmin);
|
||||
if (isAdmin) adminRefresh();
|
||||
}
|
||||
tabSttEl.addEventListener("click", () => setTab("stt"));
|
||||
tabAdminEl.addEventListener("click", () => setTab("admin"));
|
||||
|
||||
function applyTheme(theme) {
|
||||
const t = theme === "light" ? "light" : "dark";
|
||||
document.documentElement.dataset.theme = t;
|
||||
if (themeEl) themeEl.textContent = t === "dark" ? "테마: 다크" : "테마: 라이트";
|
||||
}
|
||||
const savedTheme = localStorage.getItem("theme");
|
||||
applyTheme(savedTheme || "dark");
|
||||
if (themeEl) {
|
||||
themeEl.addEventListener("click", () => {
|
||||
const cur = document.documentElement.dataset.theme || "dark";
|
||||
const next = cur === "dark" ? "light" : "dark";
|
||||
localStorage.setItem("theme", next);
|
||||
applyTheme(next);
|
||||
});
|
||||
}
|
||||
|
||||
function setError(msg) {
|
||||
errEl.textContent = msg || "";
|
||||
}
|
||||
function setStatus(msg) {
|
||||
statusEl.textContent = msg || "";
|
||||
}
|
||||
function setProgress(p) {
|
||||
if (typeof p === "number" && Number.isFinite(p)) {
|
||||
const v = Math.max(0, Math.min(1, p));
|
||||
progEl.value = v;
|
||||
progEl.removeAttribute("data-indeterminate");
|
||||
progTextEl.textContent = `${Math.round(v * 100)}%`;
|
||||
} else {
|
||||
// indeterminate
|
||||
progEl.removeAttribute("value");
|
||||
progTextEl.textContent = "…";
|
||||
}
|
||||
}
|
||||
function setIdle() {
|
||||
fileEl.disabled = false;
|
||||
goEl.disabled = !fileEl.files?.length;
|
||||
cancelEl.disabled = true;
|
||||
downloadEl.disabled = !outEl.value?.trim();
|
||||
}
|
||||
function setStarting() {
|
||||
fileEl.disabled = true;
|
||||
goEl.disabled = true;
|
||||
cancelEl.disabled = false;
|
||||
downloadEl.disabled = true;
|
||||
}
|
||||
function setRunning() {
|
||||
fileEl.disabled = true;
|
||||
goEl.disabled = true;
|
||||
cancelEl.disabled = false;
|
||||
downloadEl.disabled = true;
|
||||
}
|
||||
|
||||
async function checkHealth() {
|
||||
try {
|
||||
const r = await fetch("healthz");
|
||||
if (!r.ok) throw new Error("not ok");
|
||||
healthEl.textContent = "서버 정상";
|
||||
} catch {
|
||||
healthEl.textContent = "서버 미응답";
|
||||
}
|
||||
}
|
||||
|
||||
fileEl.addEventListener("change", () => {
|
||||
setError("");
|
||||
setStatus("");
|
||||
const f = fileEl.files?.[0];
|
||||
if (!f) {
|
||||
goEl.disabled = true;
|
||||
cancelEl.disabled = true;
|
||||
return;
|
||||
}
|
||||
const name = (f.name || "").toLowerCase();
|
||||
const ok = allowedExt.some((e) => name.endsWith(e));
|
||||
if (!ok) {
|
||||
setError(`허용되지 않는 파일 확장자입니다.\n허용: ${allowedExt.join(", ")}`);
|
||||
goEl.disabled = true;
|
||||
cancelEl.disabled = true;
|
||||
return;
|
||||
}
|
||||
goEl.disabled = false;
|
||||
cancelEl.disabled = true;
|
||||
setStatus(`선택됨: ${f.name} (${Math.round(f.size / 1024)} KB)`);
|
||||
});
|
||||
|
||||
clearEl.addEventListener("click", () => {
|
||||
cancelCurrent();
|
||||
fileEl.value = "";
|
||||
outEl.value = "";
|
||||
segEl.value = "";
|
||||
setError("");
|
||||
setStatus("");
|
||||
metaEl.textContent = "결과 대기 중";
|
||||
timingEl.textContent = "";
|
||||
setProgress(0);
|
||||
goEl.disabled = true;
|
||||
cancelEl.disabled = true;
|
||||
downloadEl.disabled = true;
|
||||
});
|
||||
|
||||
downloadEl.addEventListener("click", () => {
|
||||
const text = outEl.value || "";
|
||||
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "transcript.txt";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
cancelEl.addEventListener("click", () => {
|
||||
cancelCurrent();
|
||||
});
|
||||
|
||||
async function cancelCurrent() {
|
||||
if (uploadController) {
|
||||
try {
|
||||
uploadController.abort();
|
||||
} catch {}
|
||||
uploadController = null;
|
||||
setStatus("업로드 취소됨");
|
||||
setIdle();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentJobId) return;
|
||||
try {
|
||||
setStatus("취소 요청…");
|
||||
await fetch(`api/jobs/${encodeURIComponent(currentJobId)}/cancel`, { method: "POST" });
|
||||
} catch (e) {
|
||||
setError(String(e?.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function pollJobOnce() {
|
||||
if (!currentJobId) return;
|
||||
const r = await fetch(`api/jobs/${encodeURIComponent(currentJobId)}`);
|
||||
const body = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(body?.detail || `HTTP ${r.status}`);
|
||||
|
||||
const status = body.status;
|
||||
const progress = body.progress;
|
||||
setProgress(progress);
|
||||
|
||||
const text = body.text || "";
|
||||
const segs = Array.isArray(body.segments) ? body.segments : [];
|
||||
|
||||
if (text.length !== lastTextLen) {
|
||||
outEl.value = text;
|
||||
lastTextLen = text.length;
|
||||
}
|
||||
if (segs.length !== lastSegCount) {
|
||||
segEl.value = JSON.stringify(segs, null, 2);
|
||||
lastSegCount = segs.length;
|
||||
}
|
||||
|
||||
const lang = body.detected_language ? `${body.detected_language}` : "-";
|
||||
const prob = typeof body.language_probability === "number" ? body.language_probability.toFixed(3) : "-";
|
||||
const dur = typeof body.duration_sec === "number" ? `${body.duration_sec.toFixed(1)}s` : "-";
|
||||
metaEl.textContent = `감지 언어: ${lang} (p=${prob}), 오디오 길이: ${dur}`;
|
||||
|
||||
if (startedAt) {
|
||||
timingEl.textContent = `${((performance.now() - startedAt) / 1000).toFixed(2)}s`;
|
||||
}
|
||||
|
||||
if (status === "completed") {
|
||||
stopPolling();
|
||||
setStatus("완료");
|
||||
setProgress(1);
|
||||
currentJobId = null;
|
||||
uploadController = null;
|
||||
setIdle();
|
||||
downloadEl.disabled = !outEl.value?.trim();
|
||||
return;
|
||||
}
|
||||
if (status === "cancelled") {
|
||||
stopPolling();
|
||||
setStatus("취소됨");
|
||||
currentJobId = null;
|
||||
uploadController = null;
|
||||
setIdle();
|
||||
return;
|
||||
}
|
||||
if (status === "failed") {
|
||||
stopPolling();
|
||||
setStatus("실패");
|
||||
setError(body?.error || "실패");
|
||||
currentJobId = null;
|
||||
uploadController = null;
|
||||
setIdle();
|
||||
return;
|
||||
}
|
||||
|
||||
// running/queued
|
||||
setRunning();
|
||||
}
|
||||
|
||||
goEl.addEventListener("click", async () => {
|
||||
const f = fileEl.files?.[0];
|
||||
if (!f) return;
|
||||
|
||||
stopPolling();
|
||||
currentJobId = null;
|
||||
lastSegCount = 0;
|
||||
lastTextLen = 0;
|
||||
startedAt = performance.now();
|
||||
setStarting();
|
||||
setError("");
|
||||
setStatus("업로드/작업 생성 중…");
|
||||
metaEl.textContent = "처리 중…";
|
||||
timingEl.textContent = "";
|
||||
outEl.value = "";
|
||||
segEl.value = "";
|
||||
setProgress(0);
|
||||
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", f);
|
||||
const language = $("language").value;
|
||||
if (language) fd.append("language", language);
|
||||
const author = (authorEl?.value || "").trim();
|
||||
if (author) fd.append("author_id", author);
|
||||
fd.append("vad_filter", $("vad").checked ? "true" : "false");
|
||||
fd.append("beam_size", $("beam").value);
|
||||
|
||||
uploadController = new AbortController();
|
||||
const r = await fetch("api/jobs", { method: "POST", body: fd, signal: uploadController.signal });
|
||||
const body = await r.json().catch(() => ({}));
|
||||
if (!r.ok) {
|
||||
throw new Error(body?.detail || `HTTP ${r.status}`);
|
||||
}
|
||||
|
||||
currentJobId = body.job_id;
|
||||
uploadController = null;
|
||||
setStatus("전사(STT) 처리 중…");
|
||||
setRunning();
|
||||
|
||||
await pollJobOnce();
|
||||
pollTimer = setInterval(() => {
|
||||
pollJobOnce().catch((e) => {
|
||||
stopPolling();
|
||||
setError(String(e?.message || e));
|
||||
setStatus("실패");
|
||||
currentJobId = null;
|
||||
uploadController = null;
|
||||
setIdle();
|
||||
});
|
||||
}, 700);
|
||||
} catch (e) {
|
||||
const msg = String(e?.message || e);
|
||||
if (msg.includes("AbortError")) {
|
||||
setStatus("업로드 취소됨");
|
||||
} else {
|
||||
setError(msg);
|
||||
setStatus("실패");
|
||||
metaEl.textContent = "오류";
|
||||
}
|
||||
} finally {
|
||||
if (!currentJobId) {
|
||||
uploadController = null;
|
||||
setIdle();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
checkHealth();
|
||||
setInterval(checkHealth, 5000);
|
||||
|
||||
// Admin (DB Records)
|
||||
const adminQEl = $("admin-q");
|
||||
const adminAuthorEl = $("admin-author");
|
||||
const adminRefreshEl = $("admin-refresh");
|
||||
const adminClearFilterEl = $("admin-clear-filter");
|
||||
const adminStatusEl = $("admin-status");
|
||||
const adminTbodyEl = $("admin-tbody");
|
||||
const adminMetaEl = $("admin-meta");
|
||||
const adminSaveEl = $("admin-save");
|
||||
const adminDeleteEl = $("admin-delete");
|
||||
const adminEditAuthorEl = $("admin-edit-author");
|
||||
const adminEditStatusEl = $("admin-edit-status");
|
||||
const adminEditTextEl = $("admin-edit-text");
|
||||
let selectedRecordId = null;
|
||||
|
||||
function adminSetStatus(msg) {
|
||||
adminStatusEl.textContent = msg || "";
|
||||
}
|
||||
|
||||
function fmtDate(s) {
|
||||
try {
|
||||
const d = new Date(s);
|
||||
if (Number.isNaN(d.getTime())) return String(s || "");
|
||||
return d.toLocaleString();
|
||||
} catch {
|
||||
return String(s || "");
|
||||
}
|
||||
}
|
||||
|
||||
async function adminRefresh() {
|
||||
adminSetStatus("불러오는 중…");
|
||||
try {
|
||||
const q = (adminQEl.value || "").trim();
|
||||
const author = (adminAuthorEl.value || "").trim();
|
||||
const params = new URLSearchParams();
|
||||
params.set("limit", "50");
|
||||
if (q) params.set("q", q);
|
||||
if (author) params.set("author_id", author);
|
||||
|
||||
const r = await fetch(`api/records?${params.toString()}`);
|
||||
const body = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(body?.detail || `HTTP ${r.status}`);
|
||||
|
||||
const items = Array.isArray(body.items) ? body.items : [];
|
||||
adminTbodyEl.innerHTML = "";
|
||||
for (const it of items) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "record-row";
|
||||
tr.dataset.id = String(it.id);
|
||||
tr.innerHTML = `
|
||||
<td class="nowrap mono">${it.id}</td>
|
||||
<td class="truncate" title="${it.filename || ""}">${it.filename || "-"}</td>
|
||||
<td class="nowrap">${it.status || "-"}</td>
|
||||
<td class="truncate" title="${it.author_id || ""}">${it.author_id || "-"}</td>
|
||||
<td class="nowrap">${fmtDate(it.created_at)}</td>
|
||||
<td class="nowrap">
|
||||
<button class="btn" data-act="open" data-id="${it.id}" type="button">열기</button>
|
||||
<button class="btn" data-act="del" data-id="${it.id}" type="button">삭제</button>
|
||||
</td>
|
||||
`;
|
||||
adminTbodyEl.appendChild(tr);
|
||||
}
|
||||
adminSetStatus(`총 ${body.total ?? items.length}개 (상위 ${items.length}개 표시)`);
|
||||
} catch (e) {
|
||||
adminSetStatus(`오류: ${String(e?.message || e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function adminOpen(id) {
|
||||
selectedRecordId = Number(id);
|
||||
// selection highlight
|
||||
for (const row of adminTbodyEl.querySelectorAll("tr.record-row")) {
|
||||
row.classList.toggle("selected", Number(row.dataset.id) === selectedRecordId);
|
||||
}
|
||||
adminMetaEl.textContent = `레코드 #${selectedRecordId} 로딩…`;
|
||||
adminSaveEl.disabled = true;
|
||||
adminDeleteEl.disabled = true;
|
||||
try {
|
||||
const r = await fetch(`api/records/${encodeURIComponent(String(selectedRecordId))}`);
|
||||
const body = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(body?.detail || `HTTP ${r.status}`);
|
||||
|
||||
adminEditAuthorEl.value = body.author_id || "";
|
||||
adminEditStatusEl.value = body.status || "completed";
|
||||
adminEditTextEl.value = body.text || "";
|
||||
adminMetaEl.textContent = `레코드 #${selectedRecordId} (${body.filename || "-"})`;
|
||||
adminSaveEl.disabled = false;
|
||||
adminDeleteEl.disabled = false;
|
||||
} catch (e) {
|
||||
adminMetaEl.textContent = `오류: ${String(e?.message || e)}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function adminSave() {
|
||||
if (!selectedRecordId) return;
|
||||
adminSetStatus("저장 중…");
|
||||
try {
|
||||
const payload = {
|
||||
author_id: (adminEditAuthorEl.value || "").trim(),
|
||||
status: adminEditStatusEl.value,
|
||||
text: adminEditTextEl.value || "",
|
||||
};
|
||||
const r = await fetch(`api/records/${encodeURIComponent(String(selectedRecordId))}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const body = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(body?.detail || `HTTP ${r.status}`);
|
||||
adminSetStatus("저장 완료");
|
||||
adminMetaEl.textContent = `레코드 #${selectedRecordId} (${body.filename || "-"})`;
|
||||
await adminRefresh();
|
||||
} catch (e) {
|
||||
adminSetStatus(`오류: ${String(e?.message || e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function adminDelete(id = null) {
|
||||
const rid = id ? Number(id) : selectedRecordId;
|
||||
if (!rid) return;
|
||||
if (!confirm(`레코드 #${rid} 를 삭제할까요?`)) return;
|
||||
adminSetStatus("삭제 중…");
|
||||
try {
|
||||
const r = await fetch(`api/records/${encodeURIComponent(String(rid))}`, { method: "DELETE" });
|
||||
const body = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(body?.detail || `HTTP ${r.status}`);
|
||||
adminSetStatus("삭제 완료");
|
||||
if (selectedRecordId === rid) {
|
||||
selectedRecordId = null;
|
||||
adminMetaEl.textContent = "레코드 선택 없음";
|
||||
adminEditAuthorEl.value = "";
|
||||
adminEditTextEl.value = "";
|
||||
adminSaveEl.disabled = true;
|
||||
adminDeleteEl.disabled = true;
|
||||
}
|
||||
await adminRefresh();
|
||||
} catch (e) {
|
||||
adminSetStatus(`오류: ${String(e?.message || e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
adminRefreshEl.addEventListener("click", () => adminRefresh());
|
||||
adminClearFilterEl.addEventListener("click", () => {
|
||||
adminQEl.value = "";
|
||||
adminAuthorEl.value = "dosangyoon@gmail.com";
|
||||
adminRefresh();
|
||||
});
|
||||
adminSaveEl.addEventListener("click", () => adminSave());
|
||||
adminDeleteEl.addEventListener("click", () => adminDelete());
|
||||
|
||||
adminTbodyEl.addEventListener("click", (e) => {
|
||||
const btn = e.target?.closest?.("button[data-act]");
|
||||
if (!btn) return;
|
||||
const act = btn.getAttribute("data-act");
|
||||
const id = btn.getAttribute("data-id");
|
||||
if (act === "open") adminOpen(id);
|
||||
if (act === "del") adminDelete(id);
|
||||
});
|
||||
|
||||
// 행 클릭으로도 열기
|
||||
adminTbodyEl.addEventListener("click", (e) => {
|
||||
const btn = e.target?.closest?.("button[data-act]");
|
||||
if (btn) return; // 버튼 클릭은 위 핸들러에서 처리
|
||||
const row = e.target?.closest?.("tr.record-row");
|
||||
const id = row?.dataset?.id;
|
||||
if (id) adminOpen(id);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user