Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e7ac25120 | ||
|
|
ba0567e218 | ||
|
|
f72c5f11b8 | ||
|
|
d9a8b246f2 |
4
.htaccess
Normal file
4
.htaccess
Normal file
@ -0,0 +1,4 @@
|
||||
php_value upload_max_filesize 20M
|
||||
php_value post_max_size 20M
|
||||
php_value max_execution_time 300
|
||||
php_value max_input_time 300
|
||||
129
admin.php
Normal file
129
admin.php
Normal 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
212
admin_categories.php
Normal 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
456
admin_documents.php
Normal 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
240
admin_subcategories.php
Normal 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
212
admin_types.php
Normal 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
50
api/translate.php
Normal 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]);
|
||||
@ -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 {
|
||||
background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
|
||||
background-size: 400% 400%;
|
||||
animation: gradient 15s ease infinite;
|
||||
color: #212529;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main-wrapper {
|
||||
display: flex;
|
||||
::selection {
|
||||
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;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
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);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
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;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
transition: all 0.3s ease;
|
||||
box-sizing: border-box;
|
||||
.brand-title {
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: #23a6d5;
|
||||
box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1);
|
||||
.nav-link {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-sm);
|
||||
padding-inline: 0.85rem !important;
|
||||
}
|
||||
|
||||
.header-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
.nav-link.active,
|
||||
.nav-link:hover,
|
||||
.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;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
.admin-card {
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
padding: 2rem;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
margin-bottom: 2.5rem;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.05);
|
||||
.recent-card {
|
||||
display: block;
|
||||
height: 100%;
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
|
||||
.admin-card h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
font-weight: 700;
|
||||
.recent-card:hover,
|
||||
.recent-card:focus {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
.toast {
|
||||
min-width: 280px;
|
||||
border-radius: 0.85rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
background: #212529;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin-top: 1rem;
|
||||
.toast-stack {
|
||||
z-index: 1090;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background: #0088cc;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.8rem 1.5rem;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
width: 100%;
|
||||
transition: all 0.3s ease;
|
||||
footer a {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.webhook-url {
|
||||
font-size: 0.85em;
|
||||
color: #555;
|
||||
margin-top: 0.5rem;
|
||||
@media (max-width: 991.98px) {
|
||||
.metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.reader-frame {
|
||||
min-height: 60vh;
|
||||
}
|
||||
}
|
||||
|
||||
.history-table-container {
|
||||
overflow-x: auto;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
/* Flipbook Custom Styles */
|
||||
#flipbook-wrapper {
|
||||
background: #333;
|
||||
box-shadow: inset 0 0 30px rgba(0,0,0,0.6);
|
||||
}
|
||||
|
||||
.history-table {
|
||||
width: 100%;
|
||||
#flipbook .page {
|
||||
background-color: #fff;
|
||||
/* Soft border for realism */
|
||||
border-right: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.history-table-time {
|
||||
width: 15%;
|
||||
white-space: nowrap;
|
||||
font-size: 0.85em;
|
||||
color: #555;
|
||||
#flipbook-toolbar button {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.history-table-user {
|
||||
width: 35%;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
#flipbook-toolbar button:hover {
|
||||
transform: scale(1.1);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.history-table-ai {
|
||||
width: 50%;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.no-messages {
|
||||
text-align: center;
|
||||
color: #777;
|
||||
#flipbook-toolbar button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
@ -1,39 +1,17 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const chatForm = document.getElementById('chat-form');
|
||||
const chatInput = document.getElementById('chat-input');
|
||||
const chatMessages = document.getElementById('chat-messages');
|
||||
document.querySelectorAll('.toast').forEach((toastNode) => {
|
||||
const toast = new bootstrap.Toast(toastNode);
|
||||
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
|
||||
setTimeout(() => {
|
||||
appendMessage(data.reply, 'bot');
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
appendMessage("Sorry, something went wrong. Please try again.", 'bot');
|
||||
}
|
||||
document.querySelectorAll('[data-file-input]').forEach((input) => {
|
||||
input.addEventListener('change', () => {
|
||||
const help = input.closest('.col-12')?.querySelector('.form-text');
|
||||
if (!help) {
|
||||
return;
|
||||
}
|
||||
const fileName = input.files && input.files[0] ? input.files[0].name : 'Supported: PDF, TXT, DOC, DOCX, PPT, PPTX. Max 12 MB.';
|
||||
help.textContent = fileName;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
23
db/migrations/001_library_documents.sql
Normal file
23
db/migrations/001_library_documents.sql
Normal 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;
|
||||
8
db/migrations/002_add_library_metadata.sql
Normal file
8
db/migrations/002_add_library_metadata.sql
Normal 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;
|
||||
18
db/migrations/003_normalize_categories.sql
Normal file
18
db/migrations/003_normalize_categories.sql
Normal 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;
|
||||
7
db/migrations/004_create_types_table.sql
Normal file
7
db/migrations/004_create_types_table.sql
Normal 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;
|
||||
15
db/migrations/005_update_document_fields.sql
Normal file
15
db/migrations/005_update_document_fields.sql
Normal 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);
|
||||
2
db/migrations/006_add_author_column.sql
Normal file
2
db/migrations/006_add_author_column.sql
Normal file
@ -0,0 +1,2 @@
|
||||
ALTER TABLE library_documents
|
||||
ADD COLUMN author VARCHAR(255) DEFAULT NULL;
|
||||
330
document.php
Normal file
330
document.php
Normal 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
144
includes/admin_layout.php
Normal 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
103
includes/layout.php
Normal 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
807
includes/library.php
Normal 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
336
index.php
@ -1,150 +1,194 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
@ini_set('display_errors', '1');
|
||||
@error_reporting(E_ALL);
|
||||
@date_default_timezone_set('UTC');
|
||||
|
||||
$phpVersion = PHP_VERSION;
|
||||
$now = date('Y-m-d H:i:s');
|
||||
declare(strict_types=1);
|
||||
|
||||
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>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>New Style</title>
|
||||
<?php
|
||||
// Read project preview data from environment
|
||||
$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>
|
||||
<section class="hero-surface mb-4 mb-lg-5">
|
||||
<div class="row g-4 align-items-center">
|
||||
<div class="col-lg-7">
|
||||
<span class="eyebrow">Electronic library · Arabic + English</span>
|
||||
<h1 class="display-6 mb-3">A focused e-library for bilingual reading, controlled publishing, and AI-ready summaries.</h1>
|
||||
<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>
|
||||
</main>
|
||||
<footer>
|
||||
Page updated: <?= htmlspecialchars($now) ?> (UTC)
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
</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
|
||||
library_render_footer();
|
||||
|
||||
@ -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.
|
||||
|
||||
العربية: يوضح هذا الملف التجريبي الخاص أن المشرف يستطيع إخفاء المادة عن الواجهة العامة حتى تتم إضافة تسجيل الدخول للأعضاء.
|
||||
BIN
uploads/library/20260325102925---8a635cbb.pdf
Normal file
BIN
uploads/library/20260325102925---8a635cbb.pdf
Normal file
Binary file not shown.
BIN
uploads/library/20260325103107---099c7452.pdf
Normal file
BIN
uploads/library/20260325103107---099c7452.pdf
Normal file
Binary file not shown.
BIN
uploads/library/20260325103121---3affd94e.pdf
Normal file
BIN
uploads/library/20260325103121---3affd94e.pdf
Normal file
Binary file not shown.
BIN
uploads/library/20260325103208---ed905fee.pdf
Normal file
BIN
uploads/library/20260325103208---ed905fee.pdf
Normal file
Binary file not shown.
5
uploads/library/demo-bilingual-notes.txt
Normal file
5
uploads/library/demo-bilingual-notes.txt
Normal 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.
|
||||
|
||||
العربية: يوضح هذا الملف التجريبي الخاص أن المشرف يستطيع إخفاء المادة عن الواجهة العامة حتى تتم إضافة تسجيل الدخول للأعضاء.
|
||||
45
uploads/library/demo-library-guide.pdf
Normal file
45
uploads/library/demo-library-guide.pdf
Normal 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
539
viewer.php
Normal file
@ -0,0 +1,539 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/includes/layout.php';
|
||||
|
||||
library_bootstrap();
|
||||
|
||||
$documentId = isset($_GET['id']) ? (int) $_GET['id'] : 0;
|
||||
// Context handling (admin/public)
|
||||
$context = ($_GET['context'] ?? '') === 'admin' ? 'admin' : 'public';
|
||||
$publicOnly = $context !== 'admin';
|
||||
|
||||
$document = $documentId > 0 ? library_fetch_document($documentId, $publicOnly) : null;
|
||||
|
||||
if (!$document || empty($document['file_path'])) {
|
||||
http_response_code(404);
|
||||
die('Document not found or no file attached.');
|
||||
}
|
||||
|
||||
$fileUrl = library_file_url((string) $document['file_path']);
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= h($document['title_en'] ?: 'Document Viewer') ?> - Reader</title>
|
||||
<!-- Icons -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
||||
<!-- Bootstrap -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #2d3035;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#viewer-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
#flipbook-loader {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#flipbook {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page {
|
||||
background-color: #fff;
|
||||
box-shadow: inset -1px 0 2px rgba(0,0,0,0.1); /* subtle spine shadow */
|
||||
}
|
||||
|
||||
#toolbar {
|
||||
height: 60px;
|
||||
background-color: #1a1d21;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
box-shadow: 0 -2px 10px rgba(0,0,0,0.2);
|
||||
z-index: 1000;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #adb5bd;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
transition: color 0.2s, background-color 0.2s;
|
||||
}
|
||||
|
||||
.toolbar-btn:hover {
|
||||
color: #fff;
|
||||
background-color: rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.page-indicator {
|
||||
font-family: monospace;
|
||||
font-size: 1.1rem;
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.doc-title {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
/* Search Styles */
|
||||
.search-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #2d3035;
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
border: 1px solid #495057;
|
||||
}
|
||||
|
||||
#searchInput {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #fff;
|
||||
padding: 5px 10px;
|
||||
outline: none;
|
||||
width: 150px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
#searchInput::placeholder {
|
||||
color: #adb5bd;
|
||||
}
|
||||
|
||||
.search-controls {
|
||||
display: flex;
|
||||
border-left: 1px solid #495057;
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #adb5bd;
|
||||
padding: 5px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.search-btn:hover {
|
||||
color: #fff;
|
||||
background-color: rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
#search-status {
|
||||
font-size: 0.8rem;
|
||||
color: #adb5bd;
|
||||
margin-left: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="toolbar">
|
||||
<div class="toolbar-group">
|
||||
<a href="/document.php?id=<?= $documentId ?><?= $context === 'admin' ? '&context=admin' : '' ?>" class="toolbar-btn" title="Back to Details">
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</a>
|
||||
<div class="doc-title d-none d-lg-block"><?= h($document['title_en'] ?: 'Document') ?></div>
|
||||
</div>
|
||||
|
||||
<!-- Center: Search -->
|
||||
<div class="toolbar-group d-none d-md-flex">
|
||||
<div class="search-container">
|
||||
<input type="text" id="searchInput" placeholder="Search..." autocomplete="off">
|
||||
<div class="search-controls">
|
||||
<button id="btn-search-prev" class="search-btn" title="Previous Match"><i class="bi bi-chevron-up"></i></button>
|
||||
<button id="btn-search-next" class="search-btn" title="Next Match"><i class="bi bi-chevron-down"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<span id="search-status"></span>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-group">
|
||||
<button id="btn-prev" class="toolbar-btn" title="Previous Page">
|
||||
<i class="bi bi-caret-left-fill"></i>
|
||||
</button>
|
||||
<div class="page-indicator">
|
||||
<span id="page-current">1</span> <span class="text-secondary">/</span> <span id="page-total">--</span>
|
||||
</div>
|
||||
<button id="btn-next" class="toolbar-btn" title="Next Page">
|
||||
<i class="bi bi-caret-right-fill"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-group">
|
||||
<a href="<?= h($fileUrl) ?>" download class="toolbar-btn" title="Download PDF">
|
||||
<i class="bi bi-download"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="viewer-container">
|
||||
<div id="flipbook-loader">
|
||||
<div class="spinner-border text-light mb-3" role="status"></div>
|
||||
<div>Loading Document...</div>
|
||||
<div id="loader-status" class="text-secondary small mt-2"></div>
|
||||
</div>
|
||||
<div id="flipbook"></div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||
<script src="https://unpkg.com/page-flip/dist/js/page-flip.browser.js"></script>
|
||||
<script>
|
||||
// Global State
|
||||
let pdfDoc = null;
|
||||
let pageFlip = null;
|
||||
let searchResults = []; // Array of { pageIndex, rects: [x,y,w,h] }
|
||||
let currentMatchIndex = -1;
|
||||
let pageHighlighters = {}; // Map of pageIndex -> CanvasContext (for highlights)
|
||||
let pageViewports = {}; // Map of pageIndex -> viewport (for rect calc)
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const pdfUrl = '<?= h($fileUrl) ?>';
|
||||
const container = document.getElementById('flipbook');
|
||||
const loader = document.getElementById('flipbook-loader');
|
||||
const loaderStatus = document.getElementById('loader-status');
|
||||
const currentSpan = document.getElementById('page-current');
|
||||
const totalSpan = document.getElementById('page-total');
|
||||
const viewerContainer = document.getElementById('viewer-container');
|
||||
|
||||
// Set worker
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
||||
|
||||
try {
|
||||
const loadingTask = pdfjsLib.getDocument(pdfUrl);
|
||||
loadingTask.onProgress = (p) => {
|
||||
if (p.total > 0) {
|
||||
const percent = Math.round((p.loaded / p.total) * 100);
|
||||
loaderStatus.textContent = `Downloading... ${percent}%`;
|
||||
}
|
||||
};
|
||||
|
||||
pdfDoc = await loadingTask.promise;
|
||||
const totalPages = pdfDoc.numPages;
|
||||
totalSpan.textContent = totalPages;
|
||||
loaderStatus.textContent = 'Rendering pages...';
|
||||
|
||||
// --- Layout Logic ---
|
||||
const availWidth = viewerContainer.clientWidth - 40;
|
||||
const availHeight = viewerContainer.clientHeight - 40;
|
||||
const firstPage = await pdfDoc.getPage(1);
|
||||
const viewport = firstPage.getViewport({ scale: 1 });
|
||||
const aspectRatio = viewport.width / viewport.height;
|
||||
|
||||
let bookHeight = availHeight;
|
||||
let bookWidth = bookHeight * aspectRatio;
|
||||
if (bookWidth * 2 > availWidth) {
|
||||
bookWidth = availWidth / 2;
|
||||
bookHeight = bookWidth / aspectRatio;
|
||||
}
|
||||
|
||||
const renderScale = (bookHeight / viewport.height) * 1.5;
|
||||
|
||||
// --- Page Generation ---
|
||||
const canvasPromises = [];
|
||||
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
const pageDiv = document.createElement('div');
|
||||
pageDiv.className = 'page';
|
||||
|
||||
// Structure:
|
||||
// .page-content (relative)
|
||||
// -> .pdf-canvas (absolute, z=1)
|
||||
// -> .highlight-canvas (absolute, z=2, pointer-events: none)
|
||||
const contentDiv = document.createElement('div');
|
||||
contentDiv.style.position = 'relative';
|
||||
contentDiv.style.width = '100%';
|
||||
contentDiv.style.height = '100%';
|
||||
contentDiv.style.overflow = 'hidden';
|
||||
|
||||
const pdfCanvas = document.createElement('canvas');
|
||||
pdfCanvas.style.position = 'absolute';
|
||||
pdfCanvas.style.top = '0';
|
||||
pdfCanvas.style.left = '0';
|
||||
pdfCanvas.style.width = '100%';
|
||||
pdfCanvas.style.height = '100%';
|
||||
|
||||
const hlCanvas = document.createElement('canvas');
|
||||
hlCanvas.style.position = 'absolute';
|
||||
hlCanvas.style.top = '0';
|
||||
hlCanvas.style.left = '0';
|
||||
hlCanvas.style.width = '100%';
|
||||
hlCanvas.style.height = '100%';
|
||||
hlCanvas.style.pointerEvents = 'none'; // Click through to page
|
||||
|
||||
contentDiv.appendChild(pdfCanvas);
|
||||
contentDiv.appendChild(hlCanvas);
|
||||
pageDiv.appendChild(contentDiv);
|
||||
container.appendChild(pageDiv);
|
||||
|
||||
// Render Async
|
||||
canvasPromises.push(async () => {
|
||||
const page = await pdfDoc.getPage(i);
|
||||
const vp = page.getViewport({ scale: renderScale });
|
||||
|
||||
// Setup PDF Canvas
|
||||
pdfCanvas.height = vp.height;
|
||||
pdfCanvas.width = vp.width;
|
||||
const ctx = pdfCanvas.getContext('2d');
|
||||
|
||||
// Setup Highlight Canvas (match dims)
|
||||
hlCanvas.height = vp.height;
|
||||
hlCanvas.width = vp.width;
|
||||
const hlCtx = hlCanvas.getContext('2d');
|
||||
|
||||
// Store refs for search
|
||||
pageHighlighters[i] = hlCtx;
|
||||
pageViewports[i] = vp;
|
||||
|
||||
await page.render({ canvasContext: ctx, viewport: vp }).promise;
|
||||
});
|
||||
}
|
||||
|
||||
await Promise.all(canvasPromises.map(fn => fn()));
|
||||
|
||||
// --- Init Flipbook ---
|
||||
loader.style.display = 'none';
|
||||
container.style.display = 'block';
|
||||
|
||||
pageFlip = new St.PageFlip(container, {
|
||||
width: bookWidth,
|
||||
height: bookHeight,
|
||||
size: 'fixed',
|
||||
showCover: true,
|
||||
maxShadowOpacity: 0.5
|
||||
});
|
||||
|
||||
pageFlip.loadFromHTML(document.querySelectorAll('.page'));
|
||||
|
||||
// --- Events ---
|
||||
pageFlip.on('flip', (e) => {
|
||||
document.getElementById('page-current').textContent = (pageFlip.getCurrentPageIndex() + 1);
|
||||
});
|
||||
|
||||
document.getElementById('btn-prev').addEventListener('click', () => pageFlip.flipPrev());
|
||||
document.getElementById('btn-next').addEventListener('click', () => pageFlip.flipNext());
|
||||
|
||||
// Keyboard
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.target.tagName === 'INPUT') return; // Don't flip when typing
|
||||
if (e.key === 'ArrowLeft') pageFlip.flipPrev();
|
||||
if (e.key === 'ArrowRight') pageFlip.flipNext();
|
||||
});
|
||||
|
||||
// --- Search Implementation ---
|
||||
setupSearch();
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
loader.innerHTML = '<div class="text-danger p-3">Error: ' + error.message + '</div>';
|
||||
}
|
||||
});
|
||||
|
||||
// --- Search Logic ---
|
||||
function setupSearch() {
|
||||
const input = document.getElementById('searchInput');
|
||||
const btnPrev = document.getElementById('btn-search-prev');
|
||||
const btnNext = document.getElementById('btn-search-next');
|
||||
const status = document.getElementById('search-status');
|
||||
|
||||
let debounceTimer;
|
||||
|
||||
input.addEventListener('input', (e) => {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
performSearch(e.target.value.trim());
|
||||
}, 600);
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
clearTimeout(debounceTimer);
|
||||
performSearch(input.value.trim());
|
||||
}
|
||||
});
|
||||
|
||||
btnNext.addEventListener('click', () => {
|
||||
if (searchResults.length === 0) return;
|
||||
currentMatchIndex = (currentMatchIndex + 1) % searchResults.length;
|
||||
showMatch(currentMatchIndex);
|
||||
});
|
||||
|
||||
btnPrev.addEventListener('click', () => {
|
||||
if (searchResults.length === 0) return;
|
||||
currentMatchIndex = (currentMatchIndex - 1 + searchResults.length) % searchResults.length;
|
||||
showMatch(currentMatchIndex);
|
||||
});
|
||||
}
|
||||
|
||||
async function performSearch(query) {
|
||||
const status = document.getElementById('search-status');
|
||||
|
||||
// Clear previous highlights
|
||||
Object.values(pageHighlighters).forEach(ctx => {
|
||||
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
});
|
||||
|
||||
if (!query || query.length < 2) {
|
||||
searchResults = [];
|
||||
currentMatchIndex = -1;
|
||||
status.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
status.textContent = 'Searching...';
|
||||
searchResults = [];
|
||||
currentMatchIndex = -1;
|
||||
|
||||
try {
|
||||
// Loop all pages
|
||||
for (let i = 1; i <= pdfDoc.numPages; i++) {
|
||||
const page = await pdfDoc.getPage(i);
|
||||
const textContent = await page.getTextContent();
|
||||
const viewport = pageViewports[i]; // Use stored viewport
|
||||
const ctx = pageHighlighters[i];
|
||||
|
||||
// Simple Item Search
|
||||
// Note: This matches text *within* individual text items.
|
||||
// Complex multi-item matches (spanning lines/chunks) are harder.
|
||||
textContent.items.forEach(item => {
|
||||
if (item.str.toLowerCase().includes(query.toLowerCase())) {
|
||||
// Found a match!
|
||||
// item.transform is [scaleX, skewY, skewX, scaleY, tx, ty]
|
||||
// The PDF coordinate system origin is bottom-left (usually).
|
||||
|
||||
// Calculate approximate bounding box
|
||||
// We need the width of the item. 'item.width' is available in recent pdf.js
|
||||
// If not, we estimate.
|
||||
|
||||
const tx = item.transform;
|
||||
// Basic rect in PDF coords:
|
||||
// x = tx[4], y = tx[5]
|
||||
// w = item.width, h = item.height (or font size?)
|
||||
|
||||
// Note: item.height is often 0 or undefined in raw items, use tx[3] (scaleY) as approx font height
|
||||
let x = tx[4];
|
||||
let y = tx[5];
|
||||
let w = item.width;
|
||||
let h = Math.sqrt(tx[0]*tx[0] + tx[1]*tx[1]); // approximate font size from scale
|
||||
|
||||
if (!w) w = h * item.str.length * 0.5; // fallback
|
||||
|
||||
// Adjust for y-flip?
|
||||
// pdf.js viewport.convertToViewportRectangle handles the coordinate transform
|
||||
// including the y-flip if the viewport is set up that way.
|
||||
|
||||
// Text items are bottom-left origin?
|
||||
// We usually need to move y up by 'h' because rects are top-left?
|
||||
// Actually, let's just transform (x, y) and (x+w, y+h)
|
||||
|
||||
// PDF text is usually baseline.
|
||||
// So the rect starts at y (baseline) and goes up by h?
|
||||
// Or starts at y-h?
|
||||
// Let's assume y is baseline.
|
||||
|
||||
const rect = [x, y, x + w, y + h];
|
||||
const viewRect = viewport.convertToViewportRectangle(rect);
|
||||
|
||||
// viewRect is [x1, y1, x2, y2]
|
||||
// normalize
|
||||
const rx = Math.min(viewRect[0], viewRect[2]);
|
||||
const ry = Math.min(viewRect[1], viewRect[3]);
|
||||
const rw = Math.abs(viewRect[0] - viewRect[2]);
|
||||
const rh = Math.abs(viewRect[1] - viewRect[3]);
|
||||
|
||||
// Draw highlight (Yellow, 40% opacity)
|
||||
ctx.fillStyle = 'rgba(255, 235, 59, 0.4)';
|
||||
ctx.fillRect(rx, ry, rw, rh);
|
||||
|
||||
// Add border for visibility
|
||||
ctx.strokeStyle = 'rgba(255, 193, 7, 0.8)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(rx, ry, rw, rh);
|
||||
|
||||
// Add to results (one per page is enough for navigation, but we highlight all)
|
||||
// We only push to searchResults if it's the *first* match on this page
|
||||
// OR we push every match?
|
||||
// Let's push every match for "Next" button granularity.
|
||||
searchResults.push({
|
||||
pageIndex: i - 1, // 0-based for PageFlip
|
||||
label: `Page ${i}`
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (searchResults.length > 0) {
|
||||
currentMatchIndex = 0;
|
||||
status.textContent = `${searchResults.length} matches`;
|
||||
showMatch(0);
|
||||
} else {
|
||||
status.textContent = 'No matches';
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error('Search failed', e);
|
||||
status.textContent = 'Error';
|
||||
}
|
||||
}
|
||||
|
||||
function showMatch(index) {
|
||||
if (index < 0 || index >= searchResults.length) return;
|
||||
const match = searchResults[index];
|
||||
|
||||
// Update status text
|
||||
document.getElementById('search-status').textContent = `${index + 1} / ${searchResults.length}`;
|
||||
|
||||
// Flip to page
|
||||
// PageFlip 0-based index
|
||||
// Check if we are already there to avoid animation loop?
|
||||
// PageFlip handles it.
|
||||
|
||||
// Note: If view is 'spread', we might need to check if the page is visible.
|
||||
// But flip() is safe.
|
||||
pageFlip.flip(match.pageIndex);
|
||||
}
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
x
Reference in New Issue
Block a user