Autosave: 20260325-164659
This commit is contained in:
parent
ba0567e218
commit
3e7ac25120
4
.htaccess
Normal file
4
.htaccess
Normal file
@ -0,0 +1,4 @@
|
||||
php_value upload_max_filesize 20M
|
||||
php_value post_max_size 20M
|
||||
php_value max_execution_time 300
|
||||
php_value max_input_time 300
|
||||
@ -1,6 +1,12 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
// Debug Logging
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
error_log('AdminDocuments POST: ' . print_r($_POST, true));
|
||||
error_log('AdminDocuments FILES: ' . print_r($_FILES, true));
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/includes/admin_layout.php';
|
||||
|
||||
library_bootstrap();
|
||||
@ -9,44 +15,46 @@ $errors = [];
|
||||
|
||||
// Handle POST request
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$action = $_POST['action'] ?? '';
|
||||
$id = isset($_POST['id']) ? (int)$_POST['id'] : 0;
|
||||
// Check for max_input_vars or post_max_size issues
|
||||
if (empty($_POST) && empty($_FILES) && (int)($_SERVER['CONTENT_LENGTH'] ?? 0) > 0) {
|
||||
$errors[] = 'The uploaded file exceeds the server limit (post_max_size). Please try a smaller file or contact admin.';
|
||||
} else {
|
||||
$action = $_POST['action'] ?? '';
|
||||
$id = isset($_POST['id']) ? (int)$_POST['id'] : 0;
|
||||
|
||||
try {
|
||||
if ($action === 'create_document') {
|
||||
library_create_document($_POST, $_FILES['document_file'] ?? [], $_FILES['cover_file'] ?? []);
|
||||
library_set_flash('success', 'Document created successfully.');
|
||||
header('Location: /admin_documents.php');
|
||||
exit;
|
||||
} elseif ($action === 'update_document') {
|
||||
if (!$id) {
|
||||
throw new RuntimeException('Invalid Document ID.');
|
||||
try {
|
||||
if ($action === 'create_document') {
|
||||
library_create_document($_POST, $_FILES['document_file'] ?? [], $_FILES['cover_file'] ?? []);
|
||||
library_set_flash('success', 'Document created successfully.');
|
||||
header('Location: /admin_documents.php');
|
||||
exit;
|
||||
} elseif ($action === 'update_document') {
|
||||
if (!$id) {
|
||||
throw new RuntimeException('Invalid Document ID.');
|
||||
}
|
||||
library_update_document($id, $_POST, $_FILES['document_file'] ?? [], $_FILES['cover_file'] ?? []);
|
||||
library_set_flash('success', 'Document updated successfully.');
|
||||
header('Location: /admin_documents.php');
|
||||
exit;
|
||||
} elseif ($action === 'delete_document') {
|
||||
if (!$id) {
|
||||
throw new RuntimeException('Invalid Document ID.');
|
||||
}
|
||||
library_delete_document($id);
|
||||
library_set_flash('success', 'Document deleted successfully.');
|
||||
header('Location: /admin_documents.php');
|
||||
exit;
|
||||
}
|
||||
library_update_document($id, $_POST, $_FILES['document_file'] ?? [], $_FILES['cover_file'] ?? []);
|
||||
library_set_flash('success', 'Document updated successfully.');
|
||||
header('Location: /admin_documents.php');
|
||||
exit;
|
||||
} elseif ($action === 'delete_document') {
|
||||
if (!$id) {
|
||||
throw new RuntimeException('Invalid Document ID.');
|
||||
}
|
||||
library_delete_document($id);
|
||||
library_set_flash('success', 'Document deleted successfully.');
|
||||
header('Location: /admin_documents.php');
|
||||
exit;
|
||||
} catch (Throwable $exception) {
|
||||
$errors[] = $exception->getMessage();
|
||||
error_log('AdminDocuments Error: ' . $exception->getMessage());
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
$errors[] = $exception->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// Search Logic
|
||||
$search = isset($_GET['search']) ? trim($_GET['search']) : '';
|
||||
// We can implement search in fetch_documents if needed, currently it supports filters
|
||||
// For now, fetch all and filter in PHP or improve SQL later if specific search needed.
|
||||
// library_fetch_documents doesn't have search param yet, let's just fetch all.
|
||||
$documents = library_fetch_documents(false);
|
||||
// Basic search filter in PHP for now
|
||||
if ($search !== '') {
|
||||
$documents = array_filter($documents, function($doc) use ($search) {
|
||||
return stripos($doc['title_en'] ?? '', $search) !== false
|
||||
@ -63,7 +71,14 @@ admin_render_header('Material Entry', 'documents');
|
||||
?>
|
||||
<!-- Page Content -->
|
||||
<?php if ($errors): ?>
|
||||
<div class="alert alert-danger"><?= h(implode(' ', $errors)) ?></div>
|
||||
<div class="alert alert-danger">
|
||||
<h6 class="alert-heading fw-bold"><i class="bi bi-exclamation-triangle-fill me-2"></i>Submission Error</h6>
|
||||
<ul class="mb-0 ps-3">
|
||||
<?php foreach ($errors as $e): ?>
|
||||
<li><?= h($e) ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
@ -162,25 +177,27 @@ admin_render_header('Material Entry', 'documents');
|
||||
</div>
|
||||
|
||||
<!-- Document Modal -->
|
||||
<div class="modal fade" id="documentModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal fade" id="documentModal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<form method="post" action="/admin_documents.php" id="documentForm" enctype="multipart/form-data">
|
||||
<input type="hidden" name="action" id="doc_action" value="create_document">
|
||||
<input type="hidden" name="id" id="doc_id" value="">
|
||||
|
||||
<form class="modal-content" method="post" action="/admin_documents.php" id="documentForm" enctype="multipart/form-data">
|
||||
<div class="modal-header bg-primary text-white">
|
||||
<h5 class="modal-title" id="documentModalTitle">Add New Material</h5>
|
||||
<div class="ms-auto">
|
||||
<button type="button" class="btn btn-link text-white text-decoration-none me-2" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-light text-primary fw-bold">Save Changes</button>
|
||||
<button type="submit" class="btn btn-light text-primary fw-bold" id="saveButton">
|
||||
<span class="spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="action" id="doc_action" value="create_document">
|
||||
<input type="hidden" name="id" id="doc_id" value="">
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- Titles -->
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-bold text-uppercase text-muted">Title (English)</label>
|
||||
<label class="form-label small fw-bold text-uppercase text-muted">Title (English) <span class="text-danger">*</span></label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" name="title_en" id="doc_title_en" required>
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="translateText('doc_title_en', 'doc_title_ar', 'Arabic')"><i class="bi bi-translate"></i></button>
|
||||
@ -270,12 +287,12 @@ admin_render_header('Material Entry', 'documents');
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-bold text-uppercase text-muted">Document File</label>
|
||||
<input class="form-control" type="file" name="document_file">
|
||||
<label class="form-label small fw-bold text-uppercase text-muted">Document File <span id="file_required_indicator" class="text-danger">*</span></label>
|
||||
<input class="form-control" type="file" name="document_file" id="document_file_input">
|
||||
<div id="current_file_info" class="mt-2 d-none">
|
||||
<small class="text-muted"><i class="bi bi-file-earmark"></i> <span id="current_filename"></span></small>
|
||||
</div>
|
||||
<small class="text-muted d-block mt-1">Leave empty to keep existing file on edit.</small>
|
||||
<small class="text-muted d-block mt-1" id="file_help_text">Required for new documents.</small>
|
||||
</div>
|
||||
|
||||
<!-- Settings -->
|
||||
@ -310,8 +327,7 @@ admin_render_header('Material Entry', 'documents');
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -327,6 +343,13 @@ admin_render_header('Material Entry', 'documents');
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
documentModal = new bootstrap.Modal(document.getElementById('documentModal'));
|
||||
|
||||
// Form submission loading state
|
||||
document.getElementById('documentForm').addEventListener('submit', function() {
|
||||
const btn = document.getElementById('saveButton');
|
||||
btn.disabled = true;
|
||||
btn.querySelector('.spinner-border').classList.remove('d-none');
|
||||
});
|
||||
});
|
||||
|
||||
function updateSubcategories(selectedSubId = null) {
|
||||
@ -354,6 +377,11 @@ admin_render_header('Material Entry', 'documents');
|
||||
document.getElementById('doc_id').value = '';
|
||||
document.getElementById('documentForm').reset();
|
||||
|
||||
// UI Helpers for Create
|
||||
document.getElementById('file_required_indicator').classList.remove('d-none');
|
||||
document.getElementById('document_file_input').required = true;
|
||||
document.getElementById('file_help_text').innerText = 'Required for new documents.';
|
||||
|
||||
// Clear previews
|
||||
document.getElementById('current_cover_preview').classList.add('d-none');
|
||||
document.getElementById('current_file_info').classList.add('d-none');
|
||||
@ -369,6 +397,11 @@ admin_render_header('Material Entry', 'documents');
|
||||
document.getElementById('doc_action').value = 'update_document';
|
||||
document.getElementById('doc_id').value = doc.id;
|
||||
|
||||
// UI Helpers for Edit
|
||||
document.getElementById('file_required_indicator').classList.add('d-none');
|
||||
document.getElementById('document_file_input').required = false;
|
||||
document.getElementById('file_help_text').innerText = 'Leave empty to keep existing file.';
|
||||
|
||||
// Fill fields
|
||||
document.getElementById('doc_title_en').value = doc.title_en || '';
|
||||
document.getElementById('doc_title_ar').value = doc.title_ar || '';
|
||||
@ -420,4 +453,4 @@ admin_render_header('Material Entry', 'documents');
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php admin_render_footer(); ?>
|
||||
<?php admin_render_footer(); ?>
|
||||
@ -347,3 +347,28 @@ footer a {
|
||||
min-height: 60vh;
|
||||
}
|
||||
}
|
||||
|
||||
/* Flipbook Custom Styles */
|
||||
#flipbook-wrapper {
|
||||
background: #333;
|
||||
box-shadow: inset 0 0 30px rgba(0,0,0,0.6);
|
||||
}
|
||||
|
||||
#flipbook .page {
|
||||
background-color: #fff;
|
||||
/* Soft border for realism */
|
||||
border-right: 1px solid #ddd;
|
||||
}
|
||||
|
||||
#flipbook-toolbar button {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
#flipbook-toolbar button:hover {
|
||||
transform: scale(1.1);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
#flipbook-toolbar button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
2
db/migrations/006_add_author_column.sql
Normal file
2
db/migrations/006_add_author_column.sql
Normal file
@ -0,0 +1,2 @@
|
||||
ALTER TABLE library_documents
|
||||
ADD COLUMN author VARCHAR(255) DEFAULT NULL;
|
||||
176
document.php
176
document.php
@ -68,7 +68,7 @@ library_render_header(
|
||||
</div>
|
||||
|
||||
<div class="row g-3 small text-secondary border-top pt-3">
|
||||
<div class="col-md-4"><strong class="text-dark d-block mb-1">Author</strong><?= h((string) ($document['author_name'] ?: 'Unknown author')) ?></div>
|
||||
<div class="col-md-4"><strong class="text-dark d-block mb-1">Author</strong><?= h((string) ($document['author'] ?: 'Unknown author')) ?></div>
|
||||
<div class="col-md-4"><strong class="text-dark d-block mb-1">Views</strong><?= h((string) $document['view_count']) ?></div>
|
||||
<div class="col-md-4"><strong class="text-dark d-block mb-1">File</strong><?= h((string) ($document['file_name'] ?: 'Unavailable')) ?></div>
|
||||
</div>
|
||||
@ -81,7 +81,11 @@ library_render_header(
|
||||
<h2 class="h4 mb-0">Read in the browser</h2>
|
||||
</div>
|
||||
<?php if (!empty($document['file_path'])): ?>
|
||||
<a class="btn btn-outline-secondary btn-sm" href="<?= h(library_file_url((string) $document['file_path'])) ?>" target="_blank" rel="noopener">Open file</a>
|
||||
<?php if (library_can_preview($document)): ?>
|
||||
<a class="btn btn-outline-secondary btn-sm" href="/viewer.php?id=<?= $document['id'] ?><?= $context === 'admin' ? '&context=admin' : '' ?>" target="_blank">Open fullscreen</a>
|
||||
<?php else: ?>
|
||||
<a class="btn btn-outline-secondary btn-sm" href="<?= h(library_file_url((string) $document['file_path'])) ?>" target="_blank" rel="noopener">Open file</a>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
@ -91,9 +95,36 @@ library_render_header(
|
||||
<p class="text-secondary mb-0">This title is marked as login-required by the admin, so it stays hidden from the public reading experience.</p>
|
||||
</div>
|
||||
<?php elseif (library_can_preview($document)): ?>
|
||||
<div class="reader-frame-wrap">
|
||||
<iframe class="reader-frame" src="<?= h(library_file_url((string) $document['file_path'])) ?>#toolbar=1&navpanes=0&view=FitH" title="Reader for <?= h((string) ($document['title_en'] ?: $document['title_ar'] ?: 'document')) ?>"></iframe>
|
||||
<!-- Flipbook Container -->
|
||||
<div id="flipbook-wrapper" style="position: relative; background: #2d3035; border-radius: 8px; overflow: hidden; height: 700px; display: flex; align-items: center; justify-content: center;">
|
||||
<div id="flipbook-loader" class="text-center text-white">
|
||||
<div class="spinner-border mb-2" role="status"></div>
|
||||
<div>Loading Book...</div>
|
||||
</div>
|
||||
<!-- The actual book container for PageFlip -->
|
||||
<div id="flipbook" class="shadow-lg" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Toolbar -->
|
||||
<div id="flipbook-toolbar" class="d-flex justify-content-center align-items-center gap-3 mt-3 p-2 bg-white border rounded shadow-sm opacity-50 pe-none">
|
||||
<button id="fb-prev" class="btn btn-outline-dark border-0 btn-lg" title="Previous Page">
|
||||
<i class="bi bi-arrow-left-circle-fill"></i>
|
||||
</button>
|
||||
|
||||
<div class="text-center" style="min-width: 120px;">
|
||||
<span class="small text-uppercase text-secondary fw-bold" style="letter-spacing: 1px;">Page</span>
|
||||
<div class="d-flex align-items-baseline justify-content-center gap-1">
|
||||
<span id="fb-current" class="fs-5 fw-bold font-monospace">1</span>
|
||||
<span class="text-muted">/</span>
|
||||
<span id="fb-total" class="text-muted font-monospace">--</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="fb-next" class="btn btn-outline-dark border-0 btn-lg" title="Next Page">
|
||||
<i class="bi bi-arrow-right-circle-fill"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<?php elseif (!empty($document['file_path'])): ?>
|
||||
<div class="reader-lock">
|
||||
<h3 class="h5 mb-2">Document stored</h3>
|
||||
@ -158,5 +189,142 @@ library_render_header(
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php if (library_can_preview($document)): ?>
|
||||
<!-- Flipbook Scripts -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||
<script src="https://unpkg.com/page-flip/dist/js/page-flip.browser.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const pdfUrl = '<?= h(library_file_url((string) $document['file_path'])) ?>';
|
||||
const container = document.getElementById('flipbook');
|
||||
const wrapper = document.getElementById('flipbook-wrapper');
|
||||
const loader = document.getElementById('flipbook-loader');
|
||||
const toolbar = document.getElementById('flipbook-toolbar');
|
||||
|
||||
const prevBtn = document.getElementById('fb-prev');
|
||||
const nextBtn = document.getElementById('fb-next');
|
||||
const currentSpan = document.getElementById('fb-current');
|
||||
const totalSpan = document.getElementById('fb-total');
|
||||
|
||||
// PDF.js worker
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
||||
|
||||
try {
|
||||
const loadingTask = pdfjsLib.getDocument(pdfUrl);
|
||||
const pdf = await loadingTask.promise;
|
||||
const totalPages = pdf.numPages;
|
||||
|
||||
totalSpan.textContent = totalPages;
|
||||
|
||||
// Render pages to canvases
|
||||
// Note: For very large PDFs, this should be lazy-loaded.
|
||||
// For this demo, we render all to allow smooth flipping.
|
||||
const canvasPromises = [];
|
||||
|
||||
// Calculate dimensions based on the first page
|
||||
const firstPage = await pdf.getPage(1);
|
||||
const viewport = firstPage.getViewport({ scale: 1.5 }); // Scale up for better quality
|
||||
const width = viewport.width;
|
||||
const height = viewport.height;
|
||||
|
||||
// Define desired height for the book (e.g. 600px)
|
||||
const desiredHeight = 600;
|
||||
const scale = desiredHeight / height;
|
||||
|
||||
// Re-calculate viewport with correct scale
|
||||
const scaledViewport = firstPage.getViewport({ scale: scale });
|
||||
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
const pageDiv = document.createElement('div');
|
||||
pageDiv.className = 'page'; // Class for PageFlip
|
||||
// pageDiv.style.padding = '20px';
|
||||
pageDiv.style.backgroundColor = '#fdfdfd';
|
||||
// pageDiv.style.border = '1px solid #c2c5cc';
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.className = 'shadow-sm'; // Add some shadow to page
|
||||
pageDiv.appendChild(canvas);
|
||||
container.appendChild(pageDiv);
|
||||
|
||||
canvasPromises.push(async () => {
|
||||
const page = await pdf.getPage(i);
|
||||
const vp = page.getViewport({ scale: scale });
|
||||
canvas.height = vp.height;
|
||||
canvas.width = vp.width;
|
||||
|
||||
const renderContext = {
|
||||
canvasContext: canvas.getContext('2d'),
|
||||
viewport: vp
|
||||
};
|
||||
await page.render(renderContext).promise;
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for all pages to render (or at least start)
|
||||
// In a real app, we might want to render just the first few before showing.
|
||||
await Promise.all(canvasPromises.map(fn => fn()));
|
||||
|
||||
// Show container and hide loader
|
||||
loader.style.display = 'none';
|
||||
container.style.display = 'block';
|
||||
|
||||
// Ensure PageFlip is available
|
||||
if (typeof St === 'undefined' || typeof St.PageFlip === 'undefined') {
|
||||
throw new Error('PageFlip library failed to load.');
|
||||
}
|
||||
|
||||
// Initialize PageFlip
|
||||
const pageFlip = new St.PageFlip(container, {
|
||||
width: scaledViewport.width, // Width of one page
|
||||
height: scaledViewport.height, // Height of one page
|
||||
size: 'fixed', // Fixed size
|
||||
// minWidth: 300,
|
||||
// maxWidth: 1000,
|
||||
// minHeight: 400,
|
||||
// maxHeight: 1200,
|
||||
showCover: true,
|
||||
maxShadowOpacity: 0.5,
|
||||
mobileScrollSupport: false // Disable mobile scroll to prevent conflicts
|
||||
});
|
||||
|
||||
pageFlip.loadFromHTML(document.querySelectorAll('.page'));
|
||||
|
||||
// Enable toolbar
|
||||
toolbar.classList.remove('opacity-50', 'pe-none');
|
||||
|
||||
// Events
|
||||
pageFlip.on('flip', (e) => {
|
||||
// e.data is the page index (0-based or page number?)
|
||||
// update page number
|
||||
// PageFlip creates "leafs" (2 pages per view usually).
|
||||
// getCurrentPageIndex() returns the index of the current page.
|
||||
updatePagination();
|
||||
});
|
||||
|
||||
function updatePagination() {
|
||||
// Get current viewing pages
|
||||
// In landscape (spread) mode, it might be 1 (cover) or 2-3.
|
||||
// Let's rely on simple index for now.
|
||||
// index is 0-based
|
||||
// For single page view vs spread?
|
||||
// PageFlip usually handles spread automatically.
|
||||
|
||||
// Simplest way:
|
||||
let current = pageFlip.getCurrentPageIndex() + 1;
|
||||
currentSpan.textContent = current;
|
||||
}
|
||||
|
||||
prevBtn.addEventListener('click', () => pageFlip.flipPrev());
|
||||
nextBtn.addEventListener('click', () => pageFlip.flipNext());
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading PDF:', error);
|
||||
loader.innerHTML = '<div class="text-danger">Error loading document.<br>' + error.message + '</div>';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
library_render_footer();
|
||||
|
||||
@ -7,6 +7,7 @@ if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../db/config.php';
|
||||
require_once __DIR__ . '/../ai/LocalAIApi.php';
|
||||
|
||||
function library_bootstrap(): void
|
||||
{
|
||||
@ -64,6 +65,16 @@ function library_bootstrap(): void
|
||||
}
|
||||
}
|
||||
|
||||
$migration6Path = __DIR__ . '/../db/migrations/006_add_author_column.sql';
|
||||
if (is_file($migration6Path)) {
|
||||
// Check if column exists
|
||||
$exists = db()->query("SHOW COLUMNS FROM library_documents LIKE 'author'")->fetch();
|
||||
if (!$exists) {
|
||||
$sql = file_get_contents($migration6Path);
|
||||
db()->exec($sql);
|
||||
}
|
||||
}
|
||||
|
||||
$uploadDir = __DIR__ . '/../uploads/library';
|
||||
if (!is_dir($uploadDir)) {
|
||||
mkdir($uploadDir, 0775, true);
|
||||
@ -162,6 +173,94 @@ function library_allowed_extensions(): array
|
||||
];
|
||||
}
|
||||
|
||||
function library_file_url(string $path): string
|
||||
{
|
||||
if ($path === '') return '';
|
||||
if ($path[0] !== '/') {
|
||||
return '/' . $path;
|
||||
}
|
||||
return $path;
|
||||
}
|
||||
|
||||
function library_can_preview(array $doc): bool
|
||||
{
|
||||
// Only PDFs are supported for inline preview in this version
|
||||
return strtolower((string) ($doc['document_type'] ?? '')) === 'pdf';
|
||||
}
|
||||
|
||||
function library_increment_views(int $id): void
|
||||
{
|
||||
library_bootstrap();
|
||||
$stmt = db()->prepare('UPDATE library_documents SET view_count = view_count + 1 WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
}
|
||||
|
||||
function library_generate_summary(int $id): array
|
||||
{
|
||||
library_bootstrap();
|
||||
$doc = library_fetch_document($id);
|
||||
if (!$doc) {
|
||||
return ['success' => false, 'message' => 'Document not found.'];
|
||||
}
|
||||
|
||||
$descEn = trim((string) ($doc['description_en'] ?? ''));
|
||||
$descAr = trim((string) ($doc['description_ar'] ?? ''));
|
||||
|
||||
if ($descEn === '' && $descAr === '') {
|
||||
// Fallback to title if description is missing
|
||||
$descEn = trim((string) ($doc['title_en'] ?? ''));
|
||||
$descAr = trim((string) ($doc['title_ar'] ?? ''));
|
||||
}
|
||||
|
||||
$prompt = "Please summarize the following document content into a concise paragraph in English and a concise paragraph in Arabic.\n\n";
|
||||
if ($descEn) $prompt .= "English content: $descEn\n";
|
||||
if ($descAr) $prompt .= "Arabic content: $descAr\n";
|
||||
|
||||
$prompt .= "\nReturn the result as valid JSON with keys 'summary_en' and 'summary_ar'.";
|
||||
|
||||
// Call AI
|
||||
$resp = LocalAIApi::createResponse([
|
||||
'model' => 'gpt-5-mini',
|
||||
'messages' => [
|
||||
['role' => 'system', 'content' => 'You are a helpful bilingual assistant.'],
|
||||
['role' => 'user', 'content' => $prompt],
|
||||
]
|
||||
]);
|
||||
|
||||
if (empty($resp['success'])) {
|
||||
return ['success' => false, 'message' => 'AI request failed: ' . ($resp['error'] ?? 'Unknown error')];
|
||||
}
|
||||
|
||||
$text = LocalAIApi::extractText($resp);
|
||||
|
||||
$sumEn = '';
|
||||
$sumAr = '';
|
||||
|
||||
// Try to parse JSON
|
||||
$jsonStart = strpos($text, '{');
|
||||
$jsonEnd = strrpos($text, '}');
|
||||
if ($jsonStart !== false && $jsonEnd !== false) {
|
||||
$jsonStr = substr($text, $jsonStart, $jsonEnd - $jsonStart + 1);
|
||||
$data = json_decode($jsonStr, true);
|
||||
$sumEn = $data['summary_en'] ?? '';
|
||||
$sumAr = $data['summary_ar'] ?? '';
|
||||
}
|
||||
|
||||
// If JSON parsing failed or returned empty strings, try to infer or use raw text
|
||||
if (!$sumEn && !$sumAr) {
|
||||
// If the AI just returned text, use it for English (or both)
|
||||
$sumEn = $text;
|
||||
}
|
||||
|
||||
// Update DB
|
||||
$stmt = db()->prepare('UPDATE library_documents SET summary_text = ?, summary_en = ?, summary_ar = ? WHERE id = ?');
|
||||
// Note: summary_text is legacy/combined, we can store JSON or just English
|
||||
$combined = "English: $sumEn\n\nArabic: $sumAr";
|
||||
$stmt->execute([$combined, $sumEn, $sumAr, $id]);
|
||||
|
||||
return ['success' => true, 'message' => 'Summary generated successfully.'];
|
||||
}
|
||||
|
||||
// --- Category Functions ---
|
||||
|
||||
function library_get_categories(string $search = ''): array
|
||||
@ -521,6 +620,8 @@ function library_create_document(array $payload, array $file, array $coverFile =
|
||||
$pageCount = !empty($payload['page_count']) ? (int)$payload['page_count'] : null;
|
||||
$summaryEn = trim((string) ($payload['summary_en'] ?? ''));
|
||||
$summaryAr = trim((string) ($payload['summary_ar'] ?? ''));
|
||||
$descriptionEn = trim((string) ($payload['description_en'] ?? ''));
|
||||
$descriptionAr = trim((string) ($payload['description_ar'] ?? ''));
|
||||
|
||||
$fileData = library_handle_uploaded_file($file);
|
||||
$coverPath = library_handle_cover_image($coverFile);
|
||||
@ -531,14 +632,16 @@ function library_create_document(array $payload, array $file, array $coverFile =
|
||||
category_id, subcategory_id,
|
||||
visibility, document_type,
|
||||
file_name, file_path, file_size_kb, allow_download, allow_print, allow_copy,
|
||||
cover_image_path, publisher, publish_year, author, country, type_id, page_count, summary_en, summary_ar
|
||||
cover_image_path, publisher, publish_year, author, country, type_id, page_count, summary_en, summary_ar,
|
||||
description_en, description_ar
|
||||
) VALUES (
|
||||
:title_en, :title_ar,
|
||||
:category, :category_ar, :sub_category, :sub_category_ar,
|
||||
:category_id, :subcategory_id,
|
||||
:visibility, :document_type,
|
||||
:file_name, :file_path, :file_size_kb, :allow_download, :allow_print, :allow_copy,
|
||||
:cover_image_path, :publisher, :publish_year, :author, :country, :type_id, :page_count, :summary_en, :summary_ar
|
||||
:cover_image_path, :publisher, :publish_year, :author, :country, :type_id, :page_count, :summary_en, :summary_ar,
|
||||
:description_en, :description_ar
|
||||
)');
|
||||
|
||||
$stmt->execute([
|
||||
@ -567,6 +670,8 @@ function library_create_document(array $payload, array $file, array $coverFile =
|
||||
':page_count' => $pageCount,
|
||||
':summary_en' => $summaryEn ?: null,
|
||||
':summary_ar' => $summaryAr ?: null,
|
||||
':description_en' => $descriptionEn ?: null,
|
||||
':description_ar' => $descriptionAr ?: null,
|
||||
]);
|
||||
|
||||
return (int) db()->lastInsertId();
|
||||
@ -622,6 +727,8 @@ function library_update_document(int $id, array $payload, array $file = [], arra
|
||||
$pageCount = !empty($payload['page_count']) ? (int)$payload['page_count'] : null;
|
||||
$summaryEn = trim((string) ($payload['summary_en'] ?? ''));
|
||||
$summaryAr = trim((string) ($payload['summary_ar'] ?? ''));
|
||||
$descriptionEn = trim((string) ($payload['description_en'] ?? ''));
|
||||
$descriptionAr = trim((string) ($payload['description_ar'] ?? ''));
|
||||
|
||||
// Handle File Update
|
||||
$fileData = null;
|
||||
@ -642,7 +749,8 @@ function library_update_document(int $id, array $payload, array $file = [], arra
|
||||
visibility = :visibility,
|
||||
allow_download = :allow_download, allow_print = :allow_print, allow_copy = :allow_copy,
|
||||
publisher = :publisher, publish_year = :publish_year, author = :author, country = :country,
|
||||
type_id = :type_id, page_count = :page_count, summary_en = :summary_en, summary_ar = :summary_ar';
|
||||
type_id = :type_id, page_count = :page_count, summary_en = :summary_en, summary_ar = :summary_ar,
|
||||
description_en = :description_en, description_ar = :description_ar';
|
||||
|
||||
$params = [
|
||||
':title_en' => $titleEn ?: null,
|
||||
@ -665,6 +773,8 @@ function library_update_document(int $id, array $payload, array $file = [], arra
|
||||
':page_count' => $pageCount,
|
||||
':summary_en' => $summaryEn ?: null,
|
||||
':summary_ar' => $summaryAr ?: null,
|
||||
':description_en' => $descriptionEn ?: null,
|
||||
':description_ar' => $descriptionAr ?: null,
|
||||
':id' => $id,
|
||||
];
|
||||
|
||||
@ -694,4 +804,4 @@ function library_delete_document(int $id): void
|
||||
// For now, just delete DB record.
|
||||
$stmt = db()->prepare('DELETE FROM library_documents WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -137,7 +137,7 @@ library_render_header(
|
||||
</div>
|
||||
<dl class="row small text-secondary mb-3 gx-2 gy-1">
|
||||
<dt class="col-4">Author</dt>
|
||||
<dd class="col-8 mb-0"><?= h((string) ($document['author_name'] ?: 'Not set')) ?></dd>
|
||||
<dd class="col-8 mb-0"><?= h((string) ($document['author'] ?: 'Not set')) ?></dd>
|
||||
<dt class="col-4">Views</dt>
|
||||
<dd class="col-8 mb-0"><?= h((string) $document['view_count']) ?></dd>
|
||||
<dt class="col-4">Tags</dt>
|
||||
@ -182,7 +182,7 @@ library_render_header(
|
||||
<a class="recent-card text-decoration-none" href="/document.php?id=<?= h((string) $document['id']) ?>">
|
||||
<span class="small text-secondary d-block mb-2"><?= h(library_language_label((string) $document['document_language'])) ?></span>
|
||||
<strong class="d-block text-dark mb-1"><?= h((string) ($document['title_en'] ?: $document['title_ar'] ?: 'Untitled')) ?></strong>
|
||||
<span class="small text-secondary"><?= h((string) ($document['author_name'] ?: 'Unknown author')) ?></span>
|
||||
<span class="small text-secondary"><?= h((string) ($document['author'] ?: 'Unknown author')) ?></span>
|
||||
</a>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
BIN
uploads/library/20260325102925---8a635cbb.pdf
Normal file
BIN
uploads/library/20260325102925---8a635cbb.pdf
Normal file
Binary file not shown.
BIN
uploads/library/20260325103107---099c7452.pdf
Normal file
BIN
uploads/library/20260325103107---099c7452.pdf
Normal file
Binary file not shown.
BIN
uploads/library/20260325103121---3affd94e.pdf
Normal file
BIN
uploads/library/20260325103121---3affd94e.pdf
Normal file
Binary file not shown.
BIN
uploads/library/20260325103208---ed905fee.pdf
Normal file
BIN
uploads/library/20260325103208---ed905fee.pdf
Normal file
Binary file not shown.
539
viewer.php
Normal file
539
viewer.php
Normal file
@ -0,0 +1,539 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/includes/layout.php';
|
||||
|
||||
library_bootstrap();
|
||||
|
||||
$documentId = isset($_GET['id']) ? (int) $_GET['id'] : 0;
|
||||
// Context handling (admin/public)
|
||||
$context = ($_GET['context'] ?? '') === 'admin' ? 'admin' : 'public';
|
||||
$publicOnly = $context !== 'admin';
|
||||
|
||||
$document = $documentId > 0 ? library_fetch_document($documentId, $publicOnly) : null;
|
||||
|
||||
if (!$document || empty($document['file_path'])) {
|
||||
http_response_code(404);
|
||||
die('Document not found or no file attached.');
|
||||
}
|
||||
|
||||
$fileUrl = library_file_url((string) $document['file_path']);
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= h($document['title_en'] ?: 'Document Viewer') ?> - Reader</title>
|
||||
<!-- Icons -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
||||
<!-- Bootstrap -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #2d3035;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#viewer-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
#flipbook-loader {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#flipbook {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page {
|
||||
background-color: #fff;
|
||||
box-shadow: inset -1px 0 2px rgba(0,0,0,0.1); /* subtle spine shadow */
|
||||
}
|
||||
|
||||
#toolbar {
|
||||
height: 60px;
|
||||
background-color: #1a1d21;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
box-shadow: 0 -2px 10px rgba(0,0,0,0.2);
|
||||
z-index: 1000;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #adb5bd;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
transition: color 0.2s, background-color 0.2s;
|
||||
}
|
||||
|
||||
.toolbar-btn:hover {
|
||||
color: #fff;
|
||||
background-color: rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.page-indicator {
|
||||
font-family: monospace;
|
||||
font-size: 1.1rem;
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.doc-title {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
/* Search Styles */
|
||||
.search-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #2d3035;
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
border: 1px solid #495057;
|
||||
}
|
||||
|
||||
#searchInput {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #fff;
|
||||
padding: 5px 10px;
|
||||
outline: none;
|
||||
width: 150px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
#searchInput::placeholder {
|
||||
color: #adb5bd;
|
||||
}
|
||||
|
||||
.search-controls {
|
||||
display: flex;
|
||||
border-left: 1px solid #495057;
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #adb5bd;
|
||||
padding: 5px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.search-btn:hover {
|
||||
color: #fff;
|
||||
background-color: rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
#search-status {
|
||||
font-size: 0.8rem;
|
||||
color: #adb5bd;
|
||||
margin-left: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="toolbar">
|
||||
<div class="toolbar-group">
|
||||
<a href="/document.php?id=<?= $documentId ?><?= $context === 'admin' ? '&context=admin' : '' ?>" class="toolbar-btn" title="Back to Details">
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</a>
|
||||
<div class="doc-title d-none d-lg-block"><?= h($document['title_en'] ?: 'Document') ?></div>
|
||||
</div>
|
||||
|
||||
<!-- Center: Search -->
|
||||
<div class="toolbar-group d-none d-md-flex">
|
||||
<div class="search-container">
|
||||
<input type="text" id="searchInput" placeholder="Search..." autocomplete="off">
|
||||
<div class="search-controls">
|
||||
<button id="btn-search-prev" class="search-btn" title="Previous Match"><i class="bi bi-chevron-up"></i></button>
|
||||
<button id="btn-search-next" class="search-btn" title="Next Match"><i class="bi bi-chevron-down"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<span id="search-status"></span>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-group">
|
||||
<button id="btn-prev" class="toolbar-btn" title="Previous Page">
|
||||
<i class="bi bi-caret-left-fill"></i>
|
||||
</button>
|
||||
<div class="page-indicator">
|
||||
<span id="page-current">1</span> <span class="text-secondary">/</span> <span id="page-total">--</span>
|
||||
</div>
|
||||
<button id="btn-next" class="toolbar-btn" title="Next Page">
|
||||
<i class="bi bi-caret-right-fill"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-group">
|
||||
<a href="<?= h($fileUrl) ?>" download class="toolbar-btn" title="Download PDF">
|
||||
<i class="bi bi-download"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="viewer-container">
|
||||
<div id="flipbook-loader">
|
||||
<div class="spinner-border text-light mb-3" role="status"></div>
|
||||
<div>Loading Document...</div>
|
||||
<div id="loader-status" class="text-secondary small mt-2"></div>
|
||||
</div>
|
||||
<div id="flipbook"></div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||
<script src="https://unpkg.com/page-flip/dist/js/page-flip.browser.js"></script>
|
||||
<script>
|
||||
// Global State
|
||||
let pdfDoc = null;
|
||||
let pageFlip = null;
|
||||
let searchResults = []; // Array of { pageIndex, rects: [x,y,w,h] }
|
||||
let currentMatchIndex = -1;
|
||||
let pageHighlighters = {}; // Map of pageIndex -> CanvasContext (for highlights)
|
||||
let pageViewports = {}; // Map of pageIndex -> viewport (for rect calc)
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const pdfUrl = '<?= h($fileUrl) ?>';
|
||||
const container = document.getElementById('flipbook');
|
||||
const loader = document.getElementById('flipbook-loader');
|
||||
const loaderStatus = document.getElementById('loader-status');
|
||||
const currentSpan = document.getElementById('page-current');
|
||||
const totalSpan = document.getElementById('page-total');
|
||||
const viewerContainer = document.getElementById('viewer-container');
|
||||
|
||||
// Set worker
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
||||
|
||||
try {
|
||||
const loadingTask = pdfjsLib.getDocument(pdfUrl);
|
||||
loadingTask.onProgress = (p) => {
|
||||
if (p.total > 0) {
|
||||
const percent = Math.round((p.loaded / p.total) * 100);
|
||||
loaderStatus.textContent = `Downloading... ${percent}%`;
|
||||
}
|
||||
};
|
||||
|
||||
pdfDoc = await loadingTask.promise;
|
||||
const totalPages = pdfDoc.numPages;
|
||||
totalSpan.textContent = totalPages;
|
||||
loaderStatus.textContent = 'Rendering pages...';
|
||||
|
||||
// --- Layout Logic ---
|
||||
const availWidth = viewerContainer.clientWidth - 40;
|
||||
const availHeight = viewerContainer.clientHeight - 40;
|
||||
const firstPage = await pdfDoc.getPage(1);
|
||||
const viewport = firstPage.getViewport({ scale: 1 });
|
||||
const aspectRatio = viewport.width / viewport.height;
|
||||
|
||||
let bookHeight = availHeight;
|
||||
let bookWidth = bookHeight * aspectRatio;
|
||||
if (bookWidth * 2 > availWidth) {
|
||||
bookWidth = availWidth / 2;
|
||||
bookHeight = bookWidth / aspectRatio;
|
||||
}
|
||||
|
||||
const renderScale = (bookHeight / viewport.height) * 1.5;
|
||||
|
||||
// --- Page Generation ---
|
||||
const canvasPromises = [];
|
||||
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
const pageDiv = document.createElement('div');
|
||||
pageDiv.className = 'page';
|
||||
|
||||
// Structure:
|
||||
// .page-content (relative)
|
||||
// -> .pdf-canvas (absolute, z=1)
|
||||
// -> .highlight-canvas (absolute, z=2, pointer-events: none)
|
||||
const contentDiv = document.createElement('div');
|
||||
contentDiv.style.position = 'relative';
|
||||
contentDiv.style.width = '100%';
|
||||
contentDiv.style.height = '100%';
|
||||
contentDiv.style.overflow = 'hidden';
|
||||
|
||||
const pdfCanvas = document.createElement('canvas');
|
||||
pdfCanvas.style.position = 'absolute';
|
||||
pdfCanvas.style.top = '0';
|
||||
pdfCanvas.style.left = '0';
|
||||
pdfCanvas.style.width = '100%';
|
||||
pdfCanvas.style.height = '100%';
|
||||
|
||||
const hlCanvas = document.createElement('canvas');
|
||||
hlCanvas.style.position = 'absolute';
|
||||
hlCanvas.style.top = '0';
|
||||
hlCanvas.style.left = '0';
|
||||
hlCanvas.style.width = '100%';
|
||||
hlCanvas.style.height = '100%';
|
||||
hlCanvas.style.pointerEvents = 'none'; // Click through to page
|
||||
|
||||
contentDiv.appendChild(pdfCanvas);
|
||||
contentDiv.appendChild(hlCanvas);
|
||||
pageDiv.appendChild(contentDiv);
|
||||
container.appendChild(pageDiv);
|
||||
|
||||
// Render Async
|
||||
canvasPromises.push(async () => {
|
||||
const page = await pdfDoc.getPage(i);
|
||||
const vp = page.getViewport({ scale: renderScale });
|
||||
|
||||
// Setup PDF Canvas
|
||||
pdfCanvas.height = vp.height;
|
||||
pdfCanvas.width = vp.width;
|
||||
const ctx = pdfCanvas.getContext('2d');
|
||||
|
||||
// Setup Highlight Canvas (match dims)
|
||||
hlCanvas.height = vp.height;
|
||||
hlCanvas.width = vp.width;
|
||||
const hlCtx = hlCanvas.getContext('2d');
|
||||
|
||||
// Store refs for search
|
||||
pageHighlighters[i] = hlCtx;
|
||||
pageViewports[i] = vp;
|
||||
|
||||
await page.render({ canvasContext: ctx, viewport: vp }).promise;
|
||||
});
|
||||
}
|
||||
|
||||
await Promise.all(canvasPromises.map(fn => fn()));
|
||||
|
||||
// --- Init Flipbook ---
|
||||
loader.style.display = 'none';
|
||||
container.style.display = 'block';
|
||||
|
||||
pageFlip = new St.PageFlip(container, {
|
||||
width: bookWidth,
|
||||
height: bookHeight,
|
||||
size: 'fixed',
|
||||
showCover: true,
|
||||
maxShadowOpacity: 0.5
|
||||
});
|
||||
|
||||
pageFlip.loadFromHTML(document.querySelectorAll('.page'));
|
||||
|
||||
// --- Events ---
|
||||
pageFlip.on('flip', (e) => {
|
||||
document.getElementById('page-current').textContent = (pageFlip.getCurrentPageIndex() + 1);
|
||||
});
|
||||
|
||||
document.getElementById('btn-prev').addEventListener('click', () => pageFlip.flipPrev());
|
||||
document.getElementById('btn-next').addEventListener('click', () => pageFlip.flipNext());
|
||||
|
||||
// Keyboard
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.target.tagName === 'INPUT') return; // Don't flip when typing
|
||||
if (e.key === 'ArrowLeft') pageFlip.flipPrev();
|
||||
if (e.key === 'ArrowRight') pageFlip.flipNext();
|
||||
});
|
||||
|
||||
// --- Search Implementation ---
|
||||
setupSearch();
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
loader.innerHTML = '<div class="text-danger p-3">Error: ' + error.message + '</div>';
|
||||
}
|
||||
});
|
||||
|
||||
// --- Search Logic ---
|
||||
function setupSearch() {
|
||||
const input = document.getElementById('searchInput');
|
||||
const btnPrev = document.getElementById('btn-search-prev');
|
||||
const btnNext = document.getElementById('btn-search-next');
|
||||
const status = document.getElementById('search-status');
|
||||
|
||||
let debounceTimer;
|
||||
|
||||
input.addEventListener('input', (e) => {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
performSearch(e.target.value.trim());
|
||||
}, 600);
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
clearTimeout(debounceTimer);
|
||||
performSearch(input.value.trim());
|
||||
}
|
||||
});
|
||||
|
||||
btnNext.addEventListener('click', () => {
|
||||
if (searchResults.length === 0) return;
|
||||
currentMatchIndex = (currentMatchIndex + 1) % searchResults.length;
|
||||
showMatch(currentMatchIndex);
|
||||
});
|
||||
|
||||
btnPrev.addEventListener('click', () => {
|
||||
if (searchResults.length === 0) return;
|
||||
currentMatchIndex = (currentMatchIndex - 1 + searchResults.length) % searchResults.length;
|
||||
showMatch(currentMatchIndex);
|
||||
});
|
||||
}
|
||||
|
||||
async function performSearch(query) {
|
||||
const status = document.getElementById('search-status');
|
||||
|
||||
// Clear previous highlights
|
||||
Object.values(pageHighlighters).forEach(ctx => {
|
||||
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
});
|
||||
|
||||
if (!query || query.length < 2) {
|
||||
searchResults = [];
|
||||
currentMatchIndex = -1;
|
||||
status.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
status.textContent = 'Searching...';
|
||||
searchResults = [];
|
||||
currentMatchIndex = -1;
|
||||
|
||||
try {
|
||||
// Loop all pages
|
||||
for (let i = 1; i <= pdfDoc.numPages; i++) {
|
||||
const page = await pdfDoc.getPage(i);
|
||||
const textContent = await page.getTextContent();
|
||||
const viewport = pageViewports[i]; // Use stored viewport
|
||||
const ctx = pageHighlighters[i];
|
||||
|
||||
// Simple Item Search
|
||||
// Note: This matches text *within* individual text items.
|
||||
// Complex multi-item matches (spanning lines/chunks) are harder.
|
||||
textContent.items.forEach(item => {
|
||||
if (item.str.toLowerCase().includes(query.toLowerCase())) {
|
||||
// Found a match!
|
||||
// item.transform is [scaleX, skewY, skewX, scaleY, tx, ty]
|
||||
// The PDF coordinate system origin is bottom-left (usually).
|
||||
|
||||
// Calculate approximate bounding box
|
||||
// We need the width of the item. 'item.width' is available in recent pdf.js
|
||||
// If not, we estimate.
|
||||
|
||||
const tx = item.transform;
|
||||
// Basic rect in PDF coords:
|
||||
// x = tx[4], y = tx[5]
|
||||
// w = item.width, h = item.height (or font size?)
|
||||
|
||||
// Note: item.height is often 0 or undefined in raw items, use tx[3] (scaleY) as approx font height
|
||||
let x = tx[4];
|
||||
let y = tx[5];
|
||||
let w = item.width;
|
||||
let h = Math.sqrt(tx[0]*tx[0] + tx[1]*tx[1]); // approximate font size from scale
|
||||
|
||||
if (!w) w = h * item.str.length * 0.5; // fallback
|
||||
|
||||
// Adjust for y-flip?
|
||||
// pdf.js viewport.convertToViewportRectangle handles the coordinate transform
|
||||
// including the y-flip if the viewport is set up that way.
|
||||
|
||||
// Text items are bottom-left origin?
|
||||
// We usually need to move y up by 'h' because rects are top-left?
|
||||
// Actually, let's just transform (x, y) and (x+w, y+h)
|
||||
|
||||
// PDF text is usually baseline.
|
||||
// So the rect starts at y (baseline) and goes up by h?
|
||||
// Or starts at y-h?
|
||||
// Let's assume y is baseline.
|
||||
|
||||
const rect = [x, y, x + w, y + h];
|
||||
const viewRect = viewport.convertToViewportRectangle(rect);
|
||||
|
||||
// viewRect is [x1, y1, x2, y2]
|
||||
// normalize
|
||||
const rx = Math.min(viewRect[0], viewRect[2]);
|
||||
const ry = Math.min(viewRect[1], viewRect[3]);
|
||||
const rw = Math.abs(viewRect[0] - viewRect[2]);
|
||||
const rh = Math.abs(viewRect[1] - viewRect[3]);
|
||||
|
||||
// Draw highlight (Yellow, 40% opacity)
|
||||
ctx.fillStyle = 'rgba(255, 235, 59, 0.4)';
|
||||
ctx.fillRect(rx, ry, rw, rh);
|
||||
|
||||
// Add border for visibility
|
||||
ctx.strokeStyle = 'rgba(255, 193, 7, 0.8)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(rx, ry, rw, rh);
|
||||
|
||||
// Add to results (one per page is enough for navigation, but we highlight all)
|
||||
// We only push to searchResults if it's the *first* match on this page
|
||||
// OR we push every match?
|
||||
// Let's push every match for "Next" button granularity.
|
||||
searchResults.push({
|
||||
pageIndex: i - 1, // 0-based for PageFlip
|
||||
label: `Page ${i}`
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (searchResults.length > 0) {
|
||||
currentMatchIndex = 0;
|
||||
status.textContent = `${searchResults.length} matches`;
|
||||
showMatch(0);
|
||||
} else {
|
||||
status.textContent = 'No matches';
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error('Search failed', e);
|
||||
status.textContent = 'Error';
|
||||
}
|
||||
}
|
||||
|
||||
function showMatch(index) {
|
||||
if (index < 0 || index >= searchResults.length) return;
|
||||
const match = searchResults[index];
|
||||
|
||||
// Update status text
|
||||
document.getElementById('search-status').textContent = `${index + 1} / ${searchResults.length}`;
|
||||
|
||||
// Flip to page
|
||||
// PageFlip 0-based index
|
||||
// Check if we are already there to avoid animation loop?
|
||||
// PageFlip handles it.
|
||||
|
||||
// Note: If view is 'spread', we might need to check if the page is visible.
|
||||
// But flip() is safe.
|
||||
pageFlip.flip(match.pageIndex);
|
||||
}
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
x
Reference in New Issue
Block a user