transcript / index.html
rsnarsna
feat: Enhance transcript extraction with multi-tier fallback; add YouTube API support and update UI to display extraction method
0bcc65e
Raw
History Blame Contribute Delete
15.2 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>YT Summariser</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css" />
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f9f9f8; color: #1a1a18; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
.wrap { width: 100%; max-width: 460px; padding: 2.5rem 1.5rem; }
/* ── Logo ── */
.logo { display: flex; align-items: center; gap: 10px; margin-bottom: 2.5rem; }
.logo-icon { width: 36px; height: 36px; background: #E24B4A; border-radius: 8px; display: flex; align-items: center; justify-content: center; }
.logo-icon i { color: #fff; font-size: 18px; }
.logo-text { font-size: 15px; font-weight: 600; }
.logo-sub { font-size: 12px; color: #888; }
/* ── Auth banner ── */
.auth-banner {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 14px; border-radius: 8px; margin-bottom: 1.5rem;
font-size: 13px; border: 1px solid #ddd; background: #fff;
}
.auth-banner.ok { border-color: #86efac; background: #f0fdf4; }
.auth-banner.bad { border-color: #fca5a5; background: #fff5f5; }
.auth-left { display: flex; align-items: center; gap: 8px; }
.auth-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.auth-dot.ok { background: #22c55e; }
.auth-dot.bad { background: #ef4444; }
.auth-text { color: #444; }
.auth-btn {
font-size: 12px; font-weight: 600; padding: 5px 12px;
border-radius: 6px; border: none; cursor: pointer;
background: #1a1a18; color: #fff; text-decoration: none;
display: inline-flex; align-items: center; gap: 5px;
transition: opacity .15s;
}
.auth-btn:hover { opacity: .8; }
.auth-btn.connected { background: #dcfce7; color: #166534; cursor: default; }
/* ── Heading ── */
h1 { font-size: 22px; font-weight: 600; margin-bottom: 6px; }
.subtitle { font-size: 14px; color: #666; margin-bottom: 2rem; line-height: 1.6; }
/* ── Form ── */
.field { margin-bottom: 1rem; }
label { display: block; font-size: 13px; color: #555; margin-bottom: 5px; }
input[type=text], input[type=email] {
width: 100%; padding: 9px 12px; font-size: 14px;
border: 1px solid #ddd; border-radius: 8px; background: #fff;
outline: none; transition: border-color .15s;
}
input:focus { border-color: #E24B4A; }
/* ── Buttons ── */
.btn {
width: 100%; padding: 10px; font-size: 14px; font-weight: 600;
cursor: pointer; border-radius: 8px; border: none;
background: #E24B4A; color: #fff; margin-top: .5rem;
display: flex; align-items: center; justify-content: center; gap: 8px;
transition: opacity .15s;
}
.btn:hover { opacity: .88; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
/* ── Error ── */
.err {
font-size: 13px; color: #a32d2d; margin-top: 1rem; display: none;
padding: 10px 12px; border: 1px solid #f09595; border-radius: 8px;
background: #fcebeb;
}
/* ── Status box ── */
.status-box {
margin-top: 1.5rem; border: 1px solid #e5e5e3;
border-radius: 12px; overflow: hidden; display: none; background: #fff;
}
.status-header {
padding: 12px 16px; display: flex; align-items: center;
justify-content: space-between; border-bottom: 1px solid #e5e5e3;
}
.status-label { font-size: 13px; font-weight: 600; }
.badge { font-size: 11px; padding: 3px 8px; border-radius: 20px; font-weight: 600; }
.badge-running { background: #dbeafe; color: #1e40af; }
.badge-done { background: #dcfce7; color: #166534; }
.badge-failed { background: #fee2e2; color: #991b1b; }
/* ── Steps ── */
.steps { padding: 12px 16px; display: flex; flex-direction: column; gap: 9px; }
.step { display: flex; align-items: center; gap: 10px; font-size: 13px; color: #999; }
.step.active { color: #1a1a18; }
.step-icon {
width: 20px; height: 20px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0; font-size: 11px; border: 1px solid #ddd; background: #f5f5f4;
}
.step-icon.done { background: #dcfce7; border-color: #86efac; color: #166534; }
.step-icon.running { background: #dbeafe; border-color: #93c5fd; color: #1e40af; }
.step-icon.failed { background: #fee2e2; border-color: #fca5a5; color: #991b1b; }
/* ── Result links ── */
.result-box {
padding: 12px 16px; border-top: 1px solid #e5e5e3;
display: none; flex-direction: column; gap: 8px;
}
.result-link { display: flex; align-items: center; gap: 8px; font-size: 13px; }
.result-link a { color: #1e40af; text-decoration: none; }
.result-link a:hover { text-decoration: underline; }
.result-note { font-size: 12px; color: #888; margin-top: 2px; }
/* ── Spinner ── */
.spinner {
width: 11px; height: 11px; border: 1.5px solid currentColor;
border-top-color: transparent; border-radius: 50%;
animation: spin .7s linear infinite; display: inline-block;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="wrap">
<!-- Logo -->
<div class="logo">
<div class="logo-icon"><i class="ti ti-brand-youtube"></i></div>
<div>
<div class="logo-text">YT Summariser</div>
<div class="logo-sub">Transcript → Summary → Q&amp;A</div>
</div>
</div>
<!-- Auth banner -->
<div class="auth-banner bad" id="auth-banner">
<div class="auth-left">
<div class="auth-dot bad" id="auth-dot"></div>
<span class="auth-text" id="auth-text">Google not connected</span>
</div>
<a href="/auth/start" class="auth-btn" id="auth-btn" target="_blank" onclick="onAuthClick()">
<i class="ti ti-brand-google"></i> Connect Google
</a>
</div>
<!-- Heading -->
<h1>Summarise a YouTube video</h1>
<p class="subtitle">Paste a YouTube link and your email — we'll process the transcript and send results to your inbox.</p>
<!-- Form -->
<div class="field">
<label for="yt-url">YouTube URL</label>
<input type="text" id="yt-url" placeholder="https://www.youtube.com/watch?v=..." />
</div>
<div class="field">
<label for="email">Email address</label>
<input type="email" id="email" placeholder="you@example.com" />
</div>
<button class="btn" id="submit-btn" onclick="submitJob()">
<i class="ti ti-player-play"></i> Start processing
</button>
<div class="err" id="err-box"></div>
<!-- Status box -->
<div class="status-box" id="status-box">
<div class="status-header">
<span class="status-label" id="status-label">Processing…</span>
<span class="badge badge-running" id="status-badge">Running</span>
</div>
<div class="steps" id="steps-list"></div>
<div class="result-box" id="result-box"></div>
</div>
</div>
<script>
/* ── Step config ── */
const STEP_LABELS = {
fetch_transcript: 'Fetching transcript',
summarize: 'Summarising with Gemini',
create_drive_folder: 'Creating Drive folder',
upload_summary: 'Uploading summary',
upload_qa: 'Uploading Q&A',
upload_transcript: 'Uploading transcript',
send_email: 'Sending email',
log_sheet: 'Logging to Sheets',
};
const STEP_ICONS = {
fetch_transcript: 'ti-file-text',
summarize: 'ti-brain',
create_drive_folder: 'ti-folder-plus',
upload_summary: 'ti-upload',
upload_qa: 'ti-upload',
upload_transcript: 'ti-upload',
send_email: 'ti-mail',
log_sheet: 'ti-table',
};
let pollTimer = null;
/* ── Auth status check ── */
async function checkAuth() {
try {
const res = await fetch('/auth/status');
const data = await res.json();
setAuthBanner(data.authenticated);
} catch (e) {
setAuthBanner(false);
}
}
function setAuthBanner(ok) {
const banner = document.getElementById('auth-banner');
const dot = document.getElementById('auth-dot');
const text = document.getElementById('auth-text');
const btn = document.getElementById('auth-btn');
if (ok) {
banner.className = 'auth-banner ok';
dot.className = 'auth-dot ok';
text.textContent = 'Google configured';
btn.className = 'auth-btn connected';
btn.innerHTML = '<i class="ti ti-circle-check"></i> Connected';
btn.removeAttribute('href');
btn.onclick = null;
} else {
banner.className = 'auth-banner bad';
dot.className = 'auth-dot bad';
text.textContent = 'Google not connected';
btn.className = 'auth-btn';
btn.innerHTML = '<i class="ti ti-brand-google"></i> Connect Google';
btn.href = '/auth/start';
btn.onclick = onAuthClick;
}
}
function onAuthClick() {
// After OAuth window closes, recheck
setTimeout(() => {
const check = setInterval(async () => {
const res = await fetch('/auth/status');
const data = await res.json();
if (data.authenticated) { clearInterval(check); setAuthBanner(true); }
}, 20000);
// Stop checking after 2 min
setTimeout(() => clearInterval(check), 120000);
}, 30000);
}
/* ── Helpers ── */
function showErr(msg) {
const b = document.getElementById('err-box');
b.textContent = msg; b.style.display = 'block';
}
function clearErr() { document.getElementById('err-box').style.display = 'none'; }
/* ── Submit ── */
async function submitJob() {
clearErr();
const url = document.getElementById('yt-url').value.trim();
const email = document.getElementById('email').value.trim();
if (!url) { showErr('Please enter a YouTube URL.'); return; }
if (!email) { showErr('Please enter an email address.'); return; }
const btn = document.getElementById('submit-btn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Starting…';
try {
const res = await fetch('/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ youtube_url: url, email_to: email }),
});
const data = await res.json();
if (!res.ok) {
showErr(data.detail || 'Request failed.');
btn.disabled = false;
btn.innerHTML = '<i class="ti ti-player-play"></i> Start processing';
return;
}
startPolling(data.job_id);
} catch (e) {
showErr('Could not reach the server. Is it running on localhost:8000?');
btn.disabled = false;
btn.innerHTML = '<i class="ti ti-player-play"></i> Start processing';
}
}
/* ── Polling ── */
function startPolling(jobId) {
document.getElementById('status-box').style.display = 'block';
renderSteps({});
pollTimer = setInterval(() => poll(jobId), 2500);
}
async function poll(jobId) {
try {
const res = await fetch('/status/' + jobId);
const data = await res.json();
renderSteps(data.steps || {});
updateBadge(data.status);
const running = Object.entries(data.steps || {}).find(([, v]) => v === 'running');
if (running) document.getElementById('status-label').textContent = STEP_LABELS[running[0]] + '…';
if (data.status === 'completed') {
clearInterval(pollTimer);
document.getElementById('status-label').textContent = 'Done!';
showResult(data.result);
const btn = document.getElementById('submit-btn');
btn.disabled = false;
btn.innerHTML = '<i class="ti ti-player-play"></i> Process another';
} else if (data.status === 'failed') {
clearInterval(pollTimer);
document.getElementById('status-label').textContent = 'Failed';
showErr('Pipeline failed: ' + (data.error || 'unknown error'));
const btn = document.getElementById('submit-btn');
btn.disabled = false;
btn.innerHTML = '<i class="ti ti-player-play"></i> Try again';
}
} catch (e) {}
}
/* ── Render steps ── */
function renderSteps(steps) {
const list = document.getElementById('steps-list');
list.innerHTML = Object.entries(STEP_LABELS).map(([key, label]) => {
const state = steps[key] || 'pending';
const iconCls = STEP_ICONS[key] || 'ti-circle';
let inner = '';
if (state === 'done') inner = '<i class="ti ti-check"></i>';
else if (state === 'running') inner = '<span class="spinner"></span>';
else if (state === 'failed') inner = '<i class="ti ti-x"></i>';
return `<div class="step ${state !== 'pending' ? 'active' : ''}">
<div class="step-icon ${state}">${inner}</div>
<i class="ti ${iconCls}" style="font-size:14px;color:#aaa"></i>
<span>${label}</span>
</div>`;
}).join('');
}
function updateBadge(status) {
const b = document.getElementById('status-badge');
b.className = 'badge';
if (status === 'completed') { b.classList.add('badge-done'); b.textContent = 'Completed'; }
else if (status === 'failed') { b.classList.add('badge-failed'); b.textContent = 'Failed'; }
else { b.classList.add('badge-running'); b.textContent = 'Running'; }
}
/* ── Show result links ── */
function showResult(result) {
if (!result) return;
const box = document.getElementById('result-box');
box.style.display = 'flex';
const drive = result.drive || {};
const links = [
['ti-file-text', 'Summary', drive.summary?.web_view_link],
['ti-help-circle', 'Q&A', drive.qa?.web_view_link],
['ti-align-left', 'Transcript', drive.transcript?.web_view_link],
];
const method = result.extraction_method || '';
const methodLabel = method ? `<span style="display:inline-block;font-size:11px;padding:2px 8px;border-radius:12px;background:#f0f4ff;color:#3b5998;border:1px solid #c4d3f0;margin-left:4px">${method}</span>` : '';
box.innerHTML = links
.filter(([,, u]) => u)
.map(([icon, label, u]) => `
<div class="result-link">
<i class="ti ${icon}" style="font-size:15px;color:#888"></i>
<a href="${u}" target="_blank">${label} <i class="ti ti-external-link" style="font-size:11px"></i></a>
</div>`)
.join('') +
`<div class="result-note" style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
<span><i class="ti ti-mail" style="font-size:13px;vertical-align:-2px"></i> Results also sent to your email</span>
${methodLabel ? '<span style="color:#999">·</span> Extracted via ' + methodLabel : ''}
</div>`;
}
/* ── Init ── */
checkAuth();
</script>
</body>
</html>