UX: Live progress panel below upload form, stable progress bar, inline preview/download

This commit is contained in:
Sebastjan Artič 2026-04-28 16:19:40 +00:00
parent 6e2a13d8a3
commit c34e4aa376

View File

@ -158,6 +158,17 @@
.tab.active { color: var(--text); border-bottom-color: var(--accent); }
.hidden { display: none !important; }
code { background: var(--panel-2); padding: 1px 6px; border-radius: 3px; font-family: ui-monospace, monospace; font-size: 12px; }
.spinner {
width: 16px;
height: 16px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
flex-shrink: 0;
}
@keyframes spin { to { transform: rotate(360deg); } }
.progress-bar.smooth { transition: width 0.4s ease; }
</style>
</head>
<body>
@ -269,9 +280,22 @@
Naredi reel
</button>
<div id="upload-progress" class="hidden" style="margin-top: 12px;">
<div class="step" id="upload-status">Nalaganje...</div>
<div class="progress"><div class="progress-bar" id="upload-bar"></div></div>
<!-- Live progress panel pod upload formo -->
<div id="live-progress" class="hidden" style="margin-top: 20px; padding: 14px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px;">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 8px;">
<div class="spinner" id="live-spinner"></div>
<div style="font-weight: 600; font-size: 14px;" id="live-stage">Nalaganje...</div>
<span class="badge processing" id="live-badge" style="margin-left: auto;">aktivno</span>
</div>
<div class="progress" style="margin: 8px 0;">
<div class="progress-bar" id="live-bar" style="width: 0%;"></div>
</div>
<div style="font-size: 12px; color: var(--muted);" id="live-detail">Pripravljam...</div>
<div id="live-result" class="hidden" style="margin-top: 12px; display: flex; gap: 8px;">
<button class="small" id="live-download" style="display: none;">⬇ Download</button>
<button class="small ghost" id="live-preview" style="display: none;">▶ Preview</button>
</div>
<video id="live-video" class="hidden" controls style="margin-top: 12px; max-height: 400px; width: 100%; border-radius: 8px; background: black;"></video>
</div>
</section>
@ -352,71 +376,174 @@
return parseFloat(s);
}
// ─── Live progress panel ────────────────────────
const livePanel = $("#live-progress");
const liveStage = $("#live-stage");
const liveDetail = $("#live-detail");
const liveBar = $("#live-bar");
const liveBadge = $("#live-badge");
const liveSpinner = $("#live-spinner");
const liveResult = $("#live-result");
const liveVideo = $("#live-video");
const liveDownloadBtn = $("#live-download");
const livePreviewBtn = $("#live-preview");
function showLive(stage, detail, pct, badge = "aktivno", badgeClass = "processing") {
livePanel.classList.remove("hidden");
liveStage.textContent = stage;
liveDetail.textContent = detail || "";
liveBadge.textContent = badge;
liveBadge.className = "badge " + badgeClass;
if (pct === null || pct === undefined) {
// indeterminate
liveBar.classList.add("indeterminate");
liveBar.classList.remove("smooth");
liveBar.style.width = "30%";
} else {
liveBar.classList.remove("indeterminate");
liveBar.classList.add("smooth");
liveBar.style.width = Math.max(0, Math.min(100, pct)) + "%";
}
}
function liveDone(jobId) {
liveSpinner.style.display = "none";
liveBar.classList.remove("indeterminate");
liveBar.classList.add("smooth");
liveBar.style.width = "100%";
liveStage.textContent = "✅ Končano";
liveBadge.textContent = "končano";
liveBadge.className = "badge done";
liveDetail.textContent = "Reel je pripravljen za prenos ali predogled.";
liveResult.classList.remove("hidden");
liveDownloadBtn.style.display = "inline-block";
livePreviewBtn.style.display = "inline-block";
liveDownloadBtn.onclick = () => window.open(`/api/download/${jobId}`);
livePreviewBtn.onclick = () => {
liveVideo.src = `/api/preview/${jobId}`;
liveVideo.classList.remove("hidden");
liveVideo.play();
};
}
function liveFail(error) {
liveSpinner.style.display = "none";
liveBar.classList.remove("indeterminate");
liveBar.style.width = "100%";
liveBar.style.background = "var(--error)";
liveStage.textContent = "❌ Napaka";
liveBadge.textContent = "napaka";
liveBadge.className = "badge failed";
liveDetail.innerHTML = `<span style="color: var(--error)">${error || "Neznana napaka"}</span>`;
}
function liveReset() {
livePanel.classList.add("hidden");
liveSpinner.style.display = "block";
liveBar.style.background = "";
liveResult.classList.add("hidden");
liveDownloadBtn.style.display = "none";
livePreviewBtn.style.display = "none";
liveVideo.classList.add("hidden");
liveVideo.src = "";
}
// Stage label mapping (server step → friendly slo + percent estimate)
const STAGE_INFO = {
"Naloženo, čaka na obdelavo": { pct: 30, friendly: "Naloženo, čaka v vrsti" },
"V vrsti za obdelavo": { pct: 35, friendly: "V vrsti za obdelavo" },
"V vrsti za YouTube prenos": { pct: 30, friendly: "V vrsti za YouTube prenos" },
"YouTube download": { pct: 45, friendly: "Prenašam z YouTube..." },
"Iščem refren (Whisper + energy)": { pct: 60, friendly: "Iščem refren v pesmi..." },
"Reframe + subtitles": { pct: 75, friendly: "Reframe v 9:16 + podnapisi..." },
"Končano": { pct: 100, friendly: "✅ Končano" },
};
// ─── Submit ─────────────────────────────────────
$("#submit-btn").addEventListener("click", async () => {
const isYT = $("#tab-youtube").classList.contains("hidden") === false;
const settings = collectSettings();
$("#submit-btn").disabled = true;
$("#upload-progress").classList.remove("hidden");
liveReset();
try {
if (isYT) {
const url = $("#yt-url").value.trim();
if (!url) { alert("Vpiši YouTube URL"); return; }
$("#upload-status").textContent = "Pošiljam YouTube job...";
if (!url) { alert("Vpiši YouTube URL"); $("#submit-btn").disabled = false; return; }
showLive("Pošiljam YouTube job...", url, null);
const r = await fetch("/api/youtube", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url, ...settings }),
});
if (!r.ok) throw new Error("YouTube submit napaka");
if (!r.ok) {
const err = await r.text();
liveFail("YouTube submit napaka: " + err);
return;
}
const job = await r.json();
watchJob(job.id);
refreshJobs();
} else {
if (!pendingFile) { alert("Izberi datoteko"); return; }
if (!pendingFile) {
alert("Izberi datoteko");
$("#submit-btn").disabled = false;
return;
}
const fd = new FormData();
fd.append("file", pendingFile);
showLive("Nalaganje datoteke", `${pendingFile.name} (${(pendingFile.size / 1024 / 1024).toFixed(1)} MB)`, 0);
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = e => {
if (e.lengthComputable) {
const pct = (e.loaded / e.total) * 100;
$("#upload-bar").style.width = pct + "%";
$("#upload-status").textContent = `Nalagam... ${pct.toFixed(0)}%`;
// Upload je 025% celotnega procesa
const uploadPct = (e.loaded / e.total) * 25;
const mbDone = (e.loaded / 1024 / 1024).toFixed(1);
const mbTotal = (e.total / 1024 / 1024).toFixed(1);
showLive(
"Nalaganje datoteke",
`${mbDone} / ${mbTotal} MB (${((e.loaded / e.total) * 100).toFixed(0)}%)`,
uploadPct
);
}
};
xhr.onload = async () => {
if (xhr.status !== 200) {
alert("Upload napaka: " + xhr.responseText);
liveFail("Upload napaka: " + xhr.responseText);
$("#submit-btn").disabled = false;
return;
}
const job = JSON.parse(xhr.responseText);
$("#upload-status").textContent = "Naloženo, začenjam obdelavo...";
const proc = await fetch("/api/process", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ job_id: job.id, ...settings }),
});
if (!proc.ok) throw new Error("Process start napaka");
watchJob(job.id);
refreshJobs();
showLive("Naloženo, začenjam obdelavo...", `Job ${job.id}`, 28);
try {
const proc = await fetch("/api/process", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ job_id: job.id, ...settings }),
});
if (!proc.ok) {
liveFail("Process start napaka: " + await proc.text());
return;
}
watchJob(job.id);
refreshJobs();
} catch (e) {
liveFail("Process napaka: " + e.message);
}
};
xhr.onerror = () => liveFail("Upload prekinjen — preveri internet povezavo");
xhr.upload.onerror = () => liveFail("Upload napaka pri prenosu");
xhr.open("POST", "/api/upload");
xhr.send(fd);
}
} catch (e) {
alert("Napaka: " + e.message);
liveFail(e.message);
} finally {
setTimeout(() => {
$("#upload-progress").classList.add("hidden");
$("#submit-btn").disabled = false;
pendingFile = null;
fileInput.value = "";
dz.querySelector("div").textContent = "Klikni ali povleci video sem";
}, 2000);
// Submit button enable tudi če napaka, da lahko ponovno poskusi
setTimeout(() => { $("#submit-btn").disabled = false; }, 500);
}
});
@ -427,13 +554,40 @@
try {
const job = JSON.parse(e.data);
updateJobInList(job);
// Update live panel
const step = job.current_step || "";
const info = STAGE_INFO[step] || { pct: null, friendly: step };
if (job.status === "done") {
liveDone(jobId);
$("#submit-btn").disabled = false;
// Reset upload form
pendingFile = null;
fileInput.value = "";
dz.querySelector("div").textContent = "Klikni ali povleci video sem";
} else if (job.status === "failed") {
liveFail(job.error || "Obdelava ni uspela");
$("#submit-btn").disabled = false;
} else {
showLive(info.friendly, `Job ${job.id} · ${job.status}`, info.pct);
}
if (job.status === "done" || job.status === "failed") {
evt.close();
refreshJobs();
}
} catch {}
} catch (err) {
console.error("SSE parse err:", err);
}
};
evt.onerror = () => {
evt.close();
// SSE may close at end of stream — final fetch to confirm
fetch(`/api/jobs/${jobId}`).then(r => r.json()).then(job => {
if (job.status === "done") liveDone(jobId);
else if (job.status === "failed") liveFail(job.error || "");
}).catch(() => {});
};
evt.onerror = () => evt.close();
}
// ─── Jobs list ──────────────────────────────────