From 14d18fbad4e8804f832dbc26dece942e6ce6e57e Mon Sep 17 00:00:00 2001 From: Ashim Kumar Date: Fri, 3 Jul 2026 18:43:07 +0600 Subject: [PATCH] v4.3 ui: 3D wooden bookshelf redesign, fix player cover aspect ratio, and smooth subtitle scroll --- db.py | 2 + media-storage/project_1/thumbnail.jpeg | Bin 0 -> 30190 bytes media-storage/project_3/thumbnail.jpeg | Bin 0 -> 27846 bytes media_storage.py | 51 ++++ requirements.txt | 3 + routes/docx_routes.py | 20 +- routes/pdf_routes.py | 20 +- routes/project_routes.py | 281 ++++++++++++++++++++- static/css/style.css | 82 +++++- static/js/app.js | 297 +++++++++++++++------- static/js/pdf-handler.js | 6 + templates/index.html | 6 +- templates/public_home.html | 337 ++++++++++++++++++------- thumbnail_generator.py | 262 +++++++++++++++++++ 14 files changed, 1174 insertions(+), 193 deletions(-) create mode 100644 media-storage/project_1/thumbnail.jpeg create mode 100644 media-storage/project_3/thumbnail.jpeg create mode 100644 thumbnail_generator.py diff --git a/db.py b/db.py index 43ef179..1efb576 100644 --- a/db.py +++ b/db.py @@ -135,6 +135,8 @@ def init_db(): ('projects', 'thumbnail_path', 'TEXT'), ('markdown_blocks', 'audio_path', 'TEXT'), ('block_images', 'image_path', 'TEXT'), + # --- v4.4: thumbnail auto-generated flag (1=auto, 0=user-uploaded) --- + ('projects', 'thumbnail_auto', 'INTEGER DEFAULT 0'), ] for table, column, definition in migrations: diff --git a/media-storage/project_1/thumbnail.jpeg b/media-storage/project_1/thumbnail.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..a1d8e6ef660b55b00c2227235af4c723615f043c GIT binary patch literal 30190 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

ko)%;E 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 literal 0 HcmV?d00001 diff --git a/media-storage/project_3/thumbnail.jpeg b/media-storage/project_3/thumbnail.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..1c8352fc7eb04e0a2aa9b9a8cf7ec2221ccd4755 GIT binary patch literal 27846 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', methods=['PUT']) @login_required def update_project(project_id): - """Update project name.""" + """Update project name and metadata (author/description/category).""" data = request.json name = data.get('name', '').strip() @@ -269,10 +271,28 @@ def update_project(project_id): db = get_db() cursor = db.cursor() + # মেটাডেটা ফিল্ড — পাঠানো হলেই আপডেট হবে + updates = ['name = ?'] + params = [name] + + if 'author' in data: + updates.append('author = ?') + params.append((data.get('author') or '').strip()) + if 'description' in data: + updates.append('description = ?') + params.append((data.get('description') or '').strip()) + if 'category' in data: + updates.append('category = ?') + params.append((data.get('category') or '').strip()) + + updates.append('updated_at = CURRENT_TIMESTAMP') + params.append(project_id) + try: - cursor.execute(''' - UPDATE projects SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? - ''', (name, project_id)) + cursor.execute( + f"UPDATE projects SET {', '.join(updates)} WHERE id = ?", + params + ) db.commit() if cursor.rowcount == 0: @@ -422,6 +442,32 @@ def save_project_content(project_id): (img_rel, image_id) ) + # --- v4.4: pending auto-thumbnail commit করা (শুধু যদি প্রজেক্টে থাম্বনেইল না থাকে) --- + pending_token = clean_str(data.get('pending_thumbnail', '')) + if pending_token: + cursor.execute( + 'SELECT thumbnail_path, thumbnail_data FROM projects WHERE id = ?', + (project_id,) + ) + prow = cursor.fetchone() + already_has_thumb = prow and (prow['thumbnail_path'] or prow['thumbnail_data']) + + if not already_has_thumb: + thumb_bytes, thumb_fmt = read_pending_thumbnail(pending_token) + if thumb_bytes: + rel_path = save_thumbnail(project_id, thumb_bytes, thumb_fmt) + if rel_path: + cursor.execute(''' + UPDATE projects + SET thumbnail_path = ?, thumbnail_data = NULL, thumbnail_format = ?, + thumbnail_auto = 1 + WHERE id = ? + ''', (rel_path, thumb_fmt, project_id)) + print(f" 🖼️ Auto-thumbnail applied to project {project_id}") + + # commit বা বাতিল — যেভাবেই হোক pending ফাইল মুছে ফেলি + delete_pending_thumbnail(pending_token) + cursor.execute(''' UPDATE projects SET updated_at = CURRENT_TIMESTAMP WHERE id = ? ''', (project_id,)) @@ -531,7 +577,8 @@ def upload_thumbnail(project_id): rel_path = save_thumbnail(project_id, img_bytes, fmt) cursor.execute(''' - UPDATE projects SET thumbnail_path = ?, thumbnail_data = NULL, thumbnail_format = ? + UPDATE projects SET thumbnail_path = ?, thumbnail_data = NULL, thumbnail_format = ?, + thumbnail_auto = 0 WHERE id = ? ''', (rel_path, fmt, project_id)) db.commit() @@ -565,12 +612,230 @@ def delete_thumbnail(project_id): # v4.3: Database Maintenance (VACUUM + stats) # ============================================ +@project_bp.route('/api/projects//generate-thumbnail', methods=['POST']) +@login_required +def generate_single_thumbnail(project_id): + """ + একটি নির্দিষ্ট প্রজেক্টের থাম্বনেইল auto-generate/regenerate করে (v4.4)। + সোর্স: (১) প্রথম embedded image block, (২) fallback হিসেবে text-cover। + """ + import base64 + from thumbnail_generator import _optimize_image_bytes + + db = get_db() + cursor = db.cursor() + + cursor.execute('SELECT id, name, author FROM projects WHERE id = ?', (project_id,)) + proj = cursor.fetchone() + if not proj: + return jsonify({'error': 'Project not found'}), 404 + + thumb_bytes = None + thumb_fmt = None + source = None + + # সোর্স ১: প্রথম embedded image + cursor.execute(''' + SELECT bi.image_data, bi.image_path, bi.image_format + FROM block_images bi + JOIN markdown_blocks mb ON bi.block_id = mb.id + JOIN chapters c ON mb.chapter_id = c.id + WHERE c.project_id = ? + AND ((bi.image_path IS NOT NULL AND bi.image_path != '') + OR (bi.image_data IS NOT NULL AND bi.image_data != '')) + ORDER BY c.chapter_number, mb.block_order, bi.id + LIMIT 1 + ''', (project_id,)) + img_row = cursor.fetchone() + + if img_row: + raw = None + if img_row['image_path']: + b64 = read_file_base64(img_row['image_path']) + if b64: + try: + raw = base64.b64decode(b64) + except Exception: + raw = None + elif img_row['image_data']: + try: + raw = base64.b64decode(clean_str(img_row['image_data'])) + except Exception: + raw = None + + if raw and len(raw) > 4000: + thumb_bytes, thumb_fmt = _optimize_image_bytes( + raw, img_row['image_format'] or 'png' + ) + if thumb_bytes: + source = 'image' + + # সোর্স ২: text-cover fallback + if not thumb_bytes: + thumb_bytes, thumb_fmt = generate_text_cover(proj['name'], proj['author'] or '') + if thumb_bytes: + source = 'text' + + if not thumb_bytes: + return jsonify({'error': 'Could not generate thumbnail (Pillow may be missing)'}), 500 + + rel_path = save_thumbnail(project_id, thumb_bytes, thumb_fmt) + if not rel_path: + return jsonify({'error': 'Failed to save thumbnail'}), 500 + + cursor.execute(''' + UPDATE projects + SET thumbnail_path = ?, thumbnail_data = NULL, thumbnail_format = ?, + thumbnail_auto = 1 + WHERE id = ? + ''', (rel_path, thumb_fmt, project_id)) + db.commit() + + b64 = read_file_base64(rel_path) + return jsonify({ + 'success': True, + 'source': source, + 'thumbnail_data': b64, + 'thumbnail_format': thumb_fmt, + 'message': f'Thumbnail generated from {"first image" if source == "image" else "text cover"}' + }) + + +@project_bp.route('/api/maintenance/backfill-thumbnails', methods=['POST']) +@login_required +def backfill_thumbnails(): + """ + থাম্বনেইল জেনারেট করে (v4.4)। + সোর্স: (১) প্রথম embedded image block, (২) fallback হিসেবে text-cover। + + force=False → শুধু থাম্বনেইল-বিহীন প্রজেক্ট। + force=True → auto-generated থাম্বনেইলও নতুন করে বানায় (user-uploaded রক্ষা পায়)। + """ + data = request.json or {} + force = bool(data.get('force', False)) + + db = get_db() + cursor = db.cursor() + + if force: + # user-uploaded (thumbnail_auto=0 AND থাম্বনেইল আছে) ছাড়া সব + cursor.execute(''' + SELECT id, name, author FROM projects + WHERE (thumbnail_path IS NULL OR thumbnail_path = '') + OR thumbnail_auto = 1 + ''') + else: + cursor.execute(''' + SELECT id, name, author FROM projects + WHERE (thumbnail_path IS NULL OR thumbnail_path = '') + AND (thumbnail_data IS NULL OR thumbnail_data = '') + ''') + projects = cursor.fetchall() + + generated = 0 + from_image = 0 + from_text = 0 + failed = 0 + + for proj in projects: + pid = proj['id'] + thumb_bytes = None + thumb_fmt = None + + # সোর্স ১: প্রথম embedded image (path বা base64) + cursor.execute(''' + SELECT bi.image_data, bi.image_path, bi.image_format + FROM block_images bi + JOIN markdown_blocks mb ON bi.block_id = mb.id + JOIN chapters c ON mb.chapter_id = c.id + WHERE c.project_id = ? + AND ((bi.image_path IS NOT NULL AND bi.image_path != '') + OR (bi.image_data IS NOT NULL AND bi.image_data != '')) + ORDER BY c.chapter_number, mb.block_order, bi.id + LIMIT 1 + ''', (pid,)) + img_row = cursor.fetchone() + + if img_row: + import base64 + raw = None + if img_row['image_path']: + raw = read_file_base64(img_row['image_path']) + if raw: + try: + raw = base64.b64decode(raw) + except Exception: + raw = None + elif img_row['image_data']: + try: + raw = base64.b64decode(clean_str(img_row['image_data'])) + except Exception: + raw = None + + if raw and len(raw) > 4000: + from thumbnail_generator import _optimize_image_bytes + thumb_bytes, thumb_fmt = _optimize_image_bytes( + raw, img_row['image_format'] or 'png' + ) + if thumb_bytes: + from_image += 1 + + # সোর্স ২: text-cover fallback + if not thumb_bytes: + thumb_bytes, thumb_fmt = generate_text_cover( + proj['name'], proj['author'] or '' + ) + if thumb_bytes: + from_text += 1 + + if not thumb_bytes: + failed += 1 + continue + + rel_path = save_thumbnail(pid, thumb_bytes, thumb_fmt) + if rel_path: + cursor.execute(''' + UPDATE projects + SET thumbnail_path = ?, thumbnail_data = NULL, thumbnail_format = ?, + thumbnail_auto = 1 + WHERE id = ? + ''', (rel_path, thumb_fmt, pid)) + generated += 1 + else: + failed += 1 + + db.commit() + + return jsonify({ + 'success': True, + 'total_without_thumbnail': len(projects), + 'generated': generated, + 'from_image': from_image, + 'from_text': from_text, + 'failed': failed, + 'force': force, + 'message': f'{generated} thumbnails generated ' + f'({from_image} from images, {from_text} text covers).' + }) + + @project_bp.route('/api/maintenance/db-stats', methods=['GET']) @login_required def db_stats(): """ডেটাবেস সাইজ, ফাঁকা স্পেস (%), এবং মিডিয়া স্টোরেজ সাইজ রিটার্ন করে।""" - stats = get_db_stats() - media_bytes = get_storage_usage_bytes() + try: + stats = get_db_stats() + except Exception as e: + print(f"⚠️ get_db_stats failed: {e}") + return jsonify({'error': f'Database stats failed: {str(e)}'}), 500 + + # মিডিয়া স্ক্যান আলাদা try/except — ফোল্ডার বিশাল/দুর্গম হলেও stats রিটার্ন হবে + try: + media_bytes = get_storage_usage_bytes() + except Exception as e: + print(f"⚠️ get_storage_usage_bytes failed: {e}") + media_bytes = 0 + stats['media_size_bytes'] = media_bytes stats['media_size_mb'] = round(media_bytes / (1024 * 1024), 2) return jsonify(stats) diff --git a/static/css/style.css b/static/css/style.css index 4a225ee..03e0826 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -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; diff --git a/static/js/app.js b/static/js/app.js index 5b13a55..aa473e7 100644 --- a/static/js/app.js +++ b/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 + ? `

` + : ''; + return `
-
+
${thumbHtml}
- - Edit + +
@@ -632,11 +657,7 @@ async function openProjectArchive() { ${project.view_count} views
${project.author ? `
${escapeHtml(project.author)}
` : ''} -
- -
@@ -650,7 +671,7 @@ async function openProjectArchive() { Publish ` } -
-
`; }).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 = ` + `; + 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 = ` +
+

Loading storage info...

+ `; + } 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 = `` + - `ডেটাবেসে ${s.free_percent}% ফাঁকা স্পেস জমেছে। ` + - `Run VACUUM চালিয়ে এটি reclaim করতে পারেন।`; + `The database has ${s.free_percent}% free space accumulated. ` + + `You can run VACUUM to reclaim it.`; } else { advice.className = 'alert alert-success'; advice.style.display = 'block'; advice.innerHTML = `` + - `ফাঁকা স্পেস কম (${s.free_percent}%) — এখন VACUUM চালানোর দরকার নেই।`; + `Free space is low (${s.free_percent}%) — 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 = ` +
+ +

${msg}

+ +
+ `; + } + 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; } diff --git a/static/js/pdf-handler.js b/static/js/pdf-handler.js index fffd944..45382e1 100644 --- a/static/js/pdf-handler.js +++ b/static/js/pdf-handler.js @@ -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'; diff --git a/templates/index.html b/templates/index.html index 6a0c36d..9dad238 100644 --- a/templates/index.html +++ b/templates/index.html @@ -179,6 +179,9 @@ + + Library + + `; + + for (const c of sortedCats) { + const isActive = activeCategory.toLowerCase() === c.label.toLowerCase(); + html += ` + + `; + } + + if (othersCount > 0) { + html += ` + + `; + } + + html += ``; + return html; + } + + function selectCategory(cat) { + activeCategory = cat; + applyFilters(); // applyFilters → renderBookcase → nav নতুন করে বসবে + } + + function filterBooks(query) { + currentSearch = (query || '').toLowerCase().trim(); + applyFilters(); + } + + function applyFilters() { + let filtered = allBooks; + + // category ফিল্টার + if (activeCategory === OTHERS_KEY) { + filtered = filtered.filter(b => !(b.category || '').trim()); + } else if (activeCategory !== 'all') { + filtered = filtered.filter(b => + (b.category || '').trim().toLowerCase() === activeCategory.toLowerCase() + ); + } + + // সার্চ ফিল্টার + if (currentSearch) { + filtered = filtered.filter(b => + b.name.toLowerCase().includes(currentSearch) || + (b.author && b.author.toLowerCase().includes(currentSearch)) || + (b.description && b.description.toLowerCase().includes(currentSearch)) + ); + } + renderBookcase(filtered); } + + function escapeAttr(text) { + return (text || '').replace(/'/g, "\\'").replace(/"/g, '"'); + } function escapeHtml(text) { const div = document.createElement('div'); @@ -1748,9 +1894,14 @@ sp.classList.add('current-word'); // keep highlighted word in view inside subtitle box const box = document.getElementById('plSubtitle'); - const spTop = sp.offsetTop, spBottom = spTop + sp.offsetHeight; - if (spTop < box.scrollTop || spBottom > box.scrollTop + box.clientHeight) { - box.scrollTo({ top: spTop - box.clientHeight / 2, behavior: 'smooth' }); + const spTop = sp.offsetTop; + const spBottom = spTop + sp.offsetHeight; + const boxScrollTop = box.scrollTop; + const boxHeight = box.clientHeight; + + // Scroll if the word is near the edges (30px buffer) + if (spTop < boxScrollTop + 30 || spBottom > boxScrollTop + boxHeight - 30) { + box.scrollTo({ top: spTop - (boxHeight / 2) + (sp.offsetHeight / 2), behavior: 'smooth' }); } playerState.lastWordSpan = sp; } @@ -1829,4 +1980,4 @@ loadBooks(); - + \ No newline at end of file diff --git a/thumbnail_generator.py b/thumbnail_generator.py new file mode 100644 index 0000000..a4a601a --- /dev/null +++ b/thumbnail_generator.py @@ -0,0 +1,262 @@ +# thumbnail_generator.py - Auto thumbnail generation from document first page (v4.4) +# +# PDF → PyMuPDF (fitz) দিয়ে প্রথম পেজ রেন্ডার করে PNG থাম্বনেইল +# DOCX → docProps/thumbnail.* (embedded preview) অথবা প্রথম embedded image +# +# আউটপুট সবসময় optimize করা bytes (PNG/JPEG), যা media_storage.save_thumbnail() এ যাবে। + +import io +import zipfile + + +# থাম্বনেইলের টার্গেট সাইজ (বইয়ের কভার — portrait 2:3 ratio) +THUMB_MAX_WIDTH = 600 +THUMB_MAX_HEIGHT = 900 +JPEG_QUALITY = 82 + + +def _optimize_image_bytes(img_bytes, source_format='png'): + """ + Pillow থাকলে রিসাইজ + কম্প্রেস করে। না থাকলে raw bytes-ই ফেরত দেয়। + রিটার্ন: (optimized_bytes, format_str) + """ + try: + from PIL import Image + except ImportError: + return img_bytes, source_format + + try: + img = Image.open(io.BytesIO(img_bytes)) + + # RGBA/palette → RGB (JPEG এর জন্য) + if img.mode in ('RGBA', 'LA', 'P'): + background = Image.new('RGB', img.size, (255, 255, 255)) + if img.mode == 'P': + img = img.convert('RGBA') + if img.mode in ('RGBA', 'LA'): + background.paste(img, mask=img.split()[-1]) + img = background + else: + img = img.convert('RGB') + elif img.mode != 'RGB': + img = img.convert('RGB') + + # অনুপাত ধরে রেখে থাম্বনেইল সাইজে নামানো + img.thumbnail((THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT), Image.LANCZOS) + + out = io.BytesIO() + img.save(out, format='JPEG', quality=JPEG_QUALITY, optimize=True) + out.seek(0) + return out.read(), 'jpeg' + except Exception as e: + print(f" ⚠️ Thumbnail optimize failed: {e}") + return img_bytes, source_format + + +def generate_pdf_thumbnail(pdf_bytes): + """ + PDF-এর প্রথম পেজ রেন্ডার করে optimize করা থাম্বনেইল bytes ফেরত দেয়। + রিটার্ন: (bytes, format) অথবা (None, None) + """ + try: + import fitz # PyMuPDF + except ImportError: + print(" ⚠️ PyMuPDF not available for thumbnail") + return None, None + + try: + doc = fitz.open(stream=pdf_bytes, filetype="pdf") + if doc.page_count == 0: + doc.close() + return None, None + + page = doc.load_page(0) + + # রেন্ডার রেজোলিউশন — টার্গেট উচ্চতার উপর ভিত্তি করে zoom নির্ধারণ + page_height = page.rect.height or 792 + zoom = max(1.0, min(3.0, (THUMB_MAX_HEIGHT * 1.3) / page_height)) + matrix = fitz.Matrix(zoom, zoom) + + pix = page.get_pixmap(matrix=matrix, alpha=False) + png_bytes = pix.tobytes("png") + doc.close() + + return _optimize_image_bytes(png_bytes, 'png') + except Exception as e: + print(f" ⚠️ PDF thumbnail generation failed: {e}") + return None, None + + +def generate_docx_thumbnail(docx_bytes, extracted_blocks=None): + """ + DOCX থেকে থাম্বনেইল বানানোর চেষ্টা করে (রেন্ডারিং লাইব্রেরি ছাড়া)। + + কৌশল: + 1. docProps/thumbnail.* (Word এ Save করার সময় "Save Thumbnail" অন থাকলে) + 2. প্রথম embedded image (word/media/) — যদি ছবিটি যথেষ্ট বড় হয় + 3. extracted_blocks থেকে প্রথম image block-এর base64 data + + রিটার্ন: (bytes, format) অথবা (None, None) + """ + # কৌশল ১ + ২: zip আর্কাইভ থেকে + try: + with zipfile.ZipFile(io.BytesIO(docx_bytes)) as zf: + names = zf.namelist() + + # ১. embedded thumbnail + for name in names: + lower = name.lower() + if lower.startswith('docprops/thumbnail'): + data = zf.read(name) + if data and len(data) > 500: + fmt = lower.rsplit('.', 1)[-1] if '.' in lower else 'png' + return _optimize_image_bytes(data, fmt) + + # ২. word/media/ থেকে প্রথম বড় ইমেজ + media = sorted([n for n in names if n.lower().startswith('word/media/')]) + for name in media: + lower = name.lower() + if not any(lower.endswith(ext) for ext in ('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp')): + continue + data = zf.read(name) + # ছোট আইকন/লোগো এড়াতে ন্যূনতম সাইজ চেক + if data and len(data) > 8000: + fmt = lower.rsplit('.', 1)[-1] + return _optimize_image_bytes(data, fmt) + except Exception as e: + print(f" ⚠️ DOCX zip thumbnail failed: {e}") + + # কৌশল ৩: প্রসেস করা blocks থেকে প্রথম ইমেজ + if extracted_blocks: + import base64 + for block in extracted_blocks: + if block.get('type') == 'image' and block.get('data'): + try: + raw = base64.b64decode(block['data']) + if len(raw) > 4000: + return _optimize_image_bytes(raw, block.get('format', 'png')) + except Exception: + continue + + return None, None + + +# টাইটেলের হ্যাশ থেকে ধারাবাহিক রঙ বাছাই করার জন্য কভার প্যালেট +_COVER_PALETTES = [ + ((37, 52, 74), (62, 84, 120)), # নীল + ((58, 42, 74), (92, 66, 120)), # বেগুনি + ((44, 62, 55), (66, 98, 82)), # সবুজ + ((74, 44, 42), (120, 72, 66)), # লালচে বাদামি + ((44, 54, 74), (70, 88, 120)), # স্টিল ব্লু + ((60, 50, 40), (110, 90, 66)), # সেপিয়া +] + + +def generate_text_cover(title, author='', subtitle=''): + """ + Pillow দিয়ে একটা পরিপাটি টেক্সট-ভিত্তিক বইয়ের কভার তৈরি করে। + কোনো ইমেজ না থাকলে fallback হিসেবে ব্যবহৃত হয়। + রিটার্ন: (bytes, 'jpeg') অথবা (None, None) + """ + try: + from PIL import Image, ImageDraw, ImageFont + except ImportError: + return None, None + + try: + W, H = THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT + + # টাইটেল অনুযায়ী ধারাবাহিক রঙ (একই বই সবসময় একই রঙ পাবে) + clean_title = (title or 'Untitled').strip() + palette_idx = sum(ord(c) for c in clean_title) % len(_COVER_PALETTES) + top_color, bottom_color = _COVER_PALETTES[palette_idx] + + img = Image.new('RGB', (W, H), top_color) + draw = ImageDraw.Draw(img) + for y in range(H): + ratio = y / H + r = int(top_color[0] + (bottom_color[0] - top_color[0]) * ratio) + g = int(top_color[1] + (bottom_color[1] - top_color[1]) * ratio) + b = int(top_color[2] + (bottom_color[2] - top_color[2]) * ratio) + draw.line([(0, y), (W, y)], fill=(r, g, b)) + + # ডাবল বর্ডার ফ্রেম + draw.rectangle([26, 26, W - 26, H - 26], outline=(255, 255, 255), width=2) + draw.rectangle([36, 36, W - 36, H - 36], outline=(255, 255, 255), width=1) + + # ফন্ট লোড + def _font(size): + for name in ("DejaVuSans-Bold.ttf", "arial.ttf", "Arial Bold.ttf"): + try: + return ImageFont.truetype(name, size) + except Exception: + continue + return ImageFont.load_default() + + def _font_light(size): + for name in ("DejaVuSans.ttf", "arial.ttf"): + try: + return ImageFont.truetype(name, size) + except Exception: + continue + return ImageFont.load_default() + + title_font = _font(48) + author_font = _font_light(26) + + def _text_w(text, font): + bbox = draw.textbbox((0, 0), text, font=font) + return bbox[2] - bbox[0] + + # উপরে ডেকোরেটিভ ডাবল-লাইন সেপারেটর + deco_y = 130 + draw.line([(W // 2 - 60, deco_y), (W // 2 + 60, deco_y)], + fill=(255, 255, 255), width=2) + draw.line([(W // 2 - 40, deco_y + 10), (W // 2 + 40, deco_y + 10)], + fill=(255, 255, 255), width=1) + + # টাইটেল word-wrap (কেন্দ্রে) + def _wrap(text, font, max_width): + words = text.split() + lines, cur = [], '' + for w in words: + test = (cur + ' ' + w).strip() + if _text_w(test, font) <= max_width: + cur = test + else: + if cur: + lines.append(cur) + cur = w + if cur: + lines.append(cur) + return lines[:6] + + title_lines = _wrap(clean_title, title_font, W - 110) + line_h = 60 + total_h = len(title_lines) * line_h + y = (H - total_h) // 2 - 20 + + for line in title_lines: + lw = _text_w(line, title_font) + # হালকা shadow (গভীরতার জন্য) + draw.text(((W - lw) // 2 + 2, y + 2), line, font=title_font, fill=(0, 0, 0)) + draw.text(((W - lw) // 2, y), line, font=title_font, fill=(255, 255, 255)) + y += line_h + + # নিচে সেপারেটর + author + bottom_y = H - 130 + draw.line([(W // 2 - 50, bottom_y), (W // 2 + 50, bottom_y)], + fill=(255, 255, 255), width=1) + + author_text = f"by {author}" if author else "Audiobook" + aw = _text_w(author_text, author_font) + draw.text(((W - aw) // 2, bottom_y + 20), author_text, + font=author_font, fill=(215, 222, 230)) + + out = io.BytesIO() + img.save(out, format='JPEG', quality=JPEG_QUALITY, optimize=True) + out.seek(0) + return out.read(), 'jpeg' + except Exception as e: + print(f" ⚠️ Text cover generation failed: {e}") + return None, None \ No newline at end of file