/** * Audiobook Maker Pro v3.1 - Main Application * UPDATED: Dynamic header help button, floating guide panel, no hint bar * UPDATED: Hide guide panel by default when Interactive Reader tab is active */ // ============================================ // Global State // ============================================ let currentProject = { id: null, name: 'My Audiobook', chapters: [] }; let voices = []; let archiveModal = null; let ttsEditModal = null; let currentWorkflowStage = 'upload'; // ============================================ // Initialization // ============================================ document.addEventListener('DOMContentLoaded', function() { console.log('🎧 Audiobook Maker Pro v3.1 initializing...'); archiveModal = new bootstrap.Modal(document.getElementById('archiveModal')); ttsEditModal = new bootstrap.Modal(document.getElementById('ttsEditModal')); loadVoices(); initPdfHandler(); initMarkdownEditor(); setupEventListeners(); // Show welcome overlay for first-time users initWelcomeOverlay(); // Initialize workflow progress updateWorkflowProgress('upload'); // Initialize floating guide panel initFloatingGuidePanel(); // Load current user info loadCurrentUser(); // Ensure reader UI is hidden on startup if (typeof hideReaderUI === 'function') { hideReaderUI(); } console.log('✅ Application initialized'); }); function setupEventListeners() { document.getElementById('projectName').addEventListener('change', function() { currentProject.name = this.value; }); document.getElementById('reader-tab').addEventListener('shown.bs.tab', function() { renderInteractiveReader(); if (typeof showReaderUI === 'function') { showReaderUI(); } // Hide the guide panel when entering the reader hideGuidePanelForReader(); }); document.getElementById('editor-tab').addEventListener('shown.bs.tab', function() { if (typeof hideReaderUI === 'function') { hideReaderUI(); } // Restore the guide panel when returning to the editor restoreGuidePanelForEditor(); }); } // ============================================ // Welcome Overlay // ============================================ function initWelcomeOverlay() { const dontShow = localStorage.getItem('audiobookMakerHideWelcome'); if (dontShow === 'true') { document.getElementById('welcomeOverlay').style.display = 'none'; } else { document.getElementById('welcomeOverlay').style.display = 'flex'; } } function dismissWelcome() { const dontShowCheckbox = document.getElementById('welcomeDontShow'); if (dontShowCheckbox && dontShowCheckbox.checked) { localStorage.setItem('audiobookMakerHideWelcome', 'true'); } document.getElementById('welcomeOverlay').style.display = 'none'; } function showWelcome() { document.getElementById('welcomeOverlay').style.display = 'flex'; } // ============================================ // Dynamic Header Help Button // ============================================ function handleHeaderHelp() { if (currentWorkflowStage === 'upload') { showWelcome(); } else { showGuidePanel(); } } function updateHeaderHelpButton(stage) { const label = document.getElementById('headerHelpLabel'); const btn = document.getElementById('headerHelpBtn'); if (!label || !btn) return; if (stage === 'upload') { label.textContent = 'Quick Start'; btn.title = 'Show quick start guide'; } else { label.textContent = 'Quick Guide'; btn.title = 'Show editor quick guide'; } } // ============================================ // Floating Guide Panel // ============================================ let guidePanelDragState = { isDragging: false, startX: 0, startY: 0, offsetX: 0, offsetY: 0 }; function initFloatingGuidePanel() { const panel = document.getElementById('floatingGuidePanel'); const header = document.getElementById('guidePanelHeader'); const toggle = document.getElementById('floatingGuideToggle'); if (!panel || !header) return; // Check if user previously hid the panel permanently const hideGuide = localStorage.getItem('audiobookMakerHideGuide'); if (hideGuide === 'true') { panel.classList.remove('visible'); if (toggle) toggle.classList.add('visible'); return; } // Check if user previously collapsed const collapsed = localStorage.getItem('audiobookMakerGuideCollapsed'); if (collapsed === 'true') { panel.classList.add('collapsed'); const icon = document.getElementById('guideCollapseIcon'); if (icon) { icon.classList.remove('bi-chevron-up'); icon.classList.add('bi-chevron-down'); } } // Restore saved position const savedPos = localStorage.getItem('audiobookMakerGuidePos'); if (savedPos) { try { const pos = JSON.parse(savedPos); const maxX = window.innerWidth - 100; const maxY = window.innerHeight - 50; if (pos.x >= 0 && pos.x <= maxX && pos.y >= 0 && pos.y <= maxY) { panel.style.right = 'auto'; panel.style.left = pos.x + 'px'; panel.style.top = pos.y + 'px'; } } catch(e) { /* ignore */ } } // Setup drag events (mouse) header.addEventListener('mousedown', onGuideDragStart); document.addEventListener('mousemove', onGuideDragMove); document.addEventListener('mouseup', onGuideDragEnd); // Touch support header.addEventListener('touchstart', onGuideTouchStart, { passive: false }); document.addEventListener('touchmove', onGuideTouchMove, { passive: false }); document.addEventListener('touchend', onGuideTouchEnd); } function showGuidePanelOnEditor() { const panel = document.getElementById('floatingGuidePanel'); const toggle = document.getElementById('floatingGuideToggle'); const hideGuide = localStorage.getItem('audiobookMakerHideGuide'); if (hideGuide === 'true') { if (toggle) toggle.classList.add('visible'); return; } if (panel) panel.classList.add('visible'); if (toggle) toggle.classList.remove('visible'); } function showGuidePanel() { const panel = document.getElementById('floatingGuidePanel'); const toggle = document.getElementById('floatingGuideToggle'); if (panel) panel.classList.add('visible'); if (toggle) toggle.classList.remove('visible'); localStorage.removeItem('audiobookMakerHideGuide'); } function hideGuidePanel() { const panel = document.getElementById('floatingGuidePanel'); const toggle = document.getElementById('floatingGuideToggle'); if (panel) panel.classList.remove('visible'); if (toggle) toggle.classList.add('visible'); } function toggleGuideCollapse() { const panel = document.getElementById('floatingGuidePanel'); const icon = document.getElementById('guideCollapseIcon'); if (!panel) return; const isCollapsed = panel.classList.toggle('collapsed'); if (icon) { if (isCollapsed) { icon.classList.remove('bi-chevron-up'); icon.classList.add('bi-chevron-down'); } else { icon.classList.remove('bi-chevron-down'); icon.classList.add('bi-chevron-up'); } } localStorage.setItem('audiobookMakerGuideCollapsed', isCollapsed ? 'true' : 'false'); } function handleGuideDontShow() { const checkbox = document.getElementById('guidePanelDontShow'); if (checkbox && checkbox.checked) { localStorage.setItem('audiobookMakerHideGuide', 'true'); hideGuidePanel(); } else { localStorage.removeItem('audiobookMakerHideGuide'); } } // ============================================ // Guide Panel: Reader Tab Visibility // ============================================ let guidePanelHiddenByReader = false; function hideGuidePanelForReader() { const panel = document.getElementById('floatingGuidePanel'); const toggle = document.getElementById('floatingGuideToggle'); // If the panel is currently visible, hide it and remember we did so if (panel && panel.classList.contains('visible')) { panel.classList.remove('visible'); guidePanelHiddenByReader = true; } else { guidePanelHiddenByReader = false; } // Always show the toggle button so the user CAN show it manually if they want if (toggle) toggle.classList.add('visible'); } function restoreGuidePanelForEditor() { // Only restore if we were the ones who hid it if (guidePanelHiddenByReader) { const panel = document.getElementById('floatingGuidePanel'); const toggle = document.getElementById('floatingGuideToggle'); const hideGuide = localStorage.getItem('audiobookMakerHideGuide'); if (hideGuide !== 'true' && panel) { panel.classList.add('visible'); if (toggle) toggle.classList.remove('visible'); } guidePanelHiddenByReader = false; } } // --- Drag Logic (Mouse) --- function onGuideDragStart(e) { if (e.target.closest('.guide-panel-btn') || e.target.closest('button')) return; const panel = document.getElementById('floatingGuidePanel'); if (!panel) return; guidePanelDragState.isDragging = true; const rect = panel.getBoundingClientRect(); guidePanelDragState.offsetX = e.clientX - rect.left; guidePanelDragState.offsetY = e.clientY - rect.top; panel.style.transition = 'none'; e.preventDefault(); } function onGuideDragMove(e) { if (!guidePanelDragState.isDragging) return; const panel = document.getElementById('floatingGuidePanel'); if (!panel) return; let newX = e.clientX - guidePanelDragState.offsetX; let newY = e.clientY - guidePanelDragState.offsetY; const panelWidth = panel.offsetWidth; const panelHeight = panel.offsetHeight; newX = Math.max(0, Math.min(newX, window.innerWidth - panelWidth)); newY = Math.max(0, Math.min(newY, window.innerHeight - panelHeight)); panel.style.right = 'auto'; panel.style.left = newX + 'px'; panel.style.top = newY + 'px'; e.preventDefault(); } function onGuideDragEnd(e) { if (!guidePanelDragState.isDragging) return; guidePanelDragState.isDragging = false; const panel = document.getElementById('floatingGuidePanel'); if (panel) { panel.style.transition = ''; localStorage.setItem('audiobookMakerGuidePos', JSON.stringify({ x: parseInt(panel.style.left) || 0, y: parseInt(panel.style.top) || 0 })); } } // --- Drag Logic (Touch) --- function onGuideTouchStart(e) { if (e.target.closest('.guide-panel-btn') || e.target.closest('button')) return; const panel = document.getElementById('floatingGuidePanel'); if (!panel) return; const touch = e.touches[0]; guidePanelDragState.isDragging = true; const rect = panel.getBoundingClientRect(); guidePanelDragState.offsetX = touch.clientX - rect.left; guidePanelDragState.offsetY = touch.clientY - rect.top; panel.style.transition = 'none'; e.preventDefault(); } function onGuideTouchMove(e) { if (!guidePanelDragState.isDragging) return; const panel = document.getElementById('floatingGuidePanel'); if (!panel) return; const touch = e.touches[0]; let newX = touch.clientX - guidePanelDragState.offsetX; let newY = touch.clientY - guidePanelDragState.offsetY; const panelWidth = panel.offsetWidth; const panelHeight = panel.offsetHeight; newX = Math.max(0, Math.min(newX, window.innerWidth - panelWidth)); newY = Math.max(0, Math.min(newY, window.innerHeight - panelHeight)); panel.style.right = 'auto'; panel.style.left = newX + 'px'; panel.style.top = newY + 'px'; e.preventDefault(); } function onGuideTouchEnd(e) { if (!guidePanelDragState.isDragging) return; guidePanelDragState.isDragging = false; const panel = document.getElementById('floatingGuidePanel'); if (panel) { panel.style.transition = ''; localStorage.setItem('audiobookMakerGuidePos', JSON.stringify({ x: parseInt(panel.style.left) || 0, y: parseInt(panel.style.top) || 0 })); } } // ============================================ // Workflow Progress Bar // ============================================ function updateWorkflowProgress(stage) { const step1 = document.getElementById('wpStep1'); const step2 = document.getElementById('wpStep2'); const step3 = document.getElementById('wpStep3'); const conn1 = document.getElementById('wpConn1'); const conn2 = document.getElementById('wpConn2'); if (!step1) return; // Track current stage for help button currentWorkflowStage = stage; updateHeaderHelpButton(stage); // Reset all [step1, step2, step3].forEach(s => { s.classList.remove('completed', 'active'); }); [conn1, conn2].forEach(c => { c.classList.remove('active'); }); switch (stage) { case 'upload': step1.classList.add('active'); break; case 'edit': step1.classList.add('completed'); conn1.classList.add('active'); step2.classList.add('active'); // Show the floating guide when entering editor showGuidePanelOnEditor(); break; case 'audio-ready': step1.classList.add('completed'); conn1.classList.add('active'); step2.classList.add('completed'); conn2.classList.add('active'); step3.classList.add('active'); // Show badge on reader tab const badge = document.getElementById('readerTabBadge'); if (badge) badge.style.display = 'inline'; break; } } // ============================================ // Helper: Switch to Editor Tab // ============================================ function switchToEditorTab() { const editorTab = document.getElementById('editor-tab'); if (editorTab) { const tab = new bootstrap.Tab(editorTab); tab.show(); } } // ============================================ // Helper: Start from scratch // ============================================ function startFromScratch() { document.getElementById('uploadSection').style.display = 'none'; document.getElementById('editorSection').style.display = 'block'; updateWorkflowProgress('edit'); // Click the editor to trigger first chapter creation const editor = document.getElementById('markdownEditor'); if (editor && editorBlocks.length === 0) { addChapterMarker(1); } } // ============================================ // Loading Overlay // ============================================ function showLoader(text = 'Processing...', subtext = 'Please wait') { const overlay = document.getElementById('loadingOverlay'); document.getElementById('loadingText').textContent = text; document.getElementById('loadingSubtext').textContent = subtext; overlay.classList.add('active'); } function hideLoader() { document.getElementById('loadingOverlay').classList.remove('active'); } // ============================================ // Voice Management // ============================================ async function loadVoices() { try { const response = await fetch('/api/voices'); const data = await response.json(); voices = data.voices || []; console.log(`📢 Loaded ${voices.length} voices`); } catch (error) { console.error('Failed to load voices:', error); voices = [ { id: 'af_heart', name: 'Heart (US Female)' }, { id: 'am_adam', name: 'Adam (US Male)' } ]; } } function getVoiceOptions(selectedVoice = 'af_heart') { return voices.map(v => `` ).join(''); } // ============================================ // Project Management // ============================================ async function saveProject() { const projectName = document.getElementById('projectName').value.trim(); if (!projectName) { alert('Please enter a project name'); return; } currentProject.name = projectName; const chapters = collectEditorContent(); if (chapters.length === 0) { alert('No content to save. Add some chapters and blocks first.'); return; } showLoader('Saving Project...', 'Please wait'); try { let projectId = currentProject.id; if (!projectId) { const createResponse = await fetch('/api/projects', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: projectName }) }); const createData = await createResponse.json(); if (createData.error) { const listResponse = await fetch('/api/projects'); const listData = await listResponse.json(); const existing = listData.projects.find(p => p.name === projectName); if (existing) { projectId = existing.id; } else { throw new Error(createData.error); } } else { projectId = createData.project_id; } currentProject.id = projectId; } const saveResponse = await fetch(`/api/projects/${projectId}/save`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chapters }) }); const saveData = await saveResponse.json(); if (saveData.error) { throw new Error(saveData.error); } hideLoader(); showNotification('Project saved successfully!', 'success'); } catch (error) { hideLoader(); console.error('Save error:', error); alert('Failed to save project: ' + error.message); } } async function exportProject() { if (!currentProject.id) { await saveProject(); if (!currentProject.id) return; } showLoader('Exporting...', 'Creating ZIP file'); try { window.location.href = `/api/export/${currentProject.id}`; setTimeout(() => { hideLoader(); }, 2000); } catch (error) { hideLoader(); alert('Export failed: ' + error.message); } } async function openProjectArchive() { showLoader('Loading projects...'); try { const response = await fetch('/api/projects'); const data = await response.json(); const container = document.getElementById('projectList'); if (data.projects.length === 0) { container.innerHTML = `

No saved projects yet

`; } else { container.innerHTML = data.projects.map(project => `
${escapeHtml(project.name)}
${project.chapter_count} chapters • ${project.block_count} blocks • Updated ${formatDate(project.updated_at)}
`).join(''); } hideLoader(); archiveModal.show(); } catch (error) { hideLoader(); alert('Failed to load projects: ' + error.message); } } async function loadProject(projectId) { showLoader('Loading project...'); archiveModal.hide(); try { const response = await fetch(`/api/projects/${projectId}`); const data = await response.json(); if (data.error) throw new Error(data.error); currentProject = { id: data.id, name: data.name, chapters: data.chapters }; document.getElementById('projectName').value = data.name; // Hide upload, show editor document.getElementById('uploadSection').style.display = 'none'; document.getElementById('editorSection').style.display = 'block'; renderProjectInEditor(data); // Check if project has audio and update workflow let hasAudio = false; for (const ch of data.chapters) { for (const bl of ch.blocks) { if (bl.audio_data && bl.block_type !== 'image') { hasAudio = true; break; } } if (hasAudio) break; } updateWorkflowProgress(hasAudio ? 'audio-ready' : 'edit'); hideLoader(); showNotification('Project loaded successfully!', 'success'); } catch (error) { hideLoader(); alert('Failed to load project: ' + error.message); } } async function deleteProject(projectId) { if (!confirm('Are you sure you want to delete this project? This action cannot be undone.')) return; showLoader('Deleting...'); try { const response = await fetch(`/api/projects/${projectId}`, { method: 'DELETE' }); const data = await response.json(); if (data.error) throw new Error(data.error); await openProjectArchive(); showNotification('Project deleted', 'success'); } catch (error) { hideLoader(); alert('Failed to delete project: ' + error.message); } } // ============================================ // TTS Text Editing // ============================================ function openTtsEditor(blockId, currentContent) { const plainText = stripMarkdown(currentContent); document.getElementById('ttsTextInput').value = plainText; document.getElementById('ttsBlockId').value = blockId; ttsEditModal.show(); } function saveTtsText() { const blockId = document.getElementById('ttsBlockId').value; const ttsText = document.getElementById('ttsTextInput').value; const block = document.querySelector(`[data-block-id="${blockId}"]`); if (block) { block.dataset.ttsText = ttsText; } ttsEditModal.hide(); showNotification('TTS text saved', 'success'); } // ============================================ // Utility Functions // ============================================ function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } function formatDate(dateStr) { if (!dateStr) return ''; const date = new Date(dateStr); return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } function stripMarkdown(text) { if (!text) return ''; let result = text; result = result.replace(/^#{1,6}\s*/gm, ''); result = result.replace(/\*\*(.+?)\*\*/g, '$1'); result = result.replace(/\*(.+?)\*/g, '$1'); result = result.replace(/__(.+?)__/g, '$1'); result = result.replace(/_(.+?)_/g, '$1'); result = result.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); result = result.replace(/!\[([^\]]*)\]\([^)]+\)/g, ''); result = result.replace(/^>\s*/gm, ''); result = result.replace(/^\s*[-*+]\s+/gm, ''); result = result.replace(/^\s*\d+\.\s+/gm, ''); return result.trim(); } function showNotification(message, type = 'info') { const notification = document.createElement('div'); notification.className = `alert alert-${type === 'success' ? 'success' : type === 'error' ? 'danger' : 'info'} notification-toast`; notification.innerHTML = ` ${message} `; notification.style.cssText = ` position: fixed; top: 20px; right: 20px; z-index: 9999; min-width: 250px; animation: slideIn 0.3s ease; `; document.body.appendChild(notification); setTimeout(() => { notification.style.animation = 'slideOut 0.3s ease'; setTimeout(() => notification.remove(), 300); }, 3000); } // ============================================ // Authentication // ============================================ let changePasswordModal = null; async function loadCurrentUser() { try { const response = await fetch('/api/auth/me'); const data = await response.json(); if (data.user) { const usernameEl = document.getElementById('headerUsername'); if (usernameEl) usernameEl.textContent = data.user.username; // Show admin menu item if admin if (data.user.role === 'admin') { const adminItem = document.getElementById('adminMenuItem'); const adminDivider = document.getElementById('adminDivider'); if (adminItem) adminItem.style.display = 'block'; if (adminDivider) adminDivider.style.display = 'block'; } } } catch (error) { console.error('Failed to load user info:', error); } } async function handleLogout() { try { await fetch('/api/auth/logout', { method: 'POST' }); } catch(e) { /* ignore */ } window.location.href = '/login'; } function openChangePassword() { if (!changePasswordModal) { changePasswordModal = new bootstrap.Modal(document.getElementById('changePasswordModal')); } document.getElementById('currentPassword').value = ''; document.getElementById('newPassword').value = ''; document.getElementById('confirmPassword').value = ''; document.getElementById('changePasswordError').style.display = 'none'; changePasswordModal.show(); } async function submitChangePassword() { const currentPassword = document.getElementById('currentPassword').value; const newPassword = document.getElementById('newPassword').value; const confirmPassword = document.getElementById('confirmPassword').value; const errorDiv = document.getElementById('changePasswordError'); errorDiv.style.display = 'none'; if (!currentPassword || !newPassword) { errorDiv.textContent = 'All fields are required'; errorDiv.style.display = 'block'; return; } if (newPassword !== confirmPassword) { errorDiv.textContent = 'New passwords do not match'; errorDiv.style.display = 'block'; return; } if (newPassword.length < 4) { errorDiv.textContent = 'New password must be at least 4 characters'; errorDiv.style.display = 'block'; return; } try { const response = await fetch('/api/auth/change-password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }) }); const data = await response.json(); if (data.error) { errorDiv.textContent = data.error; errorDiv.style.display = 'block'; return; } changePasswordModal.hide(); showNotification('Password changed successfully!', 'success'); } catch(e) { errorDiv.textContent = 'Network error. Please try again.'; errorDiv.style.display = 'block'; } } const notifStyle = document.createElement('style'); notifStyle.textContent = ` @keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } @keyframes slideOut { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } } `; document.head.appendChild(notifStyle);