v4.3 ui: 3D wooden bookshelf redesign, fix player cover aspect ratio, and smooth subtitle scroll
This commit is contained in:
297
static/js/app.js
297
static/js/app.js
@@ -21,6 +21,7 @@ let dbMaintenanceModal = null;
|
||||
let publishingProjectId = null;
|
||||
let currentWorkflowStage = 'upload';
|
||||
let allArchiveProjects = [];
|
||||
let pendingThumbnailToken = null; // v4.4: auto-generated thumbnail token
|
||||
|
||||
// ============================================
|
||||
// Initialization
|
||||
@@ -540,7 +541,10 @@ async function saveProject() {
|
||||
const saveResponse = await fetch(`/api/projects/${projectId}/save`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ chapters })
|
||||
body: JSON.stringify({
|
||||
chapters,
|
||||
pending_thumbnail: pendingThumbnailToken || ''
|
||||
})
|
||||
});
|
||||
|
||||
const saveData = await saveResponse.json();
|
||||
@@ -549,6 +553,9 @@ async function saveProject() {
|
||||
throw new Error(saveData.error);
|
||||
}
|
||||
|
||||
// v4.4: থাম্বনেইল একবার commit হলে আর দরকার নেই
|
||||
pendingThumbnailToken = null;
|
||||
|
||||
hideLoader();
|
||||
showNotification('Project saved successfully!', 'success');
|
||||
|
||||
@@ -608,14 +615,32 @@ async function openProjectArchive() {
|
||||
|
||||
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" onclick="document.getElementById('thumb-input-${project.id}').click()"
|
||||
title="Click to upload thumbnail">
|
||||
<div class="project-thumb" title="Thumbnail">
|
||||
${thumbHtml}
|
||||
<div class="project-thumb-overlay">
|
||||
<i class="bi bi-camera"></i>
|
||||
<span>Edit</span>
|
||||
<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)">
|
||||
@@ -632,11 +657,7 @@ async function openProjectArchive() {
|
||||
<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>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="project-edit-form" id="project-edit-${project.id}" style="display:none; flex:1; min-width:200px;">
|
||||
<input type="text" class="form-control form-control-sm project-name-edit-input"
|
||||
id="edit-input-${project.id}" value="${escapeHtml(project.name)}">
|
||||
${publishedLinkHtml}
|
||||
</div>
|
||||
|
||||
<div class="project-actions-v2" id="project-actions-${project.id}">
|
||||
@@ -650,7 +671,7 @@ async function openProjectArchive() {
|
||||
<i class="bi bi-globe"></i> Publish
|
||||
</button>`
|
||||
}
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="startEditProjectName(${project.id})" title="Rename">
|
||||
<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})">
|
||||
@@ -661,14 +682,6 @@ async function openProjectArchive() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="project-actions-v2" id="project-edit-actions-${project.id}" style="display:none;">
|
||||
<button class="btn btn-sm btn-success" onclick="saveProjectName(${project.id})" title="Save">
|
||||
<i class="bi bi-check-lg"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="cancelEditProjectName(${project.id})" title="Cancel">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
@@ -684,85 +697,135 @@ async function openProjectArchive() {
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Rename
|
||||
// Edit Project Details (Name + Author + Description + Category)
|
||||
// ============================================
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
}
|
||||
let editProjectModal = null;
|
||||
let editingProjectId = null;
|
||||
|
||||
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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProjectName(projectId) {
|
||||
const input = document.getElementById(`edit-input-${projectId}`);
|
||||
const newName = input.value.trim();
|
||||
|
||||
if (!newName) {
|
||||
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/${projectId}`, {
|
||||
const response = await fetch(`/api/projects/${editingProjectId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: newName })
|
||||
body: JSON.stringify({ name, author, description, category })
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
if (currentProject.id === editingProjectId) {
|
||||
currentProject.name = name;
|
||||
const nameInput = document.getElementById('projectName');
|
||||
if (nameInput) nameInput.value = newName;
|
||||
if (nameInput) nameInput.value = name;
|
||||
}
|
||||
|
||||
|
||||
editProjectModal.hide();
|
||||
showNotification('Project updated successfully', 'success');
|
||||
openProjectArchive();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showNotification('Failed to rename project', 'error');
|
||||
showNotification('Failed to update project', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1053,7 +1116,7 @@ async function loadAudioBlocksInBackground(projectId, blockIds) {
|
||||
|
||||
|
||||
async function deleteProject(projectId) {
|
||||
if (!confirm('Are you sure you want to delete this project? This action cannot be undone.\n\nএই প্রজেক্টের অডিও ও ইমেজ ফাইলগুলোও মুছে যাবে।')) return;
|
||||
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...');
|
||||
|
||||
@@ -1086,16 +1149,31 @@ function openDbMaintenance() {
|
||||
async function loadDbStats() {
|
||||
const loadingEl = document.getElementById('dbStatsLoading');
|
||||
const contentEl = document.getElementById('dbStatsContent');
|
||||
if (loadingEl) loadingEl.style.display = 'block';
|
||||
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');
|
||||
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) {
|
||||
showNotification(s.error, 'error');
|
||||
return;
|
||||
throw new Error(s.error);
|
||||
}
|
||||
|
||||
document.getElementById('dbmFileSize').textContent = `${s.file_size_mb} MB`;
|
||||
@@ -1123,29 +1201,78 @@ async function loadDbStats() {
|
||||
advice.className = 'alert alert-warning';
|
||||
advice.style.display = 'block';
|
||||
advice.innerHTML = `<i class="bi bi-exclamation-triangle me-1"></i>` +
|
||||
`ডেটাবেসে <strong>${s.free_percent}%</strong> ফাঁকা স্পেস জমেছে। ` +
|
||||
`<strong>Run VACUUM</strong> চালিয়ে এটি reclaim করতে পারেন।`;
|
||||
`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>` +
|
||||
`ফাঁকা স্পেস কম (<strong>${s.free_percent}%</strong>) — এখন VACUUM চালানোর দরকার নেই।`;
|
||||
`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('VACUUM এখন চালাবেন? এটি ডেটাবেস ছোট করবে কিন্তু কিছু সময় (ডেটাবেস বড় হলে কয়েক মিনিট) নিতে পারে।')) {
|
||||
if (!confirm('Run VACUUM now? It will shrink the database but may take some time (a few minutes if the database is large).')) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user