speechify-clone/static/app.js
ifedan-ed 4fc76bc650 Show PDF instantly from local file, extract text in background
- PDF renders immediately on file select via blob URL (no server round-trip)
- Text extraction happens in background (only needed for TTS audio)
- Default view is now PDF (shows the real document, not extracted text)
- User can switch to Text view to see extracted sentences with highlighting
- Nextcloud PDFs also default to PDF view when opened

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 04:56:30 +02:00

862 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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 blob URL for native browser viewer
let pdfBlobUrl = null;
// ── 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 NATIVE RENDERING ───────────────────────────────────────────
function renderPDF(b64) {
// Revoke previous blob to free memory
if (pdfBlobUrl) { URL.revokeObjectURL(pdfBlobUrl); pdfBlobUrl = null; }
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
const blob = new Blob([bytes], { type: 'application/pdf' });
pdfBlobUrl = URL.createObjectURL(blob);
const pdfPages = $('pdf-pages');
pdfPages.innerHTML = '';
const iframe = document.createElement('iframe');
iframe.id = 'pdf-iframe';
iframe.src = pdfBlobUrl;
iframe.style.cssText = 'width:100%;height:100%;border:none;display:block;';
pdfPages.appendChild(iframe);
}
function buildSentencePageMap(pageTexts, sentences) {
// For each real sentence, find which page it appears on
const map = new Array(sentences.length).fill(0);
let pageIdx = 0;
const lowerPages = pageTexts.map(p => p.toLowerCase().replace(/\s+/g, ' '));
for (let i = 0; i < sentences.length; i++) {
if (sentences[i].isPara) { map[i] = pageIdx; continue; }
const snippet = sentences[i].text.slice(0, 40).toLowerCase().replace(/\s+/g, ' ');
// Search from current page forward
for (let p = pageIdx; p < lowerPages.length; p++) {
if (lowerPages[p].includes(snippet)) { pageIdx = p; break; }
}
map[i] = pageIdx;
}
return map;
}
function scrollPDFToPage(pageIdx) {
if (!pdfBlobUrl) return;
const iframe = $('pdf-iframe');
if (!iframe) return;
// #page= fragment navigates inside Chrome/Edge native PDF viewer
const target = pdfBlobUrl + '#page=' + (pageIdx + 1);
if (iframe.src !== target) iframe.src = target;
}
// ── 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
const viewToggle = $('view-toggle');
if (pdfB64) {
viewToggle.style.display = '';
state.sentencePage = buildSentencePageMap(pageTexts, state.sentences);
renderPDF(pdfB64);
setView('pdf'); // default to real PDF view
} else {
viewToggle.style.display = 'none';
setView('text');
state.sentencePage = [];
if (pdfBlobUrl) { URL.revokeObjectURL(pdfBlobUrl); pdfBlobUrl = null; }
$('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();
// Sync PDF view to current page
if (state.currentView === 'pdf' && state.sentencePage.length) {
scrollPDFToPage(state.sentencePage[idx] ?? 0);
}
}
// ── 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, '');
// ── Step 1: show PDF instantly from local file, no server needed ──
if (pdfBlobUrl) URL.revokeObjectURL(pdfBlobUrl);
pdfBlobUrl = URL.createObjectURL(file);
// Show header + PDF view right away
$('document-header').style.display = '';
$('document-title').textContent = title;
$('document-meta').textContent = 'Extracting text for playback…';
$('view-toggle').style.display = '';
$('pdf-pages').innerHTML = '';
const iframe = document.createElement('iframe');
iframe.id = 'pdf-iframe';
iframe.src = pdfBlobUrl;
iframe.style.cssText = 'width:100%;height:100%;border:none;display:block;';
$('pdf-pages').appendChild(iframe);
setView('pdf'); // ← show the real PDF immediately
nowPlaying.textContent = 'Extracting text for playback…';
status.textContent = 'Extracting…';
// ── Step 2: extract text in background (needed only for TTS) ──
try {
const form = new FormData();
form.append('file', file);
const resp = await fetch('/api/extract-pdf', { method: 'POST', body: form });
if (!resp.ok) throw new Error((await resp.json()).detail || 'Failed');
const data = await resp.json();
// Update state with extracted sentences (keep PDF view active)
state.sentences = splitSentences(data.text);
state.docTitle = data.title || title;
state.currentIdx = 0;
state.docId = null;
state.pageTexts = data.page_texts || [];
state.sentencePage = buildSentencePageMap(state.pageTexts, state.sentences);
audioCache.clear();
const real = state.sentences.filter(s => !s.isPara).length;
$('document-meta').textContent = `${real} sentences · ready`;
$('document-title').textContent = data.title || title;
totSent.textContent = real;
curSent.textContent = 0;
progressFill.style.width = '0%';
nowPlaying.textContent = 'Ready — press play';
// Also render text view in background for when user switches
renderText(state.sentences);
textViewer.style.fontSize = `${state.fontSize}px`;
status.textContent = '✓ Ready';
} catch (e) {
status.textContent = '✗ ' + e.message;
nowPlaying.textContent = 'Text extraction failed';
}
}
// ── 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 = '<div class="status-text">Loading…</div>';
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 = '<div class="status-text">Empty folder</div>';
return;
}
for (const item of items) {
const div = document.createElement('div');
div.className = 'nc-item' + (item.is_dir ? ' dir' : '');
const icon = item.is_dir
? `<svg class="nc-icon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`
: `<svg class="nc-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>`;
div.innerHTML = `${icon}<span>${item.name}</span>`;
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 = '<div class="status-text">Error loading</div>';
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();
await loadDoc(data.text, data.title || name.replace(/\.pdf$/i, ''), null, 0, data.pdf_b64, 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 = '<div class="status-text">Loading…</div>';
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 = '<div class="status-text">No saved documents</div>';
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 = `
<div class="saved-item-title">${escHtml(doc.title)}</div>
<div class="saved-item-meta">${escHtml(doc.source_type)} · ${date}</div>
`;
div.addEventListener('click', () => openSavedDoc(doc.id, doc.title));
list.appendChild(div);
}
} catch (e) {
list.innerHTML = '<div class="status-text">Error loading</div>';
}
}
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 = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg> Save`;
btn.disabled = false;
}, 2000);
} catch {
btn.textContent = '✗ Error';
btn.disabled = false;
setTimeout(() => {
btn.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg> Save`;
}, 2000);
}
}
$('btn-save-doc').addEventListener('click', saveDoc);
function escHtml(str) {
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// ── 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();