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

2
db.py
View File

@@ -135,6 +135,8 @@ def init_db():
('projects', 'thumbnail_path', 'TEXT'), ('projects', 'thumbnail_path', 'TEXT'),
('markdown_blocks', 'audio_path', 'TEXT'), ('markdown_blocks', 'audio_path', 'TEXT'),
('block_images', 'image_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: for table, column, definition in migrations:

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -140,6 +140,57 @@ def get_storage_usage_bytes():
return total return total
# ============================================
# Pending thumbnails (v4.4) — আপলোডের সময় জেনারেট, সেভের সময় commit
# ============================================
import uuid as _uuid
def _pending_thumbs_dir():
return os.path.join(MEDIA_STORAGE_DIR, '_pending_thumbs')
def save_pending_thumbnail(image_bytes, image_format='jpeg'):
"""
অস্থায়ী থাম্বনেইল সেভ করে একটা token রিটার্ন করে।
প্রজেক্ট এখনো তৈরি হয়নি বলে project_id ছাড়াই রাখা হয়।
"""
if not image_bytes:
return None
d = _pending_thumbs_dir()
_ensure_dir(d)
fmt = (image_format or 'jpeg').lower()
token = _uuid.uuid4().hex
filename = f'{token}.{fmt}'
with open(os.path.join(d, filename), 'wb') as f:
f.write(image_bytes)
return filename # token = filename
def read_pending_thumbnail(token):
"""Pending thumbnail-এর (bytes, format) ফেরত দেয়। না থাকলে (None, None)।"""
if not token or '/' in token or '\\' in token or '..' in token:
return None, None
path = os.path.join(_pending_thumbs_dir(), token)
if not os.path.exists(path):
return None, None
fmt = token.rsplit('.', 1)[-1] if '.' in token else 'jpeg'
with open(path, 'rb') as f:
return f.read(), fmt
def delete_pending_thumbnail(token):
"""Pending thumbnail মুছে দেয় (commit বা বাতিলের পর)।"""
if not token or '/' in token or '\\' in token or '..' in token:
return
path = os.path.join(_pending_thumbs_dir(), token)
if os.path.exists(path):
try:
os.remove(path)
except OSError:
pass
# ============================================ # ============================================
# Delete operations # Delete operations
# ============================================ # ============================================

View File

@@ -13,6 +13,9 @@ requests==2.32.3
# --- Audio processing --- # --- Audio processing ---
pydub==0.25.1 pydub==0.25.1
# --- Image processing (thumbnails) ---
Pillow==10.4.0
# --- Document processing --- # --- Document processing ---
PyMuPDF==1.24.10 PyMuPDF==1.24.10
python-docx==1.1.2 python-docx==1.1.2

View File

@@ -6,6 +6,8 @@ from flask import Blueprint, request, jsonify
from db import get_db from db import get_db
from docx_processor import process_docx_to_markdown from docx_processor import process_docx_to_markdown
from ai_processor import process_document_smartly from ai_processor import process_document_smartly
from thumbnail_generator import generate_docx_thumbnail
from media_storage import save_pending_thumbnail
from auth import login_required from auth import login_required
docx_bp = Blueprint('docx', __name__) docx_bp = Blueprint('docx', __name__)
@@ -44,11 +46,27 @@ def upload_docx():
print(f"✅ Word document processed & reconstructed: {block_count} blocks ({text_count} text, {image_count} images)") print(f"✅ Word document processed & reconstructed: {block_count} blocks ({text_count} text, {image_count} images)")
# --- v4.4: DOCX থেকে অটো থাম্বনেইল (embedded thumbnail / প্রথম ইমেজ) ---
pending_thumbnail = None
pending_thumbnail_format = None
try:
thumb_bytes, thumb_fmt = generate_docx_thumbnail(file_bytes, smart_blocks)
if thumb_bytes:
token = save_pending_thumbnail(thumb_bytes, thumb_fmt)
if token:
pending_thumbnail = token
pending_thumbnail_format = thumb_fmt
print(f" 🖼️ Auto-thumbnail generated: {token} ({len(thumb_bytes)} bytes)")
except Exception as te:
print(f" ⚠️ Thumbnail step skipped: {te}")
return jsonify({ return jsonify({
'success': True, 'success': True,
'filename': doc_file.filename, 'filename': doc_file.filename,
'metadata': result.get('metadata', {}), 'metadata': result.get('metadata', {}),
'blocks': smart_blocks 'blocks': smart_blocks,
'pending_thumbnail': pending_thumbnail,
'pending_thumbnail_format': pending_thumbnail_format
}) })
except Exception as e: except Exception as e:

View File

@@ -6,6 +6,8 @@ from flask import Blueprint, request, jsonify
from db import get_db from db import get_db
from pdf_processor import process_pdf_to_markdown from pdf_processor import process_pdf_to_markdown
from ai_processor import process_document_smartly from ai_processor import process_document_smartly
from thumbnail_generator import generate_pdf_thumbnail
from media_storage import save_pending_thumbnail
from auth import login_required from auth import login_required
pdf_bp = Blueprint('pdf', __name__) pdf_bp = Blueprint('pdf', __name__)
@@ -35,6 +37,20 @@ def upload_pdf():
# --- AI Powered Smart Reconstruction & Section Tagging --- # --- AI Powered Smart Reconstruction & Section Tagging ---
smart_blocks = process_document_smartly(result['markdown_blocks'], result['metadata']) smart_blocks = process_document_smartly(result['markdown_blocks'], result['metadata'])
# --- v4.4: প্রথম পেজ থেকে অটো থাম্বনেইল জেনারেট ---
pending_thumbnail = None
pending_thumbnail_format = None
try:
thumb_bytes, thumb_fmt = generate_pdf_thumbnail(pdf_bytes)
if thumb_bytes:
token = save_pending_thumbnail(thumb_bytes, thumb_fmt)
if token:
pending_thumbnail = token
pending_thumbnail_format = thumb_fmt
print(f" 🖼️ Auto-thumbnail generated: {token} ({len(thumb_bytes)} bytes)")
except Exception as te:
print(f" ⚠️ Thumbnail step skipped: {te}")
# Save PDF document record # Save PDF document record
db = get_db() db = get_db()
cursor = db.cursor() cursor = db.cursor()
@@ -59,7 +75,9 @@ def upload_pdf():
'filename': pdf_file.filename, 'filename': pdf_file.filename,
'page_count': result['page_count'], 'page_count': result['page_count'],
'metadata': result['metadata'], 'metadata': result['metadata'],
'blocks': smart_blocks 'blocks': smart_blocks,
'pending_thumbnail': pending_thumbnail,
'pending_thumbnail_format': pending_thumbnail_format
}) })
except Exception as e: except Exception as e:

View File

@@ -11,8 +11,10 @@ from auth import login_required
from media_storage import ( from media_storage import (
save_audio, save_image, save_thumbnail, save_audio, save_image, save_thumbnail,
read_file_base64, get_safe_abs_path, read_file_base64, get_safe_abs_path,
delete_project_media, get_storage_usage_bytes delete_project_media, get_storage_usage_bytes,
read_pending_thumbnail, delete_pending_thumbnail # v4.4
) )
from thumbnail_generator import generate_text_cover # v4.4 backfill
project_bp = Blueprint('project', __name__) project_bp = Blueprint('project', __name__)
@@ -259,7 +261,7 @@ def get_block_audio(project_id, block_id):
@project_bp.route('/api/projects/<int:project_id>', methods=['PUT']) @project_bp.route('/api/projects/<int:project_id>', methods=['PUT'])
@login_required @login_required
def update_project(project_id): def update_project(project_id):
"""Update project name.""" """Update project name and metadata (author/description/category)."""
data = request.json data = request.json
name = data.get('name', '').strip() name = data.get('name', '').strip()
@@ -269,10 +271,28 @@ def update_project(project_id):
db = get_db() db = get_db()
cursor = db.cursor() 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: try:
cursor.execute(''' cursor.execute(
UPDATE projects SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? f"UPDATE projects SET {', '.join(updates)} WHERE id = ?",
''', (name, project_id)) params
)
db.commit() db.commit()
if cursor.rowcount == 0: if cursor.rowcount == 0:
@@ -422,6 +442,32 @@ def save_project_content(project_id):
(img_rel, image_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(''' cursor.execute('''
UPDATE projects SET updated_at = CURRENT_TIMESTAMP WHERE id = ? UPDATE projects SET updated_at = CURRENT_TIMESTAMP WHERE id = ?
''', (project_id,)) ''', (project_id,))
@@ -531,7 +577,8 @@ def upload_thumbnail(project_id):
rel_path = save_thumbnail(project_id, img_bytes, fmt) rel_path = save_thumbnail(project_id, img_bytes, fmt)
cursor.execute(''' 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 = ? WHERE id = ?
''', (rel_path, fmt, project_id)) ''', (rel_path, fmt, project_id))
db.commit() db.commit()
@@ -565,12 +612,230 @@ def delete_thumbnail(project_id):
# v4.3: Database Maintenance (VACUUM + stats) # v4.3: Database Maintenance (VACUUM + stats)
# ============================================ # ============================================
@project_bp.route('/api/projects/<int:project_id>/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']) @project_bp.route('/api/maintenance/db-stats', methods=['GET'])
@login_required @login_required
def db_stats(): def db_stats():
"""ডেটাবেস সাইজ, ফাঁকা স্পেস (%), এবং মিডিয়া স্টোরেজ সাইজ রিটার্ন করে।""" """ডেটাবেস সাইজ, ফাঁকা স্পেস (%), এবং মিডিয়া স্টোরেজ সাইজ রিটার্ন করে।"""
try:
stats = get_db_stats() 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() 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_bytes'] = media_bytes
stats['media_size_mb'] = round(media_bytes / (1024 * 1024), 2) stats['media_size_mb'] = round(media_bytes / (1024 * 1024), 2)
return jsonify(stats) return jsonify(stats)

View File

@@ -1331,7 +1331,7 @@ body {
.project-thumb-overlay { .project-thumb-overlay {
position: absolute; position: absolute;
inset: 0; inset: 0;
background: rgba(0,0,0,0.6); background: rgba(0,0,0,0.62);
color: white; color: white;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1340,15 +1340,38 @@ body {
opacity: 0; opacity: 0;
transition: opacity 0.2s; transition: opacity 0.2s;
font-size: 0.7rem; font-size: 0.7rem;
gap: 4px; gap: 6px;
padding: 6px;
} }
.project-thumb:hover .project-thumb-overlay { .project-thumb:hover .project-thumb-overlay {
opacity: 1; opacity: 1;
} }
.project-thumb-overlay i { .thumb-action-btn {
font-size: 1.2rem; 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 { .project-info-v2 {
@@ -1375,6 +1398,57 @@ body {
font-style: italic; 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 { .project-actions-v2 {
display: flex; display: flex;
gap: 6px; gap: 6px;

View File

@@ -21,6 +21,7 @@ let dbMaintenanceModal = null;
let publishingProjectId = null; let publishingProjectId = null;
let currentWorkflowStage = 'upload'; let currentWorkflowStage = 'upload';
let allArchiveProjects = []; let allArchiveProjects = [];
let pendingThumbnailToken = null; // v4.4: auto-generated thumbnail token
// ============================================ // ============================================
// Initialization // Initialization
@@ -540,7 +541,10 @@ async function saveProject() {
const saveResponse = await fetch(`/api/projects/${projectId}/save`, { const saveResponse = await fetch(`/api/projects/${projectId}/save`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chapters }) body: JSON.stringify({
chapters,
pending_thumbnail: pendingThumbnailToken || ''
})
}); });
const saveData = await saveResponse.json(); const saveData = await saveResponse.json();
@@ -549,6 +553,9 @@ async function saveProject() {
throw new Error(saveData.error); throw new Error(saveData.error);
} }
// v4.4: থাম্বনেইল একবার commit হলে আর দরকার নেই
pendingThumbnailToken = null;
hideLoader(); hideLoader();
showNotification('Project saved successfully!', 'success'); showNotification('Project saved successfully!', 'success');
@@ -608,14 +615,32 @@ async function openProjectArchive() {
const canPublish = project.audio_count > 0; 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 ` return `
<div class="project-item-v2" id="project-item-${project.id}"> <div class="project-item-v2" id="project-item-${project.id}">
<div class="project-thumb" onclick="document.getElementById('thumb-input-${project.id}').click()" <div class="project-thumb" title="Thumbnail">
title="Click to upload thumbnail">
${thumbHtml} ${thumbHtml}
<div class="project-thumb-overlay"> <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> <i class="bi bi-camera"></i>
<span>Edit</span> <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> </div>
<input type="file" id="thumb-input-${project.id}" accept="image/*" hidden <input type="file" id="thumb-input-${project.id}" accept="image/*" hidden
onchange="uploadThumbnail(${project.id}, this)"> onchange="uploadThumbnail(${project.id}, this)">
@@ -632,11 +657,7 @@ async function openProjectArchive() {
<i class="bi bi-eye mx-1"></i> ${project.view_count} views <i class="bi bi-eye mx-1"></i> ${project.view_count} views
</div> </div>
${project.author ? `<div class="project-meta-author"><i class="bi bi-person me-1"></i>${escapeHtml(project.author)}</div>` : ''} ${project.author ? `<div class="project-meta-author"><i class="bi bi-person me-1"></i>${escapeHtml(project.author)}</div>` : ''}
</div> ${publishedLinkHtml}
<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)}">
</div> </div>
<div class="project-actions-v2" id="project-actions-${project.id}"> <div class="project-actions-v2" id="project-actions-${project.id}">
@@ -650,7 +671,7 @@ async function openProjectArchive() {
<i class="bi bi-globe"></i> Publish <i class="bi bi-globe"></i> Publish
</button>` </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> <i class="bi bi-pencil"></i>
</button> </button>
<button class="btn btn-sm btn-primary" onclick="loadProject(${project.id})"> <button class="btn btn-sm btn-primary" onclick="loadProject(${project.id})">
@@ -661,14 +682,6 @@ async function openProjectArchive() {
</button> </button>
</div> </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> </div>
`; `;
}).join(''); }).join('');
@@ -684,60 +697,116 @@ async function openProjectArchive() {
} }
// ============================================ // ============================================
// Rename // Edit Project Details (Name + Author + Description + Category)
// ============================================ // ============================================
function startEditProjectName(projectId) { let editProjectModal = null;
document.getElementById(`project-info-${projectId}`).style.display = 'none'; let editingProjectId = null;
document.getElementById(`project-actions-${projectId}`).style.display = 'none';
document.getElementById(`project-edit-${projectId}`).style.display = 'block'; function copyArchiveLink(url, btnEl) {
document.getElementById(`project-edit-actions-${projectId}`).style.display = 'flex'; const done = () => {
if (btnEl) {
const input = document.getElementById(`edit-input-${projectId}`); const icon = btnEl.querySelector('i');
input.focus(); if (icon) {
input.select(); icon.classList.remove('bi-clipboard');
icon.classList.add('bi-check-lg');
input.onkeydown = function(e) { setTimeout(() => {
if (e.key === 'Enter') { icon.classList.remove('bi-check-lg');
e.preventDefault(); icon.classList.add('bi-clipboard');
saveProjectName(projectId); }, 1500);
} else if (e.key === 'Escape') {
cancelEditProjectName(projectId);
} }
}
showNotification('Link copied', 'success');
}; };
} if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(url).then(done).catch(() => fallbackCopy(url, done));
function cancelEditProjectName(projectId) { } else {
document.getElementById(`project-info-${projectId}`).style.display = 'block'; fallbackCopy(url, done);
document.getElementById(`project-actions-${projectId}`).style.display = 'flex';
document.getElementById(`project-edit-${projectId}`).style.display = 'none';
document.getElementById(`project-edit-actions-${projectId}`).style.display = 'none';
const textElement = document.getElementById(`project-name-text-${projectId}`);
const input = document.getElementById(`edit-input-${projectId}`);
if (textElement && input) {
input.value = textElement.textContent;
} }
} }
async function saveProjectName(projectId) { function fallbackCopy(text, cb) {
const input = document.getElementById(`edit-input-${projectId}`); const ta = document.createElement('textarea');
const newName = input.value.trim(); 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();
}
if (!newName) { 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'); showNotification('Project name cannot be empty', 'warning');
return; return;
} }
try { try {
const response = await fetch(`/api/projects/${projectId}`, { const response = await fetch(`/api/projects/${editingProjectId}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName }) body: JSON.stringify({ name, author, description, category })
}); });
const data = await response.json(); const data = await response.json();
if (data.error) { if (data.error) {
@@ -745,24 +814,18 @@ async function saveProjectName(projectId) {
return; return;
} }
const textEl = document.getElementById(`project-name-text-${projectId}`); if (currentProject.id === editingProjectId) {
if (textEl) textEl.textContent = newName; currentProject.name = name;
const cached = allArchiveProjects.find(p => p.id === projectId);
if (cached) cached.name = newName;
cancelEditProjectName(projectId);
showNotification('Project renamed successfully', 'success');
if (currentProject.id === projectId) {
currentProject.name = newName;
const nameInput = document.getElementById('projectName'); 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) { } catch (error) {
console.error(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) { 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...'); showLoader('Deleting...');
@@ -1086,16 +1149,31 @@ function openDbMaintenance() {
async function loadDbStats() { async function loadDbStats() {
const loadingEl = document.getElementById('dbStatsLoading'); const loadingEl = document.getElementById('dbStatsLoading');
const contentEl = document.getElementById('dbStatsContent'); 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'; if (contentEl) contentEl.style.display = 'none';
// ২০ সেকেন্ডের timeout — সার্ভার আটকে থাকলেও UI মুক্ত হবে
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 20000);
try { 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(); const s = await resp.json();
if (s.error) { if (s.error) {
showNotification(s.error, 'error'); throw new Error(s.error);
return;
} }
document.getElementById('dbmFileSize').textContent = `${s.file_size_mb} MB`; document.getElementById('dbmFileSize').textContent = `${s.file_size_mb} MB`;
@@ -1123,29 +1201,78 @@ async function loadDbStats() {
advice.className = 'alert alert-warning'; advice.className = 'alert alert-warning';
advice.style.display = 'block'; advice.style.display = 'block';
advice.innerHTML = `<i class="bi bi-exclamation-triangle me-1"></i>` + advice.innerHTML = `<i class="bi bi-exclamation-triangle me-1"></i>` +
`ডেটাবেসে <strong>${s.free_percent}%</strong> ফাঁকা স্পেস জমেছে। ` + `The database has <strong>${s.free_percent}%</strong> free space accumulated. ` +
`<strong>Run VACUUM</strong> চালিয়ে এটি reclaim করতে পারেন।`; `You can run <strong>VACUUM</strong> to reclaim it.`;
} else { } else {
advice.className = 'alert alert-success'; advice.className = 'alert alert-success';
advice.style.display = 'block'; advice.style.display = 'block';
advice.innerHTML = `<i class="bi bi-check-circle me-1"></i>` + 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 (loadingEl) loadingEl.style.display = 'none';
if (contentEl) contentEl.style.display = 'block'; if (contentEl) contentEl.style.display = 'block';
} catch (e) { } catch (e) {
clearTimeout(timeoutId);
console.error(e); 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'); 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() { async function runDbVacuum() {
const vacuumBtn = document.getElementById('dbmVacuumBtn'); const vacuumBtn = document.getElementById('dbmVacuumBtn');
const refreshBtn = document.getElementById('dbmRefreshBtn'); 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; return;
} }

View File

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

View File

@@ -179,6 +179,9 @@
<button class="btn btn-header-archive" onclick="openProjectArchive()" title="Load or manage saved projects"> <button class="btn btn-header-archive" onclick="openProjectArchive()" title="Load or manage saved projects">
<i class="bi bi-archive me-1"></i> Archive <i class="bi bi-archive me-1"></i> Archive
</button> </button>
<a class="btn btn-header-archive" href="/home" target="_blank" rel="noopener" title="Open the public Audiobook Library in a new tab">
<i class="bi bi-book-half me-1"></i> Library
</a>
<button class="btn btn-header-help" id="headerHelpBtn" onclick="handleHeaderHelp()" title="Show quick start guide"> <button class="btn btn-header-help" id="headerHelpBtn" onclick="handleHeaderHelp()" title="Show quick start guide">
<i class="bi bi-question-circle me-1"></i> <i class="bi bi-question-circle me-1"></i>
<span id="headerHelpLabel">Quick Start</span> <span id="headerHelpLabel">Quick Start</span>
@@ -518,8 +521,9 @@
<p class="text-muted small mb-0"> <p class="text-muted small mb-0">
<i class="bi bi-info-circle me-1"></i> <i class="bi bi-info-circle me-1"></i>
VACUUM ডেটাবেসের ফাঁকা স্পেস reclaim করে এটি ছোট করে। প্রজেক্ট ডিলিট করার পর বা মাসে একবার চালানো ভালো। এটি কিছু সময় নিতে পারে। VACUUM reclaims free space in the database and shrinks it. It's good to run after deleting projects or once a month. It may take some time.
</p> </p>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">

View File

@@ -16,9 +16,10 @@
body { body {
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
margin: 0; margin: 0;
background: #f5e9d6;
min-height: 100vh; min-height: 100vh;
color: #3e2723; color: #3e2723;
background: radial-gradient(circle at 50% 0%, #f7ecd9 0%, #efe1c9 55%, #e8d7ba 100%);
background-attachment: fixed;
} }
/* Header */ /* Header */
@@ -111,11 +112,56 @@
color: #fff; color: #fff;
} }
/* Category navigation — integrated cleanly onto the wood base */
.category-nav {
display: flex;
flex-wrap: wrap;
gap: 12px 28px;
justify-content: flex-start; /* Left aligned as requested */
align-items: center;
padding: 8px 32px 16px; /* Reduced vertical padding for better balance */
margin: 0;
background: transparent; /* Removed dark background */
box-shadow: none;
border: none;
}
.category-pill {
background: none;
border: none;
color: rgba(255, 255, 255, 0.65); /* Crisp, slightly faded white */
padding: 4px 0; /* Reduced vertical padding */
font-family: 'Inter', sans-serif;
font-weight: 500;
font-size: 0.95rem;
cursor: pointer;
transition: color 0.2s, text-shadow 0.2s;
white-space: nowrap;
display: flex;
align-items: center;
gap: 8px;
text-shadow: 0 1px 3px rgba(0,0,0,0.5); /* Stronger shadow for readability on wood */
}
.category-pill:hover {
color: rgba(255, 255, 255, 0.9);
}
.category-pill.active {
color: #ffffff;
font-weight: 700;
text-shadow: 0 2px 5px rgba(0,0,0,0.7);
}
.category-pill .cat-count {
display: none; /* Hide counts to match the clean iBooks look */
}
/* Bookcase container */ /* Bookcase container */
.bookcase-container { .bookcase-container {
max-width: 1400px; max-width: 1400px;
margin: 0 auto; margin: 0 auto;
padding: 40px 24px; padding: 24px 24px 40px;
} }
.library-intro { .library-intro {
@@ -137,29 +183,44 @@
opacity: 0.85; opacity: 0.85;
} }
/* Bookcase shelf */ /* Realistic Wooden Bookcase (iBooks style) */
.bookcase { .bookcase {
background: linear-gradient(180deg, #c8a87b 0%, #a67c52 100%); border-radius: 8px;
border-radius: 16px; padding: 24px 24px 0; /* Bottom padding 0 to fit category nav */
padding: 24px; box-shadow: inset 0 0 30px rgba(0,0,0,0.6), 0 15px 40px rgba(0,0,0,0.3);
box-shadow: 0 12px 40px rgba(74,44,42,0.3), inset 0 2px 4px rgba(255,255,255,0.2);
position: relative; position: relative;
background-color: #b57b47;
background-image:
repeating-linear-gradient(90deg, transparent 0, transparent 2px, rgba(0,0,0,0.04) 2px, rgba(0,0,0,0.04) 4px),
linear-gradient(90deg, #8a5024 0%, #c48c58 8%, #c48c58 92%, #8a5024 100%);
border: 10px solid #6b3e1b;
/* border-bottom-width removed so the frame wraps completely around */
} }
.shelf { .shelf {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 24px; gap: 30px 24px;
padding: 20px 16px 36px; padding: 20px 16px 0;
position: relative; position: relative;
border-bottom: 8px solid #6b4226; margin-bottom: 40px;
box-shadow: 0 6px 0 #5a3520, 0 8px 12px rgba(0,0,0,0.2); z-index: 1;
margin-bottom: 24px;
border-radius: 6px;
} }
.shelf:last-child { /* 3D Shelf Board */
margin-bottom: 0; .shelf::after {
content: '';
position: absolute;
left: -10px;
right: -10px;
bottom: -18px; /* Height of the board */
height: 18px;
background: linear-gradient(to bottom, #d6a67a 0%, #9c6030 30%, #6b3e1b 100%);
border-top: 1px solid #f2cda8;
border-bottom: 2px solid #3d200a;
box-shadow: 0 12px 15px rgba(0, 0, 0, 0.5);
border-radius: 2px;
z-index: 0;
} }
/* Book card */ /* Book card */
@@ -168,6 +229,8 @@
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
transform-origin: bottom center; transform-origin: bottom center;
position: relative; position: relative;
z-index: 2; /* Ensure books sit on top of the shelf board */
margin-bottom: -2px; /* Pull down slightly to rest exactly on the edge */
} }
.book-card:hover { .book-card:hover {
@@ -177,11 +240,11 @@
.book-cover { .book-cover {
width: 100%; width: 100%;
aspect-ratio: 2 / 3; aspect-ratio: 2 / 3;
border-radius: 4px 8px 8px 4px; border-radius: 2px 6px 6px 2px;
overflow: hidden; overflow: hidden;
box-shadow: box-shadow:
-2px 2px 0 rgba(0,0,0,0.1), -2px 0px 0px rgba(255,255,255,0.4) inset,
-4px 4px 0 rgba(0,0,0,0.08), -4px 2px 10px rgba(0,0,0,0.5),
4px 6px 16px rgba(0,0,0,0.3); 4px 6px 16px rgba(0,0,0,0.3);
position: relative; position: relative;
background: linear-gradient(135deg, #2c3e50, #4a6278); background: linear-gradient(135deg, #2c3e50, #4a6278);
@@ -190,16 +253,18 @@
justify-content: flex-end; justify-content: flex-end;
padding: 16px; padding: 16px;
color: white; color: white;
border-left: 4px solid rgba(0,0,0,0.15); /* book spine effect */
} }
.book-cover::before { .book-cover::before {
content: ''; content: '';
position: absolute; position: absolute;
left: 0; left: 4px;
top: 0; top: 0;
bottom: 0; bottom: 0;
width: 4px; width: 3px;
background: linear-gradient(90deg, rgba(0,0,0,0.3), transparent); background: linear-gradient(90deg, rgba(255,255,255,0.2), transparent);
z-index: 3;
} }
.book-cover img { .book-cover img {
@@ -259,35 +324,6 @@
font-style: italic; font-style: italic;
} }
.book-meta {
margin-top: 12px;
padding: 0 4px;
text-align: center;
}
.book-meta-title {
font-size: 0.85rem;
font-weight: 600;
color: #4a2c2a;
margin-bottom: 2px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.book-meta-stats {
font-size: 0.72rem;
color: #6b4226;
opacity: 0.7;
display: flex;
justify-content: center;
gap: 8px;
align-items: center;
}
.book-meta-stats i { font-size: 0.7rem; }
/* Empty state */ /* Empty state */
.empty-state { .empty-state {
text-align: center; text-align: center;
@@ -728,10 +764,10 @@
} }
.player-cover { .player-cover {
width: 180px;
height: 180px; height: 180px;
aspect-ratio: 2 / 3;
margin: 0 auto 18px; margin: 0 auto 18px;
border-radius: 12px; border-radius: 4px 8px 8px 4px;
overflow: hidden; overflow: hidden;
box-shadow: 0 10px 30px rgba(0,0,0,0.3); box-shadow: 0 10px 30px rgba(0,0,0,0.3);
background: linear-gradient(135deg, #2c3e50, #4a6278); background: linear-gradient(135deg, #2c3e50, #4a6278);
@@ -741,6 +777,7 @@
padding: 14px; padding: 14px;
color: white; color: white;
flex-shrink: 0; flex-shrink: 0;
border-left: 4px solid rgba(0,0,0,0.15);
} }
.player-cover img { .player-cover img {
position: absolute; position: absolute;
@@ -789,10 +826,10 @@
font-size: 1.05rem; font-size: 1.05rem;
line-height: 1.7; line-height: 1.7;
color: #3e2b1d; color: #3e2b1d;
max-height: 160px; height: 180px; /* Fixed height so player size stays constant */
overflow-y: auto; overflow-y: auto;
margin-bottom: 16px; margin-bottom: 16px;
min-height: 70px; position: relative; /* Crucial for accurate offset calculations */
} }
.player-subtitle .pw { .player-subtitle .pw {
cursor: pointer; cursor: pointer;
@@ -931,7 +968,7 @@
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.player-cover { width: 150px; height: 150px; } .player-cover { height: 150px; }
.player-controls { gap: 16px; } .player-controls { gap: 16px; }
} }
@@ -966,12 +1003,12 @@
</div> </div>
</header> </header>
<main class="bookcase-container"> <div class="library-intro" style="text-align:center; padding-top: 40px;">
<div class="library-intro"> <h2 style="font-family:'Playfair Display',serif; font-size:2.2rem; font-weight:700; color:#4a2c2a; margin-bottom:8px;">Discover Stories That Speak</h2>
<h2>Discover Stories That Speak</h2> <p style="color:#6b4226; font-size:1.05rem; opacity:0.85;">Browse our collection of interactive audiobooks</p>
<p>Browse our collection of interactive audiobooks</p>
</div> </div>
<main class="bookcase-container">
<div id="bookcaseContainer"> <div id="bookcaseContainer">
<div class="loading-state"> <div class="loading-state">
<div class="spinner-border" role="status" style="color: #6b4226;"></div> <div class="spinner-border" role="status" style="color: #6b4226;"></div>
@@ -1084,13 +1121,17 @@
let allBooks = []; let allBooks = [];
let currentBook = null; let currentBook = null;
let currentBookUrl = ''; let currentBookUrl = '';
let activeCategory = 'all'; // 'all' | '<category name>' | '__others__'
let currentSearch = '';
const OTHERS_KEY = '__others__';
async function loadBooks() { async function loadBooks() {
try { try {
const resp = await fetch('/api/public/books'); const resp = await fetch('/api/public/books');
const data = await resp.json(); const data = await resp.json();
allBooks = data.books || []; allBooks = data.books || [];
renderBookcase(allBooks); applyFilters();
} catch (e) { } catch (e) {
document.getElementById('bookcaseContainer').innerHTML = ` document.getElementById('bookcaseContainer').innerHTML = `
<div class="empty-state"> <div class="empty-state">
@@ -1106,6 +1147,21 @@
const container = document.getElementById('bookcaseContainer'); const container = document.getElementById('bookcaseContainer');
if (!books || books.length === 0) { if (!books || books.length === 0) {
// পুরো লাইব্রেরি খালি নাকি শুধু ফিল্টারে কিছু মেলেনি — আলাদা বার্তা
const isFiltered = (activeCategory !== 'all') || currentSearch;
if (isFiltered) {
// ফিল্টারে কিছু মেলেনি — কিন্তু category nav রাখি যাতে অন্য category বাছা যায়
container.innerHTML = `
<div class="bookcase">
<div class="empty-state" style="color:#f0e0c4;">
<i class="bi bi-search"></i>
<h3>No matching books</h3>
<p>Try a different category or search term.</p>
</div>
${buildCategoryNavHtml()}
</div>
`;
} else {
container.innerHTML = ` container.innerHTML = `
<div class="empty-state"> <div class="empty-state">
<i class="bi bi-book"></i> <i class="bi bi-book"></i>
@@ -1113,6 +1169,7 @@
<p>The library is being curated. Check back soon!</p> <p>The library is being curated. Check back soon!</p>
</div> </div>
`; `;
}
return; return;
} }
@@ -1132,23 +1189,32 @@
html += '</div>'; html += '</div>';
} }
// category nav — কাঠের বাক্সের ভেতরে নিচের shelf-label বার
html += buildCategoryNavHtml();
html += '</div>'; html += '</div>';
container.innerHTML = html; container.innerHTML = html;
} }
function renderBookCard(book) { function renderBookCard(book) {
const thumbnailHtml = book.thumbnail_data const hasThumb = !!book.thumbnail_data;
? `<img src="data:image/${book.thumbnail_format};base64,${book.thumbnail_data}" alt="${escapeHtml(book.name)}">
<div class="book-cover-overlay"></div>`
: '';
const coverClass = book.thumbnail_data ? '' : 'book-cover-default';
const author = book.author || 'Unknown Author'; const author = book.author || 'Unknown Author';
// থাম্বনেইল থাকলে: শুধু ছবি (টাইটেল/অথর থাম্বনেইলেই আছে, ডুপ্লিকেট দেখাব না)
// না থাকলে: ডিফল্ট কভার + টাইটেল/অথর ওভারলে
if (hasThumb) {
return `
<div class="book-card" onclick="openBook(${book.id})" title="${escapeHtml(book.name)}">
<div class="book-cover">
<img src="data:image/${book.thumbnail_format};base64,${book.thumbnail_data}" alt="${escapeHtml(book.name)}">
</div>
</div>
`;
}
return ` return `
<div class="book-card" onclick="openBook(${book.id})" title="${escapeHtml(book.name)}"> <div class="book-card" onclick="openBook(${book.id})" title="${escapeHtml(book.name)}">
<div class="book-cover ${coverClass}"> <div class="book-cover book-cover-default">
${thumbnailHtml}
<div class="book-cover-content"> <div class="book-cover-content">
<div class="book-title">${escapeHtml(book.name)}</div> <div class="book-title">${escapeHtml(book.name)}</div>
<div class="book-author">by ${escapeHtml(author)}</div> <div class="book-author">by ${escapeHtml(author)}</div>
@@ -1357,20 +1423,100 @@
if (e.key === 'Escape') closeBookModal(); if (e.key === 'Escape') closeBookModal();
}); });
function filterBooks(query) { function buildCategoryNavHtml() {
query = query.toLowerCase().trim(); // category গুলো সংগ্রহ (case-insensitive dedupe, original casing রাখা)
if (!query) { const catMap = new Map(); // lowercaseKey -> { label, count }
renderBookcase(allBooks); let othersCount = 0;
return; for (const b of allBooks) {
const cat = (b.category || '').trim();
if (!cat) {
othersCount++;
continue;
} }
const filtered = allBooks.filter(b => const key = cat.toLowerCase();
b.name.toLowerCase().includes(query) || if (catMap.has(key)) {
(b.author && b.author.toLowerCase().includes(query)) || catMap.get(key).count++;
(b.description && b.description.toLowerCase().includes(query)) } else {
catMap.set(key, { label: cat, count: 1 });
}
}
const sortedCats = Array.from(catMap.values())
.sort((a, b) => a.label.localeCompare(b.label));
let html = `<nav class="category-nav" id="categoryNav">`;
html += `
<button class="category-pill ${activeCategory === 'all' ? 'active' : ''}"
onclick="selectCategory('all')">
<i class="bi bi-grid"></i> All
<span class="cat-count">${allBooks.length}</span>
</button>
`;
for (const c of sortedCats) {
const isActive = activeCategory.toLowerCase() === c.label.toLowerCase();
html += `
<button class="category-pill ${isActive ? 'active' : ''}"
onclick="selectCategory('${escapeAttr(c.label)}')">
${escapeHtml(c.label)}
<span class="cat-count">${c.count}</span>
</button>
`;
}
if (othersCount > 0) {
html += `
<button class="category-pill ${activeCategory === OTHERS_KEY ? 'active' : ''}"
onclick="selectCategory('${OTHERS_KEY}')">
<i class="bi bi-three-dots"></i> Others
<span class="cat-count">${othersCount}</span>
</button>
`;
}
html += `</nav>`;
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); renderBookcase(filtered);
} }
function escapeAttr(text) {
return (text || '').replace(/'/g, "\\'").replace(/"/g, '&quot;');
}
function escapeHtml(text) { function escapeHtml(text) {
const div = document.createElement('div'); const div = document.createElement('div');
div.textContent = text || ''; div.textContent = text || '';
@@ -1748,9 +1894,14 @@
sp.classList.add('current-word'); sp.classList.add('current-word');
// keep highlighted word in view inside subtitle box // keep highlighted word in view inside subtitle box
const box = document.getElementById('plSubtitle'); const box = document.getElementById('plSubtitle');
const spTop = sp.offsetTop, spBottom = spTop + sp.offsetHeight; const spTop = sp.offsetTop;
if (spTop < box.scrollTop || spBottom > box.scrollTop + box.clientHeight) { const spBottom = spTop + sp.offsetHeight;
box.scrollTo({ top: spTop - box.clientHeight / 2, behavior: 'smooth' }); 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; playerState.lastWordSpan = sp;
} }

262
thumbnail_generator.py Normal file
View File

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