- Email verification: send link on register via SMTP2GO (noreply@pedshub.com), verify endpoint, beautiful HTML email - Unverified users see yellow banner in app with resend button; also shown on settings page - User settings page (/settings): personal Nextcloud credentials (override admin-wide config) - Admin and per-user Nextcloud config merged (user settings take priority) - Groq PlayAI TTS: 20 English voices + 2 Arabic voices via groq-playai-tts / groq-playai-tts-arabic - Added groq-playai-tts and groq-playai-tts-arabic to litellm config - Favicon SVG (purple play button) across all pages - Settings link in user dropdown menu - verify-ok.html and verify-fail.html result pages - DB migration: added email_verified column, verification_tokens and user_settings tables Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
719 lines
26 KiB
JavaScript
719 lines
26 KiB
JavaScript
// ── STATE ──────────────────────────────────────────────────────────
|
||
const state = {
|
||
sentences: [], // [{text, isPara}]
|
||
currentIdx: 0,
|
||
isPlaying: false,
|
||
speed: 1.0,
|
||
model: 'openai-tts-1',
|
||
voice: 'nova',
|
||
fontSize: 18,
|
||
docId: null, // currently loaded saved-doc id
|
||
docTitle: '',
|
||
};
|
||
|
||
let currentAudio = null;
|
||
const audioCache = new Map();
|
||
let allModels = {};
|
||
let currentUser = 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);
|
||
});
|
||
}
|
||
|
||
// ── LOAD DOCUMENT ──────────────────────────────────────────────────
|
||
async function loadDoc(text, title = 'Document', docId = null, startIdx = 0) {
|
||
state.sentences = splitSentences(text);
|
||
state.currentIdx = startIdx;
|
||
state.docId = docId;
|
||
state.docTitle = title;
|
||
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`;
|
||
|
||
// 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();
|
||
}
|
||
|
||
// ── 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();
|
||
loadDoc(data.text, data.title || file.name);
|
||
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();
|
||
loadDoc(data.text, name.replace(/\.pdf$/i, ''));
|
||
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,'&').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();
|