Autosave: 20260325-164659

This commit is contained in:
Flatlogic Bot 2026-03-25 16:47:00 +00:00
parent ba0567e218
commit 3e7ac25120
12 changed files with 935 additions and 54 deletions

4
.htaccess Normal file
View 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

View File

@ -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(); ?>

View File

@ -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);
}

View File

@ -0,0 +1,2 @@
ALTER TABLE library_documents
ADD COLUMN author VARCHAR(255) DEFAULT NULL;

View File

@ -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();

View File

@ -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]);
}
}

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

539
viewer.php Normal file
View 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>