1483 lines
54 KiB
JavaScript
1483 lines
54 KiB
JavaScript
/**
|
|
* 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 = [];
|
|
let pendingThumbnailToken = null; // v4.4: auto-generated thumbnail token
|
|
|
|
// ============================================
|
|
// 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 =>
|
|
`<option value="${v.id}" ${v.id === currentVoice ? 'selected' : ''}>${v.name}</option>`
|
|
).join('');
|
|
}
|
|
|
|
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 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,
|
|
pending_thumbnail: pendingThumbnailToken || ''
|
|
})
|
|
});
|
|
|
|
const saveData = await saveResponse.json();
|
|
|
|
if (saveData.error) {
|
|
throw new Error(saveData.error);
|
|
}
|
|
|
|
// v4.4: থাম্বনেইল একবার commit হলে আর দরকার নেই
|
|
pendingThumbnailToken = null;
|
|
|
|
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 = `
|
|
<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 = allArchiveProjects.map(project => {
|
|
const thumbHtml = project.thumbnail_data
|
|
? `<img src="data:image/${project.thumbnail_format};base64,${project.thumbnail_data}"
|
|
class="project-thumb-img" alt="thumbnail">`
|
|
: `<div class="project-thumb-placeholder"><i class="bi bi-book"></i></div>`;
|
|
|
|
const publishBadge = project.is_published
|
|
? `<span class="badge bg-success ms-2"><i class="bi bi-globe me-1"></i>Published</span>`
|
|
: '';
|
|
|
|
const canPublish = project.audio_count > 0;
|
|
|
|
const bookUrl = `${window.location.origin}/read/${project.id}`;
|
|
const publishedLinkHtml = project.is_published
|
|
? `<div class="project-public-link">
|
|
<i class="bi bi-link-45deg"></i>
|
|
<a href="${bookUrl}" target="_blank" rel="noopener" title="${bookUrl}">${bookUrl}</a>
|
|
<button class="link-copy-btn" onclick="copyArchiveLink('${bookUrl}', this)" title="Copy link">
|
|
<i class="bi bi-clipboard"></i>
|
|
</button>
|
|
</div>`
|
|
: '';
|
|
|
|
return `
|
|
<div class="project-item-v2" id="project-item-${project.id}">
|
|
<div class="project-thumb" title="Thumbnail">
|
|
${thumbHtml}
|
|
<div class="project-thumb-overlay">
|
|
<button class="thumb-action-btn" title="Upload your own thumbnail"
|
|
onclick="event.stopPropagation(); document.getElementById('thumb-input-${project.id}').click()">
|
|
<i class="bi bi-camera"></i>
|
|
<span>Upload</span>
|
|
</button>
|
|
<button class="thumb-action-btn" title="Auto-generate from document"
|
|
onclick="event.stopPropagation(); autoGenerateThumbnail(${project.id}, this)">
|
|
<i class="bi bi-magic"></i>
|
|
<span>Auto</span>
|
|
</button>
|
|
</div>
|
|
<input type="file" id="thumb-input-${project.id}" accept="image/*" hidden
|
|
onchange="uploadThumbnail(${project.id}, this)">
|
|
</div>
|
|
|
|
<div class="project-info-v2" id="project-info-${project.id}">
|
|
<div class="d-flex align-items-center mb-1 flex-wrap">
|
|
<h6 class="mb-0" id="project-name-text-${project.id}">${escapeHtml(project.name)}</h6>
|
|
${publishBadge}
|
|
</div>
|
|
<div class="project-meta">
|
|
<i class="bi bi-collection me-1"></i> ${project.chapter_count} sections •
|
|
<i class="bi bi-soundwave mx-1"></i> ${project.audio_count} audio blocks •
|
|
<i class="bi bi-eye mx-1"></i> ${project.view_count} views
|
|
</div>
|
|
${project.author ? `<div class="project-meta-author"><i class="bi bi-person me-1"></i>${escapeHtml(project.author)}</div>` : ''}
|
|
${publishedLinkHtml}
|
|
</div>
|
|
|
|
<div class="project-actions-v2" id="project-actions-${project.id}">
|
|
${project.is_published
|
|
? `<button class="btn btn-sm btn-outline-warning" onclick="unpublishProject(${project.id})" title="Unpublish from public library">
|
|
<i class="bi bi-eye-slash"></i> Unpublish
|
|
</button>`
|
|
: `<button class="btn btn-sm btn-success" onclick="openPublishDialog(${project.id})"
|
|
title="${canPublish ? 'Publish to public library' : 'Generate audio first to publish'}"
|
|
${!canPublish ? 'disabled' : ''}>
|
|
<i class="bi bi-globe"></i> Publish
|
|
</button>`
|
|
}
|
|
<button class="btn btn-sm btn-outline-secondary" onclick="openEditProject(${project.id})" title="Edit details">
|
|
<i class="bi bi-pencil"></i>
|
|
</button>
|
|
<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})" title="Delete">
|
|
<i class="bi bi-trash"></i>
|
|
</button>
|
|
</div>
|
|
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
hideLoader();
|
|
if(archiveModal) archiveModal.show();
|
|
|
|
} catch (error) {
|
|
hideLoader();
|
|
alert('Failed to load projects: ' + error.message);
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Edit Project Details (Name + Author + Description + Category)
|
|
// ============================================
|
|
|
|
let editProjectModal = null;
|
|
let editingProjectId = null;
|
|
|
|
function copyArchiveLink(url, btnEl) {
|
|
const done = () => {
|
|
if (btnEl) {
|
|
const icon = btnEl.querySelector('i');
|
|
if (icon) {
|
|
icon.classList.remove('bi-clipboard');
|
|
icon.classList.add('bi-check-lg');
|
|
setTimeout(() => {
|
|
icon.classList.remove('bi-check-lg');
|
|
icon.classList.add('bi-clipboard');
|
|
}, 1500);
|
|
}
|
|
}
|
|
showNotification('Link copied', 'success');
|
|
};
|
|
if (navigator.clipboard && window.isSecureContext) {
|
|
navigator.clipboard.writeText(url).then(done).catch(() => fallbackCopy(url, done));
|
|
} else {
|
|
fallbackCopy(url, done);
|
|
}
|
|
}
|
|
|
|
function fallbackCopy(text, cb) {
|
|
const ta = document.createElement('textarea');
|
|
ta.value = text;
|
|
ta.style.position = 'fixed';
|
|
ta.style.opacity = '0';
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
try { document.execCommand('copy'); } catch (e) {}
|
|
document.body.removeChild(ta);
|
|
if (cb) cb();
|
|
}
|
|
|
|
function openEditProject(projectId) {
|
|
editingProjectId = projectId;
|
|
const project = allArchiveProjects.find(p => p.id === projectId);
|
|
if (!project) return;
|
|
|
|
if (!editProjectModal) {
|
|
const modalHtml = `
|
|
<div class="modal fade" id="editProjectModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title"><i class="bi bi-pencil-square me-2"></i>Edit Project Details</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="mb-3">
|
|
<label class="form-label">Project Name</label>
|
|
<input type="text" class="form-control" id="edit-name">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Author <span class="text-muted small">(optional)</span></label>
|
|
<input type="text" class="form-control" id="edit-author" placeholder="Author name">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Description <span class="text-muted small">(optional)</span></label>
|
|
<textarea class="form-control" id="edit-description" rows="3" placeholder="Short description of the audiobook"></textarea>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Category <span class="text-muted small">(optional)</span></label>
|
|
<input type="text" class="form-control" id="edit-category" placeholder="e.g., Fiction, Non-fiction, Self-help">
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-primary" onclick="saveEditProject()">
|
|
<i class="bi bi-check-lg me-1"></i>Save Changes
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
|
editProjectModal = new bootstrap.Modal(document.getElementById('editProjectModal'));
|
|
}
|
|
|
|
document.getElementById('edit-name').value = project.name || '';
|
|
document.getElementById('edit-author').value = project.author || '';
|
|
document.getElementById('edit-description').value = project.description || '';
|
|
document.getElementById('edit-category').value = project.category || '';
|
|
|
|
editProjectModal.show();
|
|
}
|
|
|
|
async function saveEditProject() {
|
|
const name = document.getElementById('edit-name').value.trim();
|
|
const author = document.getElementById('edit-author').value.trim();
|
|
const description = document.getElementById('edit-description').value.trim();
|
|
const category = document.getElementById('edit-category').value.trim();
|
|
|
|
if (!name) {
|
|
showNotification('Project name cannot be empty', 'warning');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/projects/${editingProjectId}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name, author, description, category })
|
|
});
|
|
const data = await response.json();
|
|
|
|
if (data.error) {
|
|
showNotification(data.error, 'error');
|
|
return;
|
|
}
|
|
|
|
if (currentProject.id === editingProjectId) {
|
|
currentProject.name = name;
|
|
const nameInput = document.getElementById('projectName');
|
|
if (nameInput) nameInput.value = name;
|
|
}
|
|
|
|
editProjectModal.hide();
|
|
showNotification('Project updated successfully', 'success');
|
|
openProjectArchive();
|
|
} catch (error) {
|
|
console.error(error);
|
|
showNotification('Failed to update 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 = `
|
|
<div class="modal fade" id="publishModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title"><i class="bi bi-globe me-2"></i>Publish to Public Library</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p class="text-muted small">Publishing makes your audiobook available to anyone visiting the public library at <code>/home</code>.</p>
|
|
|
|
<div class="mb-3">
|
|
<label class="form-label">Project</label>
|
|
<input type="text" class="form-control" id="pub-name" readonly>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Author <span class="text-muted small">(optional)</span></label>
|
|
<input type="text" class="form-control" id="pub-author" placeholder="Author name">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Description <span class="text-muted small">(optional)</span></label>
|
|
<textarea class="form-control" id="pub-description" rows="3" placeholder="Short description of the audiobook"></textarea>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Category <span class="text-muted small">(optional)</span></label>
|
|
<input type="text" class="form-control" id="pub-category" placeholder="e.g., Fiction, Non-fiction, Self-help">
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-success" onclick="confirmPublish()">
|
|
<i class="bi bi-globe me-1"></i>Publish Now
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
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\nThe audio and image files for this project will also be deleted.')) 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';
|
|
loadingEl.innerHTML = `
|
|
<div class="spinner-border text-primary" role="status"></div>
|
|
<p class="mt-2 text-muted">Loading storage info...</p>
|
|
`;
|
|
}
|
|
if (contentEl) contentEl.style.display = 'none';
|
|
|
|
// ২০ সেকেন্ডের timeout — সার্ভার আটকে থাকলেও UI মুক্ত হবে
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), 20000);
|
|
|
|
try {
|
|
const resp = await fetch('/api/maintenance/db-stats', { signal: controller.signal });
|
|
clearTimeout(timeoutId);
|
|
|
|
if (!resp.ok) {
|
|
throw new Error(`Server returned ${resp.status}`);
|
|
}
|
|
|
|
const s = await resp.json();
|
|
|
|
if (s.error) {
|
|
throw new Error(s.error);
|
|
}
|
|
|
|
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 = `<i class="bi bi-exclamation-triangle me-1"></i>` +
|
|
`The database has <strong>${s.free_percent}%</strong> free space accumulated. ` +
|
|
`You can run <strong>VACUUM</strong> to reclaim it.`;
|
|
} else {
|
|
advice.className = 'alert alert-success';
|
|
advice.style.display = 'block';
|
|
advice.innerHTML = `<i class="bi bi-check-circle me-1"></i>` +
|
|
`Free space is low (<strong>${s.free_percent}%</strong>) — no need to run VACUUM right now.`;
|
|
}
|
|
|
|
if (loadingEl) loadingEl.style.display = 'none';
|
|
if (contentEl) contentEl.style.display = 'block';
|
|
|
|
} catch (e) {
|
|
clearTimeout(timeoutId);
|
|
console.error(e);
|
|
|
|
const msg = e.name === 'AbortError'
|
|
? 'Loading storage info is taking too long (timeout). This can happen if the database is large.'
|
|
: `Failed to load storage info: ${e.message}`;
|
|
|
|
if (loadingEl) {
|
|
loadingEl.style.display = 'block';
|
|
loadingEl.innerHTML = `
|
|
<div class="text-center py-3">
|
|
<i class="bi bi-exclamation-triangle text-warning" style="font-size: 2rem;"></i>
|
|
<p class="mt-2 text-muted small">${msg}</p>
|
|
<button class="btn btn-sm btn-outline-primary mt-1" onclick="loadDbStats()">
|
|
<i class="bi bi-arrow-clockwise me-1"></i>Try Again
|
|
</button>
|
|
</div>
|
|
`;
|
|
}
|
|
if (contentEl) contentEl.style.display = 'none';
|
|
|
|
showNotification('Failed to load storage info', 'error');
|
|
}
|
|
}
|
|
|
|
async function autoGenerateThumbnail(projectId, btnEl) {
|
|
const overlay = btnEl ? btnEl.closest('.project-thumb-overlay') : null;
|
|
if (overlay) overlay.style.pointerEvents = 'none';
|
|
|
|
showLoader('Generating thumbnail...', 'From document content');
|
|
|
|
try {
|
|
const resp = await fetch(`/api/projects/${projectId}/generate-thumbnail`, {
|
|
method: 'POST'
|
|
});
|
|
const data = await resp.json();
|
|
|
|
hideLoader();
|
|
|
|
if (data.error) {
|
|
showNotification(data.error, 'error');
|
|
return;
|
|
}
|
|
|
|
showNotification(data.message || 'Thumbnail generated', 'success');
|
|
openProjectArchive();
|
|
} catch (e) {
|
|
hideLoader();
|
|
showNotification('Failed to generate thumbnail', 'error');
|
|
} finally {
|
|
if (overlay) overlay.style.pointerEvents = '';
|
|
}
|
|
}
|
|
|
|
async function runDbVacuum() {
|
|
const vacuumBtn = document.getElementById('dbmVacuumBtn');
|
|
const refreshBtn = document.getElementById('dbmRefreshBtn');
|
|
|
|
if (!confirm('Run VACUUM now? It will shrink the database but may take some time (a few minutes if the database is large).')) {
|
|
return;
|
|
}
|
|
|
|
if (vacuumBtn) {
|
|
vacuumBtn.disabled = true;
|
|
vacuumBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>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 = '<i class="bi bi-stars me-1"></i>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 = `
|
|
<i class="bi bi-${type === 'success' ? 'check-circle' : type === 'error' ? 'exclamation-circle' : type === 'warning' ? 'exclamation-triangle' : '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;
|
|
|
|
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);
|