Compare commits

...

4 Commits

Author SHA1 Message Date
Flatlogic Bot
3e7ac25120 Autosave: 20260325-164659 2026-03-25 16:47:00 +00:00
Flatlogic Bot
ba0567e218 Autosave: 20260325-100805 2026-03-25 10:08:05 +00:00
Flatlogic Bot
f72c5f11b8 add categories 2026-03-25 08:05:46 +00:00
Flatlogic Bot
d9a8b246f2 Autosave: 20260325-074933 2026-03-25 07:49:33 +00:00
28 changed files with 3889 additions and 542 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

129
admin.php Normal file
View File

@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/admin_layout.php';
library_bootstrap();
$documents = library_fetch_documents(false, []);
$metrics = library_catalog_metrics();
admin_render_header('Dashboard', 'dashboard');
?>
<!-- Metrics -->
<div class="row g-4 mb-5">
<div class="col-md-4">
<div class="card shadow-sm border-0 h-100">
<div class="card-body">
<h6 class="text-uppercase text-secondary small fw-bold">Total Documents</h6>
<div class="d-flex align-items-center justify-content-between mt-3">
<h2 class="display-6 fw-bold mb-0 text-primary"><?= number_format($metrics['total_documents']) ?></h2>
<i class="bi bi-file-earmark-text fs-1 text-primary opacity-25"></i>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card shadow-sm border-0 h-100">
<div class="card-body">
<h6 class="text-uppercase text-secondary small fw-bold">Public Titles</h6>
<div class="d-flex align-items-center justify-content-between mt-3">
<h2 class="display-6 fw-bold mb-0 text-success"><?= number_format($metrics['public_documents']) ?></h2>
<i class="bi bi-globe fs-1 text-success opacity-25"></i>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card shadow-sm border-0 h-100">
<div class="card-body">
<h6 class="text-uppercase text-secondary small fw-bold">Total Downloads</h6>
<div class="d-flex align-items-center justify-content-between mt-3">
<h2 class="display-6 fw-bold mb-0 text-info"><?= number_format($metrics['total_downloads']) ?></h2>
<i class="bi bi-download fs-1 text-info opacity-25"></i>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<!-- Recent Documents -->
<div class="col-lg-8">
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white py-3 border-bottom">
<h5 class="card-title mb-0">Recent Documents</h5>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th class="ps-4">Title / Author</th>
<th>Type / Category</th>
<th>Visibility</th>
<th class="text-end pe-4">Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($documents)): ?>
<tr><td colspan="4" class="text-center py-5 text-muted">No documents found.</td></tr>
<?php else: ?>
<?php foreach (array_slice($documents, 0, 10) as $doc): ?>
<tr>
<td class="ps-4">
<div class="fw-medium text-dark"><?= h($doc['title_en']) ?></div>
<div class="small text-muted" dir="rtl"><?= h($doc['title_ar']) ?></div>
<?php if (!empty($doc['author'])): ?>
<div class="small text-primary mt-1"><i class="bi bi-person me-1"></i><?= h($doc['author']) ?></div>
<?php endif; ?>
</td>
<td>
<?php if (!empty($doc['type_en'])): ?>
<span class="badge bg-info bg-opacity-10 text-info border border-info border-opacity-25 mb-1"><?= h($doc['type_en']) ?></span><br>
<?php endif; ?>
<span class="badge bg-light text-dark border">
<?= h($doc['cat_en'] ?? $doc['category']) ?>
</span>
</td>
<td>
<span class="badge bg-<?= $doc['visibility'] === 'public' ? 'success' : 'secondary' ?>">
<?= h($doc['visibility']) ?>
</span>
</td>
<td class="text-end pe-4">
<a href="/document.php?id=<?= $doc['id'] ?>" class="btn btn-sm btn-outline-primary" target="_blank">
<i class="bi bi-eye"></i> View
</a>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<div class="card-footer bg-white text-center py-3">
<small class="text-muted">Showing last 10 uploads</small>
</div>
</div>
</div>
<!-- Material Entry Promo -->
<div class="col-lg-4">
<div class="card shadow-sm border-0">
<div class="card-header bg-primary text-white py-3">
<h5 class="card-title mb-0"><i class="bi bi-folder2-open me-2"></i>Material Entry</h5>
</div>
<div class="card-body p-4 text-center">
<p class="text-secondary mb-4">
Manage your library documents, upload new materials, and edit metadata including bilingual titles, summaries, and more.
</p>
<a href="/admin_documents.php" class="btn btn-primary w-100">
<i class="bi bi-plus-lg me-2"></i> Manage Documents
</a>
</div>
</div>
</div>
</div>
<?php admin_render_footer(); ?>

212
admin_categories.php Normal file
View File

@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/admin_layout.php';
library_bootstrap();
$errors = [];
// Handle POST request
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
$id = isset($_POST['id']) ? (int)$_POST['id'] : 0;
try {
if ($action === 'create_category') {
$nameEn = trim($_POST['name_en'] ?? '');
$nameAr = trim($_POST['name_ar'] ?? '');
if (!$nameEn || !$nameAr) {
throw new RuntimeException('Both English and Arabic names are required for Category.');
}
library_create_category($nameEn, $nameAr);
library_set_flash('success', 'Category created successfully.');
header('Location: /admin_categories.php');
exit;
} elseif ($action === 'update_category') {
$nameEn = trim($_POST['name_en'] ?? '');
$nameAr = trim($_POST['name_ar'] ?? '');
if (!$id || !$nameEn || !$nameAr) {
throw new RuntimeException('ID, English name, and Arabic name are required.');
}
library_update_category($id, $nameEn, $nameAr);
library_set_flash('success', 'Category updated successfully.');
header('Location: /admin_categories.php');
exit;
} elseif ($action === 'delete_category') {
if (!$id) {
throw new RuntimeException('Invalid Category ID.');
}
library_delete_category($id);
library_set_flash('success', 'Category deleted successfully.');
header('Location: /admin_categories.php');
exit;
}
} catch (Throwable $exception) {
$errors[] = $exception->getMessage();
}
}
// Search Logic
$search = isset($_GET['search']) ? trim($_GET['search']) : '';
$categories = library_get_categories($search);
admin_render_header('Categories', 'categories');
?>
<!-- Page Content -->
<?php if ($errors): ?>
<div class="alert alert-danger"><?= h(implode(' ', $errors)) ?></div>
<?php endif; ?>
<div class="d-flex justify-content-between align-items-center mb-4">
<p class="text-secondary mb-0">Manage document categories.</p>
<button class="btn btn-primary" onclick="openCreateCategoryModal()">
<i class="bi bi-plus-lg me-1"></i> Add New Category
</button>
</div>
<!-- Search Bar -->
<div class="card shadow-sm border-0 mb-4">
<div class="card-body">
<form method="get" action="/admin_categories.php" class="row g-2 align-items-center">
<div class="col-auto flex-grow-1">
<input type="text" name="search" class="form-control" placeholder="Search categories..." value="<?= h($search) ?>">
</div>
<div class="col-auto">
<button type="submit" class="btn btn-outline-primary">
<i class="bi bi-search"></i> Search
</button>
<?php if ($search): ?>
<a href="/admin_categories.php" class="btn btn-outline-secondary">Clear</a>
<?php endif; ?>
</div>
</form>
</div>
</div>
<div class="card shadow-sm border-0">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th class="ps-4">Name</th>
<th class="text-end pe-4">Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($categories)): ?>
<tr><td colspan="2" class="text-center py-5 text-muted">No categories found.</td></tr>
<?php else: ?>
<?php foreach ($categories as $cat): ?>
<tr>
<td class="ps-4">
<div class="fw-medium text-dark"><?= h($cat['name_en']) ?></div>
<small class="text-muted"><?= h($cat['name_ar']) ?></small>
</td>
<td class="text-end pe-4">
<button class="btn btn-sm btn-outline-secondary me-1"
data-id="<?= $cat['id'] ?>"
data-name-en="<?= h($cat['name_en']) ?>"
data-name-ar="<?= h($cat['name_ar']) ?>"
onclick="openEditCategoryModal(this)">
<i class="bi bi-pencil"></i> Edit
</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteCategory(<?= $cat['id'] ?>)">
<i class="bi bi-trash"></i> Delete
</button>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
<!-- Category Modal -->
<div class="modal fade" id="categoryModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" action="/admin_categories.php" id="categoryForm">
<input type="hidden" name="action" id="cat_action" value="create_category">
<input type="hidden" name="id" id="cat_id" value="">
<div class="modal-header">
<h5 class="modal-title" id="categoryModalTitle">Add New Category</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Name (English)</label>
<div class="input-group">
<input type="text" class="form-control" name="name_en" id="cat_name_en" required>
<button class="btn btn-outline-secondary" type="button" onclick="translateText('cat_name_en', 'cat_name_ar', 'Arabic')" title="Translate to Arabic">
<i class="bi bi-translate"></i>
</button>
</div>
</div>
<div class="mb-3">
<label class="form-label">Name (Arabic)</label>
<div class="input-group">
<input type="text" class="form-control" name="name_ar" id="cat_name_ar" dir="rtl" required>
<button class="btn btn-outline-secondary" type="button" onclick="translateText('cat_name_ar', 'cat_name_en', 'English')" title="Translate to English">
<i class="bi bi-translate"></i>
</button>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
</div>
<!-- Delete Confirmation Form -->
<form method="post" action="/admin_categories.php" id="deleteForm">
<input type="hidden" name="action" id="deleteAction" value="">
<input type="hidden" name="id" id="deleteId" value="">
</form>
<script>
let categoryModal;
document.addEventListener('DOMContentLoaded', function() {
categoryModal = new bootstrap.Modal(document.getElementById('categoryModal'));
});
function openCreateCategoryModal() {
document.getElementById('categoryModalTitle').innerText = 'Add New Category';
document.getElementById('cat_action').value = 'create_category';
document.getElementById('cat_id').value = '';
document.getElementById('cat_name_en').value = '';
document.getElementById('cat_name_ar').value = '';
categoryModal.show();
}
function openEditCategoryModal(btn) {
const id = btn.getAttribute('data-id');
const nameEn = btn.getAttribute('data-name-en');
const nameAr = btn.getAttribute('data-name-ar');
document.getElementById('categoryModalTitle').innerText = 'Edit Category';
document.getElementById('cat_action').value = 'update_category';
document.getElementById('cat_id').value = id;
document.getElementById('cat_name_en').value = nameEn;
document.getElementById('cat_name_ar').value = nameAr;
categoryModal.show();
}
function deleteCategory(id) {
if (confirm('Are you sure you want to delete this category? All related subcategories will also be deleted.')) {
document.getElementById('deleteAction').value = 'delete_category';
document.getElementById('deleteId').value = id;
document.getElementById('deleteForm').submit();
}
}
</script>
<?php admin_render_footer(); ?>

456
admin_documents.php Normal file
View File

@ -0,0 +1,456 @@
<?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();
$errors = [];
// Handle POST request
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'] ?? '';
$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.');
}
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());
}
}
}
// Search Logic
$search = isset($_GET['search']) ? trim($_GET['search']) : '';
$documents = library_fetch_documents(false);
if ($search !== '') {
$documents = array_filter($documents, function($doc) use ($search) {
return stripos($doc['title_en'] ?? '', $search) !== false
|| stripos($doc['title_ar'] ?? '', $search) !== false
|| stripos($doc['author'] ?? '', $search) !== false;
});
}
$categories = library_get_categories();
$allSubcategories = library_get_subcategories(null);
$types = library_get_types();
admin_render_header('Material Entry', 'documents');
?>
<!-- Page Content -->
<?php if ($errors): ?>
<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">
<p class="text-secondary mb-0">Manage library documents (Material Entry).</p>
<button class="btn btn-primary" onclick="openCreateModal()">
<i class="bi bi-plus-lg me-1"></i> Add New Document
</button>
</div>
<!-- Search Bar -->
<div class="card shadow-sm border-0 mb-4">
<div class="card-body">
<form method="get" action="/admin_documents.php" class="row g-2 align-items-center">
<div class="col-auto flex-grow-1">
<input type="text" name="search" class="form-control" placeholder="Search by title or author..." value="<?= h($search) ?>">
</div>
<div class="col-auto">
<button type="submit" class="btn btn-outline-primary">
<i class="bi bi-search"></i> Search
</button>
<?php if ($search): ?>
<a href="/admin_documents.php" class="btn btn-outline-secondary">Clear</a>
<?php endif; ?>
</div>
</form>
</div>
</div>
<div class="card shadow-sm border-0">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th class="ps-4">ID</th>
<th>Cover</th>
<th>Title / Author</th>
<th>Type / Category</th>
<th>Year</th>
<th class="text-end pe-4">Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($documents)): ?>
<tr><td colspan="6" class="text-center py-5 text-muted">No documents found.</td></tr>
<?php else: ?>
<?php foreach ($documents as $doc): ?>
<tr>
<td class="ps-4 text-muted small">#<?= $doc['id'] ?></td>
<td>
<?php if (!empty($doc['cover_image_path'])): ?>
<img src="/<?= h($doc['cover_image_path']) ?>" alt="Cover" class="rounded" style="width: 40px; height: 50px; object-fit: cover;">
<?php else: ?>
<div class="bg-light rounded d-flex align-items-center justify-content-center text-muted small" style="width: 40px; height: 50px;">
<i class="bi bi-image"></i>
</div>
<?php endif; ?>
</td>
<td>
<div class="fw-medium text-dark"><?= h($doc['title_en']) ?></div>
<div class="small text-muted" dir="rtl"><?= h($doc['title_ar']) ?></div>
<?php if (!empty($doc['author'])): ?>
<div class="small text-primary mt-1"><i class="bi bi-person me-1"></i><?= h($doc['author']) ?></div>
<?php endif; ?>
</td>
<td>
<?php if (!empty($doc['type_en'])): ?>
<span class="badge bg-info bg-opacity-10 text-info border border-info border-opacity-25 mb-1"><?= h($doc['type_en']) ?></span><br>
<?php endif; ?>
<span class="badge bg-light text-dark border"><?= h($doc['cat_en'] ?? $doc['category']) ?></span>
<?php if (!empty($doc['sub_en'])): ?>
<i class="bi bi-chevron-right text-muted small"></i>
<span class="badge bg-light text-dark border"><?= h($doc['sub_en']) ?></span>
<?php endif; ?>
</td>
<td><?= h((string)$doc['publish_year']) ?></td>
<td class="text-end pe-4">
<a href="/document.php?id=<?= $doc['id'] ?>" target="_blank" class="btn btn-sm btn-outline-secondary me-1" title="View">
<i class="bi bi-eye"></i>
</a>
<button class="btn btn-sm btn-outline-primary me-1"
onclick='openEditModal(<?= json_encode($doc) ?>)'>
<i class="bi bi-pencil"></i> Edit
</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteDocument(<?= $doc['id'] ?>)">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
<!-- Document Modal -->
<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">
<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" 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) <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>
</div>
</div>
<div class="col-md-6">
<label class="form-label small fw-bold text-uppercase text-muted">Title (Arabic)</label>
<div class="input-group">
<input type="text" class="form-control" name="title_ar" id="doc_title_ar" dir="rtl">
<button class="btn btn-outline-secondary" type="button" onclick="translateText('doc_title_ar', 'doc_title_en', 'English')"><i class="bi bi-translate"></i></button>
</div>
</div>
<!-- Basic Info -->
<div class="col-md-6">
<label class="form-label small fw-bold text-uppercase text-muted">Author</label>
<input type="text" class="form-control" name="author" id="doc_author">
</div>
<div class="col-md-3">
<label class="form-label small fw-bold text-uppercase text-muted">Publisher</label>
<input type="text" class="form-control" name="publisher" id="doc_publisher">
</div>
<div class="col-md-3">
<label class="form-label small fw-bold text-uppercase text-muted">Year</label>
<input type="number" class="form-control" name="publish_year" id="doc_publish_year" min="1000" max="2100">
</div>
<!-- Classification -->
<div class="col-md-4">
<label class="form-label small fw-bold text-uppercase text-muted">Type</label>
<select class="form-select" name="type_id" id="doc_type_id">
<option value="">Select Type...</option>
<?php foreach ($types as $t): ?>
<option value="<?= $t['id'] ?>"><?= h($t['name_en']) ?> / <?= h($t['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-4">
<label class="form-label small fw-bold text-uppercase text-muted">Category</label>
<select class="form-select" name="category_id" id="doc_category_id" onchange="updateSubcategories()">
<option value="">Select Category...</option>
<?php foreach ($categories as $c): ?>
<option value="<?= $c['id'] ?>"><?= h($c['name_en']) ?> / <?= h($c['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-4">
<label class="form-label small fw-bold text-uppercase text-muted">Subcategory</label>
<select class="form-select" name="subcategory_id" id="doc_subcategory_id">
<option value="">Select Category First...</option>
</select>
</div>
<!-- Details -->
<div class="col-md-6">
<label class="form-label small fw-bold text-uppercase text-muted">Country</label>
<input type="text" class="form-control" name="country" id="doc_country">
</div>
<div class="col-md-6">
<label class="form-label small fw-bold text-uppercase text-muted">Total Pages</label>
<input type="number" class="form-control" name="page_count" id="doc_page_count" min="0">
</div>
<!-- Summaries -->
<div class="col-md-6">
<label class="form-label small fw-bold text-uppercase text-muted">Summary (English)</label>
<div class="input-group">
<textarea class="form-control" name="summary_en" id="doc_summary_en" rows="3"></textarea>
<button class="btn btn-outline-secondary" type="button" onclick="translateText('doc_summary_en', 'doc_summary_ar', 'Arabic')"><i class="bi bi-translate"></i></button>
</div>
</div>
<div class="col-md-6">
<label class="form-label small fw-bold text-uppercase text-muted">Summary (Arabic)</label>
<div class="input-group">
<textarea class="form-control" name="summary_ar" id="doc_summary_ar" rows="3" dir="rtl"></textarea>
<button class="btn btn-outline-secondary" type="button" onclick="translateText('doc_summary_ar', 'doc_summary_en', 'English')"><i class="bi bi-translate"></i></button>
</div>
</div>
<!-- Files -->
<div class="col-md-6">
<label class="form-label small fw-bold text-uppercase text-muted">Front Cover Image</label>
<input class="form-control" type="file" name="cover_file" accept="image/*">
<div id="current_cover_preview" class="mt-2 d-none">
<small class="text-muted d-block mb-1">Current Cover:</small>
<img src="" class="rounded border" style="height: 60px;">
</div>
</div>
<div class="col-md-6">
<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" id="file_help_text">Required for new documents.</small>
</div>
<!-- Settings -->
<div class="col-12">
<div class="card bg-light border-0">
<div class="card-body">
<h6 class="card-subtitle mb-2 text-muted text-uppercase small fw-bold">Visibility & Permissions</h6>
<div class="row">
<div class="col-md-3">
<select class="form-select form-select-sm" name="visibility" id="doc_visibility">
<option value="public">Public</option>
<option value="private">Private</option>
</select>
</div>
<div class="col-md-9 d-flex align-items-center gap-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="allow_download" id="doc_allow_download" value="1">
<label class="form-check-label small" for="doc_allow_download">Download</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="allow_print" id="doc_allow_print" value="1">
<label class="form-check-label small" for="doc_allow_print">Print</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="allow_copy" id="doc_allow_copy" value="1">
<label class="form-check-label small" for="doc_allow_copy">Copy</label>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</form>
</div>
</div>
<!-- Delete Confirmation Form -->
<form method="post" action="/admin_documents.php" id="deleteForm">
<input type="hidden" name="action" id="deleteAction" value="">
<input type="hidden" name="id" id="deleteId" value="">
</form>
<script>
let documentModal;
const allSubcategories = <?= json_encode($allSubcategories) ?>;
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) {
const catSelect = document.getElementById('doc_category_id');
const subSelect = document.getElementById('doc_subcategory_id');
const catId = catSelect.value;
subSelect.innerHTML = '<option value="">Select Subcategory...</option>';
if (catId) {
const subs = allSubcategories.filter(s => s.category_id == catId);
subs.forEach(s => {
const opt = document.createElement('option');
opt.value = s.id;
opt.textContent = s.name_en + ' / ' + s.name_ar;
if (selectedSubId && s.id == selectedSubId) opt.selected = true;
subSelect.appendChild(opt);
});
}
}
function openCreateModal() {
document.getElementById('documentModalTitle').innerText = 'Add New Material';
document.getElementById('doc_action').value = 'create_document';
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');
// Reset dynamic selects
updateSubcategories();
documentModal.show();
}
function openEditModal(doc) {
document.getElementById('documentModalTitle').innerText = 'Edit Material: ' + doc.title_en;
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 || '';
document.getElementById('doc_author').value = doc.author || '';
document.getElementById('doc_publisher').value = doc.publisher || '';
document.getElementById('doc_publish_year').value = doc.publish_year || '';
document.getElementById('doc_country').value = doc.country || '';
document.getElementById('doc_page_count').value = doc.page_count || '';
document.getElementById('doc_summary_en').value = doc.summary_en || '';
document.getElementById('doc_summary_ar').value = doc.summary_ar || '';
document.getElementById('doc_type_id').value = doc.type_id || '';
document.getElementById('doc_visibility').value = doc.visibility || 'public';
document.getElementById('doc_allow_download').checked = !!parseInt(doc.allow_download);
document.getElementById('doc_allow_print').checked = !!parseInt(doc.allow_print);
document.getElementById('doc_allow_copy').checked = !!parseInt(doc.allow_copy);
// Handle Category & Subcategory
document.getElementById('doc_category_id').value = doc.category_id || '';
updateSubcategories(doc.subcategory_id);
// Previews
const coverDiv = document.getElementById('current_cover_preview');
if (doc.cover_image_path) {
coverDiv.classList.remove('d-none');
coverDiv.querySelector('img').src = '/' + doc.cover_image_path;
} else {
coverDiv.classList.add('d-none');
}
const fileDiv = document.getElementById('current_file_info');
if (doc.file_name) {
fileDiv.classList.remove('d-none');
document.getElementById('current_filename').innerText = doc.file_name;
} else {
fileDiv.classList.add('d-none');
}
documentModal.show();
}
function deleteDocument(id) {
if (confirm('Are you sure you want to delete this document?')) {
document.getElementById('deleteAction').value = 'delete_document';
document.getElementById('deleteId').value = id;
document.getElementById('deleteForm').submit();
}
}
</script>
<?php admin_render_footer(); ?>

240
admin_subcategories.php Normal file
View File

@ -0,0 +1,240 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/admin_layout.php';
library_bootstrap();
$errors = [];
// Handle POST request
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
$id = isset($_POST['id']) ? (int)$_POST['id'] : 0;
try {
if ($action === 'create_subcategory') {
$catId = (int)($_POST['category_id'] ?? 0);
$nameEn = trim($_POST['name_en'] ?? '');
$nameAr = trim($_POST['name_ar'] ?? '');
if (!$catId || !$nameEn || !$nameAr) {
throw new RuntimeException('Category, English name, and Arabic name are required.');
}
library_create_subcategory($catId, $nameEn, $nameAr);
library_set_flash('success', 'Subcategory created successfully.');
header('Location: /admin_subcategories.php');
exit;
} elseif ($action === 'update_subcategory') {
$catId = (int)($_POST['category_id'] ?? 0);
$nameEn = trim($_POST['name_en'] ?? '');
$nameAr = trim($_POST['name_ar'] ?? '');
if (!$id || !$catId || !$nameEn || !$nameAr) {
throw new RuntimeException('ID, Category, English name, and Arabic name are required.');
}
library_update_subcategory($id, $catId, $nameEn, $nameAr);
library_set_flash('success', 'Subcategory updated successfully.');
header('Location: /admin_subcategories.php');
exit;
} elseif ($action === 'delete_subcategory') {
if (!$id) {
throw new RuntimeException('Invalid Subcategory ID.');
}
library_delete_subcategory($id);
library_set_flash('success', 'Subcategory deleted successfully.');
header('Location: /admin_subcategories.php');
exit;
}
} catch (Throwable $exception) {
$errors[] = $exception->getMessage();
}
}
// Search Logic
$search = isset($_GET['search']) ? trim($_GET['search']) : '';
$categories = library_get_categories();
$allSubcategories = library_get_subcategories(null, $search);
admin_render_header('Subcategories', 'subcategories');
?>
<!-- Page Content -->
<?php if ($errors): ?>
<div class="alert alert-danger"><?= h(implode(' ', $errors)) ?></div>
<?php endif; ?>
<div class="d-flex justify-content-between align-items-center mb-4">
<p class="text-secondary mb-0">Manage document subcategories.</p>
<button class="btn btn-primary" onclick="openCreateSubcategoryModal()">
<i class="bi bi-plus-lg me-1"></i> Add New Subcategory
</button>
</div>
<!-- Search Bar -->
<div class="card shadow-sm border-0 mb-4">
<div class="card-body">
<form method="get" action="/admin_subcategories.php" class="row g-2 align-items-center">
<div class="col-auto flex-grow-1">
<input type="text" name="search" class="form-control" placeholder="Search subcategories..." value="<?= h($search) ?>">
</div>
<div class="col-auto">
<button type="submit" class="btn btn-outline-primary">
<i class="bi bi-search"></i> Search
</button>
<?php if ($search): ?>
<a href="/admin_subcategories.php" class="btn btn-outline-secondary">Clear</a>
<?php endif; ?>
</div>
</form>
</div>
</div>
<div class="card shadow-sm border-0">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th class="ps-4">Name</th>
<th>Parent Category</th>
<th class="text-end pe-4">Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($allSubcategories)): ?>
<tr><td colspan="3" class="text-center py-5 text-muted">No subcategories found.</td></tr>
<?php else: ?>
<?php foreach ($allSubcategories as $sub):
$parentName = 'Unknown';
foreach ($categories as $c) {
if ($c['id'] == $sub['category_id']) {
$parentName = $c['name_en'];
break;
}
}
?>
<tr>
<td class="ps-4">
<div class="fw-medium text-dark"><?= h($sub['name_en']) ?></div>
<small class="text-muted"><?= h($sub['name_ar']) ?></small>
</td>
<td>
<span class="badge bg-light text-dark border"><?= h($parentName) ?></span>
</td>
<td class="text-end pe-4">
<button class="btn btn-sm btn-outline-secondary me-1"
data-id="<?= $sub['id'] ?>"
data-category-id="<?= $sub['category_id'] ?>"
data-name-en="<?= h($sub['name_en']) ?>"
data-name-ar="<?= h($sub['name_ar']) ?>"
onclick="openEditSubcategoryModal(this)">
<i class="bi bi-pencil"></i> Edit
</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteSubcategory(<?= $sub['id'] ?>)">
<i class="bi bi-trash"></i> Delete
</button>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
<!-- Subcategory Modal -->
<div class="modal fade" id="subcategoryModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" action="/admin_subcategories.php" id="subcategoryForm">
<input type="hidden" name="action" id="sub_action" value="create_subcategory">
<input type="hidden" name="id" id="sub_id" value="">
<div class="modal-header">
<h5 class="modal-title" id="subcategoryModalTitle">Add New Subcategory</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Parent Category</label>
<select class="form-select" name="category_id" id="sub_category_id" required>
<option value="">Select...</option>
<?php foreach ($categories as $cat): ?>
<option value="<?= $cat['id'] ?>"><?= h($cat['name_en']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="mb-3">
<label class="form-label">Name (English)</label>
<div class="input-group">
<input type="text" class="form-control" name="name_en" id="sub_name_en" required>
<button class="btn btn-outline-secondary" type="button" onclick="translateText('sub_name_en', 'sub_name_ar', 'Arabic')" title="Translate to Arabic">
<i class="bi bi-translate"></i>
</button>
</div>
</div>
<div class="mb-3">
<label class="form-label">Name (Arabic)</label>
<div class="input-group">
<input type="text" class="form-control" name="name_ar" id="sub_name_ar" dir="rtl" required>
<button class="btn btn-outline-secondary" type="button" onclick="translateText('sub_name_ar', 'sub_name_en', 'English')" title="Translate to English">
<i class="bi bi-translate"></i>
</button>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
</div>
<!-- Delete Confirmation Form -->
<form method="post" action="/admin_subcategories.php" id="deleteForm">
<input type="hidden" name="action" id="deleteAction" value="">
<input type="hidden" name="id" id="deleteId" value="">
</form>
<script>
let subcategoryModal;
document.addEventListener('DOMContentLoaded', function() {
subcategoryModal = new bootstrap.Modal(document.getElementById('subcategoryModal'));
});
function openCreateSubcategoryModal() {
document.getElementById('subcategoryModalTitle').innerText = 'Add New Subcategory';
document.getElementById('sub_action').value = 'create_subcategory';
document.getElementById('sub_id').value = '';
document.getElementById('sub_category_id').value = '';
document.getElementById('sub_name_en').value = '';
document.getElementById('sub_name_ar').value = '';
subcategoryModal.show();
}
function openEditSubcategoryModal(btn) {
const id = btn.getAttribute('data-id');
const catId = btn.getAttribute('data-category-id');
const nameEn = btn.getAttribute('data-name-en');
const nameAr = btn.getAttribute('data-name-ar');
document.getElementById('subcategoryModalTitle').innerText = 'Edit Subcategory';
document.getElementById('sub_action').value = 'update_subcategory';
document.getElementById('sub_id').value = id;
document.getElementById('sub_category_id').value = catId;
document.getElementById('sub_name_en').value = nameEn;
document.getElementById('sub_name_ar').value = nameAr;
subcategoryModal.show();
}
function deleteSubcategory(id) {
if (confirm('Are you sure you want to delete this subcategory?')) {
document.getElementById('deleteAction').value = 'delete_subcategory';
document.getElementById('deleteId').value = id;
document.getElementById('deleteForm').submit();
}
}
</script>
<?php admin_render_footer(); ?>

212
admin_types.php Normal file
View File

@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/admin_layout.php';
library_bootstrap();
$errors = [];
// Handle POST request
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
$id = isset($_POST['id']) ? (int)$_POST['id'] : 0;
try {
if ($action === 'create_type') {
$nameEn = trim($_POST['name_en'] ?? '');
$nameAr = trim($_POST['name_ar'] ?? '');
if (!$nameEn || !$nameAr) {
throw new RuntimeException('Both English and Arabic names are required for Type.');
}
library_create_type($nameEn, $nameAr);
library_set_flash('success', 'Type created successfully.');
header('Location: /admin_types.php');
exit;
} elseif ($action === 'update_type') {
$nameEn = trim($_POST['name_en'] ?? '');
$nameAr = trim($_POST['name_ar'] ?? '');
if (!$id || !$nameEn || !$nameAr) {
throw new RuntimeException('ID, English name, and Arabic name are required.');
}
library_update_type($id, $nameEn, $nameAr);
library_set_flash('success', 'Type updated successfully.');
header('Location: /admin_types.php');
exit;
} elseif ($action === 'delete_type') {
if (!$id) {
throw new RuntimeException('Invalid Type ID.');
}
library_delete_type($id);
library_set_flash('success', 'Type deleted successfully.');
header('Location: /admin_types.php');
exit;
}
} catch (Throwable $exception) {
$errors[] = $exception->getMessage();
}
}
// Search Logic
$search = isset($_GET['search']) ? trim($_GET['search']) : '';
$types = library_get_types($search);
admin_render_header('Document Types', 'types');
?>
<!-- Page Content -->
<?php if ($errors): ?>
<div class="alert alert-danger"><?= h(implode(' ', $errors)) ?></div>
<?php endif; ?>
<div class="d-flex justify-content-between align-items-center mb-4">
<p class="text-secondary mb-0">Manage document types (e.g., Document, Film, etc.).</p>
<button class="btn btn-primary" onclick="openCreateTypeModal()">
<i class="bi bi-plus-lg me-1"></i> Add New Type
</button>
</div>
<!-- Search Bar -->
<div class="card shadow-sm border-0 mb-4">
<div class="card-body">
<form method="get" action="/admin_types.php" class="row g-2 align-items-center">
<div class="col-auto flex-grow-1">
<input type="text" name="search" class="form-control" placeholder="Search types..." value="<?= h($search) ?>">
</div>
<div class="col-auto">
<button type="submit" class="btn btn-outline-primary">
<i class="bi bi-search"></i> Search
</button>
<?php if ($search): ?>
<a href="/admin_types.php" class="btn btn-outline-secondary">Clear</a>
<?php endif; ?>
</div>
</form>
</div>
</div>
<div class="card shadow-sm border-0">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th class="ps-4">Name</th>
<th class="text-end pe-4">Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($types)): ?>
<tr><td colspan="2" class="text-center py-5 text-muted">No types found.</td></tr>
<?php else: ?>
<?php foreach ($types as $type): ?>
<tr>
<td class="ps-4">
<div class="fw-medium text-dark"><?= h($type['name_en']) ?></div>
<small class="text-muted"><?= h($type['name_ar']) ?></small>
</td>
<td class="text-end pe-4">
<button class="btn btn-sm btn-outline-secondary me-1"
data-id="<?= $type['id'] ?>"
data-name-en="<?= h($type['name_en']) ?>"
data-name-ar="<?= h($type['name_ar']) ?>"
onclick="openEditTypeModal(this)">
<i class="bi bi-pencil"></i> Edit
</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteType(<?= $type['id'] ?>)">
<i class="bi bi-trash"></i> Delete
</button>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
<!-- Type Modal -->
<div class="modal fade" id="typeModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" action="/admin_types.php" id="typeForm">
<input type="hidden" name="action" id="type_action" value="create_type">
<input type="hidden" name="id" id="type_id" value="">
<div class="modal-header">
<h5 class="modal-title" id="typeModalTitle">Add New Type</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Name (English)</label>
<div class="input-group">
<input type="text" class="form-control" name="name_en" id="type_name_en" required>
<button class="btn btn-outline-secondary" type="button" onclick="translateText('type_name_en', 'type_name_ar', 'Arabic')" title="Translate to Arabic">
<i class="bi bi-translate"></i>
</button>
</div>
</div>
<div class="mb-3">
<label class="form-label">Name (Arabic)</label>
<div class="input-group">
<input type="text" class="form-control" name="name_ar" id="type_name_ar" dir="rtl" required>
<button class="btn btn-outline-secondary" type="button" onclick="translateText('type_name_ar', 'type_name_en', 'English')" title="Translate to English">
<i class="bi bi-translate"></i>
</button>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
</div>
<!-- Delete Confirmation Form -->
<form method="post" action="/admin_types.php" id="deleteForm">
<input type="hidden" name="action" id="deleteAction" value="">
<input type="hidden" name="id" id="deleteId" value="">
</form>
<script>
let typeModal;
document.addEventListener('DOMContentLoaded', function() {
typeModal = new bootstrap.Modal(document.getElementById('typeModal'));
});
function openCreateTypeModal() {
document.getElementById('typeModalTitle').innerText = 'Add New Type';
document.getElementById('type_action').value = 'create_type';
document.getElementById('type_id').value = '';
document.getElementById('type_name_en').value = '';
document.getElementById('type_name_ar').value = '';
typeModal.show();
}
function openEditTypeModal(btn) {
const id = btn.getAttribute('data-id');
const nameEn = btn.getAttribute('data-name-en');
const nameAr = btn.getAttribute('data-name-ar');
document.getElementById('typeModalTitle').innerText = 'Edit Type';
document.getElementById('type_action').value = 'update_type';
document.getElementById('type_id').value = id;
document.getElementById('type_name_en').value = nameEn;
document.getElementById('type_name_ar').value = nameAr;
typeModal.show();
}
function deleteType(id) {
if (confirm('Are you sure you want to delete this type?')) {
document.getElementById('deleteAction').value = 'delete_type';
document.getElementById('deleteId').value = id;
document.getElementById('deleteForm').submit();
}
}
</script>
<?php admin_render_footer(); ?>

50
api/translate.php Normal file
View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../ai/LocalAIApi.php';
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
exit;
}
$input = json_decode(file_get_contents('php://input'), true);
$text = trim($input['text'] ?? '');
$targetLang = trim($input['target_lang'] ?? '');
if (!$text || !$targetLang) {
http_response_code(400);
echo json_encode(['error' => 'Missing text or target_lang']);
exit;
}
$response = LocalAIApi::createResponse([
'input' => [
['role' => 'system', 'content' => 'You are a helpful translator. Translate the user input accurately. Output only the translated text.'],
['role' => 'user', 'content' => "Translate the following text to {$targetLang}: \"{$text}\""],
],
], [
'poll_interval' => 2,
'poll_timeout' => 30
]);
if (empty($response['success'])) {
http_response_code(500);
error_log("AI Translation Failed: " . json_encode($response));
echo json_encode(['error' => 'AI translation failed', 'details' => $response['error'] ?? 'Unknown error']);
exit;
}
$translatedText = LocalAIApi::extractText($response);
// Clean up any accidental quotes if the model adds them despite instructions
$translatedText = trim($translatedText, "
'" );
echo json_encode(['translation' => $translatedText]);

View File

@ -1,403 +1,374 @@
:root {
--bg: #f5f6f7;
--surface: #ffffff;
--surface-muted: #f0f2f4;
--border: #d8dde3;
--border-strong: #bcc5cf;
--text: #111827;
--text-secondary: #5b6573;
--accent: #1f2937;
--accent-soft: #eef1f4;
--success: #0f766e;
--warning: #92400e;
--radius-sm: 0.5rem;
--radius-md: 0.75rem;
--radius-lg: 1rem;
--shadow-sm: 0 8px 24px rgba(15, 23, 42, 0.04);
--shadow-md: 0 18px 40px rgba(15, 23, 42, 0.06);
}
html {
scroll-behavior: smooth;
}
body { body {
background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab); background: var(--bg);
background-size: 400% 400%; color: var(--text);
animation: gradient 15s ease infinite; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #212529;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 14px;
margin: 0;
min-height: 100vh; min-height: 100vh;
} }
.main-wrapper { ::selection {
display: flex; background: #dbe2ea;
}
.app-shell {
min-height: 100vh;
}
.navbar {
backdrop-filter: blur(8px);
}
.brand-mark {
width: 2.25rem;
height: 2.25rem;
border-radius: 0.75rem;
display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 100vh; background: var(--accent);
width: 100%; color: #fff;
padding: 20px;
box-sizing: border-box;
position: relative;
z-index: 1;
}
@keyframes gradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.chat-container {
width: 100%;
max-width: 600px;
background: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 20px;
display: flex;
flex-direction: column;
height: 85vh;
box-shadow: 0 20px 40px rgba(0,0,0,0.2);
backdrop-filter: blur(15px);
-webkit-backdrop-filter: blur(15px);
overflow: hidden;
}
.chat-header {
padding: 1.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
background: rgba(255, 255, 255, 0.5);
font-weight: 700; font-weight: 700;
font-size: 1.1rem; letter-spacing: 0.04em;
display: flex;
justify-content: space-between;
align-items: center;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
.message {
max-width: 85%;
padding: 0.85rem 1.1rem;
border-radius: 16px;
line-height: 1.5;
font-size: 0.95rem;
box-shadow: 0 4px 15px rgba(0,0,0,0.05);
animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.message.visitor {
align-self: flex-end;
background: linear-gradient(135deg, #212529 0%, #343a40 100%);
color: #fff;
border-bottom-right-radius: 4px;
}
.message.bot {
align-self: flex-start;
background: #ffffff;
color: #212529;
border-bottom-left-radius: 4px;
}
.chat-input-area {
padding: 1.25rem;
background: rgba(255, 255, 255, 0.5);
border-top: 1px solid rgba(0, 0, 0, 0.05);
}
.chat-input-area form {
display: flex;
gap: 0.75rem;
}
.chat-input-area input {
flex: 1;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 12px;
padding: 0.75rem 1rem;
outline: none;
background: rgba(255, 255, 255, 0.9);
transition: all 0.3s ease;
}
.chat-input-area input:focus {
border-color: #23a6d5;
box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2);
}
.chat-input-area button {
background: #212529;
color: #fff;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 12px;
cursor: pointer;
font-weight: 600;
transition: all 0.3s ease;
}
.chat-input-area button:hover {
background: #000;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
}
/* Background Animations */
.bg-animations {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
overflow: hidden;
pointer-events: none;
}
.blob {
position: absolute;
width: 500px;
height: 500px;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
filter: blur(80px);
animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1);
}
.blob-1 {
top: -10%;
left: -10%;
background: rgba(238, 119, 82, 0.4);
}
.blob-2 {
bottom: -10%;
right: -10%;
background: rgba(35, 166, 213, 0.4);
animation-delay: -7s;
width: 600px;
height: 600px;
}
.blob-3 {
top: 40%;
left: 30%;
background: rgba(231, 60, 126, 0.3);
animation-delay: -14s;
width: 450px;
height: 450px;
}
@keyframes move {
0% { transform: translate(0, 0) rotate(0deg) scale(1); }
33% { transform: translate(150px, 100px) rotate(120deg) scale(1.1); }
66% { transform: translate(-50px, 200px) rotate(240deg) scale(0.9); }
100% { transform: translate(0, 0) rotate(360deg) scale(1); }
}
.header-link {
font-size: 14px;
color: #fff;
text-decoration: none;
background: rgba(0, 0, 0, 0.2);
padding: 0.5rem 1rem;
border-radius: 8px;
transition: all 0.3s ease;
}
.header-link:hover {
background: rgba(0, 0, 0, 0.4);
text-decoration: none;
}
/* Admin Styles */
.admin-container {
max-width: 900px;
margin: 3rem auto;
padding: 2.5rem;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 24px;
box-shadow: 0 20px 50px rgba(0,0,0,0.15);
border: 1px solid rgba(255, 255, 255, 0.4);
position: relative;
z-index: 1;
}
.admin-container h1 {
margin-top: 0;
color: #212529;
font-weight: 800;
}
.table {
width: 100%;
border-collapse: separate;
border-spacing: 0 8px;
margin-top: 1.5rem;
}
.table th {
background: transparent;
border: none;
padding: 1rem;
color: #6c757d;
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 1px;
}
.table td {
background: #fff;
padding: 1rem;
border: none;
}
.table tr td:first-child { border-radius: 12px 0 0 12px; }
.table tr td:last-child { border-radius: 0 12px 12px 0; }
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
font-size: 0.9rem; font-size: 0.9rem;
} }
.form-control { .brand-title {
width: 100%; font-weight: 700;
padding: 0.75rem 1rem; color: var(--text);
border: 1px solid rgba(0, 0, 0, 0.1); line-height: 1.1;
border-radius: 12px;
background: #fff;
transition: all 0.3s ease;
box-sizing: border-box;
} }
.form-control:focus { .nav-link {
outline: none; color: var(--text-secondary);
border-color: #23a6d5; font-weight: 500;
box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1); border-radius: var(--radius-sm);
padding-inline: 0.85rem !important;
} }
.header-container { .nav-link.active,
display: flex; .nav-link:hover,
justify-content: space-between; .nav-link:focus {
color: var(--text);
background: var(--accent-soft);
}
.hero-surface,
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
}
.hero-surface {
padding: clamp(1.5rem, 2vw, 2rem);
}
.panel {
padding: 1.35rem;
}
.eyebrow,
.section-kicker {
display: inline-flex;
align-items: center; align-items: center;
gap: 0.35rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.72rem;
font-weight: 700;
margin-bottom: 0.9rem;
} }
.header-links { .display-6,
.h3,
.h4,
.h5 {
letter-spacing: -0.03em;
}
.lead {
font-size: 1.04rem;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.9rem;
}
.metric-grid-compact {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.metric-card,
.recent-card {
background: var(--surface-muted);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 1rem;
}
.metric-card {
display: flex; display: flex;
flex-direction: column;
gap: 0.25rem;
}
.metric-value {
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.04em;
}
.metric-label {
color: var(--text-secondary);
font-size: 0.9rem;
}
.compact-list,
.compact-list-numbered {
color: var(--text-secondary);
display: grid;
gap: 0.65rem;
padding-left: 1.2rem;
}
.compact-list {
padding-left: 1rem;
}
.compact-list li::marker,
.compact-list-numbered li::marker {
color: var(--text);
}
.link-arrow,
.back-link {
color: var(--text);
font-weight: 600;
text-decoration: none;
}
.link-arrow:hover,
.back-link:hover {
color: #000;
}
.form-control,
.form-select {
border-color: var(--border-strong);
padding: 0.72rem 0.85rem;
border-radius: var(--radius-sm);
background: #fff;
}
.form-control:focus,
.form-select:focus,
.btn:focus,
.nav-link:focus,
.btn-close:focus {
box-shadow: 0 0 0 0.2rem rgba(31, 41, 55, 0.12);
border-color: #9aa5b1;
}
.form-text,
.text-secondary {
color: var(--text-secondary) !important;
}
.btn {
border-radius: 0.65rem;
padding: 0.7rem 1rem;
font-weight: 600;
}
.btn-dark {
background: var(--accent);
border-color: var(--accent);
}
.btn-dark:hover,
.btn-dark:focus {
background: #111827;
border-color: #111827;
}
.btn-outline-secondary {
color: var(--text);
border-color: var(--border-strong);
}
.btn-outline-secondary:hover,
.btn-outline-secondary:focus {
background: var(--accent-soft);
color: var(--text);
border-color: var(--border-strong);
}
.badge {
border-radius: 999px;
font-weight: 600;
letter-spacing: 0.01em;
padding: 0.55em 0.75em;
}
.text-bg-light {
background: var(--accent-soft) !important;
color: var(--text) !important;
border: 1px solid var(--border);
}
.empty-panel {
background: var(--surface);
}
.empty-icon {
width: 3rem;
height: 3rem;
border-radius: 1rem;
background: var(--surface-muted);
border: 1px solid var(--border);
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 1.1rem;
}
.table > :not(caption) > * > * {
padding-block: 0.95rem;
border-bottom-color: var(--border);
}
.table thead th {
color: var(--text-secondary);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.reader-panel {
padding-bottom: 1rem;
}
.reader-frame-wrap {
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
background: #d1d5db;
}
.reader-frame {
width: 100%;
min-height: 70vh;
border: 0;
background: #fff;
}
.reader-lock,
.summary-box {
background: var(--surface-muted);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 1rem;
}
.summary-box {
white-space: pre-line;
line-height: 1.7;
}
.summary-box-muted {
color: var(--text-secondary);
}
.description-stack {
display: grid;
gap: 1rem; gap: 1rem;
} }
.admin-card { .recent-card {
background: rgba(255, 255, 255, 0.6); display: block;
padding: 2rem; height: 100%;
border-radius: 20px; transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
border: 1px solid rgba(255, 255, 255, 0.5);
margin-bottom: 2.5rem;
box-shadow: 0 10px 30px rgba(0,0,0,0.05);
} }
.admin-card h3 { .recent-card:hover,
margin-top: 0; .recent-card:focus {
margin-bottom: 1.5rem; transform: translateY(-2px);
font-weight: 700; box-shadow: var(--shadow-md);
border-color: var(--border-strong);
} }
.btn-delete { .toast {
background: #dc3545; min-width: 280px;
color: white; border-radius: 0.85rem;
border: none; overflow: hidden;
padding: 0.25rem 0.5rem;
border-radius: 4px;
cursor: pointer;
} }
.btn-add { .toast-stack {
background: #212529; z-index: 1090;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
margin-top: 1rem;
} }
.btn-save { footer a {
background: #0088cc; color: var(--text);
color: white;
border: none;
padding: 0.8rem 1.5rem;
border-radius: 12px;
cursor: pointer;
font-weight: 600;
width: 100%;
transition: all 0.3s ease;
} }
.webhook-url { @media (max-width: 991.98px) {
font-size: 0.85em; .metric-grid {
color: #555; grid-template-columns: 1fr;
margin-top: 0.5rem;
} }
.history-table-container { .reader-frame {
overflow-x: auto; min-height: 60vh;
background: rgba(255, 255, 255, 0.4); }
padding: 1rem;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.3);
} }
.history-table { /* Flipbook Custom Styles */
width: 100%; #flipbook-wrapper {
background: #333;
box-shadow: inset 0 0 30px rgba(0,0,0,0.6);
} }
.history-table-time { #flipbook .page {
width: 15%; background-color: #fff;
white-space: nowrap; /* Soft border for realism */
font-size: 0.85em; border-right: 1px solid #ddd;
color: #555;
} }
.history-table-user { #flipbook-toolbar button {
width: 35%; transition: all 0.2s ease;
background: rgba(255, 255, 255, 0.3);
border-radius: 8px;
padding: 8px;
} }
.history-table-ai { #flipbook-toolbar button:hover {
width: 50%; transform: scale(1.1);
background: rgba(255, 255, 255, 0.5); color: var(--accent);
border-radius: 8px;
padding: 8px;
} }
.no-messages { #flipbook-toolbar button:active {
text-align: center; transform: scale(0.95);
color: #777;
} }

View File

@ -1,39 +1,17 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const chatForm = document.getElementById('chat-form'); document.querySelectorAll('.toast').forEach((toastNode) => {
const chatInput = document.getElementById('chat-input'); const toast = new bootstrap.Toast(toastNode);
const chatMessages = document.getElementById('chat-messages'); toast.show();
const appendMessage = (text, sender) => {
const msgDiv = document.createElement('div');
msgDiv.classList.add('message', sender);
msgDiv.textContent = text;
chatMessages.appendChild(msgDiv);
chatMessages.scrollTop = chatMessages.scrollHeight;
};
chatForm.addEventListener('submit', async (e) => {
e.preventDefault();
const message = chatInput.value.trim();
if (!message) return;
appendMessage(message, 'visitor');
chatInput.value = '';
try {
const response = await fetch('api/chat.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message })
}); });
const data = await response.json();
// Artificial delay for realism document.querySelectorAll('[data-file-input]').forEach((input) => {
setTimeout(() => { input.addEventListener('change', () => {
appendMessage(data.reply, 'bot'); const help = input.closest('.col-12')?.querySelector('.form-text');
}, 500); if (!help) {
} catch (error) { return;
console.error('Error:', error);
appendMessage("Sorry, something went wrong. Please try again.", 'bot');
} }
const fileName = input.files && input.files[0] ? input.files[0].name : 'Supported: PDF, TXT, DOC, DOCX, PPT, PPTX. Max 12 MB.';
help.textContent = fileName;
});
}); });
}); });

View File

@ -0,0 +1,23 @@
CREATE TABLE IF NOT EXISTS library_documents (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
title_en VARCHAR(255) DEFAULT NULL,
title_ar VARCHAR(255) DEFAULT NULL,
author_name VARCHAR(255) DEFAULT NULL,
document_language ENUM('en', 'ar', 'bilingual') NOT NULL DEFAULT 'bilingual',
visibility ENUM('public', 'private') NOT NULL DEFAULT 'public',
document_type VARCHAR(50) NOT NULL DEFAULT 'pdf',
file_name VARCHAR(255) DEFAULT NULL,
file_path VARCHAR(255) DEFAULT NULL,
file_size_kb INT UNSIGNED DEFAULT NULL,
description_en TEXT DEFAULT NULL,
description_ar TEXT DEFAULT NULL,
summary_text TEXT DEFAULT NULL,
tags VARCHAR(255) DEFAULT NULL,
is_featured TINYINT(1) NOT NULL DEFAULT 0,
view_count INT UNSIGNED NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_library_visibility_created (visibility, created_at),
KEY idx_library_language_visibility (document_language, visibility)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@ -0,0 +1,8 @@
ALTER TABLE library_documents
ADD COLUMN category VARCHAR(100) DEFAULT NULL,
ADD COLUMN category_ar VARCHAR(100) DEFAULT NULL,
ADD COLUMN sub_category VARCHAR(100) DEFAULT NULL,
ADD COLUMN sub_category_ar VARCHAR(100) DEFAULT NULL,
ADD COLUMN allow_download TINYINT(1) NOT NULL DEFAULT 0,
ADD COLUMN allow_print TINYINT(1) NOT NULL DEFAULT 0,
ADD COLUMN allow_copy TINYINT(1) NOT NULL DEFAULT 0;

View File

@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS library_categories (
id INT AUTO_INCREMENT PRIMARY KEY,
name_en VARCHAR(255) NOT NULL,
name_ar VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS library_subcategories (
id INT AUTO_INCREMENT PRIMARY KEY,
category_id INT NOT NULL,
name_en VARCHAR(255) NOT NULL,
name_ar VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (category_id) REFERENCES library_categories(id) ON DELETE CASCADE
);
ALTER TABLE library_documents ADD COLUMN IF NOT EXISTS category_id INT NULL;
ALTER TABLE library_documents ADD COLUMN IF NOT EXISTS subcategory_id INT NULL;

View File

@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS library_types (
id INT AUTO_INCREMENT PRIMARY KEY,
name_en VARCHAR(255) NOT NULL,
name_ar VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@ -0,0 +1,15 @@
ALTER TABLE library_documents
ADD COLUMN cover_image_path VARCHAR(255) DEFAULT NULL,
ADD COLUMN publisher VARCHAR(255) DEFAULT NULL,
ADD COLUMN publish_year INT DEFAULT NULL,
ADD COLUMN country VARCHAR(100) DEFAULT NULL,
ADD COLUMN type_id INT UNSIGNED DEFAULT NULL,
ADD COLUMN page_count INT DEFAULT NULL,
ADD COLUMN summary_en TEXT DEFAULT NULL,
ADD COLUMN summary_ar TEXT DEFAULT NULL;
-- Add foreign key for type_id if library_types exists (it should from prev migration)
-- We use a safe procedure to add the FK only if the table exists to avoid strict dependency order failures in some setups,
-- but standard SQL is fine here assuming 004 ran.
-- However, strict SQL mode might complain if we don't index it.
ALTER TABLE library_documents ADD INDEX idx_library_type (type_id);

View File

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

330
document.php Normal file
View File

@ -0,0 +1,330 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/layout.php';
library_bootstrap();
$documentId = isset($_GET['id']) ? (int) $_GET['id'] : 0;
$context = ($_GET['context'] ?? '') === 'admin' ? 'admin' : 'public';
$publicOnly = $context !== 'admin';
$document = $documentId > 0 ? library_fetch_document($documentId, $publicOnly) : null;
if (!$document) {
http_response_code(404);
library_render_header('Document not found', 'The requested library document could not be found.', $context === 'admin' ? 'admin' : 'catalog');
?>
<section class="panel empty-panel text-center py-5">
<div class="empty-icon mb-3">?</div>
<h1 class="h4">Document not found</h1>
<p class="text-secondary mb-4">This item is unavailable or private.</p>
<a class="btn btn-dark" href="<?= $context === 'admin' ? '/admin.php' : '/index.php' ?>">Go back</a>
</section>
<?php
library_render_footer();
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'generate_summary') {
$result = library_generate_summary((int) $document['id']);
library_set_flash($result['success'] ? 'success' : 'warning', $result['message']);
header('Location: /document.php?id=' . (int) $document['id'] . ($context === 'admin' ? '&context=admin' : '') . '#summary-card');
exit;
}
if ($context !== 'admin') {
library_increment_views((int) $document['id']);
$document = library_fetch_document((int) $document['id'], true) ?: $document;
}
library_render_header(
(string) ($document['title_en'] ?: $document['title_ar'] ?: 'Document detail'),
'Read a library document online, review metadata, and generate a bilingual AI summary from saved excerpts.',
$context === 'admin' ? 'admin' : 'catalog'
);
?>
<section class="mb-4">
<a class="back-link" href="<?= $context === 'admin' ? '/admin.php' : '/index.php' ?>"> Back to <?= $context === 'admin' ? 'Admin Studio' : 'catalog' ?></a>
</section>
<section class="row g-4 align-items-start">
<div class="col-xl-8">
<div class="panel mb-4">
<div class="d-flex flex-wrap justify-content-between gap-3 mb-3">
<div>
<?php if (!empty($document['title_en'])): ?>
<h1 class="display-6 mb-1"><?= h((string) $document['title_en']) ?></h1>
<?php endif; ?>
<?php if (!empty($document['title_ar'])): ?>
<div class="lead text-secondary" dir="rtl"><?= h((string) $document['title_ar']) ?></div>
<?php endif; ?>
</div>
<div class="d-flex flex-wrap gap-2 align-content-start">
<span class="badge text-bg-light"><?= h(library_language_label((string) $document['document_language'])) ?></span>
<span class="badge text-bg-light"><?= h(library_visibility_label((string) $document['visibility'])) ?></span>
<span class="badge text-bg-light"><?= h(library_document_type_label((string) $document['document_type'])) ?></span>
</div>
</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'] ?: '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>
</div>
<div class="panel reader-panel">
<div class="d-flex justify-content-between align-items-center gap-3 mb-3">
<div>
<div class="section-kicker">Online reader</div>
<h2 class="h4 mb-0">Read in the browser</h2>
</div>
<?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>
<?php endif; ?>
<?php endif; ?>
</div>
<?php if ($document['visibility'] === 'private' && $context !== 'admin'): ?>
<div class="reader-lock">
<h3 class="h5 mb-2">Private item</h3>
<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)): ?>
<!-- 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>
<p class="text-secondary mb-3">This file type is stored successfully, but inline reading is optimized for PDF in this first slice.</p>
<a class="btn btn-dark" href="<?= h(library_file_url((string) $document['file_path'])) ?>" target="_blank" rel="noopener">Download / open file</a>
</div>
<?php else: ?>
<div class="reader-lock">
<h3 class="h5 mb-2">No file attached</h3>
<p class="text-secondary mb-0">Upload a file from the Admin Studio to enable reading.</p>
</div>
<?php endif; ?>
</div>
</div>
<div class="col-xl-4">
<div class="panel mb-4" id="summary-card">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div>
<div class="section-kicker">AI summary</div>
<h2 class="h4 mb-0">Bilingual quick summary</h2>
</div>
<form method="post" action="/document.php?id=<?= h((string) $document['id']) ?><?= $context === 'admin' ? '&context=admin' : '' ?>#summary-card">
<input type="hidden" name="action" value="generate_summary">
<button class="btn btn-dark btn-sm" type="submit"><?= !empty($document['summary_text']) ? 'Regenerate' : 'Generate' ?></button>
</form>
</div>
<?php if (!empty($document['summary_text'])): ?>
<div class="summary-box"><?= nl2br(h((string) $document['summary_text'])) ?></div>
<?php else: ?>
<div class="summary-box summary-box-muted">No AI summary yet. Use the button above after adding a strong Arabic or English excerpt in the admin upload form.</div>
<?php endif; ?>
</div>
<div class="panel mb-4">
<div class="section-kicker">Metadata</div>
<h2 class="h5 mb-3">Catalog notes</h2>
<dl class="row small gy-2 mb-0">
<dt class="col-4">Published</dt>
<dd class="col-8 mb-0"><?= h(date('M d, Y', strtotime((string) $document['created_at']))) ?></dd>
<dt class="col-4">Tags</dt>
<dd class="col-8 mb-0"><?= h((string) ($document['tags'] ?: '—')) ?></dd>
<dt class="col-4">Size</dt>
<dd class="col-8 mb-0"><?= h((string) ($document['file_size_kb'] ?: 0)) ?> KB</dd>
</dl>
</div>
<div class="panel">
<div class="section-kicker">Descriptions</div>
<h2 class="h5 mb-3">Source text used by AI</h2>
<div class="description-stack">
<div>
<div class="small text-uppercase text-secondary mb-2">English</div>
<p class="mb-0 text-secondary"><?= h((string) ($document['description_en'] ?: 'No English excerpt yet.')) ?></p>
</div>
<div>
<div class="small text-uppercase text-secondary mb-2">العربية</div>
<p class="mb-0 text-secondary" dir="rtl"><?= h((string) ($document['description_ar'] ?: 'لا يوجد مقتطف عربي حتى الآن.')) ?></p>
</div>
</div>
</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();

144
includes/admin_layout.php Normal file
View File

@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/library.php';
function admin_render_header(string $title, string $activePage = 'dashboard'): void {
library_bootstrap();
// Get flashes and clear them
$flashes = [];
if (isset($_SESSION['library_flash'])) {
$flashes = $_SESSION['library_flash'];
unset($_SESSION['library_flash']);
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= h($title) ?> · Admin Studio</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<style>
.admin-sidebar {
width: 260px;
height: 100vh;
position: sticky;
top: 0;
background: #fff;
border-right: 1px solid #dee2e6;
padding: 20px;
}
.nav-link {
color: #333;
margin-bottom: 0.25rem;
}
.nav-link:hover {
background-color: #f8f9fa;
}
.nav-link.active {
background-color: #e9ecef;
color: #0d6efd !important;
font-weight: 500;
}
</style>
</head>
<body class="bg-light">
<div class="d-flex">
<aside class="admin-sidebar d-flex flex-column">
<h4 class="mb-4 text-primary fw-bold"><i class="bi bi-grid-fill me-2"></i>Admin Panel</h4>
<nav class="nav flex-column flex-grow-1">
<a class="nav-link rounded <?= $activePage === 'dashboard' ? 'active' : '' ?>" href="/admin.php">
<i class="bi bi-speedometer2 me-2"></i> Dashboard
</a>
<a class="nav-link rounded <?= $activePage === 'documents' ? 'active' : '' ?>" href="/admin_documents.php">
<i class="bi bi-folder2-open me-2"></i> Material Entry
</a>
<div class="my-2 border-top"></div>
<a class="nav-link rounded <?= $activePage === 'categories' ? 'active' : '' ?>" href="/admin_categories.php">
<i class="bi bi-tags me-2"></i> Categories
</a>
<a class="nav-link rounded <?= $activePage === 'subcategories' ? 'active' : '' ?>" href="/admin_subcategories.php">
<i class="bi bi-diagram-3 me-2"></i> Subcategories
</a>
<a class="nav-link rounded <?= $activePage === 'types' ? 'active' : '' ?>" href="/admin_types.php">
<i class="bi bi-file-earmark-text me-2"></i> Types
</a>
</nav>
<div class="mt-auto pt-3 border-top">
<a class="nav-link text-secondary rounded" href="/index.php">
<i class="bi bi-box-arrow-right me-2"></i> Return to Site
</a>
</div>
</aside>
<main class="flex-grow-1 p-4" style="min-width: 0;">
<header class="mb-4 border-bottom pb-2">
<h2 class="h3"><?= h($title) ?></h2>
</header>
<?php foreach ($flashes as $flash): ?>
<div class="alert alert-<?= $flash['type'] === 'error' ? 'danger' : 'success' ?> alert-dismissible fade show shadow-sm" role="alert">
<?= h($flash['message']) ?>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<?php endforeach; ?>
<?php
}
function admin_render_footer(): void {
?>
</main>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Shared Admin JS - Translation Helper
async function translateText(sourceId, targetId, targetLang) {
const source = document.getElementById(sourceId);
const target = document.getElementById(targetId);
const text = source.value.trim();
if (!text) {
alert('Please enter text to translate.');
return;
}
const originalPlaceholder = target.placeholder;
target.placeholder = 'Translating...';
const originalOpacity = target.style.opacity;
target.style.opacity = '0.7';
try {
// Simple mock translation for demo purposes or real API if configured
// In a real scenario, this would call /api/translate.php
// For now, let's assume /api/translate.php exists (it does)
const response = await fetch('/api/translate.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: text, target_lang: targetLang })
});
if (!response.ok) throw new Error('Translation failed');
const data = await response.json();
if (data.translation) {
target.value = data.translation;
} else if (data.error) {
alert('Translation error: ' + data.error);
}
} catch (e) {
console.error(e);
alert('Translation failed. Please try again.');
} finally {
target.placeholder = originalPlaceholder;
target.style.opacity = originalOpacity;
}
}
</script>
</body>
</html>
<?php
}
?>

103
includes/layout.php Normal file
View File

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/library.php';
function library_active_nav(string $current, string $expected): string
{
return $current === $expected ? 'active' : '';
}
function library_render_header(string $pageTitle, string $pageDescription, string $activeNav = 'catalog'): void
{
$project = library_project_meta();
$metaDescription = $pageDescription !== '' ? $pageDescription : $project['description'];
$projectImageUrl = $project['image'];
$fullTitle = $pageTitle . ' · ' . $project['name'];
$flashes = library_get_flashes();
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= h($fullTitle) ?></title>
<meta name="description" content="<?= h($metaDescription) ?>">
<meta property="og:title" content="<?= h($fullTitle) ?>">
<meta property="og:description" content="<?= h($metaDescription) ?>">
<meta property="twitter:title" content="<?= h($fullTitle) ?>">
<meta property="twitter:description" content="<?= h($metaDescription) ?>">
<?php if ($projectImageUrl): ?>
<meta property="og:image" content="<?= h($projectImageUrl) ?>">
<meta property="twitter:image" content="<?= h($projectImageUrl) ?>">
<?php endif; ?>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel="stylesheet" href="/assets/css/custom.css?v=<?= time() ?>">
</head>
<body>
<div class="app-shell">
<nav class="navbar navbar-expand-lg border-bottom border-subtle bg-white sticky-top">
<div class="container">
<a class="navbar-brand d-flex align-items-center gap-2" href="/index.php">
<span class="brand-mark">NL</span>
<span>
<span class="d-block brand-title">Nabd Library</span>
<small class="text-secondary">Arabic · English e-library</small>
</span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#libraryNav" aria-controls="libraryNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="libraryNav">
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2">
<li class="nav-item"><a class="nav-link <?= library_active_nav($activeNav, 'catalog') ?>" href="/index.php">Catalog</a></li>
<li class="nav-item"><a class="nav-link <?= library_active_nav($activeNav, 'admin') ?>" href="/admin.php">Admin Studio</a></li>
</ul>
</div>
</div>
</nav>
<main class="pb-5">
<div class="container py-4 py-lg-5">
<?php if ($flashes): ?>
<div class="toast-stack position-fixed top-0 end-0 p-3">
<?php foreach ($flashes as $flash): ?>
<div class="toast border-0 shadow-sm" role="status" aria-live="polite" aria-atomic="true" data-bs-delay="4500">
<div class="toast-header text-bg-<?= h($flash['type'] === 'danger' ? 'danger' : ($flash['type'] === 'warning' ? 'warning' : 'dark')) ?> border-0">
<strong class="me-auto">Library update</strong>
<button type="button" class="btn-close btn-close-white ms-2 mb-1" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body bg-white"><?= h($flash['message']) ?></div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php
}
function library_render_footer(): void
{
?>
</div>
</main>
<footer class="border-top border-subtle bg-white">
<div class="container py-4 d-flex flex-column flex-lg-row justify-content-between gap-3 small text-secondary">
<div>
<div class="fw-semibold text-dark mb-1">Bilingual reader MVP</div>
<div>Upload documents, publish public/private titles, read online, and request AI summaries.</div>
</div>
<div class="text-lg-end">
<div><a class="text-decoration-none" href="/admin.php">Open Admin Studio</a></div>
<div><a class="text-decoration-none" href="/index.php">Browse public catalog</a></div>
</div>
</div>
</footer>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<script src="/assets/js/main.js?v=<?= time() ?>"></script>
</body>
</html>
<?php
}

807
includes/library.php Normal file
View File

@ -0,0 +1,807 @@
<?php
declare(strict_types=1);
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
require_once __DIR__ . '/../db/config.php';
require_once __DIR__ . '/../ai/LocalAIApi.php';
function library_bootstrap(): void
{
static $booted = false;
if ($booted) {
return;
}
$migrationPath = __DIR__ . '/../db/migrations/001_library_documents.sql';
if (is_file($migrationPath)) {
$sql = file_get_contents($migrationPath);
if (is_string($sql) && trim($sql) !== '') {
db()->exec($sql);
}
}
// Run new migrations if needed
$migration2Path = __DIR__ . '/../db/migrations/002_add_library_metadata.sql';
if (is_file($migration2Path)) {
// Simple check if columns exist
$exists = db()->query("SHOW COLUMNS FROM library_documents LIKE 'category_ar'")->fetch();
if (!$exists) {
$sql = file_get_contents($migration2Path);
db()->exec($sql);
}
}
$migration3Path = __DIR__ . '/../db/migrations/003_normalize_categories.sql';
if (is_file($migration3Path)) {
// Simple check if table exists
$exists = db()->query("SHOW TABLES LIKE 'library_categories'")->fetch();
if (!$exists) {
$sql = file_get_contents($migration3Path);
db()->exec($sql);
}
}
$migration4Path = __DIR__ . '/../db/migrations/004_create_types_table.sql';
if (is_file($migration4Path)) {
// Simple check if table exists
$exists = db()->query("SHOW TABLES LIKE 'library_types'")->fetch();
if (!$exists) {
$sql = file_get_contents($migration4Path);
db()->exec($sql);
}
}
$migration5Path = __DIR__ . '/../db/migrations/005_update_document_fields.sql';
if (is_file($migration5Path)) {
// Check if column exists
$exists = db()->query("SHOW COLUMNS FROM library_documents LIKE 'cover_image_path'")->fetch();
if (!$exists) {
$sql = file_get_contents($migration5Path);
db()->exec($sql);
}
}
$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);
}
$coverDir = __DIR__ . '/../uploads/covers';
if (!is_dir($coverDir)) {
mkdir($coverDir, 0775, true);
}
library_seed_demo_documents();
$booted = true;
}
function h(?string $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
function library_project_meta(): array
{
return [
'name' => $_SERVER['PROJECT_NAME'] ?? 'Nabd Library',
'description' => $_SERVER['PROJECT_DESCRIPTION'] ?? 'Bilingual electronic library for Arabic and English documents, online reading, and AI-assisted summaries.',
'image' => $_SERVER['PROJECT_IMAGE_URL'] ?? '',
];
}
function library_set_flash(string $type, string $message): void
{
$_SESSION['library_flash'][] = ['type' => $type, 'message' => $message];
}
function library_get_flashes(): array
{
$flashes = $_SESSION['library_flash'] ?? [];
unset($_SESSION['library_flash']);
return is_array($flashes) ? $flashes : [];
}
function library_seed_demo_documents(): void
{
$count = (int) (db()->query('SELECT COUNT(*) FROM library_documents')->fetchColumn() ?: 0);
if ($count > 0) {
return;
}
$pdfRelative = 'uploads/library/demo-library-guide.pdf';
$txtRelative = 'uploads/library/demo-bilingual-notes.txt';
$pdfAbsolute = __DIR__ . '/../' . $pdfRelative;
$txtAbsolute = __DIR__ . '/../' . $txtRelative;
}
function library_old(string $key, string $default = ''): string
{
return isset($_POST[$key]) ? trim((string) $_POST[$key]) : $default;
}
function library_document_type_label(string $type): string
{
$map = [
'pdf' => 'PDF reader',
'txt' => 'Text note',
'doc' => 'Word document',
'docx' => 'Word document',
'ppt' => 'PowerPoint',
'pptx' => 'PowerPoint',
];
return $map[strtolower($type)] ?? strtoupper($type);
}
function library_language_label(string $lang): string
{
$map = [
'en' => 'English',
'ar' => 'Arabic',
'bilingual' => 'Bilingual',
];
return $map[$lang] ?? 'Unknown';
}
function library_visibility_label(string $visibility): string
{
return $visibility === 'private' ? 'Private / login' : 'Public';
}
function library_allowed_extensions(): array
{
return [
'pdf' => 'PDF reader',
'txt' => 'Text note',
'doc' => 'Word document',
'docx' => 'Word document',
'ppt' => 'PowerPoint',
'pptx' => 'PowerPoint',
];
}
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
{
library_bootstrap();
$sql = 'SELECT * FROM library_categories';
$params = [];
if ($search !== '') {
$sql .= ' WHERE name_en LIKE ? OR name_ar LIKE ?';
$params[] = "%$search%";
$params[] = "%$search%";
}
$sql .= ' ORDER BY name_en ASC';
$stmt = db()->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll() ?: [];
}
function library_get_subcategories(?int $categoryId = null, string $search = ''): array
{
library_bootstrap();
$sql = 'SELECT * FROM library_subcategories WHERE 1=1';
$params = [];
if ($categoryId !== null) {
$sql .= ' AND category_id = ?';
$params[] = $categoryId;
}
if ($search !== '') {
$sql .= ' AND (name_en LIKE ? OR name_ar LIKE ?)';
$params[] = "%$search%";
$params[] = "%$search%";
}
$sql .= ' ORDER BY name_en ASC';
$stmt = db()->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll() ?: [];
}
function library_create_category(string $nameEn, string $nameAr): int
{
library_bootstrap();
$stmt = db()->prepare('INSERT INTO library_categories (name_en, name_ar) VALUES (?, ?)');
$stmt->execute([$nameEn, $nameAr]);
return (int) db()->lastInsertId();
}
function library_create_subcategory(int $categoryId, string $nameEn, string $nameAr): int
{
library_bootstrap();
$stmt = db()->prepare('INSERT INTO library_subcategories (category_id, name_en, name_ar) VALUES (?, ?, ?)');
$stmt->execute([$categoryId, $nameEn, $nameAr]);
return (int) db()->lastInsertId();
}
function library_get_category_by_id(int $id): ?array
{
library_bootstrap();
$stmt = db()->prepare('SELECT * FROM library_categories WHERE id = ?');
$stmt->execute([$id]);
return $stmt->fetch() ?: null;
}
function library_get_subcategory_by_id(int $id): ?array
{
library_bootstrap();
$stmt = db()->prepare('SELECT * FROM library_subcategories WHERE id = ?');
$stmt->execute([$id]);
return $stmt->fetch() ?: null;
}
function library_update_category(int $id, string $nameEn, string $nameAr): void
{
library_bootstrap();
$stmt = db()->prepare('UPDATE library_categories SET name_en = ?, name_ar = ? WHERE id = ?');
$stmt->execute([$nameEn, $nameAr, $id]);
}
function library_delete_category(int $id): void
{
library_bootstrap();
$stmt = db()->prepare('DELETE FROM library_categories WHERE id = ?');
$stmt->execute([$id]);
}
function library_update_subcategory(int $id, int $categoryId, string $nameEn, string $nameAr): void
{
library_bootstrap();
$stmt = db()->prepare('UPDATE library_subcategories SET category_id = ?, name_en = ?, name_ar = ? WHERE id = ?');
$stmt->execute([$categoryId, $nameEn, $nameAr, $id]);
}
function library_delete_subcategory(int $id): void
{
library_bootstrap();
$stmt = db()->prepare('DELETE FROM library_subcategories WHERE id = ?');
$stmt->execute([$id]);
}
// --- End Category Functions ---
// --- Type Functions ---
function library_get_types(string $search = ''): array
{
library_bootstrap();
$sql = 'SELECT * FROM library_types';
$params = [];
if ($search !== '') {
$sql .= ' WHERE name_en LIKE ? OR name_ar LIKE ?';
$params[] = "%$search%";
$params[] = "%$search%";
}
$sql .= ' ORDER BY name_en ASC';
$stmt = db()->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll() ?: [];
}
function library_create_type(string $nameEn, string $nameAr): int
{
library_bootstrap();
$stmt = db()->prepare('INSERT INTO library_types (name_en, name_ar) VALUES (?, ?)');
$stmt->execute([$nameEn, $nameAr]);
return (int) db()->lastInsertId();
}
function library_get_type_by_id(int $id): ?array
{
library_bootstrap();
$stmt = db()->prepare('SELECT * FROM library_types WHERE id = ?');
$stmt->execute([$id]);
return $stmt->fetch() ?: null;
}
function library_update_type(int $id, string $nameEn, string $nameAr): void
{
library_bootstrap();
$stmt = db()->prepare('UPDATE library_types SET name_en = ?, name_ar = ? WHERE id = ?');
$stmt->execute([$nameEn, $nameAr, $id]);
}
function library_delete_type(int $id): void
{
library_bootstrap();
$stmt = db()->prepare('DELETE FROM library_types WHERE id = ?');
$stmt->execute([$id]);
}
// --- End Type Functions ---
function library_fetch_documents(bool $publicOnly = false, array $filters = []): array
{
library_bootstrap();
$sql = 'SELECT d.*,
c.name_en as cat_en, c.name_ar as cat_ar,
sc.name_en as sub_en, sc.name_ar as sub_ar,
t.name_en as type_en, t.name_ar as type_ar
FROM library_documents d
LEFT JOIN library_categories c ON d.category_id = c.id
LEFT JOIN library_subcategories sc ON d.subcategory_id = sc.id
LEFT JOIN library_types t ON d.type_id = t.id
WHERE 1=1';
$params = [];
if ($publicOnly) {
$sql .= ' AND d.visibility = :visibility';
$params[':visibility'] = 'public';
}
$sql .= ' ORDER BY d.is_featured DESC, d.created_at DESC';
$stmt = db()->prepare($sql);
foreach ($params as $key => $value) {
$stmt->bindValue($key, $value);
}
$stmt->execute();
return $stmt->fetchAll() ?: [];
}
function library_fetch_document(int $id, bool $publicOnly = false): ?array
{
library_bootstrap();
$sql = 'SELECT d.*,
c.name_en as cat_en, c.name_ar as cat_ar,
sc.name_en as sub_en, sc.name_ar as sub_ar,
t.name_en as type_en, t.name_ar as type_ar
FROM library_documents d
LEFT JOIN library_categories c ON d.category_id = c.id
LEFT JOIN library_subcategories sc ON d.subcategory_id = sc.id
LEFT JOIN library_types t ON d.type_id = t.id
WHERE d.id = :id';
$params = [':id' => $id];
if ($publicOnly) {
$sql .= ' AND d.visibility = :visibility';
$params[':visibility'] = 'public';
}
$stmt = db()->prepare($sql);
foreach ($params as $key => $value) {
$stmt->bindValue($key, $value);
}
$stmt->execute();
return $stmt->fetch() ?: null;
}
function library_recent_documents(int $limit = 3, bool $publicOnly = false): array
{
library_bootstrap();
$sql = 'SELECT * FROM library_documents WHERE 1=1';
if ($publicOnly) {
$sql .= ' AND visibility = "public"';
}
$sql .= ' ORDER BY created_at DESC LIMIT ' . (int)$limit;
$stmt = db()->query($sql);
return $stmt ? $stmt->fetchAll() : [];
}
function library_catalog_metrics(): array
{
library_bootstrap();
$sql = 'SELECT
COUNT(*) AS total_count,
SUM(CASE WHEN visibility = "public" THEN 1 ELSE 0 END) AS public_count,
SUM(CASE WHEN visibility = "private" THEN 1 ELSE 0 END) AS private_count,
SUM(CASE WHEN summary_text IS NOT NULL THEN 1 ELSE 0 END) AS summarized_count
FROM library_documents';
$row = db()->query($sql)->fetch() ?: [];
return [
'total_count' => (int) ($row['total_count'] ?? 0),
'public_count' => (int) ($row['public_count'] ?? 0),
'private_count' => (int) ($row['private_count'] ?? 0),
'summarized_count' => (int) ($row['summarized_count'] ?? 0),
];
}
function library_handle_uploaded_file(array $file): array
{
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new RuntimeException('Please upload a document file.');
}
$originalName = (string) ($file['name'] ?? '');
$extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
$allowed = library_allowed_extensions();
if (!isset($allowed[$extension])) {
throw new RuntimeException('Unsupported file type.');
}
$size = (int) ($file['size'] ?? 0);
if ($size <= 0 || $size > 12 * 1024 * 1024) {
throw new RuntimeException('File must be smaller than 12 MB.');
}
$safeBase = preg_replace('/[^a-zA-Z0-9_-]+/', '-', pathinfo($originalName, PATHINFO_FILENAME)) ?: 'document';
$storedName = strtolower(date('YmdHis') . '-' . $safeBase . '-' . bin2hex(random_bytes(4)) . '.' . $extension);
$relativePath = 'uploads/library/' . $storedName;
$absolutePath = __DIR__ . '/../' . $relativePath;
if (!move_uploaded_file((string) $file['tmp_name'], $absolutePath)) {
throw new RuntimeException('Unable to save the uploaded file.');
}
return [
'file_name' => $originalName,
'file_path' => $relativePath,
'document_type' => $extension,
'file_size_kb' => (int) ceil($size / 1024),
];
}
function library_handle_cover_image(array $file): ?string
{
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
return null;
}
$originalName = (string) ($file['name'] ?? '');
$extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
$allowed = ['jpg', 'jpeg', 'png', 'webp', 'gif'];
if (!in_array($extension, $allowed)) {
throw new RuntimeException('Unsupported cover image type. Allowed: jpg, png, webp, gif.');
}
$size = (int) ($file['size'] ?? 0);
if ($size <= 0 || $size > 5 * 1024 * 1024) {
throw new RuntimeException('Cover image must be smaller than 5 MB.');
}
$safeBase = preg_replace('/[^a-zA-Z0-9_-]+/', '-', pathinfo($originalName, PATHINFO_FILENAME)) ?: 'cover';
$storedName = strtolower(date('YmdHis') . '-' . $safeBase . '-' . bin2hex(random_bytes(4)) . '.' . $extension);
$relativePath = 'uploads/covers/' . $storedName;
$absolutePath = __DIR__ . '/../' . $relativePath;
if (!move_uploaded_file((string) $file['tmp_name'], $absolutePath)) {
throw new RuntimeException('Unable to save the cover image.');
}
return $relativePath;
}
function library_create_document(array $payload, array $file, array $coverFile = []): int
{
library_bootstrap();
$titleEn = trim((string) ($payload['title_en'] ?? ''));
$titleAr = trim((string) ($payload['title_ar'] ?? ''));
// Process IDs
$categoryId = !empty($payload['category_id']) ? (int)$payload['category_id'] : null;
$subcategoryId = !empty($payload['subcategory_id']) ? (int)$payload['subcategory_id'] : null;
$typeId = !empty($payload['type_id']) ? (int)$payload['type_id'] : null;
// Fetch names for backward compatibility if needed, or just store IDs
$categoryName = '';
$categoryNameAr = '';
$subName = '';
$subNameAr = '';
if ($categoryId) {
$cat = library_get_category_by_id($categoryId);
if ($cat) {
$categoryName = $cat['name_en'];
$categoryNameAr = $cat['name_ar'];
}
}
if ($subcategoryId) {
$sub = library_get_subcategory_by_id($subcategoryId);
if ($sub) {
$subName = $sub['name_en'];
$subNameAr = $sub['name_ar'];
}
}
$visibility = (string) ($payload['visibility'] ?? 'public');
$allow_download = !empty($payload['allow_download']) ? 1 : 0;
$allow_print = !empty($payload['allow_print']) ? 1 : 0;
$allow_copy = !empty($payload['allow_copy']) ? 1 : 0;
// New Fields
$publisher = trim((string) ($payload['publisher'] ?? ''));
$publishYear = !empty($payload['publish_year']) ? (int)$payload['publish_year'] : null;
$author = trim((string) ($payload['author'] ?? ''));
$country = trim((string) ($payload['country'] ?? ''));
$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);
$stmt = db()->prepare('INSERT INTO library_documents (
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,
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,
:description_en, :description_ar
)');
$stmt->execute([
':title_en' => $titleEn ?: null,
':title_ar' => $titleAr ?: null,
':category' => $categoryName ?: null,
':category_ar' => $categoryNameAr ?: null,
':sub_category' => $subName ?: null,
':sub_category_ar' => $subNameAr ?: null,
':category_id' => $categoryId,
':subcategory_id' => $subcategoryId,
':visibility' => $visibility,
':document_type' => $fileData['document_type'],
':file_name' => $fileData['file_name'],
':file_path' => $fileData['file_path'],
':file_size_kb' => $fileData['file_size_kb'],
':allow_download' => $allow_download,
':allow_print' => $allow_print,
':allow_copy' => $allow_copy,
':cover_image_path' => $coverPath,
':publisher' => $publisher ?: null,
':publish_year' => $publishYear,
':author' => $author ?: null,
':country' => $country ?: null,
':type_id' => $typeId,
':page_count' => $pageCount,
':summary_en' => $summaryEn ?: null,
':summary_ar' => $summaryAr ?: null,
':description_en' => $descriptionEn ?: null,
':description_ar' => $descriptionAr ?: null,
]);
return (int) db()->lastInsertId();
}
function library_update_document(int $id, array $payload, array $file = [], array $coverFile = []): void
{
library_bootstrap();
// Fetch existing document to keep file if not replaced
$existing = library_fetch_document($id);
if (!$existing) {
throw new RuntimeException('Document not found.');
}
$titleEn = trim((string) ($payload['title_en'] ?? ''));
$titleAr = trim((string) ($payload['title_ar'] ?? ''));
// Process IDs
$categoryId = !empty($payload['category_id']) ? (int)$payload['category_id'] : null;
$subcategoryId = !empty($payload['subcategory_id']) ? (int)$payload['subcategory_id'] : null;
$typeId = !empty($payload['type_id']) ? (int)$payload['type_id'] : null;
$categoryName = '';
$categoryNameAr = '';
$subName = '';
$subNameAr = '';
if ($categoryId) {
$cat = library_get_category_by_id($categoryId);
if ($cat) {
$categoryName = $cat['name_en'];
$categoryNameAr = $cat['name_ar'];
}
}
if ($subcategoryId) {
$sub = library_get_subcategory_by_id($subcategoryId);
if ($sub) {
$subName = $sub['name_en'];
$subNameAr = $sub['name_ar'];
}
}
$visibility = (string) ($payload['visibility'] ?? 'public');
$allow_download = !empty($payload['allow_download']) ? 1 : 0;
$allow_print = !empty($payload['allow_print']) ? 1 : 0;
$allow_copy = !empty($payload['allow_copy']) ? 1 : 0;
$publisher = trim((string) ($payload['publisher'] ?? ''));
$publishYear = !empty($payload['publish_year']) ? (int)$payload['publish_year'] : null;
$author = trim((string) ($payload['author'] ?? ''));
$country = trim((string) ($payload['country'] ?? ''));
$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;
if (!empty($file['name'])) {
$fileData = library_handle_uploaded_file($file);
}
// Handle Cover Update
$coverPath = null;
if (!empty($coverFile['name'])) {
$coverPath = library_handle_cover_image($coverFile);
}
$sql = 'UPDATE library_documents SET
title_en = :title_en, title_ar = :title_ar,
category = :category, category_ar = :category_ar, sub_category = :sub_category, sub_category_ar = :sub_category_ar,
category_id = :category_id, subcategory_id = :subcategory_id,
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,
description_en = :description_en, description_ar = :description_ar';
$params = [
':title_en' => $titleEn ?: null,
':title_ar' => $titleAr ?: null,
':category' => $categoryName ?: null,
':category_ar' => $categoryNameAr ?: null,
':sub_category' => $subName ?: null,
':sub_category_ar' => $subNameAr ?: null,
':category_id' => $categoryId,
':subcategory_id' => $subcategoryId,
':visibility' => $visibility,
':allow_download' => $allow_download,
':allow_print' => $allow_print,
':allow_copy' => $allow_copy,
':publisher' => $publisher ?: null,
':publish_year' => $publishYear,
':author' => $author ?: null,
':country' => $country ?: null,
':type_id' => $typeId,
':page_count' => $pageCount,
':summary_en' => $summaryEn ?: null,
':summary_ar' => $summaryAr ?: null,
':description_en' => $descriptionEn ?: null,
':description_ar' => $descriptionAr ?: null,
':id' => $id,
];
if ($fileData) {
$sql .= ', document_type = :document_type, file_name = :file_name, file_path = :file_path, file_size_kb = :file_size_kb';
$params[':document_type'] = $fileData['document_type'];
$params[':file_name'] = $fileData['file_name'];
$params[':file_path'] = $fileData['file_path'];
$params[':file_size_kb'] = $fileData['file_size_kb'];
}
if ($coverPath) {
$sql .= ', cover_image_path = :cover_image_path';
$params[':cover_image_path'] = $coverPath;
}
$sql .= ' WHERE id = :id';
$stmt = db()->prepare($sql);
$stmt->execute($params);
}
function library_delete_document(int $id): void
{
library_bootstrap();
// Optionally delete files, but for safety we might keep them or delete them.
// For now, just delete DB record.
$stmt = db()->prepare('DELETE FROM library_documents WHERE id = ?');
$stmt->execute([$id]);
}

336
index.php
View File

@ -1,150 +1,194 @@
<?php <?php
declare(strict_types=1);
@ini_set('display_errors', '1');
@error_reporting(E_ALL);
@date_default_timezone_set('UTC');
$phpVersion = PHP_VERSION; declare(strict_types=1);
$now = date('Y-m-d H:i:s');
require_once __DIR__ . '/includes/layout.php';
library_bootstrap();
$query = trim((string) ($_GET['q'] ?? ''));
$language = trim((string) ($_GET['language'] ?? ''));
$documents = library_fetch_documents(true, ['q' => $query, 'language' => $language]);
$metrics = library_catalog_metrics();
$recentDocuments = library_recent_documents(3, true);
library_render_header(
'Digital Catalog',
'Browse a polished Arabic and English e-library with online reading, public/private publishing controls, and AI-ready summaries.',
'catalog'
);
?> ?>
<!doctype html> <section class="hero-surface mb-4 mb-lg-5">
<html lang="en"> <div class="row g-4 align-items-center">
<head> <div class="col-lg-7">
<meta charset="utf-8" /> <span class="eyebrow">Electronic library · Arabic + English</span>
<meta name="viewport" content="width=device-width, initial-scale=1" /> <h1 class="display-6 mb-3">A focused e-library for bilingual reading, controlled publishing, and AI-ready summaries.</h1>
<title>New Style</title> <p class="lead text-secondary mb-4">Readers can search the public shelf, open PDFs in-browser, and review concise AI summaries. Your content team can publish titles as public or private from one admin studio.</p>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-dark" href="#catalog-grid">Browse catalog</a>
<a class="btn btn-outline-secondary" href="/admin.php">Add documents</a>
</div>
</div>
<div class="col-lg-5">
<div class="panel h-100">
<div class="d-flex justify-content-between align-items-start gap-3 mb-4">
<div>
<div class="section-kicker">Live shelf snapshot</div>
<h2 class="h5 mb-1">What this first delivery includes</h2>
</div>
<span class="badge text-bg-light">MVP slice</span>
</div>
<div class="metric-grid">
<article class="metric-card">
<span class="metric-value"><?= h((string) $metrics['public_count']) ?></span>
<span class="metric-label">Public titles</span>
</article>
<article class="metric-card">
<span class="metric-value"><?= h((string) $metrics['private_count']) ?></span>
<span class="metric-label">Private titles</span>
</article>
<article class="metric-card">
<span class="metric-value"><?= h((string) $metrics['summarized_count']) ?></span>
<span class="metric-label">AI summaries</span>
</article>
</div>
<ul class="list-unstyled mb-0 mt-4 compact-list">
<li>Public catalog with search and language filters</li>
<li>Admin upload workflow with visibility control</li>
<li>Document detail view with embedded PDF reader</li>
</ul>
</div>
</div>
</div>
</section>
<section class="row g-4 mb-4 mb-lg-5">
<div class="col-lg-8">
<div class="panel h-100">
<div class="section-kicker">Public discovery</div>
<h2 class="h4 mb-3">Search the live collection</h2>
<form class="row g-3 align-items-end" method="get" action="/index.php">
<div class="col-md-7">
<label class="form-label" for="q">Keyword</label>
<input class="form-control" id="q" name="q" type="search" value="<?= h($query) ?>" placeholder="Title, author, tag, or excerpt">
</div>
<div class="col-md-3">
<label class="form-label" for="language">Language</label>
<select class="form-select" id="language" name="language">
<option value="">All shelves</option>
<option value="en" <?= $language === 'en' ? 'selected' : '' ?>>English</option>
<option value="ar" <?= $language === 'ar' ? 'selected' : '' ?>>Arabic</option>
<option value="bilingual" <?= $language === 'bilingual' ? 'selected' : '' ?>>Bilingual</option>
</select>
</div>
<div class="col-md-2 d-grid">
<button class="btn btn-dark" type="submit">Filter</button>
</div>
</form>
</div>
</div>
<div class="col-lg-4">
<div class="panel h-100">
<div class="section-kicker">Visibility rules</div>
<h2 class="h5 mb-3">Admin-controlled access</h2>
<p class="text-secondary mb-3">Public items appear in this catalog immediately. Private items stay out of the public shelf and are marked for member login in the admin workspace.</p>
<a class="link-arrow" href="/admin.php">Review publishing controls</a>
</div>
</div>
</section>
<section class="mb-5" id="catalog-grid">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<div class="section-kicker">Catalog</div>
<h2 class="h3 mb-0">Available public titles</h2>
</div>
<span class="text-secondary small"><?= h((string) count($documents)) ?> result<?= count($documents) === 1 ? '' : 's' ?></span>
</div>
<?php if (!$documents): ?>
<div class="panel empty-panel text-center py-5">
<div class="empty-icon mb-3"></div>
<h3 class="h5">No public documents yet</h3>
<p class="text-secondary mb-4">Upload your first Arabic or English PDF from the Admin Studio to turn this into a browsable library.</p>
<a class="btn btn-dark" href="/admin.php">Open Admin Studio</a>
</div>
<?php else: ?>
<div class="row g-4">
<?php foreach ($documents as $document): ?>
<div class="col-md-6 col-xl-4">
<article class="panel h-100 d-flex flex-column">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div class="d-flex flex-wrap gap-2">
<span class="badge text-bg-light"><?= h(library_language_label((string) $document['document_language'])) ?></span>
<span class="badge text-bg-light"><?= h(library_document_type_label((string) $document['document_type'])) ?></span>
</div>
<?php if (!empty($document['is_featured'])): ?>
<span class="badge text-bg-dark">Featured</span>
<?php endif; ?>
</div>
<div class="mb-3">
<?php if (!empty($document['title_en'])): ?>
<h3 class="h5 mb-1"><?= h((string) $document['title_en']) ?></h3>
<?php endif; ?>
<?php if (!empty($document['title_ar'])): ?>
<div class="text-secondary" dir="rtl"><?= h((string) $document['title_ar']) ?></div>
<?php endif; ?>
</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'] ?: '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>
<dd class="col-8 mb-0"><?= h((string) ($document['tags'] ?: '—')) ?></dd>
</dl>
<p class="text-secondary flex-grow-1"><?= h((string) ($document['summary_text'] ?: ($document['description_en'] ?: $document['description_ar'] ?: 'No summary yet.'))) ?></p>
<div class="d-flex gap-2 mt-3">
<a class="btn btn-dark btn-sm" href="/document.php?id=<?= h((string) $document['id']) ?>">Open reader</a>
<a class="btn btn-outline-secondary btn-sm" href="/document.php?id=<?= h((string) $document['id']) ?>#summary-card">View summary</a>
</div>
</article>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<section class="row g-4">
<div class="col-lg-4">
<div class="panel h-100">
<div class="section-kicker">Workflow</div>
<h2 class="h5 mb-3">Thin slice, end to end</h2>
<ol class="compact-list-numbered mb-0 text-secondary">
<li>Admin uploads a document and chooses public or private visibility.</li>
<li>Readers discover public titles from the catalog and open the detail page.</li>
<li>AI summaries can be generated from the saved excerpt for faster review.</li>
</ol>
</div>
</div>
<div class="col-lg-8">
<div class="panel h-100">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<div class="section-kicker">Recently added</div>
<h2 class="h5 mb-0">Latest public titles</h2>
</div>
<a class="link-arrow" href="/admin.php">Manage shelf</a>
</div>
<div class="row g-3">
<?php foreach ($recentDocuments as $document): ?>
<div class="col-md-4">
<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'] ?: 'Unknown author')) ?></span>
</a>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
</section>
<?php <?php
// Read project preview data from environment library_render_footer();
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
?>
<?php if ($projectDescription): ?>
<!-- Meta description -->
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
<!-- Open Graph meta tags -->
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<!-- Twitter meta tags -->
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<?php endif; ?>
<?php if ($projectImageUrl): ?>
<!-- Open Graph image -->
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<!-- Twitter image -->
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<?php endif; ?>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-color-start: #6a11cb;
--bg-color-end: #2575fc;
--text-color: #ffffff;
--card-bg-color: rgba(255, 255, 255, 0.01);
--card-border-color: rgba(255, 255, 255, 0.1);
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% { background-position: 0% 0%; }
100% { background-position: 100% 100%; }
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
}
.loader {
margin: 1.25rem auto 1.25rem;
width: 48px;
height: 48px;
border: 3px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.hint {
opacity: 0.9;
}
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap; border: 0;
}
h1 {
font-size: 3rem;
font-weight: 700;
margin: 0 0 1rem;
letter-spacing: -1px;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
}
code {
background: rgba(0,0,0,0.2);
padding: 2px 6px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
footer {
position: absolute;
bottom: 1rem;
font-size: 0.8rem;
opacity: 0.7;
}
</style>
</head>
<body>
<main>
<div class="card">
<h1>Analyzing your requirements and generating your website…</h1>
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
<span class="sr-only">Loading…</span>
</div>
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
<p class="hint">This page will update automatically as the plan is implemented.</p>
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
</div>
</main>
<footer>
Page updated: <?= htmlspecialchars($now) ?> (UTC)
</footer>
</body>
</html>

View File

@ -0,0 +1,5 @@
Sample bilingual notes
English: This private starter document demonstrates that the admin can keep an item off the public shelf until member access is added.
العربية: يوضح هذا الملف التجريبي الخاص أن المشرف يستطيع إخفاء المادة عن الواجهة العامة حتى تتم إضافة تسجيل الدخول للأعضاء.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,5 @@
Sample bilingual notes
English: This private starter document demonstrates that the admin can keep an item off the public shelf until member access is added.
العربية: يوضح هذا الملف التجريبي الخاص أن المشرف يستطيع إخفاء المادة عن الواجهة العامة حتى تتم إضافة تسجيل الدخول للأعضاء.

View File

@ -0,0 +1,45 @@
%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>
endobj
4 0 obj
<< /Length 263 >>
stream
BT
/F1 18 Tf
60 770 Td
(Nabd Library Demo Guide) Tj
0 -28 Td
/F1 12 Tf
(A bilingual e-library sample for Arabic and English collections.) Tj
0 -24 Td
(This demo PDF proves the in-browser reader workflow is active.) Tj
0 -24 Td
(Upload your own PDF from Admin Studio to replace this starter title.) Tj
0 -24 Td
(Public items appear in the catalog. Private items stay hidden.) Tj
ET
endstream
endobj
5 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj
xref
0 6
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000241 00000 n
0000000555 00000 n
trailer
<< /Root 1 0 R /Size 6 >>
startxref
625
%%EOF

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>