speechify-clone/static/app.js
ifedan-ed 7afbe3a22a Replace PDF.js canvas with native browser iframe PDF viewer
- Browser renders PDF exactly as-is using its built-in viewer (Chrome/Edge/Firefox)
- No external dependencies - just a blob URL in an <iframe>
- Page navigation via #page=N fragment syncs PDF view with playback position
- Fixed flex layout so iframe fills remaining height correctly
- Removed PDF.js script tag

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

822 lines
30 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);
// Render asynchronously so the text view appears immediately
renderPDF(pdfB64).catch(console.error);
// Default to text view
setView('text');
} else {
viewToggle.style.display = 'none';
setView('text');
state.sentencePage = [];
pdfDoc = 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') uploadPDF(f);
});
pdfInput.addEventListener('change', () => { if (pdfInput.files[0]) uploadPDF(pdfInput.files[0]); });
async function uploadPDF(file) {
const status = $('pdf-status');
showLoading('Extracting PDF…');
const form = new FormData();
form.append('file', file);
try {
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();
await loadDoc(data.text, data.title || file.name, null, 0, data.pdf_b64, data.page_texts || []);
status.textContent = '✓ Loaded';
} catch (e) {
status.textContent = '✗ ' + e.message;
} finally {
hideLoading();
}
}
// ── 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();