// ── STATE ────────────────────────────────────────────────────────── const state = { sentences: [], // [{text, isPara}] currentIdx: 0, isPlaying: false, speed: 1.0, model: 'openai-tts-1', voice: 'nova', fontSize: 18, docId: null, docTitle: '', // PDF state pdfB64: null, // base64 PDF data pageTexts: [], // text per page for page-sentence mapping sentencePage: [], // maps sentence index → page index (0-based) currentView: 'text', // 'text' | 'pdf' }; let currentAudio = null; const audioCache = new Map(); let allModels = {}; let currentUser = null; // PDF.js state const PDF_SCALE = 1.5; let pdfJsDoc = null; let pdfAllWords = []; // [{page, x, y, w, h, str}] let pdfWordMap = []; // sentence_idx → [word_idx, ...] let pageOverlays = []; // [{overlay, wrap}] per page // ── DOM ──────────────────────────────────────────────────────────── const $ = id => document.getElementById(id); const textViewer = $('text-viewer'); const modelSelect = $('model-select'); const voiceSelect = $('voice-select'); const speedSlider = $('speed-slider'); const speedLabel = $('speed-label'); const btnPlay = $('btn-play'); const btnPrev = $('btn-prev'); const btnNext = $('btn-next'); const nowPlaying = $('now-playing'); const curSent = $('cur-sent'); const totSent = $('tot-sent'); const progressFill = $('progress-fill'); const loadingOv = $('loading-overlay'); const loadingText = $('loading-text'); const lockNotice = $('lock-notice'); // ── LOADING ──────────────────────────────────────────────────────── const showLoading = (msg = 'Loading…') => { loadingText.textContent = msg; loadingOv.classList.add('show'); }; const hideLoading = () => loadingOv.classList.remove('show'); // ── AUTH ─────────────────────────────────────────────────────────── async function initAuth() { try { const resp = await fetch('/api/auth/me'); if (resp.status === 401) { window.location.href = '/login'; return; } currentUser = await resp.json(); } catch { window.location.href = '/login'; return; } $('user-label').textContent = currentUser.username; // Show admin panel link only for admins const ddAdmin = $('dd-admin'); if (currentUser.role !== 'admin') ddAdmin.classList.add('hidden'); // Email verification reminder if (!currentUser.email_verified) { $('verify-banner').classList.add('show'); } // User menu dropdown toggle $('user-btn').addEventListener('click', e => { e.stopPropagation(); $('user-dropdown').classList.toggle('open'); }); document.addEventListener('click', () => $('user-dropdown').classList.remove('open')); } function logout() { fetch('/api/auth/logout', { method: 'POST' }).finally(() => { window.location.href = '/login'; }); } window.logout = logout; async function resendVerification() { const btn = $('verify-banner').querySelector('button'); btn.textContent = 'Sending…'; btn.disabled = true; try { await fetch('/api/auth/resend-verification', { method: 'POST' }); $('verify-banner').querySelector('span').textContent = '✓ Verification email sent — check your inbox.'; } finally { btn.remove(); } } window.resendVerification = resendVerification; // ── VOICES INIT ──────────────────────────────────────────────────── async function initVoices() { const resp = await fetch('/api/voices'); allModels = await resp.json(); modelSelect.innerHTML = ''; for (const [id, info] of Object.entries(allModels)) { const o = document.createElement('option'); o.value = id; o.textContent = `${info.name} — ${info.quality}`; modelSelect.appendChild(o); } modelSelect.value = 'openai-tts-1'; refreshVoices(); } function refreshVoices() { state.model = modelSelect.value; const voices = allModels[state.model]?.voices || []; voiceSelect.innerHTML = ''; for (const v of voices) { const o = document.createElement('option'); o.value = v.id; o.textContent = v.name; voiceSelect.appendChild(o); } state.voice = voiceSelect.value; audioCache.clear(); } // Voice locking during playback function setVoiceLock(locked) { modelSelect.disabled = locked; voiceSelect.disabled = locked; lockNotice.classList.toggle('visible', locked); } modelSelect.addEventListener('change', () => { if (state.isPlaying) return; // ignore if locked refreshVoices(); }); voiceSelect.addEventListener('change', () => { if (state.isPlaying) return; state.voice = voiceSelect.value; audioCache.clear(); }); // ── SENTENCE SPLITTING ───────────────────────────────────────────── function splitSentences(text) { text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); const paras = text.split(/\n{2,}/); const result = []; for (let pi = 0; pi < paras.length; pi++) { const para = paras[pi].trim(); if (!para) continue; const parts = para.split(/(?<=[.!?…])\s+(?=[A-Z"'"'(])/); for (const part of parts) { const t = part.trim(); if (t) result.push({ text: t, isPara: false }); } if (pi < paras.length - 1) result.push({ text: '', isPara: true }); } return result; } // ── RENDER TEXT ──────────────────────────────────────────────────── function renderText(sentences) { textViewer.innerHTML = ''; sentences.forEach((s, i) => { if (s.isPara) { textViewer.appendChild(Object.assign(document.createElement('span'), { className: 'para-gap' })); return; } const span = document.createElement('span'); span.className = 's'; span.dataset.i = i; span.textContent = s.text + ' '; span.addEventListener('click', () => jumpTo(i)); textViewer.appendChild(span); }); } // ── VIEW SWITCHING ───────────────────────────────────────────────── function setView(mode) { state.currentView = mode; const contentArea = $('content-area'); if (mode === 'pdf') { $('text-viewer').style.display = 'none'; $('pdf-viewer').style.display = 'flex'; contentArea.classList.add('pdf-mode'); $('btn-view-pdf').classList.add('active'); $('btn-view-text').classList.remove('active'); $('doc-hint').textContent = 'Viewing original PDF · switch to Text to click sentences'; } else { $('text-viewer').style.display = ''; $('pdf-viewer').style.display = 'none'; contentArea.classList.remove('pdf-mode'); $('btn-view-text').classList.add('active'); $('btn-view-pdf').classList.remove('active'); $('doc-hint').textContent = 'Click any sentence to jump there · Space to play/pause · ← → to skip'; } } window.setView = setView; // ── PDF.js RENDERING + HIGHLIGHTING ─────────────────────────────── async function renderPDF(source) { // source: File object OR base64 string (from Nextcloud) pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'; let data; if (typeof source === 'string') { const bin = atob(source); data = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) data[i] = bin.charCodeAt(i); } else { data = new Uint8Array(await source.arrayBuffer()); } pdfJsDoc = await pdfjsLib.getDocument({ data }).promise; pageOverlays = []; pdfAllWords = []; const container = $('pdf-pages'); container.innerHTML = ''; for (let pageNum = 1; pageNum <= pdfJsDoc.numPages; pageNum++) { const page = await pdfJsDoc.getPage(pageNum); const vp = page.getViewport({ scale: PDF_SCALE }); // Page wrapper (relative so overlay sits on top) const wrap = document.createElement('div'); wrap.className = 'pdf-page-wrap'; wrap.style.cssText = `position:relative;width:${vp.width}px;max-width:100%;flex-shrink:0;`; // Render canvas const canvas = document.createElement('canvas'); canvas.width = vp.width; canvas.height = vp.height; canvas.style.cssText = 'display:block;width:100%;'; await page.render({ canvasContext: canvas.getContext('2d'), viewport: vp }).promise; // Transparent overlay for highlights const overlay = document.createElement('canvas'); overlay.width = vp.width; overlay.height = vp.height; overlay.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;'; wrap.appendChild(canvas); wrap.appendChild(overlay); container.appendChild(wrap); pageOverlays.push({ overlay, wrap }); // Extract word positions (PDF user-space → canvas pixels) const tc = await page.getTextContent(); for (const item of tc.items) { const s = item.str.trim(); if (!s) continue; // convertToViewportPoint flips y: PDF has y-up, canvas has y-down const [cx, cy] = vp.convertToViewportPoint(item.transform[4], item.transform[5]); const wPx = Math.abs(item.width * PDF_SCALE); const hPx = Math.abs(item.height * PDF_SCALE); pdfAllWords.push({ page: pageNum - 1, x: cx, y: cy - hPx, // cy is baseline; move up by height to get top w: wPx, h: hPx, str: s, }); } } } // Map each sentence to the word indices it covers in pdfAllWords function buildPDFWordMap(sentences) { const strs = pdfAllWords.map(w => w.str); const allText = strs.join(' '); // Pre-compute character start of every word const charStarts = []; let pos = 0; for (const s of strs) { charStarts.push(pos); pos += s.length + 1; } pdfWordMap = []; let searchFrom = 0; for (const sent of sentences) { if (sent.isPara) { pdfWordMap.push([]); continue; } const needle = sent.text.replace(/\s+/g, ' ').trim(); let found = allText.indexOf(needle, searchFrom); // Fallback: try first 50 chars (handles slight extraction differences) if (found === -1) found = allText.indexOf(needle.slice(0, 50), searchFrom); if (found === -1) { pdfWordMap.push([]); continue; } const end = found + needle.length; const idxs = []; for (let i = 0; i < charStarts.length; i++) { if (charStarts[i] + strs[i].length > found && charStarts[i] < end) idxs.push(i); } pdfWordMap.push(idxs); searchFrom = Math.max(0, found - 5); } } function clearPDFHighlights() { for (const { overlay } of pageOverlays) { overlay.getContext('2d').clearRect(0, 0, overlay.width, overlay.height); } } function highlightPDFSentence(sentIdx) { clearPDFHighlights(); const idxs = pdfWordMap[sentIdx] || []; if (!idxs.length) return; // Draw highlight rects grouped by page const byPage = {}; for (const wi of idxs) { const w = pdfAllWords[wi]; (byPage[w.page] ??= []).push(w); } for (const [pg, words] of Object.entries(byPage)) { const entry = pageOverlays[Number(pg)]; if (!entry) continue; const ctx = entry.overlay.getContext('2d'); ctx.fillStyle = 'rgba(124,58,237,0.38)'; for (const w of words) { ctx.fillRect(w.x - 1, w.y - 1, w.w + 2, w.h + 2); } } // Scroll to page of first word const firstWord = pdfAllWords[idxs[0]]; if (firstWord !== undefined) { const entry = pageOverlays[firstWord.page]; if (entry) entry.wrap.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } } // ── LOAD DOCUMENT ────────────────────────────────────────────────── async function loadDoc(text, title = 'Document', docId = null, startIdx = 0, pdfB64 = null, pageTexts = []) { state.sentences = splitSentences(text); state.currentIdx = startIdx; state.docId = docId; state.docTitle = title; state.pdfB64 = pdfB64; state.pageTexts = pageTexts; audioCache.clear(); stopPlayback(); const real = state.sentences.filter(s => !s.isPara).length; $('document-header').style.display = ''; $('document-title').textContent = title; $('document-meta').textContent = `${real} sentences`; totSent.textContent = real; curSent.textContent = 0; progressFill.style.width = '0%'; nowPlaying.textContent = 'Ready — press play'; renderText(state.sentences); textViewer.style.fontSize = `${state.fontSize}px`; // Set up PDF view (for saved Nextcloud docs that return pdf_b64) const viewToggle = $('view-toggle'); if (pdfB64) { viewToggle.style.display = ''; setView('pdf'); renderPDF(pdfB64).then(() => buildPDFWordMap(state.sentences)).catch(console.error); } else { viewToggle.style.display = 'none'; setView('text'); pdfJsDoc = null; pdfAllWords = []; pdfWordMap = []; pageOverlays = []; $('pdf-pages').innerHTML = ''; } // Load saved progress if we have a docId and no explicit startIdx if (docId && startIdx === 0) { try { const r = await fetch(`/api/progress/${docId}`); if (r.ok) { const p = await r.json(); if (p.sentence_index > 0) { const realIdx = nextReal(p.sentence_index); if (realIdx >= 0) { state.currentIdx = realIdx; highlight(realIdx); } } } } catch {} } else if (startIdx > 0) { highlight(startIdx); } } // ── PROGRESS SAVING ──────────────────────────────────────────────── let progressSaveTimer = null; function scheduleSaveProgress() { if (!state.docId) return; clearTimeout(progressSaveTimer); progressSaveTimer = setTimeout(saveProgress, 2000); } async function saveProgress() { if (!state.docId) return; try { await fetch(`/api/progress/${state.docId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sentence_index: state.currentIdx }), }); } catch {} } // ── AUDIO ────────────────────────────────────────────────────────── async function generateAudio(idx) { if (audioCache.has(idx)) return audioCache.get(idx); const s = state.sentences[idx]; const resp = await fetch('/api/tts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: s.text, model: state.model, voice: state.voice, speed: state.speed }), }); if (!resp.ok) throw new Error(await resp.text()); const blob = await resp.blob(); const url = URL.createObjectURL(blob); audioCache.set(idx, url); return url; } function prefetch(fromIdx, count = 3) { let n = 0, i = fromIdx + 1; while (n < count && i < state.sentences.length) { const s = state.sentences[i]; if (!s.isPara && !audioCache.has(i)) generateAudio(i).catch(() => {}); if (!s.isPara) n++; i++; } } function stopAudio() { if (currentAudio) { currentAudio.pause(); currentAudio.onended = null; currentAudio = null; } } // ── HIGHLIGHT ────────────────────────────────────────────────────── function highlight(idx) { document.querySelectorAll('.s.active').forEach(el => el.classList.remove('active')); const el = document.querySelector(`.s[data-i="${idx}"]`); if (el) { el.classList.add('active'); el.scrollIntoView({ behavior: 'smooth', block: 'center' }); } state.currentIdx = idx; const s = state.sentences[idx]; if (s && !s.isPara) nowPlaying.textContent = s.text; const realIdx = state.sentences.slice(0, idx + 1).filter(s => !s.isPara).length; const realTotal = state.sentences.filter(s => !s.isPara).length; curSent.textContent = realIdx; progressFill.style.width = realTotal ? `${(realIdx / realTotal) * 100}%` : '0%'; scheduleSaveProgress(); // Highlight directly on PDF canvas if (state.currentView === 'pdf' && pdfWordMap.length) { highlightPDFSentence(idx); } } // ── NAVIGATION HELPERS ───────────────────────────────────────────── function nextReal(from) { for (let i = from; i < state.sentences.length; i++) if (!state.sentences[i].isPara) return i; return -1; } function prevReal(from) { for (let i = from; i >= 0; i--) if (!state.sentences[i].isPara) return i; return -1; } // ── PLAYBACK ─────────────────────────────────────────────────────── async function playSentence(idx) { if (idx < 0 || idx >= state.sentences.length) { stopFinished(); return; } const s = state.sentences[idx]; if (s.isPara) { playSentence(nextReal(idx + 1)); return; } highlight(idx); stopAudio(); try { const url = await generateAudio(idx); if (!state.isPlaying) return; const audio = new Audio(url); audio.playbackRate = state.speed; currentAudio = audio; prefetch(idx); audio.play().catch(console.error); audio.onended = () => { if (state.isPlaying) playSentence(nextReal(idx + 1)); }; audio.onerror = () => { if (state.isPlaying) playSentence(nextReal(idx + 1)); }; } catch (e) { console.error('TTS error:', e); if (state.isPlaying) playSentence(nextReal(idx + 1)); } } function stopFinished() { state.isPlaying = false; stopAudio(); setVoiceLock(false); updatePlayBtn(); document.querySelectorAll('.s.active').forEach(el => el.classList.remove('active')); nowPlaying.textContent = 'Finished'; saveProgress(); } function stopPlayback() { state.isPlaying = false; stopAudio(); setVoiceLock(false); updatePlayBtn(); } function updatePlayBtn() { btnPlay.querySelector('.icon-play').style.display = state.isPlaying ? 'none' : ''; btnPlay.querySelector('.icon-pause').style.display = state.isPlaying ? '' : 'none'; } function jumpTo(idx) { state.currentIdx = idx; if (state.isPlaying) { stopAudio(); playSentence(idx); } else { highlight(idx); } } // ── PLAYER CONTROLS ──────────────────────────────────────────────── btnPlay.addEventListener('click', () => { if (!state.sentences.length) return; state.isPlaying = !state.isPlaying; updatePlayBtn(); if (state.isPlaying) { setVoiceLock(true); const idx = state.sentences[state.currentIdx]?.isPara ? nextReal(state.currentIdx) : state.currentIdx; playSentence(idx >= 0 ? idx : nextReal(0)); } else { stopAudio(); setVoiceLock(false); nowPlaying.textContent = 'Paused'; saveProgress(); } }); btnNext.addEventListener('click', () => { const n = nextReal(state.currentIdx + 1); if (n >= 0) jumpTo(n); }); btnPrev.addEventListener('click', () => { const p = prevReal(state.currentIdx - 1); if (p >= 0) jumpTo(p); }); $('progress-track').addEventListener('click', e => { const rect = e.currentTarget.getBoundingClientRect(); const ratio = (e.clientX - rect.left) / rect.width; const reals = state.sentences.map((s, i) => s.isPara ? null : i).filter(i => i !== null); const target = reals[Math.floor(ratio * reals.length)]; if (target !== undefined) jumpTo(target); }); // ── SPEED ────────────────────────────────────────────────────────── function setSpeed(v) { state.speed = v; speedSlider.value = v; speedLabel.textContent = `${v}×`; if (currentAudio) currentAudio.playbackRate = v; document.querySelectorAll('.speed-btn').forEach(b => { b.classList.toggle('active', parseFloat(b.dataset.speed) === v); }); } speedSlider.addEventListener('input', () => setSpeed(parseFloat(speedSlider.value))); document.querySelectorAll('.speed-btn').forEach(b => { b.addEventListener('click', () => setSpeed(parseFloat(b.dataset.speed))); }); // ── FONT SIZE ────────────────────────────────────────────────────── $('font-increase').addEventListener('click', () => { state.fontSize = Math.min(state.fontSize + 2, 34); textViewer.style.fontSize = `${state.fontSize}px`; $('font-size-label').textContent = `${state.fontSize}px`; }); $('font-decrease').addEventListener('click', () => { state.fontSize = Math.max(state.fontSize - 2, 12); textViewer.style.fontSize = `${state.fontSize}px`; $('font-size-label').textContent = `${state.fontSize}px`; }); // ── SIDEBAR TOGGLES ──────────────────────────────────────────────── $('btn-sidebar-toggle').addEventListener('click', () => $('sidebar-left').classList.toggle('collapsed')); $('btn-settings-toggle').addEventListener('click', () => $('sidebar-right').classList.toggle('collapsed')); // ── TABS ─────────────────────────────────────────────────────────── document.querySelectorAll('.tabs .tab').forEach(tab => { tab.addEventListener('click', () => { document.querySelectorAll('.tabs .tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active')); tab.classList.add('active'); $(`tab-${tab.dataset.tab}`).classList.add('active'); if (tab.dataset.tab === 'saved') loadSavedDocs(); }); }); // ── TEXT INPUT ───────────────────────────────────────────────────── $('btn-load-text').addEventListener('click', () => { const text = $('text-input').value.trim(); if (text) loadDoc(text, 'Pasted Text'); }); // ── URL INPUT ────────────────────────────────────────────────────── $('btn-load-url').addEventListener('click', async () => { const url = $('url-input').value.trim(); if (!url) return; const status = $('url-status'); showLoading('Fetching article…'); try { const resp = await fetch('/api/extract-url', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }), }); if (!resp.ok) throw new Error((await resp.json()).detail || 'Failed'); const data = await resp.json(); loadDoc(data.text, data.title || url); status.textContent = '✓ Loaded'; } catch (e) { status.textContent = '✗ ' + e.message; } finally { hideLoading(); } }); // ── PDF INPUT ────────────────────────────────────────────────────── const uploadArea = $('upload-area'); const pdfInput = $('pdf-input'); uploadArea.addEventListener('click', () => pdfInput.click()); uploadArea.addEventListener('dragover', e => { e.preventDefault(); uploadArea.classList.add('drag-over'); }); uploadArea.addEventListener('dragleave', () => uploadArea.classList.remove('drag-over')); uploadArea.addEventListener('drop', e => { e.preventDefault(); uploadArea.classList.remove('drag-over'); const f = e.dataTransfer.files[0]; if (f?.type === 'application/pdf') handlePDF(f); }); pdfInput.addEventListener('change', () => { if (pdfInput.files[0]) handlePDF(pdfInput.files[0]); }); async function handlePDF(file) { const status = $('pdf-status'); const title = file.name.replace(/\.pdf$/i, ''); // Show header immediately in PDF mode $('document-header').style.display = ''; $('document-title').textContent = title; $('document-meta').textContent = 'Loading…'; $('view-toggle').style.display = ''; $('pdf-pages').innerHTML = ''; setView('pdf'); stopPlayback(); audioCache.clear(); state.sentences = []; state.docId = null; state.docTitle = title; pdfJsDoc = null; pdfAllWords = []; pdfWordMap = []; pageOverlays = []; nowPlaying.textContent = 'Loading PDF…'; status.textContent = 'Loading…'; // Run PDF rendering (shows pages as they complete) and text extraction in parallel const form = new FormData(); form.append('file', file); const renderPromise = renderPDF(file); // reads File directly, no server const extractPromise = fetch('/api/extract-pdf', { method: 'POST', body: form }) .then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e.detail))); try { // Text extraction usually finishes before all pages render const data = await extractPromise; state.sentences = splitSentences(data.text); state.docTitle = data.title || title; state.currentIdx = 0; const real = state.sentences.filter(s => !s.isPara).length; $('document-meta').textContent = `${real} sentences`; $('document-title').textContent = state.docTitle; totSent.textContent = real; curSent.textContent = 0; progressFill.style.width = '0%'; // Render text view in background for switching renderText(state.sentences); textViewer.style.fontSize = `${state.fontSize}px`; // Wait for PDF rendering to finish, then build word→sentence map await renderPromise; buildPDFWordMap(state.sentences); nowPlaying.textContent = 'Ready — press play'; status.textContent = '✓ Ready'; } catch (e) { await renderPromise.catch(() => {}); status.textContent = '✗ ' + (e.message || e); nowPlaying.textContent = 'Error — check PDF tab is visible'; } } // ── NEXTCLOUD BROWSER ─────────────────────────────────────────────── let ncCurrentPath = '/'; async function ncBrowse() { ncCurrentPath = '/'; await ncLoadPath('/'); } window.ncBrowse = ncBrowse; async function ncUp() { if (ncCurrentPath === '/') return; const parts = ncCurrentPath.replace(/\/$/, '').split('/'); parts.pop(); ncCurrentPath = parts.join('/') || '/'; await ncLoadPath(ncCurrentPath); } window.ncUp = ncUp; async function ncNavigate(path) { ncCurrentPath = path; await ncLoadPath(path); } async function ncLoadPath(path) { const fileList = $('nc-file-list'); const status = $('nc-status'); fileList.innerHTML = '
Loading…
'; status.textContent = ''; $('nc-path-display').textContent = path; try { const resp = await fetch('/api/nextcloud/files?path=' + encodeURIComponent(path)); if (!resp.ok) { const err = await resp.json(); throw new Error(err.detail || 'Failed to load'); } const items = await resp.json(); fileList.innerHTML = ''; if (!items.length) { fileList.innerHTML = '
Empty folder
'; return; } for (const item of items) { const div = document.createElement('div'); div.className = 'nc-item' + (item.is_dir ? ' dir' : ''); const icon = item.is_dir ? `` : ``; div.innerHTML = `${icon}${item.name}`; if (item.is_dir) { div.addEventListener('click', () => ncNavigate(item.path)); } else { div.addEventListener('click', () => ncOpenFile(item.path, item.name)); } fileList.appendChild(div); } } catch (e) { fileList.innerHTML = '
Error loading
'; status.textContent = '✗ ' + e.message; } } async function ncOpenFile(path, name) { showLoading('Loading from Nextcloud…'); const status = $('nc-status'); try { const resp = await fetch('/api/nextcloud/download', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path }), }); if (!resp.ok) throw new Error((await resp.json()).detail || 'Failed'); const data = await resp.json(); // loadDoc handles pdf_b64 via PDF.js await loadDoc(data.text, data.title || name.replace(/\.pdf$/i, ''), null, 0, data.pdf_b64 || null, data.page_texts || []); status.textContent = '✓ Loaded'; } catch (e) { status.textContent = '✗ ' + e.message; } finally { hideLoading(); } } // ── SAVED DOCUMENTS ──────────────────────────────────────────────── async function loadSavedDocs() { const list = $('saved-list'); list.innerHTML = '
Loading…
'; try { const resp = await fetch('/api/documents'); if (!resp.ok) throw new Error('Failed'); const docs = await resp.json(); list.innerHTML = ''; if (!docs.length) { list.innerHTML = '
No saved documents
'; return; } for (const doc of docs) { const div = document.createElement('div'); div.className = 'saved-item'; const date = new Date(doc.created_at).toLocaleDateString(); div.innerHTML = `
${escHtml(doc.title)}
${escHtml(doc.source_type)} · ${date}
`; div.addEventListener('click', () => openSavedDoc(doc.id, doc.title)); list.appendChild(div); } } catch (e) { list.innerHTML = '
Error loading
'; } } window.loadSavedDocs = loadSavedDocs; async function openSavedDoc(docId, title) { showLoading('Loading document…'); try { const resp = await fetch(`/api/documents/${docId}`); if (!resp.ok) throw new Error('Not found'); const doc = await resp.json(); await loadDoc(doc.content, doc.title, doc.id, 0); } catch (e) { console.error('Failed to open doc:', e); } finally { hideLoading(); } } // Save current document async function saveDoc() { if (!state.sentences.length) return; const btn = $('btn-save-doc'); btn.disabled = true; btn.textContent = 'Saving…'; const fullText = state.sentences .map(s => s.isPara ? '\n' : s.text) .join(' ') .replace(/ \n /g, '\n\n'); try { const method = state.docId ? 'PUT' : 'POST'; const url = state.docId ? `/api/documents/${state.docId}` : '/api/documents'; const resp = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: state.docTitle, content: fullText }), }); if (!resp.ok) throw new Error('Failed'); const doc = await resp.json(); state.docId = doc.id; btn.textContent = '✓ Saved'; setTimeout(() => { btn.innerHTML = ` Save`; btn.disabled = false; }, 2000); } catch { btn.textContent = '✗ Error'; btn.disabled = false; setTimeout(() => { btn.innerHTML = ` Save`; }, 2000); } } $('btn-save-doc').addEventListener('click', saveDoc); function escHtml(str) { return str.replace(/&/g,'&').replace(//g,'>'); } // ── VOICE PREVIEW ───────────────────────────────────────────────── $('btn-preview').addEventListener('click', async () => { showLoading('Generating preview…'); try { const resp = await fetch('/api/tts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: 'Hello! This is a preview of the selected voice. How does it sound to you?', model: state.model, voice: state.voice, speed: state.speed, }), }); const blob = await resp.blob(); const url = URL.createObjectURL(blob); const a = new Audio(url); a.playbackRate = state.speed; a.play(); a.onended = () => URL.revokeObjectURL(url); } catch (e) { console.error('Preview failed:', e); } finally { hideLoading(); } }); // ── KEYBOARD SHORTCUTS ───────────────────────────────────────────── document.addEventListener('keydown', e => { if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) return; if (e.code === 'Space') { e.preventDefault(); btnPlay.click(); } else if (e.code === 'ArrowRight') btnNext.click(); else if (e.code === 'ArrowLeft') btnPrev.click(); }); // ── BOOT ─────────────────────────────────────────────────────────── async function boot() { await initAuth(); await initVoices(); } boot();