speechify-clone/static/app.js
ifedan-ed ee3ffcfe90 Add email verification, user Nextcloud settings, Groq TTS, favicon, and settings page
- 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>
2026-04-11 04:12:25 +02:00

719 lines
26 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, // 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,'&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();