v4.3 ui: 3D wooden bookshelf redesign, fix player cover aspect ratio, and smooth subtitle scroll
This commit is contained in:
2
db.py
2
db.py
@@ -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:
|
||||||
|
|||||||
BIN
media-storage/project_1/thumbnail.jpeg
Normal file
BIN
media-storage/project_1/thumbnail.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
BIN
media-storage/project_3/thumbnail.jpeg
Normal file
BIN
media-storage/project_3/thumbnail.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
@@ -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
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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():
|
||||||
"""ডেটাবেস সাইজ, ফাঁকা স্পেস (%), এবং মিডিয়া স্টোরেজ সাইজ রিটার্ন করে।"""
|
"""ডেটাবেস সাইজ, ফাঁকা স্পেস (%), এবং মিডিয়া স্টোরেজ সাইজ রিটার্ন করে।"""
|
||||||
stats = get_db_stats()
|
try:
|
||||||
media_bytes = get_storage_usage_bytes()
|
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_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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
283
static/js/app.js
283
static/js/app.js
@@ -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">
|
||||||
<i class="bi bi-camera"></i>
|
<button class="thumb-action-btn" title="Upload your own thumbnail"
|
||||||
<span>Edit</span>
|
onclick="event.stopPropagation(); document.getElementById('thumb-input-${project.id}').click()">
|
||||||
|
<i class="bi bi-camera"></i>
|
||||||
|
<span>Upload</span>
|
||||||
|
</button>
|
||||||
|
<button class="thumb-action-btn" title="Auto-generate from document"
|
||||||
|
onclick="event.stopPropagation(); autoGenerateThumbnail(${project.id}, this)">
|
||||||
|
<i class="bi bi-magic"></i>
|
||||||
|
<span>Auto</span>
|
||||||
|
</button>
|
||||||
</div>
|
</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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,13 +1147,29 @@
|
|||||||
const container = document.getElementById('bookcaseContainer');
|
const container = document.getElementById('bookcaseContainer');
|
||||||
|
|
||||||
if (!books || books.length === 0) {
|
if (!books || books.length === 0) {
|
||||||
container.innerHTML = `
|
// পুরো লাইব্রেরি খালি নাকি শুধু ফিল্টারে কিছু মেলেনি — আলাদা বার্তা
|
||||||
<div class="empty-state">
|
const isFiltered = (activeCategory !== 'all') || currentSearch;
|
||||||
<i class="bi bi-book"></i>
|
if (isFiltered) {
|
||||||
<h3>No books yet</h3>
|
// ফিল্টারে কিছু মেলেনি — কিন্তু category nav রাখি যাতে অন্য category বাছা যায়
|
||||||
<p>The library is being curated. Check back soon!</p>
|
container.innerHTML = `
|
||||||
</div>
|
<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 = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="bi bi-book"></i>
|
||||||
|
<h3>No books yet</h3>
|
||||||
|
<p>The library is being curated. Check back soon!</p>
|
||||||
|
</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 key = cat.toLowerCase();
|
||||||
|
if (catMap.has(key)) {
|
||||||
|
catMap.get(key).count++;
|
||||||
|
} else {
|
||||||
|
catMap.set(key, { label: cat, count: 1 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const filtered = allBooks.filter(b =>
|
|
||||||
b.name.toLowerCase().includes(query) ||
|
const sortedCats = Array.from(catMap.values())
|
||||||
(b.author && b.author.toLowerCase().includes(query)) ||
|
.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
(b.description && b.description.toLowerCase().includes(query))
|
|
||||||
);
|
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, '"');
|
||||||
|
}
|
||||||
|
|
||||||
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
262
thumbnail_generator.py
Normal 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
|
||||||
Reference in New Issue
Block a user