/**
* Audiobook Maker Pro v4.3 - Main Application
* UPDATED: Lazy audio loading + Storage & Maintenance (VACUUM)
*/
// ============================================
// Global State
// ============================================
let currentProject = {
id: null,
name: 'My Audiobook',
chapters: []
};
let voices = [];
let archiveModal = null;
let ttsEditModal = null;
let publishModal = null;
let dbMaintenanceModal = null;
let publishingProjectId = null;
let currentWorkflowStage = 'upload';
let allArchiveProjects = [];
// ============================================
// Initialization
// ============================================
document.addEventListener('DOMContentLoaded', function() {
console.log('đ§ Audiobook Maker Pro v4.3 initializing...');
archiveModal = new bootstrap.Modal(document.getElementById('archiveModal'));
ttsEditModal = new bootstrap.Modal(document.getElementById('ttsEditModal'));
loadVoices();
initPdfHandler();
initMarkdownEditor();
setupEventListeners();
initWelcomeOverlay();
updateWorkflowProgress('upload');
initFloatingGuidePanel();
loadCurrentUser();
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();
}
hideGuidePanelForReader();
});
document.getElementById('editor-tab').addEventListener('shown.bs.tab', function() {
if (typeof hideReaderUI === 'function') {
hideReaderUI();
}
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;
const hideGuide = localStorage.getItem('audiobookMakerHideGuide');
if (hideGuide === 'true') {
panel.classList.remove('visible');
if (toggle) toggle.classList.add('visible');
return;
}
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');
}
}
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 */ }
}
header.addEventListener('mousedown', onGuideDragStart);
document.addEventListener('mousemove', onGuideDragMove);
document.addEventListener('mouseup', onGuideDragEnd);
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');
}
}
let guidePanelHiddenByReader = false;
function hideGuidePanelForReader() {
const panel = document.getElementById('floatingGuidePanel');
const toggle = document.getElementById('floatingGuideToggle');
if (panel && panel.classList.contains('visible')) {
panel.classList.remove('visible');
guidePanelHiddenByReader = true;
} else {
guidePanelHiddenByReader = false;
}
if (toggle) toggle.classList.add('visible');
}
function restoreGuidePanelForEditor() {
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;
}
}
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 pw = panel.offsetWidth, ph = panel.offsetHeight;
newX = Math.max(0, Math.min(newX, window.innerWidth - pw));
newY = Math.max(0, Math.min(newY, window.innerHeight - ph));
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
}));
}
}
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 pw = panel.offsetWidth, ph = panel.offsetHeight;
newX = Math.max(0, Math.min(newX, window.innerWidth - pw));
newY = Math.max(0, Math.min(newY, window.innerHeight - ph));
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;
currentWorkflowStage = stage;
updateHeaderHelpButton(stage);
[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');
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');
const badge = document.getElementById('readerTabBadge');
if (badge) badge.style.display = 'inline';
break;
}
}
// ============================================
// Helpers
// ============================================
function switchToEditorTab() {
const editorTab = document.getElementById('editor-tab');
if (editorTab) {
const tab = new bootstrap.Tab(editorTab);
tab.show();
}
}
function startFromScratch() {
document.getElementById('uploadSection').style.display = 'none';
document.getElementById('editorSection').style.display = 'block';
updateWorkflowProgress('edit');
const panel = document.getElementById('audiobookMakerPanel');
if (panel) panel.style.display = 'flex';
const sidebar = document.getElementById('documentOutlineSidebar');
if (sidebar) sidebar.style.display = 'block';
const editor = document.getElementById('markdownEditor');
if (editor && (typeof editorBlocks === 'undefined' || editorBlocks.length === 0)) {
addBlock('paragraph', '');
repairAllNewBlockLines();
updatePanelUI();
}
}
function showLoader(text = 'Processing...', subtext = 'Please wait') {
const overlay = document.getElementById('loadingOverlay');
if(overlay) {
document.getElementById('loadingText').textContent = text;
document.getElementById('loadingSubtext').textContent = subtext;
overlay.classList.add('active');
}
}
function hideLoader() {
const overlay = document.getElementById('loadingOverlay');
if(overlay) overlay.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`);
populatePanelVoiceSelect();
} catch (error) {
console.error('Failed to load voices:', error);
voices = [
{ id: 'af_heart', name: 'Heart (US Female)' },
{ id: 'am_adam', name: 'Adam (US Male)' }
];
populatePanelVoiceSelect();
}
}
function populatePanelVoiceSelect() {
const select = document.getElementById('ampVoiceSelect');
if (!select) return;
let currentVoice = 'af_heart';
if (typeof panelState !== 'undefined' && panelState.voice) {
currentVoice = panelState.voice;
}
select.innerHTML = voices.map(v =>
``
).join('');
}
function getVoiceOptions(selectedVoice = 'af_heart') {
return voices.map(v =>
``
).join('');
}
// ============================================
// Project Management
// ============================================
async function saveProject() {
const projectNameInput = document.getElementById('projectName');
if(!projectNameInput) return;
const projectName = projectNameInput.value.trim();
if (!projectName) {
alert('Please enter a project name');
return;
}
currentProject.name = projectName;
const chapters = typeof collectEditorContent === 'function' ? collectEditorContent() : [];
if (chapters.length === 0) {
alert('No content to save. Add some 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();
allArchiveProjects = data.projects || [];
const container = document.getElementById('projectList');
if(!container) return;
if (allArchiveProjects.length === 0) {
container.innerHTML = `
`;
} else {
container.innerHTML = allArchiveProjects.map(project => {
const thumbHtml = project.thumbnail_data
? `
`
: `
`;
const publishBadge = project.is_published
? `Published`
: '';
const canPublish = project.audio_count > 0;
return `
${escapeHtml(project.name)}
${publishBadge}
${project.chapter_count} sections âĸ
${project.audio_count} audio blocks âĸ
${project.view_count} views
${project.author ? `
${escapeHtml(project.author)}
` : ''}
${project.is_published
? ``
: ``
}
`;
}).join('');
}
hideLoader();
if(archiveModal) archiveModal.show();
} catch (error) {
hideLoader();
alert('Failed to load projects: ' + error.message);
}
}
// ============================================
// Rename
// ============================================
function startEditProjectName(projectId) {
document.getElementById(`project-info-${projectId}`).style.display = 'none';
document.getElementById(`project-actions-${projectId}`).style.display = 'none';
document.getElementById(`project-edit-${projectId}`).style.display = 'block';
document.getElementById(`project-edit-actions-${projectId}`).style.display = 'flex';
const input = document.getElementById(`edit-input-${projectId}`);
input.focus();
input.select();
input.onkeydown = function(e) {
if (e.key === 'Enter') {
e.preventDefault();
saveProjectName(projectId);
} else if (e.key === 'Escape') {
cancelEditProjectName(projectId);
}
};
}
function cancelEditProjectName(projectId) {
document.getElementById(`project-info-${projectId}`).style.display = 'block';
document.getElementById(`project-actions-${projectId}`).style.display = 'flex';
document.getElementById(`project-edit-${projectId}`).style.display = 'none';
document.getElementById(`project-edit-actions-${projectId}`).style.display = 'none';
const textElement = document.getElementById(`project-name-text-${projectId}`);
const input = document.getElementById(`edit-input-${projectId}`);
if (textElement && input) {
input.value = textElement.textContent;
}
}
async function saveProjectName(projectId) {
const input = document.getElementById(`edit-input-${projectId}`);
const newName = input.value.trim();
if (!newName) {
showNotification('Project name cannot be empty', 'warning');
return;
}
try {
const response = await fetch(`/api/projects/${projectId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName })
});
const data = await response.json();
if (data.error) {
showNotification(data.error, 'error');
return;
}
const textEl = document.getElementById(`project-name-text-${projectId}`);
if (textEl) textEl.textContent = newName;
const cached = allArchiveProjects.find(p => p.id === projectId);
if (cached) cached.name = newName;
cancelEditProjectName(projectId);
showNotification('Project renamed successfully', 'success');
if (currentProject.id === projectId) {
currentProject.name = newName;
const nameInput = document.getElementById('projectName');
if (nameInput) nameInput.value = newName;
}
} catch (error) {
console.error(error);
showNotification('Failed to rename project', 'error');
}
}
// ============================================
// Thumbnail Upload
// ============================================
async function uploadThumbnail(projectId, inputEl) {
const file = inputEl.files[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024) {
showNotification('Image too large (max 5MB)', 'error');
return;
}
const formData = new FormData();
formData.append('file', file);
showLoader('Uploading thumbnail...');
try {
const resp = await fetch(`/api/projects/${projectId}/thumbnail`, {
method: 'POST',
body: formData
});
const data = await resp.json();
hideLoader();
if (data.error) {
showNotification(data.error, 'error');
return;
}
showNotification('Thumbnail updated', 'success');
openProjectArchive();
} catch (e) {
hideLoader();
showNotification('Failed to upload thumbnail', 'error');
}
}
// ============================================
// Publishing
// ============================================
function openPublishDialog(projectId) {
publishingProjectId = projectId;
const project = allArchiveProjects.find(p => p.id === projectId);
if (!publishModal) {
const modalHtml = `
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
publishModal = new bootstrap.Modal(document.getElementById('publishModal'));
}
document.getElementById('pub-name').value = project ? project.name : '';
document.getElementById('pub-author').value = project ? (project.author || '') : '';
document.getElementById('pub-description').value = project ? (project.description || '') : '';
document.getElementById('pub-category').value = project ? (project.category || '') : '';
publishModal.show();
}
async function confirmPublish() {
const author = document.getElementById('pub-author').value.trim();
const description = document.getElementById('pub-description').value.trim();
const category = document.getElementById('pub-category').value.trim();
try {
const resp = await fetch(`/api/projects/${publishingProjectId}/publish`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ author, description, category })
});
const data = await resp.json();
if (data.error) {
showNotification(data.error, 'error');
return;
}
publishModal.hide();
showNotification(data.message || 'Published!', 'success');
openProjectArchive();
} catch (e) {
showNotification('Failed to publish', 'error');
}
}
async function unpublishProject(projectId) {
if (!confirm('Unpublish this audiobook? It will no longer appear on the public library.')) return;
try {
const resp = await fetch(`/api/projects/${projectId}/unpublish`, { method: 'POST' });
const data = await resp.json();
if (data.error) {
showNotification(data.error, 'error');
return;
}
showNotification('Unpublished', 'success');
openProjectArchive();
} catch (e) {
showNotification('Failed to unpublish', 'error');
}
}
// ============================================
// Project Load / Delete - LAZY AUDIO LOADING
// ============================================
async function loadProject(projectId) {
showLoader('Loading project...', 'Fetching metadata');
if(archiveModal) archiveModal.hide();
try {
// Step 1: Load lightweight metadata (no audio_data)
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;
document.getElementById('uploadSection').style.display = 'none';
document.getElementById('editorSection').style.display = 'block';
const panel = document.getElementById('audiobookMakerPanel');
if (panel) panel.style.display = 'flex';
const sidebar = document.getElementById('documentOutlineSidebar');
if (sidebar) sidebar.style.display = 'block';
if (typeof renderProjectInEditor === 'function') {
renderProjectInEditor(data);
}
// Step 2: Find blocks that need audio fetched
const audioBlocks = [];
for (const ch of data.chapters) {
for (const bl of ch.blocks) {
if (bl.has_audio && bl.block_type !== 'image') {
audioBlocks.push(bl.id);
}
}
}
updateWorkflowProgress(audioBlocks.length > 0 ? 'audio-ready' : 'edit');
hideLoader();
if (audioBlocks.length === 0) {
showNotification('Project loaded successfully!', 'success');
return;
}
// Step 3: Lazy-load audio in background, parallel batches
showNotification(`Project loaded. Fetching ${audioBlocks.length} audio blocks in background...`, 'info');
loadAudioBlocksInBackground(projectId, audioBlocks);
} catch (error) {
hideLoader();
console.error('Load project error:', error);
alert('Failed to load project: ' + error.message);
}
}
async function loadAudioBlocksInBackground(projectId, blockIds) {
const BATCH_SIZE = 5;
let loaded = 0;
let failed = 0;
async function fetchOne(blockId) {
try {
const resp = await fetch(`/api/projects/${projectId}/audio/${blockId}`);
// v4.3: āĻāύā§āĻĄāĻĒāϝāĻŧā§āύā§āĻ āĻŦāĻžāĻāύāĻžāϰāĻŋ āĻ
āĻĄāĻŋāĻ (audio/*) āĻ
āĻĨāĻŦāĻž legacy base64 JSON āĻĻāĻŋāϤ⧠āĻĒāĻžāϰā§
const contentType = resp.headers.get('content-type') || '';
const blockData = editorBlocks.find(b => b.db_id === blockId);
if (contentType.startsWith('audio/')) {
// āύāϤā§āύ: āĻĢāĻžāĻāϞ āĻāĻā§ â āĻļā§āϧ⧠indicator āĻāĻĒāĻĄā§āĻ āĻāϰāĻŋ, base64 āĻŽā§āĻŽāϰāĻŋāϤ⧠āϰāĻžāĻāĻŋ āύāĻž
// (reader āύāĻŋāĻā§āĻ lazy fetch āĻāϰāĻŦā§)
if (blockData) {
blockData.has_audio = true;
const blockEl = document.getElementById(blockData.id);
if (blockEl) {
const indicator = blockEl.querySelector('.audio-indicator');
if (indicator) {
indicator.classList.remove('no-audio');
indicator.classList.add('has-audio');
indicator.title = 'Audio available';
}
}
}
loaded++;
return;
}
// Legacy base64 JSON
const data = await resp.json();
if (data.error || !data.audio_data) {
failed++;
return;
}
if (blockData) {
blockData.audio_data = data.audio_data;
blockData.audio_format = data.audio_format;
blockData.has_audio = true;
const blockEl = document.getElementById(blockData.id);
if (blockEl) {
const indicator = blockEl.querySelector('.audio-indicator');
if (indicator) {
indicator.classList.remove('no-audio');
indicator.classList.add('has-audio');
indicator.title = 'Audio loaded';
}
}
}
loaded++;
} catch (e) {
failed++;
console.warn(`Failed to load audio for block ${blockId}:`, e);
}
}
for (let i = 0; i < blockIds.length; i += BATCH_SIZE) {
const batch = blockIds.slice(i, i + BATCH_SIZE);
await Promise.all(batch.map(fetchOne));
}
const msg = failed > 0
? `Verified ${loaded}/${blockIds.length} audio blocks (${failed} failed)`
: `All ${loaded} audio blocks verified â`;
showNotification(msg, failed > 0 ? 'warning' : 'success');
if (typeof updatePanelUI === 'function') {
updatePanelUI();
}
}
async function deleteProject(projectId) {
if (!confirm('Are you sure you want to delete this project? This action cannot be undone.\n\nāĻāĻ āĻĒā§āϰāĻā§āĻā§āĻā§āϰ āĻ
āĻĄāĻŋāĻ āĻ āĻāĻŽā§āĻ āĻĢāĻžāĻāϞāĻā§āϞā§āĻ āĻŽā§āĻā§ āϝāĻžāĻŦā§āĨ¤')) 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);
}
}
// ============================================
// v4.3: Storage & Maintenance (VACUUM)
// ============================================
function openDbMaintenance() {
if (!dbMaintenanceModal) {
dbMaintenanceModal = new bootstrap.Modal(document.getElementById('dbMaintenanceModal'));
}
dbMaintenanceModal.show();
loadDbStats();
}
async function loadDbStats() {
const loadingEl = document.getElementById('dbStatsLoading');
const contentEl = document.getElementById('dbStatsContent');
if (loadingEl) loadingEl.style.display = 'block';
if (contentEl) contentEl.style.display = 'none';
try {
const resp = await fetch('/api/maintenance/db-stats');
const s = await resp.json();
if (s.error) {
showNotification(s.error, 'error');
return;
}
document.getElementById('dbmFileSize').textContent = `${s.file_size_mb} MB`;
document.getElementById('dbmMediaSize').textContent = `${s.media_size_mb} MB`;
document.getElementById('dbmFreeText').textContent =
`${s.free_mb} MB (${s.free_percent}%)`;
const bar = document.getElementById('dbmFreeBar');
const pct = Math.min(s.free_percent, 100);
bar.style.width = pct + '%';
bar.textContent = `${s.free_percent}%`;
// āϰāĻ: āĻāĻŽ āĻšāϞ⧠āϏāĻŦā§āĻ, āĻŦā§āĻļāĻŋ āĻšāϞ⧠āĻšāϞā§āĻĻ/āϞāĻžāϞ
bar.className = 'progress-bar';
if (s.free_percent >= 30) {
bar.classList.add('bg-danger');
} else if (s.free_percent >= 15) {
bar.classList.add('bg-warning');
} else {
bar.classList.add('bg-success');
}
const advice = document.getElementById('dbmAdvice');
if (s.free_percent >= 15) {
advice.className = 'alert alert-warning';
advice.style.display = 'block';
advice.innerHTML = `` +
`āĻĄā§āĻāĻžāĻŦā§āϏ⧠${s.free_percent}% āĻĢāĻžāĻāĻāĻž āϏā§āĻĒā§āϏ āĻāĻŽā§āĻā§āĨ¤ ` +
`Run VACUUM āĻāĻžāϞāĻŋāϝāĻŧā§ āĻāĻāĻŋ reclaim āĻāϰāϤ⧠āĻĒāĻžāϰā§āύāĨ¤`;
} else {
advice.className = 'alert alert-success';
advice.style.display = 'block';
advice.innerHTML = `` +
`āĻĢāĻžāĻāĻāĻž āϏā§āĻĒā§āϏ āĻāĻŽ (${s.free_percent}%) â āĻāĻāύ VACUUM āĻāĻžāϞāĻžāύā§āϰ āĻĻāϰāĻāĻžāϰ āύā§āĻāĨ¤`;
}
if (loadingEl) loadingEl.style.display = 'none';
if (contentEl) contentEl.style.display = 'block';
} catch (e) {
console.error(e);
showNotification('Failed to load storage info', 'error');
}
}
async function runDbVacuum() {
const vacuumBtn = document.getElementById('dbmVacuumBtn');
const refreshBtn = document.getElementById('dbmRefreshBtn');
if (!confirm('VACUUM āĻāĻāύ āĻāĻžāϞāĻžāĻŦā§āύ? āĻāĻāĻŋ āĻĄā§āĻāĻžāĻŦā§āϏ āĻā§āĻ āĻāϰāĻŦā§ āĻāĻŋāύā§āϤ⧠āĻāĻŋāĻā§ āϏāĻŽāϝāĻŧ (āĻĄā§āĻāĻžāĻŦā§āϏ āĻŦāĻĄāĻŧ āĻšāϞ⧠āĻāϝāĻŧā§āĻ āĻŽāĻŋāύāĻŋāĻ) āύāĻŋāϤ⧠āĻĒāĻžāϰā§āĨ¤')) {
return;
}
if (vacuumBtn) {
vacuumBtn.disabled = true;
vacuumBtn.innerHTML = 'Running...';
}
if (refreshBtn) refreshBtn.disabled = true;
try {
const resp = await fetch('/api/maintenance/vacuum', { method: 'POST' });
const data = await resp.json();
if (data.error) {
showNotification(data.error, 'error');
} else {
showNotification(data.message || 'VACUUM complete', 'success');
loadDbStats();
}
} catch (e) {
showNotification('VACUUM failed', 'error');
} finally {
if (vacuumBtn) {
vacuumBtn.disabled = false;
vacuumBtn.innerHTML = 'Run VACUUM';
}
if (refreshBtn) refreshBtn.disabled = false;
}
}
// ============================================
// TTS Text Editing
// ============================================
function openTtsEditor(blockId, currentContent) {
const plainText = stripMarkdown(currentContent);
document.getElementById('ttsTextInput').value = plainText;
document.getElementById('ttsBlockId').value = blockId;
if(ttsEditModal) 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;
}
if(ttsEditModal) ttsEditModal.hide();
showNotification('TTS text saved', 'success');
}
// ============================================
// Utility Functions
// ============================================
function escapeHtml(text) {
if(!text) return '';
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' : type === 'warning' ? 'warning' : '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;
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);