v4.3 ui: 3D wooden bookshelf redesign, fix player cover aspect ratio, and smooth subtitle scroll

This commit is contained in:
Ashim Kumar
2026-07-03 18:43:07 +06:00
parent cf93085e22
commit 14d18fbad4
14 changed files with 1174 additions and 193 deletions

View File

@@ -1331,7 +1331,7 @@ body {
.project-thumb-overlay {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.6);
background: rgba(0,0,0,0.62);
color: white;
display: flex;
flex-direction: column;
@@ -1340,15 +1340,38 @@ body {
opacity: 0;
transition: opacity 0.2s;
font-size: 0.7rem;
gap: 4px;
gap: 6px;
padding: 6px;
}
.project-thumb:hover .project-thumb-overlay {
opacity: 1;
}
.project-thumb-overlay i {
font-size: 1.2rem;
.thumb-action-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
width: 100%;
background: rgba(255,255,255,0.14);
border: 1px solid rgba(255,255,255,0.3);
color: white;
border-radius: 6px;
padding: 5px 4px;
font-size: 0.66rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.thumb-action-btn:hover {
background: rgba(255,255,255,0.28);
}
.thumb-action-btn i {
font-size: 1rem;
}
.project-info-v2 {
@@ -1375,6 +1398,57 @@ body {
font-style: italic;
}
.project-public-link {
display: flex;
align-items: center;
gap: 4px;
margin-top: 6px;
font-size: 0.78rem;
color: var(--text-muted);
max-width: 100%;
}
.project-public-link > i {
color: var(--success-color);
flex-shrink: 0;
}
.project-public-link a {
color: var(--primary-color);
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 280px;
}
.project-public-link a:hover {
text-decoration: underline;
}
.link-copy-btn {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
border-radius: 5px;
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
font-size: 0.72rem;
transition: all 0.15s;
padding: 0;
}
.link-copy-btn:hover {
background: var(--primary-color);
color: white;
border-color: var(--primary-color);
}
.project-actions-v2 {
display: flex;
gap: 6px;

View File

@@ -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;
}

View File

@@ -92,6 +92,9 @@ async function handlePdfFile(file) {
document.getElementById('projectName').value = projectName;
currentProject.name = projectName;
// v4.4: auto-generated thumbnail token সংরক্ষণ
pendingThumbnailToken = data.pending_thumbnail || null;
renderDocumentBlocks(data.blocks);
document.getElementById('uploadSection').style.display = 'none';
@@ -145,6 +148,9 @@ async function handleWordFile(file) {
document.getElementById('projectName').value = projectName;
currentProject.name = projectName;
// v4.4: auto-generated thumbnail token সংরক্ষণ
pendingThumbnailToken = data.pending_thumbnail || null;
renderDocumentBlocks(data.blocks);
document.getElementById('uploadSection').style.display = 'none';