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
|
<?php
|
||||||
declare(strict_types=1);
|
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';
|
require_once __DIR__ . '/includes/admin_layout.php';
|
||||||
|
|
||||||
library_bootstrap();
|
library_bootstrap();
|
||||||
@ -9,6 +15,10 @@ $errors = [];
|
|||||||
|
|
||||||
// Handle POST request
|
// Handle POST request
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
// 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'] ?? '';
|
$action = $_POST['action'] ?? '';
|
||||||
$id = isset($_POST['id']) ? (int)$_POST['id'] : 0;
|
$id = isset($_POST['id']) ? (int)$_POST['id'] : 0;
|
||||||
|
|
||||||
@ -37,16 +47,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
}
|
}
|
||||||
} catch (Throwable $exception) {
|
} catch (Throwable $exception) {
|
||||||
$errors[] = $exception->getMessage();
|
$errors[] = $exception->getMessage();
|
||||||
|
error_log('AdminDocuments Error: ' . $exception->getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search Logic
|
// Search Logic
|
||||||
$search = isset($_GET['search']) ? trim($_GET['search']) : '';
|
$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);
|
$documents = library_fetch_documents(false);
|
||||||
// Basic search filter in PHP for now
|
|
||||||
if ($search !== '') {
|
if ($search !== '') {
|
||||||
$documents = array_filter($documents, function($doc) use ($search) {
|
$documents = array_filter($documents, function($doc) use ($search) {
|
||||||
return stripos($doc['title_en'] ?? '', $search) !== false
|
return stripos($doc['title_en'] ?? '', $search) !== false
|
||||||
@ -63,7 +71,14 @@ admin_render_header('Material Entry', 'documents');
|
|||||||
?>
|
?>
|
||||||
<!-- Page Content -->
|
<!-- Page Content -->
|
||||||
<?php if ($errors): ?>
|
<?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; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
@ -162,25 +177,27 @@ admin_render_header('Material Entry', 'documents');
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Document Modal -->
|
<!-- 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-dialog modal-lg modal-dialog-scrollable">
|
||||||
<div class="modal-content">
|
<form class="modal-content" method="post" action="/admin_documents.php" id="documentForm" enctype="multipart/form-data">
|
||||||
<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="">
|
|
||||||
|
|
||||||
<div class="modal-header bg-primary text-white">
|
<div class="modal-header bg-primary text-white">
|
||||||
<h5 class="modal-title" id="documentModalTitle">Add New Material</h5>
|
<h5 class="modal-title" id="documentModalTitle">Add New Material</h5>
|
||||||
<div class="ms-auto">
|
<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="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>
|
</div>
|
||||||
<div class="modal-body">
|
<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">
|
<div class="row g-3">
|
||||||
<!-- Titles -->
|
<!-- Titles -->
|
||||||
<div class="col-md-6">
|
<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">
|
<div class="input-group">
|
||||||
<input type="text" class="form-control" name="title_en" id="doc_title_en" required>
|
<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>
|
<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>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label small fw-bold text-uppercase text-muted">Document File</label>
|
<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">
|
<input class="form-control" type="file" name="document_file" id="document_file_input">
|
||||||
<div id="current_file_info" class="mt-2 d-none">
|
<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>
|
<small class="text-muted"><i class="bi bi-file-earmark"></i> <span id="current_filename"></span></small>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Settings -->
|
<!-- Settings -->
|
||||||
@ -312,7 +329,6 @@ admin_render_header('Material Entry', 'documents');
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Delete Confirmation Form -->
|
<!-- Delete Confirmation Form -->
|
||||||
@ -327,6 +343,13 @@ admin_render_header('Material Entry', 'documents');
|
|||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
documentModal = new bootstrap.Modal(document.getElementById('documentModal'));
|
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) {
|
function updateSubcategories(selectedSubId = null) {
|
||||||
@ -354,6 +377,11 @@ admin_render_header('Material Entry', 'documents');
|
|||||||
document.getElementById('doc_id').value = '';
|
document.getElementById('doc_id').value = '';
|
||||||
document.getElementById('documentForm').reset();
|
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
|
// Clear previews
|
||||||
document.getElementById('current_cover_preview').classList.add('d-none');
|
document.getElementById('current_cover_preview').classList.add('d-none');
|
||||||
document.getElementById('current_file_info').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_action').value = 'update_document';
|
||||||
document.getElementById('doc_id').value = doc.id;
|
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
|
// Fill fields
|
||||||
document.getElementById('doc_title_en').value = doc.title_en || '';
|
document.getElementById('doc_title_en').value = doc.title_en || '';
|
||||||
document.getElementById('doc_title_ar').value = doc.title_ar || '';
|
document.getElementById('doc_title_ar').value = doc.title_ar || '';
|
||||||
|
|||||||
@ -347,3 +347,28 @@ footer a {
|
|||||||
min-height: 60vh;
|
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;
|
||||||
174
document.php
174
document.php
@ -68,7 +68,7 @@ library_render_header(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-3 small text-secondary border-top pt-3">
|
<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">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 class="col-md-4"><strong class="text-dark d-block mb-1">File</strong><?= h((string) ($document['file_name'] ?: 'Unavailable')) ?></div>
|
||||||
</div>
|
</div>
|
||||||
@ -81,8 +81,12 @@ library_render_header(
|
|||||||
<h2 class="h4 mb-0">Read in the browser</h2>
|
<h2 class="h4 mb-0">Read in the browser</h2>
|
||||||
</div>
|
</div>
|
||||||
<?php if (!empty($document['file_path'])): ?>
|
<?php if (!empty($document['file_path'])): ?>
|
||||||
|
<?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>
|
<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; ?>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if ($document['visibility'] === 'private' && $context !== 'admin'): ?>
|
<?php if ($document['visibility'] === 'private' && $context !== 'admin'): ?>
|
||||||
@ -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>
|
<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>
|
</div>
|
||||||
<?php elseif (library_can_preview($document)): ?>
|
<?php elseif (library_can_preview($document)): ?>
|
||||||
<div class="reader-frame-wrap">
|
<!-- Flipbook Container -->
|
||||||
<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>
|
<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>
|
</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'])): ?>
|
<?php elseif (!empty($document['file_path'])): ?>
|
||||||
<div class="reader-lock">
|
<div class="reader-lock">
|
||||||
<h3 class="h5 mb-2">Document stored</h3>
|
<h3 class="h5 mb-2">Document stored</h3>
|
||||||
@ -158,5 +189,142 @@ library_render_header(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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
|
<?php
|
||||||
library_render_footer();
|
library_render_footer();
|
||||||
|
|||||||
@ -7,6 +7,7 @@ if (session_status() !== PHP_SESSION_ACTIVE) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
require_once __DIR__ . '/../db/config.php';
|
require_once __DIR__ . '/../db/config.php';
|
||||||
|
require_once __DIR__ . '/../ai/LocalAIApi.php';
|
||||||
|
|
||||||
function library_bootstrap(): void
|
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';
|
$uploadDir = __DIR__ . '/../uploads/library';
|
||||||
if (!is_dir($uploadDir)) {
|
if (!is_dir($uploadDir)) {
|
||||||
mkdir($uploadDir, 0775, true);
|
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 ---
|
// --- Category Functions ---
|
||||||
|
|
||||||
function library_get_categories(string $search = ''): array
|
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;
|
$pageCount = !empty($payload['page_count']) ? (int)$payload['page_count'] : null;
|
||||||
$summaryEn = trim((string) ($payload['summary_en'] ?? ''));
|
$summaryEn = trim((string) ($payload['summary_en'] ?? ''));
|
||||||
$summaryAr = trim((string) ($payload['summary_ar'] ?? ''));
|
$summaryAr = trim((string) ($payload['summary_ar'] ?? ''));
|
||||||
|
$descriptionEn = trim((string) ($payload['description_en'] ?? ''));
|
||||||
|
$descriptionAr = trim((string) ($payload['description_ar'] ?? ''));
|
||||||
|
|
||||||
$fileData = library_handle_uploaded_file($file);
|
$fileData = library_handle_uploaded_file($file);
|
||||||
$coverPath = library_handle_cover_image($coverFile);
|
$coverPath = library_handle_cover_image($coverFile);
|
||||||
@ -531,14 +632,16 @@ function library_create_document(array $payload, array $file, array $coverFile =
|
|||||||
category_id, subcategory_id,
|
category_id, subcategory_id,
|
||||||
visibility, document_type,
|
visibility, document_type,
|
||||||
file_name, file_path, file_size_kb, allow_download, allow_print, allow_copy,
|
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 (
|
) VALUES (
|
||||||
:title_en, :title_ar,
|
:title_en, :title_ar,
|
||||||
:category, :category_ar, :sub_category, :sub_category_ar,
|
:category, :category_ar, :sub_category, :sub_category_ar,
|
||||||
:category_id, :subcategory_id,
|
:category_id, :subcategory_id,
|
||||||
:visibility, :document_type,
|
:visibility, :document_type,
|
||||||
:file_name, :file_path, :file_size_kb, :allow_download, :allow_print, :allow_copy,
|
: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([
|
$stmt->execute([
|
||||||
@ -567,6 +670,8 @@ function library_create_document(array $payload, array $file, array $coverFile =
|
|||||||
':page_count' => $pageCount,
|
':page_count' => $pageCount,
|
||||||
':summary_en' => $summaryEn ?: null,
|
':summary_en' => $summaryEn ?: null,
|
||||||
':summary_ar' => $summaryAr ?: null,
|
':summary_ar' => $summaryAr ?: null,
|
||||||
|
':description_en' => $descriptionEn ?: null,
|
||||||
|
':description_ar' => $descriptionAr ?: null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (int) db()->lastInsertId();
|
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;
|
$pageCount = !empty($payload['page_count']) ? (int)$payload['page_count'] : null;
|
||||||
$summaryEn = trim((string) ($payload['summary_en'] ?? ''));
|
$summaryEn = trim((string) ($payload['summary_en'] ?? ''));
|
||||||
$summaryAr = trim((string) ($payload['summary_ar'] ?? ''));
|
$summaryAr = trim((string) ($payload['summary_ar'] ?? ''));
|
||||||
|
$descriptionEn = trim((string) ($payload['description_en'] ?? ''));
|
||||||
|
$descriptionAr = trim((string) ($payload['description_ar'] ?? ''));
|
||||||
|
|
||||||
// Handle File Update
|
// Handle File Update
|
||||||
$fileData = null;
|
$fileData = null;
|
||||||
@ -642,7 +749,8 @@ function library_update_document(int $id, array $payload, array $file = [], arra
|
|||||||
visibility = :visibility,
|
visibility = :visibility,
|
||||||
allow_download = :allow_download, allow_print = :allow_print, allow_copy = :allow_copy,
|
allow_download = :allow_download, allow_print = :allow_print, allow_copy = :allow_copy,
|
||||||
publisher = :publisher, publish_year = :publish_year, author = :author, country = :country,
|
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 = [
|
$params = [
|
||||||
':title_en' => $titleEn ?: null,
|
':title_en' => $titleEn ?: null,
|
||||||
@ -665,6 +773,8 @@ function library_update_document(int $id, array $payload, array $file = [], arra
|
|||||||
':page_count' => $pageCount,
|
':page_count' => $pageCount,
|
||||||
':summary_en' => $summaryEn ?: null,
|
':summary_en' => $summaryEn ?: null,
|
||||||
':summary_ar' => $summaryAr ?: null,
|
':summary_ar' => $summaryAr ?: null,
|
||||||
|
':description_en' => $descriptionEn ?: null,
|
||||||
|
':description_ar' => $descriptionAr ?: null,
|
||||||
':id' => $id,
|
':id' => $id,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -137,7 +137,7 @@ library_render_header(
|
|||||||
</div>
|
</div>
|
||||||
<dl class="row small text-secondary mb-3 gx-2 gy-1">
|
<dl class="row small text-secondary mb-3 gx-2 gy-1">
|
||||||
<dt class="col-4">Author</dt>
|
<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>
|
<dt class="col-4">Views</dt>
|
||||||
<dd class="col-8 mb-0"><?= h((string) $document['view_count']) ?></dd>
|
<dd class="col-8 mb-0"><?= h((string) $document['view_count']) ?></dd>
|
||||||
<dt class="col-4">Tags</dt>
|
<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']) ?>">
|
<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>
|
<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>
|
<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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<?php endforeach; ?>
|
<?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