Autosave: 20260325-074933

This commit is contained in:
Flatlogic Bot 2026-03-25 07:49:33 +00:00
parent 060a58b7f7
commit d9a8b246f2
14 changed files with 1688 additions and 551 deletions

390
admin.php Normal file
View File

@ -0,0 +1,390 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/layout.php';
library_bootstrap();
$errors = [];
$successMessage = '';
// Handle POST request
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
try {
if ($action === 'upload_document') {
$documentId = library_create_document($_POST, $_FILES['document_file'] ?? []);
library_set_flash('success', 'Document uploaded successfully.');
header('Location: /admin.php?created=' . $documentId . '#catalog-manager');
exit;
} elseif ($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.php');
exit;
} elseif ($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.php');
exit;
}
} catch (Throwable $exception) {
$errors[] = $exception->getMessage();
}
}
$documents = library_fetch_documents(false, []);
$metrics = library_catalog_metrics();
$categories = library_get_categories();
// Fetch all subcategories to pass to JS for filtering
$allSubcategories = library_get_subcategories(null);
$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>Admin Studio · Nabd Library</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;
}
</style>
</head>
<body class="bg-light">
<div class="d-flex">
<aside class="admin-sidebar">
<h4 class="mb-4">Admin Dashboard</h4>
<nav class="nav flex-column gap-2">
<a class="nav-link text-dark bg-light rounded" href="/admin.php">Catalog Manager</a>
<a class="nav-link text-secondary" href="/index.php">Return to site</a>
</nav>
<hr class="my-4">
<h6 class="text-uppercase text-muted small">Metadata</h6>
<div class="d-grid gap-2">
<button type="button" class="btn btn-outline-secondary btn-sm text-start" data-bs-toggle="modal" data-bs-target="#categoryModal">
+ New Category
</button>
<button type="button" class="btn btn-outline-secondary btn-sm text-start" data-bs-toggle="modal" data-bs-target="#subcategoryModal">
+ New Subcategory
</button>
</div>
</aside>
<main class="flex-grow-1 p-4">
<h1>Catalog Manager</h1>
<p class="text-secondary">Upload manuscripts and manage permissions.</p>
<?php foreach ($flashes as $flash): ?>
<div class="alert alert-<?= $flash['type'] === 'error' ? 'danger' : 'success' ?> alert-dismissible fade show" role="alert">
<?= h($flash['message']) ?>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<?php endforeach; ?>
<?php if ($errors): ?>
<div class="alert alert-danger"><?= h(implode(' ', $errors)) ?></div>
<?php endif; ?>
<form method="post" action="/admin.php#catalog-manager" enctype="multipart/form-data" class="panel border p-4 bg-white rounded shadow-sm">
<input type="hidden" name="action" value="upload_document">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Title (English)</label>
<input class="form-control" name="title_en" type="text" required>
</div>
<div class="col-md-6">
<label class="form-label">Title (Arabic)</label>
<input class="form-control" name="title_ar" type="text" dir="rtl">
</div>
<div class="col-md-6">
<label class="form-label">Category</label>
<div class="input-group">
<select class="form-select" name="category_id" id="docCategorySelect" required>
<option value="">Select Category...</option>
<?php foreach ($categories as $cat): ?>
<option value="<?= $cat['id'] ?>">
<?= h($cat['name_en']) ?> / <?= h($cat['name_ar']) ?>
</option>
<?php endforeach; ?>
</select>
<button class="btn btn-outline-secondary" type="button" data-bs-toggle="modal" data-bs-target="#categoryModal">+</button>
</div>
</div>
<div class="col-md-6">
<label class="form-label">Sub Category</label>
<div class="input-group">
<select class="form-select" name="subcategory_id" id="docSubcategorySelect">
<option value="">Select Category First</option>
</select>
<button class="btn btn-outline-secondary" type="button" data-bs-toggle="modal" data-bs-target="#subcategoryModal">+</button>
</div>
</div>
<div class="col-md-12">
<label class="form-label">Visibility</label>
<select class="form-select" name="visibility">
<option value="public">Public</option>
<option value="private">Private</option>
</select>
</div>
<div class="col-12">
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" name="allow_download" value="1">
<label class="form-check-label">Allow Download</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" name="allow_print" value="1">
<label class="form-check-label">Allow Print</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" name="allow_copy" value="1">
<label class="form-check-label">Allow Copy</label>
</div>
</div>
<div class="col-12">
<label class="form-label">Document file</label>
<input class="form-control" name="document_file" type="file" required>
</div>
</div>
<button class="btn btn-primary mt-3" type="submit">Upload manuscript</button>
</form>
<h3 class="mt-5 mb-3">Recent Documents</h3>
<div class="table-responsive bg-white border rounded">
<table class="table mb-0">
<thead class="table-light">
<tr>
<th>ID</th>
<th>Title</th>
<th>Category</th>
<th>Visibility</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($documents)): ?>
<tr><td colspan="5" class="text-center py-4 text-muted">No documents found.</td></tr>
<?php else: ?>
<?php foreach (array_slice($documents, 0, 10) as $doc): ?>
<tr>
<td><?= $doc['id'] ?></td>
<td>
<?= h($doc['title_en']) ?><br>
<small class="text-muted"><?= h($doc['title_ar']) ?></small>
</td>
<td>
<?= h($doc['cat_en'] ?? $doc['category']) ?>
<?php if (!empty($doc['sub_en'])): ?>
<small class="text-muted"> > <?= h($doc['sub_en']) ?></small>
<?php elseif (!empty($doc['sub_category'])): ?>
<small class="text-muted"> > <?= h($doc['sub_category']) ?></small>
<?php endif; ?>
</td>
<td>
<span class="badge bg-<?= $doc['visibility'] === 'public' ? 'success' : 'secondary' ?>">
<?= h($doc['visibility']) ?>
</span>
</td>
<td>
<a href="/document.php?id=<?= $doc['id'] ?>" class="btn btn-sm btn-outline-primary" target="_blank">View</a>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</main>
</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.php">
<input type="hidden" name="action" value="create_category">
<div class="modal-header">
<h5 class="modal-title">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>
<!-- 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.php">
<input type="hidden" name="action" value="create_subcategory">
<div class="modal-header">
<h5 class="modal-title">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" 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>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Pass PHP data to JS safely
const allSubcategories = <?= json_encode($allSubcategories) ?>;
const catSelect = document.getElementById('docCategorySelect');
const subSelect = document.getElementById('docSubcategorySelect');
function updateSubcategories() {
const selectedCatId = catSelect.value;
subSelect.innerHTML = '<option value="">Select Subcategory...</option>';
if (!selectedCatId) {
return;
}
// Loose equality check for ID comparison
const filtered = allSubcategories.filter(sub => sub.category_id == selectedCatId);
filtered.forEach(sub => {
const option = document.createElement('option');
option.value = sub.id;
// Use only English name for now in the dropdown as per "One language" request,
// or maybe keep bilingual if it helps selection.
// Let's keep it bilingual in the dropdown for clarity as before,
// since the user request was specifically about the modal form UI.
option.textContent = sub.name_en + ' / ' + sub.name_ar;
subSelect.appendChild(option);
});
}
if (catSelect && subSelect) {
catSelect.addEventListener('change', updateSubcategories);
updateSubcategories();
}
async function translateText(sourceId, targetId, lang) {
const source = document.getElementById(sourceId);
const target = document.getElementById(targetId);
const text = source.value.trim();
if (!text) {
alert('Please enter text to translate.');
return;
}
// Show loading state
const originalPlaceholder = target.placeholder;
target.placeholder = 'Translating...';
const originalOpacity = target.style.opacity;
target.style.opacity = '0.7';
try {
const response = await fetch('/api/translate.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: text, target_lang: lang })
});
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>

46
api/translate.php Normal file
View File

@ -0,0 +1,46 @@
<?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;
}
$prompt = "Translate the following text to {$targetLang}. Provide ONLY the translation, without any additional text, quotes, or explanations.";
$response = LocalAIApi::createResponse([
'model' => 'gemini-2.0-flash-001', // Using a fast model if available, or default
'input' => [
['role' => 'system', 'content' => 'You are a helpful translator. Translate the user input accurately. Output only the translated text.'],
['role' => 'user', 'content' => $text],
],
]);
if (empty($response['success'])) {
http_response_code(500);
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, " \t\n\r\0\x0B\"'");
echo json_encode(['translation' => $translatedText]);

View File

@ -1,403 +1,349 @@
: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;
}
.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);
.reader-frame {
min-height: 60vh;
}
}
.history-table {
width: 100%;
}
.history-table-time {
width: 15%;
white-space: nowrap;
font-size: 0.85em;
color: #555;
}
.history-table-user {
width: 35%;
background: rgba(255, 255, 255, 0.3);
border-radius: 8px;
padding: 8px;
}
.history-table-ai {
width: 50%;
background: rgba(255, 255, 255, 0.5);
border-radius: 8px;
padding: 8px;
}
.no-messages {
text-align: center;
color: #777;
}

View File

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

View File

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

View File

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

View File

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

162
document.php Normal file
View File

@ -0,0 +1,162 @@
<?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_name'] ?: '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'])): ?>
<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; ?>
</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)): ?>
<div class="reader-frame-wrap">
<iframe class="reader-frame" src="<?= h(library_file_url((string) $document['file_path'])) ?>#toolbar=1&navpanes=0&view=FitH" title="Reader for <?= h((string) ($document['title_en'] ?: $document['title_ar'] ?: 'document')) ?>"></iframe>
</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
library_render_footer();

103
includes/layout.php Normal file
View File

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

364
includes/library.php Normal file
View File

@ -0,0 +1,364 @@
<?php
declare(strict_types=1);
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
require_once __DIR__ . '/../db/config.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);
}
}
$uploadDir = __DIR__ . '/../uploads/library';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 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',
];
}
// --- Category Functions ---
function library_get_categories(): array
{
library_bootstrap();
$stmt = db()->query('SELECT * FROM library_categories ORDER BY name_en ASC');
return $stmt ? $stmt->fetchAll() : [];
}
function library_get_subcategories(?int $categoryId = null): array
{
library_bootstrap();
if ($categoryId !== null) {
$stmt = db()->prepare('SELECT * FROM library_subcategories WHERE category_id = ? ORDER BY name_en ASC');
$stmt->execute([$categoryId]);
return $stmt->fetchAll() ?: [];
}
$stmt = db()->query('SELECT * FROM library_subcategories ORDER BY name_en ASC');
return $stmt ? $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;
}
// --- End Category 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
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
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_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_create_document(array $payload, array $file): 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;
// 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;
$fileData = library_handle_uploaded_file($file);
$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
) 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
)');
$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,
]);
return (int) db()->lastInsertId();
}

336
index.php
View File

@ -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_name'] ?: '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_name'] ?: 'Unknown author')) ?></span>
</a>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
</section>
<?php
library_render_footer();

View File

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

View File

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

View File

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