first commit
This commit is contained in:
899
static/js/app.js
Normal file
899
static/js/app.js
Normal file
@@ -0,0 +1,899 @@
|
||||
/**
|
||||
* 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 =>
|
||||
`<option value="${v.id}" ${v.id === selectedVoice ? 'selected' : ''}>${v.name}</option>`
|
||||
).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 = `
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-folder2-open" style="font-size: 3rem;"></i>
|
||||
<p class="mt-3">No saved projects yet</p>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
container.innerHTML = data.projects.map(project => `
|
||||
<div class="project-item">
|
||||
<div class="project-info">
|
||||
<h6>${escapeHtml(project.name)}</h6>
|
||||
<div class="project-meta">
|
||||
${project.chapter_count} chapters • ${project.block_count} blocks •
|
||||
Updated ${formatDate(project.updated_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="project-actions">
|
||||
<button class="btn btn-sm btn-primary" onclick="loadProject(${project.id})">
|
||||
<i class="bi bi-folder2-open"></i> Load
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteProject(${project.id})">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).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 = `
|
||||
<i class="bi bi-${type === 'success' ? 'check-circle' : type === 'error' ? 'exclamation-circle' : 'info-circle'} me-2"></i>
|
||||
${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);
|
||||
Reference in New Issue
Block a user