Autosave: 20260409-184507
This commit is contained in:
parent
510602945b
commit
8c6abeb8a7
236
admin_library_profile.php
Normal file
236
admin_library_profile.php
Normal file
@ -0,0 +1,236 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/includes/admin_layout.php';
|
||||
|
||||
library_bootstrap();
|
||||
|
||||
$lang = library_get_language();
|
||||
$isRtl = $lang === 'ar';
|
||||
$pageTitle = $isRtl ? 'ملف المكتبة' : 'Library Profile';
|
||||
$saveMessage = $isRtl ? 'تم حفظ ملف المكتبة بنجاح.' : 'Library profile saved successfully.';
|
||||
$introText = $isRtl
|
||||
? 'حدّث هوية المكتبة العامة: الاسم، الشعار، الأيقونة، الوصف، وبيانات التواصل التي تظهر في الواجهة.'
|
||||
: 'Update the public library identity: name, logo, favicon, descriptions, and contact details shown on the site.';
|
||||
$errors = [];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
try {
|
||||
library_save_profile($_POST, $_FILES);
|
||||
library_set_flash('success', $saveMessage);
|
||||
header('Location: /admin_library_profile.php');
|
||||
exit;
|
||||
} catch (Throwable $exception) {
|
||||
$errors[] = $exception->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$profile = library_get_profile();
|
||||
$fields = [
|
||||
'library_name_en', 'library_name_ar', 'short_name', 'tagline_en', 'tagline_ar',
|
||||
'description_en', 'description_ar', 'contact_email', 'contact_phone', 'whatsapp_number',
|
||||
'website_url', 'address_en', 'address_ar', 'opening_hours_en', 'opening_hours_ar',
|
||||
'facebook_url', 'instagram_url', 'x_url', 'youtube_url', 'copyright_text_en', 'copyright_text_ar'
|
||||
];
|
||||
foreach ($fields as $field) {
|
||||
if (isset($_POST[$field])) {
|
||||
$profile[$field] = trim((string) $_POST[$field]);
|
||||
}
|
||||
}
|
||||
|
||||
admin_render_header($pageTitle, 'library_profile');
|
||||
?>
|
||||
<?php if ($errors): ?>
|
||||
<div class="alert alert-danger">
|
||||
<h6 class="alert-heading fw-bold mb-2"><i class="bi bi-exclamation-triangle-fill me-2"></i><?= $isRtl ? 'تعذّر حفظ البيانات' : 'Unable to save profile' ?></h6>
|
||||
<ul class="mb-0 ps-3">
|
||||
<?php foreach ($errors as $error): ?>
|
||||
<li><?= h($error) ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-end gap-3 mb-4">
|
||||
<div>
|
||||
<p class="text-secondary mb-1"><?= h($introText) ?></p>
|
||||
<div class="small text-muted"><?= $isRtl ? 'الملف المحفوظ هنا سيظهر في الشريط العلوي، التذييل، العنوان، والـ favicon.' : 'Saved here will appear in the top bar, footer, page title, and favicon.' ?></div>
|
||||
</div>
|
||||
<a href="/index.php" target="_blank" class="btn btn-outline-primary">
|
||||
<i class="bi bi-box-arrow-up-right me-1"></i> <?= $isRtl ? 'معاينة الموقع' : 'Preview site' ?>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/admin_library_profile.php" enctype="multipart/form-data" class="row g-4">
|
||||
<div class="col-12 col-xl-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"><i class="bi bi-stars me-2 text-primary"></i><?= $isRtl ? 'هوية المكتبة' : 'Library identity' ?></h5>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label"><?= $isRtl ? 'اسم المكتبة بالإنجليزية' : 'Library name (English)' ?></label>
|
||||
<input type="text" class="form-control" name="library_name_en" value="<?= h($profile['library_name_en'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label"><?= $isRtl ? 'اسم المكتبة بالعربية' : 'Library name (Arabic)' ?></label>
|
||||
<input type="text" class="form-control" name="library_name_ar" dir="rtl" value="<?= h($profile['library_name_ar'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label"><?= $isRtl ? 'الاختصار / العلامة القصيرة' : 'Short name / monogram' ?></label>
|
||||
<input type="text" class="form-control" name="short_name" maxlength="32" value="<?= h($profile['short_name'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label"><?= $isRtl ? 'الشعار النصي بالإنجليزية' : 'Tagline (English)' ?></label>
|
||||
<input type="text" class="form-control" name="tagline_en" value="<?= h($profile['tagline_en'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label"><?= $isRtl ? 'الشعار النصي بالعربية' : 'Tagline (Arabic)' ?></label>
|
||||
<input type="text" class="form-control" name="tagline_ar" dir="rtl" value="<?= h($profile['tagline_ar'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label"><?= $isRtl ? 'وصف المكتبة بالإنجليزية' : 'Library description (English)' ?></label>
|
||||
<textarea class="form-control" name="description_en" rows="4"><?= h($profile['description_en'] ?? '') ?></textarea>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label"><?= $isRtl ? 'وصف المكتبة بالعربية' : 'Library description (Arabic)' ?></label>
|
||||
<textarea class="form-control" name="description_ar" rows="4" dir="rtl"><?= h($profile['description_ar'] ?? '') ?></textarea>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label"><?= $isRtl ? 'نص حقوق النشر بالإنجليزية' : 'Copyright line (English)' ?></label>
|
||||
<input type="text" class="form-control" name="copyright_text_en" value="<?= h($profile['copyright_text_en'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label"><?= $isRtl ? 'نص حقوق النشر بالعربية' : 'Copyright line (Arabic)' ?></label>
|
||||
<input type="text" class="form-control" name="copyright_text_ar" dir="rtl" value="<?= h($profile['copyright_text_ar'] ?? '') ?>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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"><i class="bi bi-telephone me-2 text-primary"></i><?= $isRtl ? 'التواصل والموقع' : 'Contact and location' ?></h5>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label"><?= $isRtl ? 'البريد الإلكتروني' : 'Email' ?></label>
|
||||
<input type="email" class="form-control" name="contact_email" value="<?= h($profile['contact_email'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label"><?= $isRtl ? 'الهاتف' : 'Phone' ?></label>
|
||||
<input type="text" class="form-control" name="contact_phone" value="<?= h($profile['contact_phone'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label"><?= $isRtl ? 'واتساب' : 'WhatsApp' ?></label>
|
||||
<input type="text" class="form-control" name="whatsapp_number" value="<?= h($profile['whatsapp_number'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label"><?= $isRtl ? 'الموقع الإلكتروني' : 'Website URL' ?></label>
|
||||
<input type="text" class="form-control" name="website_url" placeholder="https://example.com" value="<?= h($profile['website_url'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label"><?= $isRtl ? 'العنوان بالإنجليزية' : 'Address (English)' ?></label>
|
||||
<input type="text" class="form-control" name="address_en" value="<?= h($profile['address_en'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label"><?= $isRtl ? 'العنوان بالعربية' : 'Address (Arabic)' ?></label>
|
||||
<input type="text" class="form-control" name="address_ar" dir="rtl" value="<?= h($profile['address_ar'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label"><?= $isRtl ? 'ساعات العمل بالإنجليزية' : 'Opening hours (English)' ?></label>
|
||||
<input type="text" class="form-control" name="opening_hours_en" value="<?= h($profile['opening_hours_en'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label"><?= $isRtl ? 'ساعات العمل بالعربية' : 'Opening hours (Arabic)' ?></label>
|
||||
<input type="text" class="form-control" name="opening_hours_ar" dir="rtl" value="<?= h($profile['opening_hours_ar'] ?? '') ?>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-header bg-white py-3 border-bottom">
|
||||
<h5 class="card-title mb-0"><i class="bi bi-share me-2 text-primary"></i><?= $isRtl ? 'الروابط الاجتماعية' : 'Social links' ?></h5>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Facebook</label>
|
||||
<input type="text" class="form-control" name="facebook_url" placeholder="https://facebook.com/..." value="<?= h($profile['facebook_url'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Instagram</label>
|
||||
<input type="text" class="form-control" name="instagram_url" placeholder="https://instagram.com/..." value="<?= h($profile['instagram_url'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">X / Twitter</label>
|
||||
<input type="text" class="form-control" name="x_url" placeholder="https://x.com/..." value="<?= h($profile['x_url'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">YouTube</label>
|
||||
<input type="text" class="form-control" name="youtube_url" placeholder="https://youtube.com/..." value="<?= h($profile['youtube_url'] ?? '') ?>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-4">
|
||||
<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"><i class="bi bi-image me-2 text-primary"></i><?= $isRtl ? 'الهوية البصرية' : 'Brand assets' ?></h5>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold"><?= $isRtl ? 'شعار المكتبة' : 'Library logo' ?></label>
|
||||
<input type="file" class="form-control" name="logo_file" accept=".png,.jpg,.jpeg,.webp">
|
||||
<div class="form-text"><?= $isRtl ? 'PNG / JPG / WEBP حتى 4MB' : 'PNG / JPG / WEBP up to 4MB' ?></div>
|
||||
<div class="mt-3 p-3 border rounded-4 bg-light text-center">
|
||||
<?php if (!empty($profile['logo_path'])): ?>
|
||||
<img src="/<?= h($profile['logo_path']) ?>?v=<?= time() ?>" alt="Library logo" style="max-width: 100%; max-height: 140px; object-fit: contain;">
|
||||
<?php else: ?>
|
||||
<div class="text-muted small"><?= $isRtl ? 'لا يوجد شعار مرفوع بعد.' : 'No logo uploaded yet.' ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label fw-semibold"><?= $isRtl ? 'أيقونة الموقع (Favicon)' : 'Favicon' ?></label>
|
||||
<input type="file" class="form-control" name="favicon_file" accept=".ico,.png,.webp">
|
||||
<div class="form-text"><?= $isRtl ? 'ICO / PNG / WEBP حتى 1MB' : 'ICO / PNG / WEBP up to 1MB' ?></div>
|
||||
<div class="mt-3 p-3 border rounded-4 bg-light text-center">
|
||||
<?php if (!empty($profile['favicon_path'])): ?>
|
||||
<img src="/<?= h($profile['favicon_path']) ?>?v=<?= time() ?>" alt="Favicon" style="width: 64px; height: 64px; object-fit: contain;">
|
||||
<?php else: ?>
|
||||
<div class="text-muted small"><?= $isRtl ? 'لا توجد أيقونة مرفوعة بعد.' : 'No favicon uploaded yet.' ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-body p-4">
|
||||
<h6 class="text-uppercase text-secondary small fw-bold mb-3"><?= $isRtl ? 'ما الذي سيتغيّر؟' : 'What this updates' ?></h6>
|
||||
<ul class="small text-secondary mb-0 ps-3">
|
||||
<li class="mb-2"><?= $isRtl ? 'اسم المكتبة ووصفها في الواجهة العامة والعناوين.' : 'Library name and description across the public site and page titles.' ?></li>
|
||||
<li class="mb-2"><?= $isRtl ? 'الشعار في الشريط العلوي والتذييل ولوحة الإدارة.' : 'Logo in the top bar, footer, and admin sidebar.' ?></li>
|
||||
<li class="mb-2"><?= $isRtl ? 'أيقونة المتصفح / favicon للموقع.' : 'Browser tab favicon for the site.' ?></li>
|
||||
<li><?= $isRtl ? 'معلومات التواصل، العنوان، الروابط الاجتماعية، وساعات العمل.' : 'Contact details, address, social links, and opening hours.' ?></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
<i class="bi bi-save me-2"></i> <?= $isRtl ? 'حفظ ملف المكتبة' : 'Save library profile' ?>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php admin_render_footer(); ?>
|
||||
File diff suppressed because it is too large
Load Diff
47
db/migrations/007_create_reader_activity.sql
Normal file
47
db/migrations/007_create_reader_activity.sql
Normal file
@ -0,0 +1,47 @@
|
||||
CREATE TABLE IF NOT EXISTS library_readers (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
reader_token CHAR(64) NOT NULL,
|
||||
preferred_language VARCHAR(12) NOT NULL DEFAULT 'en',
|
||||
last_path VARCHAR(255) DEFAULT NULL,
|
||||
user_agent VARCHAR(255) DEFAULT NULL,
|
||||
first_seen_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uniq_library_reader_token (reader_token),
|
||||
KEY idx_library_readers_last_seen (last_seen_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS library_reader_visits (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
reader_id BIGINT UNSIGNED NOT NULL,
|
||||
session_key VARCHAR(64) NOT NULL,
|
||||
entry_path VARCHAR(255) DEFAULT NULL,
|
||||
started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_activity_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
page_views INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uniq_library_reader_visit (reader_id, session_key),
|
||||
KEY idx_library_reader_visits_started (reader_id, started_at),
|
||||
CONSTRAINT fk_library_reader_visits_reader FOREIGN KEY (reader_id) REFERENCES library_readers(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS library_reader_activities (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
reader_id BIGINT UNSIGNED NOT NULL,
|
||||
visit_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
document_id INT UNSIGNED DEFAULT NULL,
|
||||
event_type VARCHAR(50) NOT NULL,
|
||||
page_path VARCHAR(255) DEFAULT NULL,
|
||||
context VARCHAR(20) NOT NULL DEFAULT 'public',
|
||||
meta_json TEXT DEFAULT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_library_reader_activities_reader_created (reader_id, created_at),
|
||||
KEY idx_library_reader_activities_visit (visit_id),
|
||||
KEY idx_library_reader_activities_document (document_id),
|
||||
CONSTRAINT fk_library_reader_activities_reader FOREIGN KEY (reader_id) REFERENCES library_readers(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_library_reader_activities_visit FOREIGN KEY (visit_id) REFERENCES library_reader_visits(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_library_reader_activities_document FOREIGN KEY (document_id) REFERENCES library_documents(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
29
db/migrations/008_create_library_settings.sql
Normal file
29
db/migrations/008_create_library_settings.sql
Normal file
@ -0,0 +1,29 @@
|
||||
CREATE TABLE IF NOT EXISTS library_settings (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
library_name_en VARCHAR(255) DEFAULT NULL,
|
||||
library_name_ar VARCHAR(255) DEFAULT NULL,
|
||||
short_name VARCHAR(32) DEFAULT NULL,
|
||||
tagline_en VARCHAR(255) DEFAULT NULL,
|
||||
tagline_ar VARCHAR(255) DEFAULT NULL,
|
||||
description_en TEXT DEFAULT NULL,
|
||||
description_ar TEXT DEFAULT NULL,
|
||||
contact_email VARCHAR(255) DEFAULT NULL,
|
||||
contact_phone VARCHAR(80) DEFAULT NULL,
|
||||
whatsapp_number VARCHAR(80) DEFAULT NULL,
|
||||
website_url VARCHAR(255) DEFAULT NULL,
|
||||
address_en VARCHAR(255) DEFAULT NULL,
|
||||
address_ar VARCHAR(255) DEFAULT NULL,
|
||||
opening_hours_en VARCHAR(255) DEFAULT NULL,
|
||||
opening_hours_ar VARCHAR(255) DEFAULT NULL,
|
||||
facebook_url VARCHAR(255) DEFAULT NULL,
|
||||
instagram_url VARCHAR(255) DEFAULT NULL,
|
||||
x_url VARCHAR(255) DEFAULT NULL,
|
||||
youtube_url VARCHAR(255) DEFAULT NULL,
|
||||
logo_path VARCHAR(255) DEFAULT NULL,
|
||||
favicon_path VARCHAR(255) DEFAULT NULL,
|
||||
copyright_text_en VARCHAR(255) DEFAULT NULL,
|
||||
copyright_text_ar VARCHAR(255) DEFAULT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@ -126,6 +126,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'gener
|
||||
if ($context !== 'admin') {
|
||||
library_increment_views((int) $document['id']);
|
||||
$document = library_fetch_document((int) $document['id'], true) ?: $document;
|
||||
library_track_request('document_viewed', (int) $document['id'], [
|
||||
'document_language' => (string) ($document['document_language'] ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
$selectedTitle = library_localized_document_title($document, $lang, $pageCopy['detail_fallback']);
|
||||
|
||||
@ -8,8 +8,13 @@ function admin_render_header(string $title, string $activePage = 'dashboard'): v
|
||||
$lang = library_get_language();
|
||||
$dir = $lang === 'ar' ? 'rtl' : 'ltr';
|
||||
$isRtl = $lang === 'ar';
|
||||
|
||||
// Get flashes and clear them
|
||||
$profile = library_get_profile();
|
||||
$brandName = library_profile_name($lang);
|
||||
$brandTagline = library_profile_tagline($lang);
|
||||
$brandShortName = trim((string) ($profile['short_name'] ?? '')) ?: 'NL';
|
||||
$logoPath = trim((string) ($profile['logo_path'] ?? ''));
|
||||
$faviconPath = trim((string) ($profile['favicon_path'] ?? ''));
|
||||
|
||||
$flashes = [];
|
||||
if (isset($_SESSION['library_flash'])) {
|
||||
$flashes = $_SESSION['library_flash'];
|
||||
@ -22,6 +27,10 @@ function admin_render_header(string $title, string $activePage = 'dashboard'): v
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= h($title) ?> · <?= library_trans('admin_panel') ?></title>
|
||||
<?php if ($faviconPath !== ''): ?>
|
||||
<link rel="icon" href="/<?= h($faviconPath) ?>?v=<?= time() ?>" sizes="any">
|
||||
<link rel="shortcut icon" href="/<?= h($faviconPath) ?>?v=<?= time() ?>">
|
||||
<?php endif; ?>
|
||||
<?php if ($isRtl): ?>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.rtl.min.css" integrity="sha384-dpuaG1suU0eT09tx5plTaGMLBsfDLzUCCUXOY2j/LSvXYuG6Bqs43ALlhIqAJVRb" crossorigin="anonymous">
|
||||
<?php else: ?>
|
||||
@ -30,7 +39,7 @@ function admin_render_header(string $title, string $activePage = 'dashboard'): v
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<style>
|
||||
.admin-sidebar {
|
||||
width: 260px;
|
||||
width: 280px;
|
||||
height: 100vh;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
@ -50,15 +59,54 @@ function admin_render_header(string $title, string $activePage = 'dashboard'): v
|
||||
color: #0d6efd !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
.admin-brand-card {
|
||||
border: 1px solid #e9ecef;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
|
||||
}
|
||||
.admin-brand-logo,
|
||||
.admin-brand-mark {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.admin-brand-logo {
|
||||
object-fit: cover;
|
||||
border: 1px solid #e9ecef;
|
||||
background: #fff;
|
||||
}
|
||||
.admin-brand-mark {
|
||||
background: linear-gradient(135deg, #51d0ff, #ff9c52);
|
||||
color: #07111b;
|
||||
font-weight: 800;
|
||||
letter-spacing: .08em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<div class="d-flex">
|
||||
<aside class="admin-sidebar d-flex flex-column">
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<h4 class="mb-0 text-primary fw-bold"><i class="bi bi-grid-fill me-2"></i><?= library_trans('admin_panel') ?></h4>
|
||||
<div class="card admin-brand-card shadow-sm rounded-4 mb-4">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<?php if ($logoPath !== ''): ?>
|
||||
<img src="/<?= h($logoPath) ?>?v=<?= time() ?>" alt="<?= h($brandName) ?> logo" class="admin-brand-logo">
|
||||
<?php else: ?>
|
||||
<span class="admin-brand-mark"><?= h($brandShortName) ?></span>
|
||||
<?php endif; ?>
|
||||
<div>
|
||||
<div class="fw-bold text-primary"><?= h($brandName) ?></div>
|
||||
<?php if ($brandTagline !== ''): ?>
|
||||
<div class="small text-secondary"><?= h($brandTagline) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mb-3">
|
||||
<a href="?lang=<?= library_trans('switch_lang_code') ?>" class="btn btn-sm btn-outline-secondary w-100">
|
||||
<i class="bi bi-translate me-2"></i><?= library_trans('switch_lang') ?>
|
||||
@ -69,6 +117,9 @@ function admin_render_header(string $title, string $activePage = 'dashboard'): v
|
||||
<a class="nav-link rounded <?= $activePage === 'dashboard' ? 'active' : '' ?>" href="/admin.php">
|
||||
<i class="bi bi-speedometer2 me-2"></i> <?= library_trans('dashboard') ?>
|
||||
</a>
|
||||
<a class="nav-link rounded <?= $activePage === 'library_profile' ? 'active' : '' ?>" href="/admin_library_profile.php">
|
||||
<i class="bi bi-building-gear me-2"></i> <?= $isRtl ? 'ملف المكتبة' : 'Library Profile' ?>
|
||||
</a>
|
||||
<a class="nav-link rounded <?= $activePage === 'documents' ? 'active' : '' ?>" href="/admin_documents.php">
|
||||
<i class="bi bi-folder2-open me-2"></i> <?= library_trans('material_entry') ?>
|
||||
</a>
|
||||
@ -83,7 +134,7 @@ function admin_render_header(string $title, string $activePage = 'dashboard'): v
|
||||
<i class="bi bi-file-earmark-text me-2"></i> <?= library_trans('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> <?= library_trans('return_to_site') ?>
|
||||
@ -110,7 +161,6 @@ function admin_render_footer(): void {
|
||||
</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);
|
||||
@ -154,4 +204,4 @@ function admin_render_footer(): void {
|
||||
</html>
|
||||
<?php
|
||||
}
|
||||
?>
|
||||
?>
|
||||
|
||||
@ -11,23 +11,26 @@ function library_active_nav(string $current, string $expected): string
|
||||
|
||||
function library_render_header(string $pageTitle, string $pageDescription, string $activeNav = 'catalog'): void
|
||||
{
|
||||
$lang = library_get_language();
|
||||
$isRtl = $lang === 'ar';
|
||||
$profile = library_get_profile();
|
||||
$project = library_project_meta();
|
||||
$metaDescription = $pageDescription !== '' ? $pageDescription : $project['description'];
|
||||
$projectImageUrl = $project['image'];
|
||||
$fullTitle = $pageTitle . ' · ' . $project['name'];
|
||||
$flashes = library_get_flashes();
|
||||
|
||||
// Language Logic
|
||||
$lang = library_get_language();
|
||||
$isRtl = $lang === 'ar';
|
||||
$targetLang = $isRtl ? 'en' : 'ar';
|
||||
$switchLabel = library_trans('switch_lang', $lang);
|
||||
|
||||
// Build Switch URL
|
||||
$profileLabel = $isRtl ? 'صفحتي' : 'My Activity';
|
||||
$brandName = library_profile_name($lang);
|
||||
$brandTagline = library_profile_tagline($lang);
|
||||
$brandShortName = trim((string) ($profile['short_name'] ?? '')) ?: 'NL';
|
||||
$logoPath = trim((string) ($profile['logo_path'] ?? ''));
|
||||
$faviconPath = trim((string) ($profile['favicon_path'] ?? ''));
|
||||
|
||||
$params = $_GET;
|
||||
$params['lang'] = $targetLang;
|
||||
$switchUrl = '?' . http_build_query($params);
|
||||
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="<?= h($lang) ?>" dir="<?= $isRtl ? 'rtl' : 'ltr' ?>">
|
||||
@ -44,27 +47,45 @@ function library_render_header(string $pageTitle, string $pageDescription, strin
|
||||
<meta property="og:image" content="<?= h($projectImageUrl) ?>">
|
||||
<meta property="twitter:image" content="<?= h($projectImageUrl) ?>">
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($faviconPath !== ''): ?>
|
||||
<link rel="icon" href="/<?= h($faviconPath) ?>?v=<?= time() ?>" sizes="any">
|
||||
<link rel="shortcut icon" href="/<?= h($faviconPath) ?>?v=<?= time() ?>">
|
||||
<?php endif; ?>
|
||||
<?php if ($isRtl): ?>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.rtl.min.css" integrity="sha384-dpuaG1suU0eT09tx5plTaGMLBsfDLzUCCUXOY2j/LSvXYuG6Bqs43ALlhIqAJVRb" crossorigin="anonymous">
|
||||
<?php else: ?>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
||||
<?php endif; ?>
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<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;500;600;700&family=Noto+Sans+Arabic:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cairo:wght@400;500;600;700;800&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<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"><?= library_trans('nabd_library') ?></span>
|
||||
<small class="text-secondary"><?= library_trans('tagline') ?></small>
|
||||
<div class="app-shell cinematic-library-shell">
|
||||
<div class="library-backdrop" aria-hidden="true">
|
||||
<span class="backdrop-orb orb-cyan"></span>
|
||||
<span class="backdrop-orb orb-amber"></span>
|
||||
<span class="backdrop-grid"></span>
|
||||
<span class="backdrop-books"></span>
|
||||
<span class="backdrop-scripture"></span>
|
||||
<span class="backdrop-column column-left"></span>
|
||||
<span class="backdrop-column column-right"></span>
|
||||
</div>
|
||||
<nav class="navbar navbar-expand-lg sticky-top library-topbar">
|
||||
<div class="container library-nav-shell">
|
||||
<a class="navbar-brand d-flex align-items-center gap-3" href="/index.php">
|
||||
<?php if ($logoPath !== ''): ?>
|
||||
<img class="brand-logo" src="/<?= h($logoPath) ?>?v=<?= time() ?>" alt="<?= h($brandName) ?> logo">
|
||||
<?php else: ?>
|
||||
<span class="brand-mark"><?= h($brandShortName) ?></span>
|
||||
<?php endif; ?>
|
||||
<span class="brand-lockup">
|
||||
<span class="d-block brand-title"><?= h($brandName) ?></span>
|
||||
<?php if ($brandTagline !== ''): ?>
|
||||
<small class="text-secondary d-block"><?= h($brandTagline) ?></small>
|
||||
<?php endif; ?>
|
||||
</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">
|
||||
@ -73,10 +94,9 @@ function library_render_header(string $pageTitle, string $pageDescription, strin
|
||||
<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"><?= library_trans('catalog') ?></a></li>
|
||||
<li class="nav-item"><a class="nav-link <?= library_active_nav($activeNav, 'profile') ?>" href="/user.php"><?= h($profileLabel) ?></a></li>
|
||||
<li class="nav-item"><a class="nav-link <?= library_active_nav($activeNav, 'admin') ?>" href="/admin.php"><?= library_trans('admin_studio') ?></a></li>
|
||||
|
||||
<li class="nav-item border-start border-secondary mx-2 d-none d-lg-block" style="opacity: 0.3; height: 24px;"></li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a class="btn btn-sm btn-outline-dark topbar-lang-switch" href="<?= h($switchUrl) ?>" lang="<?= h($targetLang) ?>" dir="<?= $targetLang === 'ar' ? 'rtl' : 'ltr' ?>">
|
||||
<?= h($switchLabel) ?>
|
||||
@ -87,8 +107,8 @@ function library_render_header(string $pageTitle, string $pageDescription, strin
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="pb-5">
|
||||
<div class="container py-4 py-lg-5">
|
||||
<main class="pb-5 library-main">
|
||||
<div class="container py-4 py-lg-5 library-stage">
|
||||
<?php if ($flashes): ?>
|
||||
<div class="toast-stack position-fixed top-0 end-0 p-3">
|
||||
<?php foreach ($flashes as $flash): ?>
|
||||
@ -102,24 +122,98 @@ function library_render_header(string $pageTitle, string $pageDescription, strin
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
}
|
||||
|
||||
function library_render_footer(): void
|
||||
{
|
||||
$lang = library_get_language();
|
||||
$isRtl = $lang === 'ar';
|
||||
$profile = library_get_profile();
|
||||
$brandName = library_profile_name($lang);
|
||||
$brandTagline = library_profile_tagline($lang);
|
||||
$brandDescription = library_profile_description($lang);
|
||||
$brandShortName = trim((string) ($profile['short_name'] ?? '')) ?: 'NL';
|
||||
$logoPath = trim((string) ($profile['logo_path'] ?? ''));
|
||||
$address = library_profile_address($lang);
|
||||
$openingHours = library_profile_opening_hours($lang);
|
||||
$copyright = library_localized_value(
|
||||
$profile['copyright_text_en'] ?? null,
|
||||
$profile['copyright_text_ar'] ?? null,
|
||||
$lang,
|
||||
''
|
||||
);
|
||||
$socialLinks = [
|
||||
['label' => 'Facebook', 'icon' => 'bi-facebook', 'url' => trim((string) ($profile['facebook_url'] ?? ''))],
|
||||
['label' => 'Instagram', 'icon' => 'bi-instagram', 'url' => trim((string) ($profile['instagram_url'] ?? ''))],
|
||||
['label' => 'X', 'icon' => 'bi-twitter-x', 'url' => trim((string) ($profile['x_url'] ?? ''))],
|
||||
['label' => 'YouTube', 'icon' => 'bi-youtube', 'url' => trim((string) ($profile['youtube_url'] ?? ''))],
|
||||
];
|
||||
?>
|
||||
</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"><?= library_trans('mvp_label') ?></div>
|
||||
<div><?= library_trans('upload_docs') ?></div>
|
||||
</div>
|
||||
<div class="text-lg-end">
|
||||
<div><a class="text-decoration-none" href="/admin.php"><?= library_trans('open_admin') ?></a></div>
|
||||
<div><a class="text-decoration-none" href="/index.php"><?= library_trans('browse_public') ?></a></div>
|
||||
<footer class="library-footer">
|
||||
<div class="container">
|
||||
<div class="library-footer-panel py-4 px-4 px-lg-5">
|
||||
<div class="row g-4 align-items-start">
|
||||
<div class="col-lg-5">
|
||||
<div class="footer-brand d-flex gap-3 align-items-start">
|
||||
<?php if ($logoPath !== ''): ?>
|
||||
<img class="brand-logo footer-brand-logo" src="/<?= h($logoPath) ?>?v=<?= time() ?>" alt="<?= h($brandName) ?> logo">
|
||||
<?php else: ?>
|
||||
<span class="brand-mark"><?= h($brandShortName) ?></span>
|
||||
<?php endif; ?>
|
||||
<div>
|
||||
<div class="fw-semibold footer-title mb-1"><?= h($brandName) ?></div>
|
||||
<?php if ($brandTagline !== ''): ?>
|
||||
<div class="footer-copy mb-2"><?= h($brandTagline) ?></div>
|
||||
<?php endif; ?>
|
||||
<div class="footer-copy footer-description"><?= h($brandDescription) ?></div>
|
||||
<?php if ($copyright !== ''): ?>
|
||||
<div class="footer-meta-note mt-3"><?= h($copyright) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<div class="footer-section-title mb-3"><?= $isRtl ? 'تفاصيل المكتبة' : 'Library details' ?></div>
|
||||
<div class="footer-detail-list small">
|
||||
<?php if (!empty($profile['contact_email'])): ?>
|
||||
<div><i class="bi bi-envelope"></i> <a href="mailto:<?= h($profile['contact_email']) ?>"><?= h($profile['contact_email']) ?></a></div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($profile['contact_phone'])): ?>
|
||||
<div><i class="bi bi-telephone"></i> <span><?= h($profile['contact_phone']) ?></span></div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($profile['whatsapp_number'])): ?>
|
||||
<div><i class="bi bi-whatsapp"></i> <span><?= h($profile['whatsapp_number']) ?></span></div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($profile['website_url'])): ?>
|
||||
<div><i class="bi bi-globe2"></i> <a href="<?= h($profile['website_url']) ?>" target="_blank" rel="noopener noreferrer"><?= $isRtl ? 'الموقع الإلكتروني' : 'Website' ?></a></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($address !== ''): ?>
|
||||
<div><i class="bi bi-geo-alt"></i> <span dir="<?= library_text_dir($address, $lang) ?>"><?= h($address) ?></span></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($openingHours !== ''): ?>
|
||||
<div><i class="bi bi-clock"></i> <span dir="<?= library_text_dir($openingHours, $lang) ?>"><?= h($openingHours) ?></span></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3">
|
||||
<div class="footer-section-title mb-3"><?= $isRtl ? 'روابط سريعة' : 'Quick links' ?></div>
|
||||
<div class="footer-links mb-3">
|
||||
<div><a class="text-decoration-none" href="/user.php"><?= h($isRtl ? 'سجل نشاطي' : 'My activity log') ?></a></div>
|
||||
<div><a class="text-decoration-none" href="/admin.php"><?= library_trans('open_admin') ?></a></div>
|
||||
<div><a class="text-decoration-none" href="/index.php"><?= library_trans('browse_public') ?></a></div>
|
||||
</div>
|
||||
<div class="footer-socials">
|
||||
<?php foreach ($socialLinks as $social): ?>
|
||||
<?php if ($social['url'] === '') { continue; } ?>
|
||||
<a href="<?= h($social['url']) ?>" target="_blank" rel="noopener noreferrer" aria-label="<?= h($social['label']) ?>">
|
||||
<i class="bi <?= h($social['icon']) ?>"></i>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@ -129,4 +223,4 @@ function library_render_footer(): void
|
||||
</body>
|
||||
</html>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
|
||||
@ -353,6 +353,28 @@ function library_bootstrap(): void
|
||||
}
|
||||
}
|
||||
|
||||
$migration7Path = __DIR__ . '/../db/migrations/007_create_reader_activity.sql';
|
||||
if (is_file($migration7Path)) {
|
||||
$exists = db()->query("SHOW TABLES LIKE 'library_readers'")->fetch();
|
||||
if (!$exists) {
|
||||
$sql = file_get_contents($migration7Path);
|
||||
if (is_string($sql) && trim($sql) !== '') {
|
||||
db()->exec($sql);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$migration8Path = __DIR__ . '/../db/migrations/008_create_library_settings.sql';
|
||||
if (is_file($migration8Path)) {
|
||||
$exists = db()->query("SHOW TABLES LIKE 'library_settings'")->fetch();
|
||||
if (!$exists) {
|
||||
$sql = file_get_contents($migration8Path);
|
||||
if (is_string($sql) && trim($sql) !== '') {
|
||||
db()->exec($sql);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$uploadDir = __DIR__ . '/../uploads/library';
|
||||
if (!is_dir($uploadDir)) {
|
||||
mkdir($uploadDir, 0775, true);
|
||||
@ -363,6 +385,11 @@ function library_bootstrap(): void
|
||||
mkdir($coverDir, 0775, true);
|
||||
}
|
||||
|
||||
$brandingDir = __DIR__ . '/../uploads/branding';
|
||||
if (!is_dir($brandingDir)) {
|
||||
mkdir($brandingDir, 0775, true);
|
||||
}
|
||||
|
||||
library_seed_demo_documents();
|
||||
$booted = true;
|
||||
}
|
||||
@ -374,13 +401,311 @@ function h(?string $value): string
|
||||
|
||||
function library_project_meta(): array
|
||||
{
|
||||
$lang = library_get_language();
|
||||
|
||||
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.',
|
||||
'name' => library_profile_name($lang),
|
||||
'description' => library_profile_description($lang),
|
||||
'image' => $_SERVER['PROJECT_IMAGE_URL'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
function library_profile_defaults(): array
|
||||
{
|
||||
return [
|
||||
'library_name_en' => $_SERVER['PROJECT_NAME'] ?? 'Nabd Library',
|
||||
'library_name_ar' => 'مكتبة نبض',
|
||||
'short_name' => 'NL',
|
||||
'tagline_en' => 'Arabic · English e-library',
|
||||
'tagline_ar' => 'مكتبة رقمية عربية · إنجليزية',
|
||||
'description_en' => $_SERVER['PROJECT_DESCRIPTION'] ?? 'Bilingual electronic library for Arabic and English documents, online reading, and AI-assisted summaries.',
|
||||
'description_ar' => 'مكتبة إلكترونية ثنائية اللغة للوثائق العربية والإنجليزية، مع قراءة عبر الإنترنت وملخصات مدعومة بالذكاء الاصطناعي.',
|
||||
'contact_email' => '',
|
||||
'contact_phone' => '',
|
||||
'whatsapp_number' => '',
|
||||
'website_url' => '',
|
||||
'address_en' => '',
|
||||
'address_ar' => '',
|
||||
'opening_hours_en' => '',
|
||||
'opening_hours_ar' => '',
|
||||
'facebook_url' => '',
|
||||
'instagram_url' => '',
|
||||
'x_url' => '',
|
||||
'youtube_url' => '',
|
||||
'logo_path' => '',
|
||||
'favicon_path' => '',
|
||||
'copyright_text_en' => '',
|
||||
'copyright_text_ar' => '',
|
||||
];
|
||||
}
|
||||
|
||||
function library_get_profile(): array
|
||||
{
|
||||
static $profile = null;
|
||||
if ($profile !== null) {
|
||||
return $profile;
|
||||
}
|
||||
|
||||
library_bootstrap();
|
||||
$defaults = library_profile_defaults();
|
||||
$stmt = db()->query('SELECT * FROM library_settings ORDER BY id ASC LIMIT 1');
|
||||
$row = $stmt ? ($stmt->fetch() ?: []) : [];
|
||||
$profile = array_merge($defaults, is_array($row) ? $row : []);
|
||||
|
||||
return $profile;
|
||||
}
|
||||
|
||||
function library_profile_name(?string $lang = null): string
|
||||
{
|
||||
$lang = $lang ?? library_get_language();
|
||||
$profile = library_get_profile();
|
||||
|
||||
return library_localized_value(
|
||||
$profile['library_name_en'] ?? null,
|
||||
$profile['library_name_ar'] ?? null,
|
||||
$lang,
|
||||
$_SERVER['PROJECT_NAME'] ?? 'Nabd Library'
|
||||
);
|
||||
}
|
||||
|
||||
function library_profile_tagline(?string $lang = null): string
|
||||
{
|
||||
$lang = $lang ?? library_get_language();
|
||||
$profile = library_get_profile();
|
||||
|
||||
return library_localized_value(
|
||||
$profile['tagline_en'] ?? null,
|
||||
$profile['tagline_ar'] ?? null,
|
||||
$lang,
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
function library_profile_description(?string $lang = null): string
|
||||
{
|
||||
$lang = $lang ?? library_get_language();
|
||||
$profile = library_get_profile();
|
||||
|
||||
return library_localized_value(
|
||||
$profile['description_en'] ?? null,
|
||||
$profile['description_ar'] ?? null,
|
||||
$lang,
|
||||
$_SERVER['PROJECT_DESCRIPTION'] ?? 'Bilingual electronic library for Arabic and English documents, online reading, and AI-assisted summaries.'
|
||||
);
|
||||
}
|
||||
|
||||
function library_profile_address(?string $lang = null): string
|
||||
{
|
||||
$lang = $lang ?? library_get_language();
|
||||
$profile = library_get_profile();
|
||||
|
||||
return library_localized_value(
|
||||
$profile['address_en'] ?? null,
|
||||
$profile['address_ar'] ?? null,
|
||||
$lang,
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
function library_profile_opening_hours(?string $lang = null): string
|
||||
{
|
||||
$lang = $lang ?? library_get_language();
|
||||
$profile = library_get_profile();
|
||||
|
||||
return library_localized_value(
|
||||
$profile['opening_hours_en'] ?? null,
|
||||
$profile['opening_hours_ar'] ?? null,
|
||||
$lang,
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
function library_normalize_profile_value($value, int $maxLength = 0): ?string
|
||||
{
|
||||
$normalized = trim((string) $value);
|
||||
if ($normalized === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($maxLength > 0 && function_exists('mb_substr')) {
|
||||
$normalized = mb_substr($normalized, 0, $maxLength);
|
||||
} elseif ($maxLength > 0) {
|
||||
$normalized = substr($normalized, 0, $maxLength);
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
function library_normalize_profile_url($value): ?string
|
||||
{
|
||||
$normalized = trim((string) $value);
|
||||
if ($normalized === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!preg_match('~^https?://~i', $normalized)) {
|
||||
$normalized = 'https://' . ltrim($normalized, '/');
|
||||
}
|
||||
|
||||
if (!filter_var($normalized, FILTER_VALIDATE_URL)) {
|
||||
throw new RuntimeException('Please provide valid URLs for website and social links.');
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
function library_handle_brand_asset(array $file, string $kind): ?string
|
||||
{
|
||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$originalName = (string) ($file['name'] ?? '');
|
||||
$extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
|
||||
$rules = [
|
||||
'logo' => [
|
||||
'allowed' => ['jpg', 'jpeg', 'png', 'webp'],
|
||||
'max_size' => 4 * 1024 * 1024,
|
||||
'label' => 'logo',
|
||||
],
|
||||
'favicon' => [
|
||||
'allowed' => ['ico', 'png', 'webp'],
|
||||
'max_size' => 1024 * 1024,
|
||||
'label' => 'favicon',
|
||||
],
|
||||
];
|
||||
|
||||
if (!isset($rules[$kind])) {
|
||||
throw new RuntimeException('Unsupported upload type.');
|
||||
}
|
||||
|
||||
$rule = $rules[$kind];
|
||||
if (!in_array($extension, $rule['allowed'], true)) {
|
||||
throw new RuntimeException(sprintf('Unsupported %s format. Allowed: %s.', $rule['label'], implode(', ', $rule['allowed'])));
|
||||
}
|
||||
|
||||
$size = (int) ($file['size'] ?? 0);
|
||||
if ($size <= 0 || $size > $rule['max_size']) {
|
||||
throw new RuntimeException(sprintf('The %s file is too large.', $rule['label']));
|
||||
}
|
||||
|
||||
$safeBase = preg_replace('/[^a-zA-Z0-9_-]+/', '-', pathinfo($originalName, PATHINFO_FILENAME)) ?: $kind;
|
||||
$storedName = strtolower($kind . '-' . date('YmdHis') . '-' . $safeBase . '-' . bin2hex(random_bytes(4)) . '.' . $extension);
|
||||
$relativePath = 'uploads/branding/' . $storedName;
|
||||
$absolutePath = __DIR__ . '/../' . $relativePath;
|
||||
|
||||
if (!move_uploaded_file((string) $file['tmp_name'], $absolutePath)) {
|
||||
throw new RuntimeException(sprintf('Unable to save the %s file.', $rule['label']));
|
||||
}
|
||||
|
||||
return $relativePath;
|
||||
}
|
||||
|
||||
function library_save_profile(array $payload, array $files = []): void
|
||||
{
|
||||
library_bootstrap();
|
||||
|
||||
$profile = library_get_profile();
|
||||
|
||||
$libraryNameEn = library_normalize_profile_value($payload['library_name_en'] ?? null, 255);
|
||||
$libraryNameAr = library_normalize_profile_value($payload['library_name_ar'] ?? null, 255);
|
||||
if ($libraryNameEn === null && $libraryNameAr === null) {
|
||||
throw new RuntimeException('Please provide at least one library name.');
|
||||
}
|
||||
|
||||
$contactEmail = library_normalize_profile_value($payload['contact_email'] ?? null, 255);
|
||||
if ($contactEmail !== null && !filter_var($contactEmail, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new RuntimeException('Please provide a valid contact email address.');
|
||||
}
|
||||
|
||||
$logoPath = $profile['logo_path'] ?? null;
|
||||
$faviconPath = $profile['favicon_path'] ?? null;
|
||||
$newLogo = library_handle_brand_asset($files['logo_file'] ?? [], 'logo');
|
||||
$newFavicon = library_handle_brand_asset($files['favicon_file'] ?? [], 'favicon');
|
||||
if ($newLogo !== null) {
|
||||
$logoPath = $newLogo;
|
||||
}
|
||||
if ($newFavicon !== null) {
|
||||
$faviconPath = $newFavicon;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'library_name_en' => $libraryNameEn,
|
||||
'library_name_ar' => library_normalize_profile_value($payload['library_name_ar'] ?? null, 255),
|
||||
'short_name' => library_normalize_profile_value($payload['short_name'] ?? null, 32),
|
||||
'tagline_en' => library_normalize_profile_value($payload['tagline_en'] ?? null, 255),
|
||||
'tagline_ar' => library_normalize_profile_value($payload['tagline_ar'] ?? null, 255),
|
||||
'description_en' => library_normalize_profile_value($payload['description_en'] ?? null),
|
||||
'description_ar' => library_normalize_profile_value($payload['description_ar'] ?? null),
|
||||
'contact_email' => $contactEmail,
|
||||
'contact_phone' => library_normalize_profile_value($payload['contact_phone'] ?? null, 80),
|
||||
'whatsapp_number' => library_normalize_profile_value($payload['whatsapp_number'] ?? null, 80),
|
||||
'website_url' => library_normalize_profile_url($payload['website_url'] ?? null),
|
||||
'address_en' => library_normalize_profile_value($payload['address_en'] ?? null, 255),
|
||||
'address_ar' => library_normalize_profile_value($payload['address_ar'] ?? null, 255),
|
||||
'opening_hours_en' => library_normalize_profile_value($payload['opening_hours_en'] ?? null, 255),
|
||||
'opening_hours_ar' => library_normalize_profile_value($payload['opening_hours_ar'] ?? null, 255),
|
||||
'facebook_url' => library_normalize_profile_url($payload['facebook_url'] ?? null),
|
||||
'instagram_url' => library_normalize_profile_url($payload['instagram_url'] ?? null),
|
||||
'x_url' => library_normalize_profile_url($payload['x_url'] ?? null),
|
||||
'youtube_url' => library_normalize_profile_url($payload['youtube_url'] ?? null),
|
||||
'logo_path' => $logoPath,
|
||||
'favicon_path' => $faviconPath,
|
||||
'copyright_text_en' => library_normalize_profile_value($payload['copyright_text_en'] ?? null, 255),
|
||||
'copyright_text_ar' => library_normalize_profile_value($payload['copyright_text_ar'] ?? null, 255),
|
||||
];
|
||||
|
||||
$existingId = db()->query('SELECT id FROM library_settings ORDER BY id ASC LIMIT 1')->fetchColumn();
|
||||
if ($existingId) {
|
||||
$sql = 'UPDATE library_settings SET
|
||||
library_name_en = :library_name_en,
|
||||
library_name_ar = :library_name_ar,
|
||||
short_name = :short_name,
|
||||
tagline_en = :tagline_en,
|
||||
tagline_ar = :tagline_ar,
|
||||
description_en = :description_en,
|
||||
description_ar = :description_ar,
|
||||
contact_email = :contact_email,
|
||||
contact_phone = :contact_phone,
|
||||
whatsapp_number = :whatsapp_number,
|
||||
website_url = :website_url,
|
||||
address_en = :address_en,
|
||||
address_ar = :address_ar,
|
||||
opening_hours_en = :opening_hours_en,
|
||||
opening_hours_ar = :opening_hours_ar,
|
||||
facebook_url = :facebook_url,
|
||||
instagram_url = :instagram_url,
|
||||
x_url = :x_url,
|
||||
youtube_url = :youtube_url,
|
||||
logo_path = :logo_path,
|
||||
favicon_path = :favicon_path,
|
||||
copyright_text_en = :copyright_text_en,
|
||||
copyright_text_ar = :copyright_text_ar
|
||||
WHERE id = :id';
|
||||
$stmt = db()->prepare($sql);
|
||||
$data['id'] = (int) $existingId;
|
||||
$stmt->execute($data);
|
||||
} else {
|
||||
$sql = 'INSERT INTO library_settings (
|
||||
library_name_en, library_name_ar, short_name, tagline_en, tagline_ar,
|
||||
description_en, description_ar, contact_email, contact_phone, whatsapp_number,
|
||||
website_url, address_en, address_ar, opening_hours_en, opening_hours_ar,
|
||||
facebook_url, instagram_url, x_url, youtube_url, logo_path, favicon_path,
|
||||
copyright_text_en, copyright_text_ar
|
||||
) VALUES (
|
||||
:library_name_en, :library_name_ar, :short_name, :tagline_en, :tagline_ar,
|
||||
:description_en, :description_ar, :contact_email, :contact_phone, :whatsapp_number,
|
||||
:website_url, :address_en, :address_ar, :opening_hours_en, :opening_hours_ar,
|
||||
:facebook_url, :instagram_url, :x_url, :youtube_url, :logo_path, :favicon_path,
|
||||
:copyright_text_en, :copyright_text_ar
|
||||
)';
|
||||
$stmt = db()->prepare($sql);
|
||||
$stmt->execute($data);
|
||||
}
|
||||
|
||||
unset($GLOBALS['library_profile_cache']);
|
||||
}
|
||||
|
||||
function library_set_flash(string $type, string $message): void
|
||||
{
|
||||
$_SESSION['library_flash'][] = ['type' => $type, 'message' => $message];
|
||||
@ -552,6 +877,323 @@ function library_increment_views(int $id): void
|
||||
$stmt->execute([$id]);
|
||||
}
|
||||
|
||||
function library_reader_cookie_name(): string
|
||||
{
|
||||
return 'nabd_reader_token';
|
||||
}
|
||||
|
||||
function library_current_request_path(): string
|
||||
{
|
||||
$path = (string) parse_url((string) ($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH);
|
||||
$path = $path !== '' ? $path : '/';
|
||||
return substr($path, 0, 255);
|
||||
}
|
||||
|
||||
function library_current_reader_token(bool $create = true): string
|
||||
{
|
||||
static $token = null;
|
||||
if ($token !== null) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
$candidate = strtolower(trim((string) ($_SESSION['library_reader_token'] ?? ($_COOKIE[library_reader_cookie_name()] ?? ''))));
|
||||
if (preg_match('/^[a-f0-9]{64}$/', $candidate) === 1) {
|
||||
$_SESSION['library_reader_token'] = $candidate;
|
||||
$token = $candidate;
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
if (!$create) {
|
||||
$token = '';
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
$candidate = bin2hex(random_bytes(32));
|
||||
} catch (Throwable $e) {
|
||||
$candidate = hash('sha256', uniqid('reader', true) . microtime(true));
|
||||
}
|
||||
|
||||
$_SESSION['library_reader_token'] = $candidate;
|
||||
$_COOKIE[library_reader_cookie_name()] = $candidate;
|
||||
|
||||
if (!headers_sent()) {
|
||||
setcookie(library_reader_cookie_name(), $candidate, [
|
||||
'expires' => time() + (60 * 60 * 24 * 365),
|
||||
'path' => '/',
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
|
||||
]);
|
||||
}
|
||||
|
||||
$token = $candidate;
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
function library_get_current_reader(bool $create = true): ?array
|
||||
{
|
||||
static $reader = null;
|
||||
|
||||
if (is_array($reader)) {
|
||||
return $reader;
|
||||
}
|
||||
|
||||
library_bootstrap();
|
||||
$token = library_current_reader_token($create);
|
||||
if ($token === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = db()->prepare('SELECT * FROM library_readers WHERE reader_token = :token LIMIT 1');
|
||||
$stmt->bindValue(':token', $token, PDO::PARAM_STR);
|
||||
$stmt->execute();
|
||||
$reader = $stmt->fetch();
|
||||
|
||||
if (!$reader && $create) {
|
||||
$insert = db()->prepare('INSERT INTO library_readers (reader_token, preferred_language, last_path, user_agent) VALUES (:token, :language, :path, :user_agent)');
|
||||
$insert->bindValue(':token', $token, PDO::PARAM_STR);
|
||||
$insert->bindValue(':language', library_get_language(), PDO::PARAM_STR);
|
||||
$insert->bindValue(':path', library_current_request_path(), PDO::PARAM_STR);
|
||||
$insert->bindValue(':user_agent', substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255), PDO::PARAM_STR);
|
||||
$insert->execute();
|
||||
|
||||
$stmt->execute();
|
||||
$reader = $stmt->fetch();
|
||||
}
|
||||
|
||||
return is_array($reader) ? $reader : null;
|
||||
}
|
||||
|
||||
function library_touch_current_reader(int $readerId): void
|
||||
{
|
||||
$stmt = db()->prepare('UPDATE library_readers SET preferred_language = :language, last_path = :path, user_agent = :user_agent, last_seen_at = NOW() WHERE id = :id');
|
||||
$stmt->bindValue(':language', library_get_language(), PDO::PARAM_STR);
|
||||
$stmt->bindValue(':path', library_current_request_path(), PDO::PARAM_STR);
|
||||
$stmt->bindValue(':user_agent', substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255), PDO::PARAM_STR);
|
||||
$stmt->bindValue(':id', $readerId, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
}
|
||||
|
||||
function library_touch_current_visit(int $readerId): array
|
||||
{
|
||||
static $visit = null;
|
||||
|
||||
if (is_array($visit) && (int) ($visit['reader_id'] ?? 0) === $readerId) {
|
||||
return $visit;
|
||||
}
|
||||
|
||||
$sessionKey = (string) ($_SESSION['library_visit_key'] ?? '');
|
||||
if (preg_match('/^[a-f0-9]{32}$/', $sessionKey) !== 1) {
|
||||
try {
|
||||
$sessionKey = bin2hex(random_bytes(16));
|
||||
} catch (Throwable $e) {
|
||||
$sessionKey = substr(hash('sha256', session_id() . microtime(true)), 0, 32);
|
||||
}
|
||||
$_SESSION['library_visit_key'] = $sessionKey;
|
||||
}
|
||||
|
||||
$select = db()->prepare('SELECT * FROM library_reader_visits WHERE reader_id = :reader_id AND session_key = :session_key LIMIT 1');
|
||||
$select->bindValue(':reader_id', $readerId, PDO::PARAM_INT);
|
||||
$select->bindValue(':session_key', $sessionKey, PDO::PARAM_STR);
|
||||
$select->execute();
|
||||
$visit = $select->fetch();
|
||||
|
||||
if ($visit) {
|
||||
$update = db()->prepare('UPDATE library_reader_visits SET last_activity_at = NOW(), page_views = page_views + 1 WHERE id = :id');
|
||||
$update->bindValue(':id', (int) $visit['id'], PDO::PARAM_INT);
|
||||
$update->execute();
|
||||
|
||||
$select->execute();
|
||||
$visit = $select->fetch();
|
||||
return is_array($visit) ? $visit : [];
|
||||
}
|
||||
|
||||
$insert = db()->prepare('INSERT INTO library_reader_visits (reader_id, session_key, entry_path) VALUES (:reader_id, :session_key, :entry_path)');
|
||||
$insert->bindValue(':reader_id', $readerId, PDO::PARAM_INT);
|
||||
$insert->bindValue(':session_key', $sessionKey, PDO::PARAM_STR);
|
||||
$insert->bindValue(':entry_path', library_current_request_path(), PDO::PARAM_STR);
|
||||
$insert->execute();
|
||||
|
||||
$select->execute();
|
||||
$visit = $select->fetch();
|
||||
|
||||
return is_array($visit) ? $visit : [];
|
||||
}
|
||||
|
||||
function library_log_reader_activity(int $readerId, ?int $visitId, string $eventType, ?int $documentId = null, array $meta = [], string $context = 'public'): void
|
||||
{
|
||||
$filteredMeta = array_filter($meta, static fn ($value) => $value !== null && $value !== '');
|
||||
$metaJson = $filteredMeta ? json_encode($filteredMeta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null;
|
||||
|
||||
$stmt = db()->prepare('INSERT INTO library_reader_activities (reader_id, visit_id, document_id, event_type, page_path, context, meta_json) VALUES (:reader_id, :visit_id, :document_id, :event_type, :page_path, :context, :meta_json)');
|
||||
$stmt->bindValue(':reader_id', $readerId, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':visit_id', $visitId, $visitId === null ? PDO::PARAM_NULL : PDO::PARAM_INT);
|
||||
$stmt->bindValue(':document_id', $documentId, $documentId === null ? PDO::PARAM_NULL : PDO::PARAM_INT);
|
||||
$stmt->bindValue(':event_type', substr($eventType, 0, 50), PDO::PARAM_STR);
|
||||
$stmt->bindValue(':page_path', library_current_request_path(), PDO::PARAM_STR);
|
||||
$stmt->bindValue(':context', substr($context, 0, 20), PDO::PARAM_STR);
|
||||
$stmt->bindValue(':meta_json', $metaJson, $metaJson === null ? PDO::PARAM_NULL : PDO::PARAM_STR);
|
||||
$stmt->execute();
|
||||
}
|
||||
|
||||
function library_track_request(string $eventType, ?int $documentId = null, array $meta = [], string $context = 'public'): void
|
||||
{
|
||||
static $tracked = false;
|
||||
if ($tracked) {
|
||||
return;
|
||||
}
|
||||
|
||||
$reader = library_get_current_reader(true);
|
||||
if (!$reader) {
|
||||
return;
|
||||
}
|
||||
|
||||
$readerId = (int) ($reader['id'] ?? 0);
|
||||
if ($readerId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
library_touch_current_reader($readerId);
|
||||
$visit = library_touch_current_visit($readerId);
|
||||
$visitId = isset($visit['id']) ? (int) $visit['id'] : null;
|
||||
|
||||
library_log_reader_activity($readerId, $visitId, $eventType, $documentId, $meta, $context);
|
||||
$tracked = true;
|
||||
}
|
||||
|
||||
function library_get_current_reader_summary(): ?array
|
||||
{
|
||||
$reader = library_get_current_reader(true);
|
||||
if (!$reader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$readerId = (int) ($reader['id'] ?? 0);
|
||||
if ($readerId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$readerStmt = db()->prepare('SELECT * FROM library_readers WHERE id = :id LIMIT 1');
|
||||
$readerStmt->bindValue(':id', $readerId, PDO::PARAM_INT);
|
||||
$readerStmt->execute();
|
||||
$readerRow = $readerStmt->fetch();
|
||||
if (!$readerRow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = db()->prepare(
|
||||
'SELECT
|
||||
(SELECT COUNT(*) FROM library_reader_visits WHERE reader_id = :reader_id_1) AS total_visits,
|
||||
(SELECT COUNT(*) FROM library_reader_activities WHERE reader_id = :reader_id_2) AS total_activities,
|
||||
(SELECT COUNT(DISTINCT document_id) FROM library_reader_activities WHERE reader_id = :reader_id_3 AND document_id IS NOT NULL) AS documents_opened,
|
||||
(SELECT MAX(created_at) FROM library_reader_activities WHERE reader_id = :reader_id_4 AND document_id IS NOT NULL) AS last_read_at'
|
||||
);
|
||||
$stmt->bindValue(':reader_id_1', $readerId, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':reader_id_2', $readerId, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':reader_id_3', $readerId, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':reader_id_4', $readerId, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
$metrics = $stmt->fetch() ?: [];
|
||||
|
||||
return array_merge($readerRow, $metrics);
|
||||
}
|
||||
|
||||
function library_get_current_reader_history(int $limit = 8): array
|
||||
{
|
||||
$reader = library_get_current_reader(true);
|
||||
if (!$reader) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$stmt = db()->prepare(
|
||||
'SELECT
|
||||
d.id,
|
||||
d.title_en,
|
||||
d.title_ar,
|
||||
d.author,
|
||||
d.document_language,
|
||||
d.cover_image_path,
|
||||
MAX(a.created_at) AS last_opened_at,
|
||||
COUNT(*) AS open_count,
|
||||
MAX(CASE WHEN a.event_type = "reader_opened" THEN a.created_at ELSE NULL END) AS last_reader_opened_at
|
||||
FROM library_reader_activities a
|
||||
INNER JOIN library_documents d ON d.id = a.document_id
|
||||
WHERE a.reader_id = :reader_id
|
||||
AND a.event_type IN ("document_viewed", "reader_opened")
|
||||
GROUP BY d.id, d.title_en, d.title_ar, d.author, d.document_language, d.cover_image_path
|
||||
ORDER BY last_opened_at DESC
|
||||
LIMIT :limit'
|
||||
);
|
||||
$stmt->bindValue(':reader_id', (int) $reader['id'], PDO::PARAM_INT);
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return $stmt->fetchAll() ?: [];
|
||||
}
|
||||
|
||||
function library_get_current_reader_activities(int $limit = 16): array
|
||||
{
|
||||
$reader = library_get_current_reader(true);
|
||||
if (!$reader) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$stmt = db()->prepare(
|
||||
'SELECT
|
||||
a.id,
|
||||
a.visit_id,
|
||||
a.document_id,
|
||||
a.event_type,
|
||||
a.page_path,
|
||||
a.context,
|
||||
a.meta_json,
|
||||
a.created_at,
|
||||
d.title_en,
|
||||
d.title_ar,
|
||||
d.author,
|
||||
d.document_language
|
||||
FROM library_reader_activities a
|
||||
LEFT JOIN library_documents d ON d.id = a.document_id
|
||||
WHERE a.reader_id = :reader_id
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT :limit'
|
||||
);
|
||||
$stmt->bindValue(':reader_id', (int) $reader['id'], PDO::PARAM_INT);
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return $stmt->fetchAll() ?: [];
|
||||
}
|
||||
|
||||
function library_get_current_reader_visits(int $limit = 8): array
|
||||
{
|
||||
$reader = library_get_current_reader(true);
|
||||
if (!$reader) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$stmt = db()->prepare(
|
||||
'SELECT
|
||||
v.id,
|
||||
v.entry_path,
|
||||
v.started_at,
|
||||
v.last_activity_at,
|
||||
v.page_views,
|
||||
(SELECT COUNT(*) FROM library_reader_activities a WHERE a.visit_id = v.id) AS activity_count,
|
||||
(SELECT COUNT(*) FROM library_reader_activities a WHERE a.visit_id = v.id AND a.document_id IS NOT NULL) AS document_count
|
||||
FROM library_reader_visits v
|
||||
WHERE v.reader_id = :reader_id
|
||||
ORDER BY v.started_at DESC
|
||||
LIMIT :limit'
|
||||
);
|
||||
$stmt->bindValue(':reader_id', (int) $reader['id'], PDO::PARAM_INT);
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return $stmt->fetchAll() ?: [];
|
||||
}
|
||||
|
||||
function library_generate_summary(int $id): array
|
||||
{
|
||||
library_bootstrap();
|
||||
|
||||
287
index.php
287
index.php
@ -9,42 +9,57 @@ library_bootstrap();
|
||||
$lang = library_get_language();
|
||||
$query = trim((string) ($_GET['q'] ?? ''));
|
||||
$language = trim((string) ($_GET['language'] ?? ''));
|
||||
library_track_request('catalog_viewed', null, [
|
||||
'query' => $query,
|
||||
'language_filter' => $language,
|
||||
]);
|
||||
|
||||
$pageCopy = [
|
||||
'en' => [
|
||||
'meta_title' => 'Digital Catalog',
|
||||
'meta_description' => 'Browse the public digital catalog, switch the interface language from the top bar, and open documents in the browser.',
|
||||
'hero_eyebrow' => 'Electronic library · one language per view',
|
||||
'hero_title' => 'A cleaner catalog with a top-bar language switch.',
|
||||
'hero_copy' => 'The public library now stays in one interface language at a time. Use the switch in the top bar to move between English and Arabic while keeping the same page.',
|
||||
'browse_catalog' => 'Browse catalog',
|
||||
'add_documents' => 'Add documents',
|
||||
'snapshot_kicker' => 'Live shelf snapshot',
|
||||
'snapshot_title' => 'What this delivery includes',
|
||||
'snapshot_badge' => 'Updated UI',
|
||||
'meta_title' => 'Cinematic Digital Library',
|
||||
'meta_description' => 'Explore a bespoke digital library with an editorial catalog, immersive discovery panels, and bilingual browsing for public documents.',
|
||||
'hero_eyebrow' => 'Cinematic editorial library',
|
||||
'hero_title' => 'A digital archive staged like a premium reading room.',
|
||||
'hero_copy' => 'Browse the public collection inside a more expressive library shell: dark graphite atmosphere, warm archival surfaces, and faster discovery across English and Arabic titles.',
|
||||
'browse_catalog' => 'Enter the catalog',
|
||||
'add_documents' => 'Open Admin Studio',
|
||||
'hero_stat_public' => 'public works on display',
|
||||
'hero_stat_private' => 'private works in reserve',
|
||||
'hero_stat_summaries' => 'AI summaries prepared',
|
||||
'curator_kicker' => 'Curator note',
|
||||
'curator_title' => 'Designed for a modern archive, not a generic dashboard.',
|
||||
'curator_copy' => 'This first theme pass turns the library into a more memorable public-facing experience while keeping the structure fast, readable, and ready for the document pages next.',
|
||||
'curator_name' => 'Editorial Library Mode',
|
||||
'curator_role' => 'Graphite base · ivory surfaces · cyan and amber glow',
|
||||
'snapshot_kicker' => 'Shelf spotlight',
|
||||
'snapshot_title' => 'What now feels different',
|
||||
'snapshot_badge' => 'Bespoke theme',
|
||||
'public_titles' => 'Public titles',
|
||||
'private_titles' => 'Private titles',
|
||||
'ai_summaries' => 'AI summaries',
|
||||
'snapshot_item_1' => 'Single-language public pages with a top-bar switch',
|
||||
'snapshot_item_2' => 'Catalog filters for keyword and content language',
|
||||
'snapshot_item_3' => 'Document pages with localized title, summary, and description',
|
||||
'discovery_kicker' => 'Public discovery',
|
||||
'discovery_title' => 'Search the live collection',
|
||||
'snapshot_item_1' => 'Shared public pages inherit the new cinematic shell automatically',
|
||||
'snapshot_item_2' => 'Discovery controls now sit in a more premium editorial layout',
|
||||
'snapshot_item_3' => 'The visual system is ready to extend into document and profile pages next',
|
||||
'discovery_kicker' => 'Discovery desk',
|
||||
'discovery_title' => 'Search the active collection',
|
||||
'keyword' => 'Keyword',
|
||||
'keyword_placeholder' => 'Title, author, tag, or excerpt',
|
||||
'language_filter' => 'Content language',
|
||||
'all_shelves' => 'All shelves',
|
||||
'filter' => 'Filter',
|
||||
'rules_kicker' => 'Visibility rules',
|
||||
'rules_title' => 'Admin-controlled access',
|
||||
'rules_copy' => 'Public items appear in this catalog immediately. Private items stay off the public shelf and remain available only from the admin workspace.',
|
||||
'filter' => 'Refine',
|
||||
'rules_kicker' => 'Reading room rules',
|
||||
'rules_title' => 'Public and private shelves remain clearly separated.',
|
||||
'rules_copy' => 'Only public works appear in this catalog. Private items stay reserved for the admin workspace, so the public stage remains intentional and curated.',
|
||||
'rules_link' => 'Review publishing controls',
|
||||
'catalog_kicker' => 'Catalog',
|
||||
'catalog_title' => 'Available public titles',
|
||||
'palette_title' => 'Theme palette',
|
||||
'palette_copy' => 'Graphite depth, archival ivory cards, luminous cyan highlights, and warm amber contrast create a premium “digital archive” mood.',
|
||||
'catalog_kicker' => 'Public collection',
|
||||
'catalog_title' => 'Available titles on stage',
|
||||
'catalog_copy' => 'A cleaner one-language-per-view catalog with stronger visual hierarchy and a more collectible feel.',
|
||||
'result_singular' => 'result',
|
||||
'result_plural' => 'results',
|
||||
'empty_title' => 'No public documents yet',
|
||||
'empty_copy' => 'Upload your first Arabic or English document from the Admin Studio to turn this into a browsable library.',
|
||||
'empty_copy' => 'Upload your first Arabic or English document from the Admin Studio to turn this stage into a browsable library.',
|
||||
'open_admin' => 'Open Admin Studio',
|
||||
'no_cover' => 'No cover',
|
||||
'untitled' => 'Untitled document',
|
||||
@ -54,72 +69,82 @@ $pageCopy = [
|
||||
'tags' => 'Tags',
|
||||
'author_fallback' => 'Not set',
|
||||
'open_reader' => 'Open reader →',
|
||||
'workflow_kicker' => 'Workflow',
|
||||
'workflow_title' => 'Thin slice, end to end',
|
||||
'workflow_item_1' => 'Admin uploads a document and chooses public or private visibility.',
|
||||
'workflow_item_2' => 'Readers discover public titles from the catalog and open the detail page.',
|
||||
'workflow_item_3' => 'Summaries and descriptions follow the selected interface language.',
|
||||
'recent_kicker' => 'Recently added',
|
||||
'recent_title' => 'Latest public titles',
|
||||
'manage_shelf' => 'Manage shelf',
|
||||
'workflow_kicker' => 'Reader journey',
|
||||
'workflow_title' => 'How the experience flows',
|
||||
'workflow_item_1' => 'An admin uploads a document and decides whether it belongs on the public stage or in private reserve.',
|
||||
'workflow_item_2' => 'Readers discover public titles from the catalog, then open the detail page and immersive reader.',
|
||||
'workflow_item_3' => 'Language, summaries, and activity tracking stay aligned with the current browsing context.',
|
||||
'recent_kicker' => 'Fresh arrivals',
|
||||
'recent_title' => 'Latest public additions',
|
||||
'manage_shelf' => 'Manage the shelf',
|
||||
'unknown_author' => 'Unknown author',
|
||||
],
|
||||
'ar' => [
|
||||
'meta_title' => 'الفهرس الرقمي',
|
||||
'meta_description' => 'تصفح الفهرس الرقمي العام، وبدّل لغة الواجهة من الشريط العلوي، وافتح المستندات داخل المتصفح.',
|
||||
'hero_eyebrow' => 'مكتبة إلكترونية · لغة واحدة لكل عرض',
|
||||
'hero_title' => 'فهرس أوضح مع مفتاح تبديل اللغة في الشريط العلوي.',
|
||||
'hero_copy' => 'تعرض المكتبة العامة الآن لغة واجهة واحدة في كل مرة. استخدم المفتاح في الشريط العلوي للتبديل بين العربية والإنجليزية مع البقاء في الصفحة نفسها.',
|
||||
'browse_catalog' => 'تصفح الفهرس',
|
||||
'add_documents' => 'إضافة مستندات',
|
||||
'snapshot_kicker' => 'نظرة مباشرة على الرف',
|
||||
'snapshot_title' => 'ما الذي يتضمنه هذا التحديث',
|
||||
'snapshot_badge' => 'واجهة محدثة',
|
||||
'meta_title' => 'مكتبة رقمية سينمائية',
|
||||
'meta_description' => 'استكشف مكتبة رقمية بتصميم مخصص مع فهرس تحريري ولوحات اكتشاف غامرة وتصفح ثنائي اللغة للمستندات العامة.',
|
||||
'hero_eyebrow' => 'مكتبة تحريرية سينمائية',
|
||||
'hero_title' => 'أرشيف رقمي بواجهة تشبه قاعة قراءة فاخرة.',
|
||||
'hero_copy' => 'تصفح المجموعة العامة داخل هوية بصرية أكثر تميزاً: أجواء جرافيت داكنة، وأسِطح أرشيفية دافئة، واكتشاف أسرع للعناوين العربية والإنجليزية.',
|
||||
'browse_catalog' => 'ادخل إلى الفهرس',
|
||||
'add_documents' => 'افتح مساحة الإدارة',
|
||||
'hero_stat_public' => 'أعمال عامة معروضة',
|
||||
'hero_stat_private' => 'أعمال خاصة محفوظة',
|
||||
'hero_stat_summaries' => 'ملخصات ذكاء اصطناعي جاهزة',
|
||||
'curator_kicker' => 'ملاحظة قيّمة',
|
||||
'curator_title' => 'التصميم الآن أقرب إلى أرشيف حديث منه إلى لوحة تقليدية.',
|
||||
'curator_copy' => 'هذا التحديث الأول يحول المكتبة إلى تجربة عامة أكثر تميزاً مع الحفاظ على السرعة والوضوح والاستعداد لتوسيع النمط إلى صفحات المستندات والملف الشخصي لاحقاً.',
|
||||
'curator_name' => 'وضع المكتبة التحريرية',
|
||||
'curator_role' => 'قاعدة جرافيتية · أسطح عاجية · توهج سماوي وعنّابي دافئ',
|
||||
'snapshot_kicker' => 'واجهة الرف',
|
||||
'snapshot_title' => 'ما الذي أصبح مختلفاً الآن',
|
||||
'snapshot_badge' => 'هوية مخصصة',
|
||||
'public_titles' => 'العناوين العامة',
|
||||
'private_titles' => 'العناوين الخاصة',
|
||||
'ai_summaries' => 'ملخصات الذكاء الاصطناعي',
|
||||
'snapshot_item_1' => 'صفحات عامة بلغة واحدة مع مفتاح تبديل في الشريط العلوي',
|
||||
'snapshot_item_2' => 'مرشحات للفهرس حسب الكلمة المفتاحية ولغة المحتوى',
|
||||
'snapshot_item_3' => 'صفحات مستندات بعنوان وملخص ووصف حسب اللغة المختارة',
|
||||
'discovery_kicker' => 'الاكتشاف العام',
|
||||
'discovery_title' => 'ابحث في المجموعة المباشرة',
|
||||
'snapshot_item_1' => 'جميع الصفحات العامة ترث الآن الغلاف السينمائي الجديد تلقائياً',
|
||||
'snapshot_item_2' => 'أدوات الاكتشاف أصبحت داخل تخطيط تحريري أكثر فخامة',
|
||||
'snapshot_item_3' => 'النظام البصري أصبح جاهزاً لتمديده إلى صفحات المستند والملف الشخصي لاحقاً',
|
||||
'discovery_kicker' => 'مكتب الاكتشاف',
|
||||
'discovery_title' => 'ابحث في المجموعة النشطة',
|
||||
'keyword' => 'الكلمة المفتاحية',
|
||||
'keyword_placeholder' => 'العنوان أو المؤلف أو الوسوم أو المقتطف',
|
||||
'language_filter' => 'لغة المحتوى',
|
||||
'all_shelves' => 'كل الرفوف',
|
||||
'filter' => 'تصفية',
|
||||
'rules_kicker' => 'قواعد الظهور',
|
||||
'rules_title' => 'وصول يتحكم به المشرف',
|
||||
'rules_copy' => 'تظهر العناصر العامة في هذا الفهرس فوراً، بينما تبقى العناصر الخاصة خارج الرف العام ومتاحة فقط من مساحة الإدارة.',
|
||||
'rules_link' => 'مراجعة إعدادات النشر',
|
||||
'catalog_kicker' => 'الفهرس',
|
||||
'catalog_title' => 'العناوين العامة المتاحة',
|
||||
'filter' => 'تنقيح',
|
||||
'rules_kicker' => 'قواعد قاعة القراءة',
|
||||
'rules_title' => 'يبقى الفصل واضحاً بين الرف العام والرف الخاص.',
|
||||
'rules_copy' => 'لا تظهر هنا إلا الأعمال العامة. أما العناصر الخاصة فتبقى محفوظة داخل مساحة الإدارة حتى يظل المشهد العام مقصوداً ومنسقاً.',
|
||||
'rules_link' => 'راجع ضوابط النشر',
|
||||
'palette_title' => 'لوحة الألوان',
|
||||
'palette_copy' => 'عمق جرافيتي، وبطاقات عاجية أرشيفية، ولمسات سماوية مضيئة، وتباين كهرماني دافئ يمنح المكتبة مزاج أرشيف رقمي فاخر.',
|
||||
'catalog_kicker' => 'المجموعة العامة',
|
||||
'catalog_title' => 'العناوين المتاحة على الواجهة',
|
||||
'catalog_copy' => 'فهرس أوضح بلغة واجهة واحدة في كل مرة مع هرمية بصرية أقوى وطابع أكثر قابلية للاقتناء.',
|
||||
'result_singular' => 'نتيجة',
|
||||
'result_plural' => 'نتائج',
|
||||
'empty_title' => 'لا توجد مستندات عامة بعد',
|
||||
'empty_copy' => 'ارفع أول مستند عربي أو إنجليزي من استوديو الإدارة لتحويل هذا القسم إلى مكتبة قابلة للتصفح.',
|
||||
'open_admin' => 'فتح استوديو الإدارة',
|
||||
'no_cover' => 'بلا غلاف',
|
||||
'empty_copy' => 'ارفع أول مستند عربي أو إنجليزي من مساحة الإدارة لتحويل هذه الواجهة إلى مكتبة قابلة للتصفح.',
|
||||
'open_admin' => 'افتح مساحة الإدارة',
|
||||
'no_cover' => 'بدون غلاف',
|
||||
'untitled' => 'مستند بدون عنوان',
|
||||
'cover_alt' => 'صورة غلاف لـ',
|
||||
'cover_alt' => 'صورة الغلاف لـ',
|
||||
'author' => 'المؤلف',
|
||||
'views' => 'المشاهدات',
|
||||
'tags' => 'الوسوم',
|
||||
'author_fallback' => 'غير محدد',
|
||||
'open_reader' => 'فتح القارئ ←',
|
||||
'workflow_kicker' => 'سير العمل',
|
||||
'workflow_title' => 'مسار كامل ومختصر',
|
||||
'workflow_item_1' => 'يرفع المشرف مستنداً ويحدد ما إذا كان عاماً أو خاصاً.',
|
||||
'workflow_item_2' => 'يكتشف القراء العناوين العامة من الفهرس ويفتحون صفحة التفاصيل.',
|
||||
'workflow_item_3' => 'تتبع الملخصات والأوصاف لغة الواجهة المختارة.',
|
||||
'recent_kicker' => 'أضيف مؤخراً',
|
||||
'recent_title' => 'أحدث العناوين العامة',
|
||||
'workflow_kicker' => 'رحلة القارئ',
|
||||
'workflow_title' => 'كيف تتدفق التجربة',
|
||||
'workflow_item_1' => 'يرفع المشرف مستنداً ويقرر ما إذا كان سيظهر على الواجهة العامة أو يبقى محفوظاً بشكل خاص.',
|
||||
'workflow_item_2' => 'يكتشف القراء العناوين العامة من الفهرس ثم يفتحون صفحة التفاصيل والقارئ الغامر.',
|
||||
'workflow_item_3' => 'تظل اللغة والملخصات وتتبع النشاط متوافقة مع سياق التصفح الحالي.',
|
||||
'recent_kicker' => 'وصل حديثاً',
|
||||
'recent_title' => 'أحدث الإضافات العامة',
|
||||
'manage_shelf' => 'إدارة الرف',
|
||||
'unknown_author' => 'مؤلف غير معروف',
|
||||
],
|
||||
][$lang];
|
||||
|
||||
// Pagination Logic
|
||||
$page = isset($_GET['page']) ? max(1, (int) $_GET['page']) : 1;
|
||||
$limit = 12;
|
||||
$offset = ($page - 1) * $limit;
|
||||
@ -138,45 +163,74 @@ library_render_header(
|
||||
'catalog'
|
||||
);
|
||||
?>
|
||||
<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"><?= h($pageCopy['hero_eyebrow']) ?></span>
|
||||
<h1 class="display-6 mb-3"><?= h($pageCopy['hero_title']) ?></h1>
|
||||
<p class="lead text-secondary mb-4"><?= h($pageCopy['hero_copy']) ?></p>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a class="btn btn-dark" href="#catalog-grid"><?= h($pageCopy['browse_catalog']) ?></a>
|
||||
<a class="btn btn-outline-secondary" href="/admin.php"><?= h($pageCopy['add_documents']) ?></a>
|
||||
<section class="hero-surface hero-surface--immersive mb-4 mb-lg-5">
|
||||
<div class="row g-4 g-xl-5 align-items-center">
|
||||
<div class="col-xl-7">
|
||||
<div class="hero-copy-wrap">
|
||||
<span class="eyebrow"><?= h($pageCopy['hero_eyebrow']) ?></span>
|
||||
<h1 class="display-5 mb-3"><?= h($pageCopy['hero_title']) ?></h1>
|
||||
<p class="lead text-secondary mb-4"><?= h($pageCopy['hero_copy']) ?></p>
|
||||
<div class="hero-actions">
|
||||
<a class="btn btn-dark" href="#catalog-grid"><?= h($pageCopy['browse_catalog']) ?></a>
|
||||
<a class="btn btn-outline-secondary" href="/admin.php"><?= h($pageCopy['add_documents']) ?></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-stat-strip">
|
||||
<article class="hero-stat">
|
||||
<span class="hero-stat-value"><?= h((string) $metrics['public_count']) ?></span>
|
||||
<span class="hero-stat-label"><?= h($pageCopy['hero_stat_public']) ?></span>
|
||||
</article>
|
||||
<article class="hero-stat">
|
||||
<span class="hero-stat-value"><?= h((string) $metrics['private_count']) ?></span>
|
||||
<span class="hero-stat-label"><?= h($pageCopy['hero_stat_private']) ?></span>
|
||||
</article>
|
||||
<article class="hero-stat">
|
||||
<span class="hero-stat-value"><?= h((string) $metrics['summarized_count']) ?></span>
|
||||
<span class="hero-stat-label"><?= h($pageCopy['hero_stat_summaries']) ?></span>
|
||||
</article>
|
||||
</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"><?= h($pageCopy['snapshot_kicker']) ?></div>
|
||||
<h2 class="h5 mb-1"><?= h($pageCopy['snapshot_title']) ?></h2>
|
||||
|
||||
<div class="col-xl-5">
|
||||
<div class="hero-showcase">
|
||||
<div class="curator-card">
|
||||
<div class="section-kicker"><?= h($pageCopy['curator_kicker']) ?></div>
|
||||
<h2 class="h4 mb-3"><?= h($pageCopy['curator_title']) ?></h2>
|
||||
<p class="curator-note"><?= h($pageCopy['curator_copy']) ?></p>
|
||||
<div class="curator-signature">
|
||||
<span class="signature-mark">NL</span>
|
||||
<div>
|
||||
<span class="signature-name"><?= h($pageCopy['curator_name']) ?></span>
|
||||
<span class="signature-role"><?= h($pageCopy['curator_role']) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="badge text-bg-light"><?= h($pageCopy['snapshot_badge']) ?></span>
|
||||
</div>
|
||||
<div class="metric-grid">
|
||||
<article class="metric-card">
|
||||
<span class="metric-value"><?= h((string) $metrics['public_count']) ?></span>
|
||||
<span class="metric-label"><?= h($pageCopy['public_titles']) ?></span>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-value"><?= h((string) $metrics['private_count']) ?></span>
|
||||
<span class="metric-label"><?= h($pageCopy['private_titles']) ?></span>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-value"><?= h((string) $metrics['summarized_count']) ?></span>
|
||||
<span class="metric-label"><?= h($pageCopy['ai_summaries']) ?></span>
|
||||
</article>
|
||||
|
||||
<div class="spotlight-card">
|
||||
<div class="spotlight-head">
|
||||
<div>
|
||||
<div class="section-kicker"><?= h($pageCopy['snapshot_kicker']) ?></div>
|
||||
<h2 class="h5 mb-1"><?= h($pageCopy['snapshot_title']) ?></h2>
|
||||
</div>
|
||||
<span class="badge spotlight-badge"><?= h($pageCopy['snapshot_badge']) ?></span>
|
||||
</div>
|
||||
<div class="metric-grid metric-grid-compact mb-3">
|
||||
<article class="metric-card">
|
||||
<span class="metric-value"><?= h((string) $metrics['public_count']) ?></span>
|
||||
<span class="metric-label"><?= h($pageCopy['public_titles']) ?></span>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-value"><?= h((string) $metrics['private_count']) ?></span>
|
||||
<span class="metric-label"><?= h($pageCopy['private_titles']) ?></span>
|
||||
</article>
|
||||
</div>
|
||||
<ul class="curated-points mb-0">
|
||||
<li><?= h($pageCopy['snapshot_item_1']) ?></li>
|
||||
<li><?= h($pageCopy['snapshot_item_2']) ?></li>
|
||||
<li><?= h($pageCopy['snapshot_item_3']) ?></li>
|
||||
</ul>
|
||||
</div>
|
||||
<ul class="list-unstyled mb-0 mt-4 compact-list">
|
||||
<li><?= h($pageCopy['snapshot_item_1']) ?></li>
|
||||
<li><?= h($pageCopy['snapshot_item_2']) ?></li>
|
||||
<li><?= h($pageCopy['snapshot_item_3']) ?></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -184,15 +238,15 @@ library_render_header(
|
||||
|
||||
<section class="row g-4 mb-4 mb-lg-5">
|
||||
<div class="col-lg-8">
|
||||
<div class="panel h-100">
|
||||
<div class="discovery-panel h-100">
|
||||
<div class="section-kicker"><?= h($pageCopy['discovery_kicker']) ?></div>
|
||||
<h2 class="h4 mb-3"><?= h($pageCopy['discovery_title']) ?></h2>
|
||||
<form class="row g-3 align-items-end" method="get" action="/index.php">
|
||||
<div class="col-md-7">
|
||||
<form class="discovery-form-grid" method="get" action="/index.php">
|
||||
<div>
|
||||
<label class="form-label" for="q"><?= h($pageCopy['keyword']) ?></label>
|
||||
<input class="form-control" id="q" name="q" type="search" value="<?= h($query) ?>" placeholder="<?= h($pageCopy['keyword_placeholder']) ?>">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div>
|
||||
<label class="form-label" for="language"><?= h($pageCopy['language_filter']) ?></label>
|
||||
<select class="form-select" id="language" name="language">
|
||||
<option value=""><?= h($pageCopy['all_shelves']) ?></option>
|
||||
@ -201,40 +255,51 @@ library_render_header(
|
||||
<option value="bilingual" <?= $language === 'bilingual' ? 'selected' : '' ?>><?= h(library_language_label('bilingual')) ?></option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 d-grid">
|
||||
<div class="d-grid align-self-end">
|
||||
<button class="btn btn-dark" type="submit"><?= h($pageCopy['filter']) ?></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<div class="panel h-100">
|
||||
<div class="tone-panel h-100">
|
||||
<div class="section-kicker"><?= h($pageCopy['rules_kicker']) ?></div>
|
||||
<h2 class="h5 mb-3"><?= h($pageCopy['rules_title']) ?></h2>
|
||||
<p class="text-secondary mb-3"><?= h($pageCopy['rules_copy']) ?></p>
|
||||
<div class="tone-swatch-row" aria-hidden="true">
|
||||
<span class="tone-swatch cyan"></span>
|
||||
<span class="tone-swatch ivory"></span>
|
||||
<span class="tone-swatch amber"></span>
|
||||
</div>
|
||||
<h3 class="h6 mb-2"><?= h($pageCopy['palette_title']) ?></h3>
|
||||
<p class="text-secondary mb-3"><?= h($pageCopy['palette_copy']) ?></p>
|
||||
<a class="link-arrow" href="/admin.php"><?= h($pageCopy['rules_link']) ?></a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-5" id="catalog-grid">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 gap-3 flex-wrap">
|
||||
<section id="catalog-grid" class="catalog-shell mb-4 mb-lg-5">
|
||||
<div class="catalog-section-head">
|
||||
<div>
|
||||
<div class="section-kicker"><?= h($pageCopy['catalog_kicker']) ?></div>
|
||||
<h2 class="h3 mb-0"><?= h($pageCopy['catalog_title']) ?></h2>
|
||||
<h2 class="h3 mb-2"><?= h($pageCopy['catalog_title']) ?></h2>
|
||||
<p class="text-secondary mb-0"><?= h($pageCopy['catalog_copy']) ?></p>
|
||||
</div>
|
||||
<div class="small text-secondary">
|
||||
<?= h((string) $totalDocuments) ?>
|
||||
<?= h($totalDocuments === 1 ? $pageCopy['result_singular'] : $pageCopy['result_plural']) ?>
|
||||
</div>
|
||||
<span class="text-secondary small"><?= h((string) $totalDocuments) ?> <?= h($totalDocuments === 1 ? $pageCopy['result_singular'] : $pageCopy['result_plural']) ?></span>
|
||||
</div>
|
||||
|
||||
<?php if (!$documents): ?>
|
||||
<div class="panel empty-panel text-center py-5">
|
||||
<div class="empty-icon mb-3">⌘</div>
|
||||
<h3 class="h5"><?= h($pageCopy['empty_title']) ?></h3>
|
||||
<div class="empty-icon mx-auto mb-3">+</div>
|
||||
<h3 class="h5 mb-2"><?= h($pageCopy['empty_title']) ?></h3>
|
||||
<p class="text-secondary mb-4"><?= h($pageCopy['empty_copy']) ?></p>
|
||||
<a class="btn btn-dark" href="/admin.php"><?= h($pageCopy['open_admin']) ?></a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-xl-2 g-4">
|
||||
<?php foreach ($documents as $document): ?>
|
||||
<?php
|
||||
$cardTitle = library_localized_document_title($document, $lang, $pageCopy['untitled']);
|
||||
|
||||
302
user.php
Normal file
302
user.php
Normal file
@ -0,0 +1,302 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/includes/layout.php';
|
||||
|
||||
library_bootstrap();
|
||||
|
||||
function user_profile_copy(string $lang): array
|
||||
{
|
||||
$copy = [
|
||||
'en' => [
|
||||
'meta_title' => 'My Activity',
|
||||
'meta_description' => 'Review this browser\'s reading history, recent visits, and document activity in the bilingual library.',
|
||||
'hero_kicker' => 'Reader profile',
|
||||
'hero_title' => 'Reading history and visit activity',
|
||||
'hero_copy' => 'This is a lightweight visitor profile for the current browser. It tracks your catalog visits and document reading flow without requiring a login.',
|
||||
'browser_only' => 'Browser-only profile',
|
||||
'browser_only_copy' => 'The data below belongs to this browser token only. Opening the site from another browser or device will create a separate profile.',
|
||||
'visits' => 'Visits',
|
||||
'activities' => 'Activities',
|
||||
'documents_opened' => 'Documents opened',
|
||||
'last_read' => 'Last read',
|
||||
'first_seen' => 'First seen',
|
||||
'last_seen' => 'Last seen',
|
||||
'preferred_language' => 'Preferred interface language',
|
||||
'last_page' => 'Last page',
|
||||
'reading_kicker' => 'Reading history',
|
||||
'reading_title' => 'Recently opened documents',
|
||||
'reading_empty_title' => 'No reading history yet',
|
||||
'reading_empty_copy' => 'Open a public document from the catalog and it will appear here.',
|
||||
'open_count' => 'Opens',
|
||||
'open_document' => 'Open document',
|
||||
'open_catalog' => 'Browse catalog',
|
||||
'open_reader' => 'Reader used',
|
||||
'never' => 'Not yet',
|
||||
'unknown_author' => 'Unknown author',
|
||||
'untitled' => 'Untitled document',
|
||||
'visits_kicker' => 'Visit sessions',
|
||||
'visits_title' => 'Recent visits',
|
||||
'visits_empty' => 'Your visit list will appear after you browse a few pages.',
|
||||
'entry_page' => 'Entry page',
|
||||
'page_views' => 'Page views',
|
||||
'document_actions' => 'Document actions',
|
||||
'activity_kicker' => 'Activity timeline',
|
||||
'activity_title' => 'Latest actions',
|
||||
'activity_empty' => 'Your recent activity will show up here as you browse.',
|
||||
'activity_path' => 'Path',
|
||||
'activity_document' => 'Document',
|
||||
'activity_catalog_viewed' => 'Opened the catalog',
|
||||
'activity_document_viewed' => 'Opened a document page',
|
||||
'activity_reader_opened' => 'Opened the fullscreen reader',
|
||||
'activity_profile_viewed' => 'Viewed the activity page',
|
||||
'activity_default' => 'Visited a page',
|
||||
'time_unknown' => 'Unknown',
|
||||
'language_en' => 'English',
|
||||
'language_ar' => 'Arabic',
|
||||
'language_bilingual' => 'Bilingual',
|
||||
],
|
||||
'ar' => [
|
||||
'meta_title' => 'سجل نشاطي',
|
||||
'meta_description' => 'راجع سجل القراءة والزيارات الأخيرة ونشاط المستندات لهذا المتصفح داخل المكتبة الثنائية اللغة.',
|
||||
'hero_kicker' => 'ملف القارئ',
|
||||
'hero_title' => 'سجل القراءة ونشاط الزيارات',
|
||||
'hero_copy' => 'هذه صفحة زائر خفيفة خاصة بالمتصفح الحالي. وهي تتتبع زيارات الفهرس ومسار قراءة المستندات بدون الحاجة إلى تسجيل دخول.',
|
||||
'browser_only' => 'ملف خاص بالمتصفح',
|
||||
'browser_only_copy' => 'البيانات أدناه مرتبطة برمز هذا المتصفح فقط. فتح الموقع من متصفح أو جهاز آخر سيُنشئ ملفاً منفصلاً.',
|
||||
'visits' => 'الزيارات',
|
||||
'activities' => 'الأنشطة',
|
||||
'documents_opened' => 'المستندات المفتوحة',
|
||||
'last_read' => 'آخر قراءة',
|
||||
'first_seen' => 'أول ظهور',
|
||||
'last_seen' => 'آخر ظهور',
|
||||
'preferred_language' => 'لغة الواجهة المفضلة',
|
||||
'last_page' => 'آخر صفحة',
|
||||
'reading_kicker' => 'سجل القراءة',
|
||||
'reading_title' => 'المستندات المفتوحة مؤخراً',
|
||||
'reading_empty_title' => 'لا يوجد سجل قراءة بعد',
|
||||
'reading_empty_copy' => 'افتح مستنداً عاماً من الفهرس وسيظهر هنا.',
|
||||
'open_count' => 'مرات الفتح',
|
||||
'open_document' => 'فتح المستند',
|
||||
'open_catalog' => 'تصفح الفهرس',
|
||||
'open_reader' => 'استخدام القارئ',
|
||||
'never' => 'ليس بعد',
|
||||
'unknown_author' => 'مؤلف غير معروف',
|
||||
'untitled' => 'مستند بدون عنوان',
|
||||
'visits_kicker' => 'جلسات الزيارة',
|
||||
'visits_title' => 'الزيارات الأخيرة',
|
||||
'visits_empty' => 'ستظهر قائمة زياراتك بعد تصفح عدة صفحات.',
|
||||
'entry_page' => 'صفحة البداية',
|
||||
'page_views' => 'مشاهدات الصفحة',
|
||||
'document_actions' => 'أنشطة المستندات',
|
||||
'activity_kicker' => 'الخط الزمني للنشاط',
|
||||
'activity_title' => 'أحدث الإجراءات',
|
||||
'activity_empty' => 'سيظهر نشاطك الأخير هنا أثناء التصفح.',
|
||||
'activity_path' => 'المسار',
|
||||
'activity_document' => 'المستند',
|
||||
'activity_catalog_viewed' => 'فتح الفهرس',
|
||||
'activity_document_viewed' => 'فتح صفحة مستند',
|
||||
'activity_reader_opened' => 'فتح القارئ بملء الشاشة',
|
||||
'activity_profile_viewed' => 'عرض صفحة النشاط',
|
||||
'activity_default' => 'زيارة صفحة',
|
||||
'time_unknown' => 'غير معروف',
|
||||
'language_en' => 'الإنجليزية',
|
||||
'language_ar' => 'العربية',
|
||||
'language_bilingual' => 'ثنائي اللغة',
|
||||
],
|
||||
];
|
||||
|
||||
return $copy[$lang] ?? $copy['en'];
|
||||
}
|
||||
|
||||
function user_profile_event_label(string $eventType, array $copy): string
|
||||
{
|
||||
return $copy['activity_' . $eventType] ?? $copy['activity_default'];
|
||||
}
|
||||
|
||||
function user_profile_datetime(?string $value, array $copy): string
|
||||
{
|
||||
if (!$value) {
|
||||
return $copy['time_unknown'];
|
||||
}
|
||||
|
||||
$timestamp = strtotime($value);
|
||||
if ($timestamp === false) {
|
||||
return $copy['time_unknown'];
|
||||
}
|
||||
|
||||
return date('Y-m-d H:i', $timestamp);
|
||||
}
|
||||
|
||||
$lang = library_get_language();
|
||||
$pageCopy = user_profile_copy($lang);
|
||||
library_track_request('profile_viewed');
|
||||
|
||||
$summary = library_get_current_reader_summary();
|
||||
$readingHistory = library_get_current_reader_history(8);
|
||||
$recentActivities = library_get_current_reader_activities(18);
|
||||
$recentVisits = library_get_current_reader_visits(8);
|
||||
|
||||
$preferredLanguageLabel = library_language_label((string) ($summary['preferred_language'] ?? 'en'));
|
||||
|
||||
library_render_header($pageCopy['meta_title'], $pageCopy['meta_description'], 'profile');
|
||||
?>
|
||||
<section class="row g-4 mb-4">
|
||||
<div class="col-lg-8">
|
||||
<div class="hero-surface profile-hero h-100">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-3">
|
||||
<div>
|
||||
<div class="eyebrow"><?= h($pageCopy['hero_kicker']) ?></div>
|
||||
<h1 class="display-6 mb-3"><?= h($pageCopy['hero_title']) ?></h1>
|
||||
<p class="lead text-secondary mb-0"><?= h($pageCopy['hero_copy']) ?></p>
|
||||
</div>
|
||||
<span class="badge text-bg-light"><?= h($pageCopy['browser_only']) ?></span>
|
||||
</div>
|
||||
<div class="metric-grid profile-metric-grid mt-4">
|
||||
<article class="metric-card">
|
||||
<span class="metric-value"><?= h((string) ($summary['total_visits'] ?? 0)) ?></span>
|
||||
<span class="metric-label"><?= h($pageCopy['visits']) ?></span>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-value"><?= h((string) ($summary['total_activities'] ?? 0)) ?></span>
|
||||
<span class="metric-label"><?= h($pageCopy['activities']) ?></span>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-value"><?= h((string) ($summary['documents_opened'] ?? 0)) ?></span>
|
||||
<span class="metric-label"><?= h($pageCopy['documents_opened']) ?></span>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-value metric-value-small"><?= h(user_profile_datetime($summary['last_read_at'] ?? null, $pageCopy)) ?></span>
|
||||
<span class="metric-label"><?= h($pageCopy['last_read']) ?></span>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<div class="panel h-100">
|
||||
<div class="section-kicker"><?= h($pageCopy['browser_only']) ?></div>
|
||||
<h2 class="h5 mb-3"><?= h($pageCopy['browser_only_copy']) ?></h2>
|
||||
<dl class="row small mb-0 profile-summary-list">
|
||||
<dt class="col-5 text-secondary"><?= h($pageCopy['first_seen']) ?></dt>
|
||||
<dd class="col-7 mb-3"><?= h(user_profile_datetime($summary['first_seen_at'] ?? null, $pageCopy)) ?></dd>
|
||||
|
||||
<dt class="col-5 text-secondary"><?= h($pageCopy['last_seen']) ?></dt>
|
||||
<dd class="col-7 mb-3"><?= h(user_profile_datetime($summary['last_seen_at'] ?? null, $pageCopy)) ?></dd>
|
||||
|
||||
<dt class="col-5 text-secondary"><?= h($pageCopy['preferred_language']) ?></dt>
|
||||
<dd class="col-7 mb-3"><?= h($preferredLanguageLabel) ?></dd>
|
||||
|
||||
<dt class="col-5 text-secondary"><?= h($pageCopy['last_page']) ?></dt>
|
||||
<dd class="col-7 mb-0"><span class="badge text-bg-light text-wrap"><?= h((string) ($summary['last_path'] ?? '/')) ?></span></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="row g-4 mb-4">
|
||||
<div class="col-lg-8">
|
||||
<div class="panel h-100">
|
||||
<div class="d-flex justify-content-between align-items-center gap-3 flex-wrap mb-4">
|
||||
<div>
|
||||
<div class="section-kicker"><?= h($pageCopy['reading_kicker']) ?></div>
|
||||
<h2 class="h4 mb-0"><?= h($pageCopy['reading_title']) ?></h2>
|
||||
</div>
|
||||
<a class="link-arrow" href="/index.php"><?= h($pageCopy['open_catalog']) ?></a>
|
||||
</div>
|
||||
|
||||
<?php if (!$readingHistory): ?>
|
||||
<div class="reader-lock text-center">
|
||||
<div class="empty-icon mb-3">↗</div>
|
||||
<h3 class="h5"><?= h($pageCopy['reading_empty_title']) ?></h3>
|
||||
<p class="text-secondary mb-0"><?= h($pageCopy['reading_empty_copy']) ?></p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="row g-3">
|
||||
<?php foreach ($readingHistory as $item): ?>
|
||||
<?php $docTitle = library_localized_document_title($item, $lang, $pageCopy['untitled']); ?>
|
||||
<div class="col-md-6">
|
||||
<a class="recent-card history-card text-decoration-none" href="/document.php?id=<?= (int) $item['id'] ?>">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 mb-2">
|
||||
<div>
|
||||
<h3 class="h6 mb-1 text-dark"><?= h($docTitle) ?></h3>
|
||||
<p class="text-secondary small mb-0"><?= h((string) ($item['author'] ?: $pageCopy['unknown_author'])) ?></p>
|
||||
</div>
|
||||
<span class="badge text-bg-light"><?= h(library_language_label((string) ($item['document_language'] ?? 'en'))) ?></span>
|
||||
</div>
|
||||
<div class="history-card-meta small text-secondary">
|
||||
<span><?= h($pageCopy['open_count']) ?>: <?= h((string) $item['open_count']) ?></span>
|
||||
<span><?= h($pageCopy['last_read']) ?>: <?= h(user_profile_datetime($item['last_opened_at'] ?? null, $pageCopy)) ?></span>
|
||||
<span><?= h($pageCopy['open_reader']) ?>: <?= h(user_profile_datetime($item['last_reader_opened_at'] ?? null, $pageCopy)) ?></span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<div class="panel h-100">
|
||||
<div class="section-kicker"><?= h($pageCopy['visits_kicker']) ?></div>
|
||||
<h2 class="h5 mb-3"><?= h($pageCopy['visits_title']) ?></h2>
|
||||
|
||||
<?php if (!$recentVisits): ?>
|
||||
<p class="text-secondary mb-0"><?= h($pageCopy['visits_empty']) ?></p>
|
||||
<?php else: ?>
|
||||
<div class="visit-stack">
|
||||
<?php foreach ($recentVisits as $visit): ?>
|
||||
<article class="visit-card">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 mb-2">
|
||||
<span class="badge text-bg-light">#<?= h((string) $visit['id']) ?></span>
|
||||
<span class="small text-secondary"><?= h(user_profile_datetime($visit['started_at'] ?? null, $pageCopy)) ?></span>
|
||||
</div>
|
||||
<div class="small text-secondary mb-2"><?= h($pageCopy['entry_page']) ?>: <span class="text-dark"><?= h((string) ($visit['entry_path'] ?? '/')) ?></span></div>
|
||||
<div class="visit-stats small">
|
||||
<span><?= h($pageCopy['page_views']) ?>: <?= h((string) $visit['page_views']) ?></span>
|
||||
<span><?= h($pageCopy['activities']) ?>: <?= h((string) $visit['activity_count']) ?></span>
|
||||
<span><?= h($pageCopy['document_actions']) ?>: <?= h((string) $visit['document_count']) ?></span>
|
||||
</div>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="d-flex justify-content-between align-items-center gap-3 flex-wrap mb-4">
|
||||
<div>
|
||||
<div class="section-kicker"><?= h($pageCopy['activity_kicker']) ?></div>
|
||||
<h2 class="h4 mb-0"><?= h($pageCopy['activity_title']) ?></h2>
|
||||
</div>
|
||||
<span class="badge text-bg-light"><?= h((string) count($recentActivities)) ?></span>
|
||||
</div>
|
||||
|
||||
<?php if (!$recentActivities): ?>
|
||||
<p class="text-secondary mb-0"><?= h($pageCopy['activity_empty']) ?></p>
|
||||
<?php else: ?>
|
||||
<div class="timeline-list">
|
||||
<?php foreach ($recentActivities as $activity): ?>
|
||||
<?php $activityTitle = !empty($activity['document_id']) ? library_localized_document_title($activity, $lang, $pageCopy['untitled']) : ''; ?>
|
||||
<article class="timeline-item">
|
||||
<div class="timeline-dot" aria-hidden="true"></div>
|
||||
<div class="timeline-content">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-1">
|
||||
<h3 class="h6 mb-0"><?= h(user_profile_event_label((string) $activity['event_type'], $pageCopy)) ?></h3>
|
||||
<span class="small text-secondary"><?= h(user_profile_datetime($activity['created_at'] ?? null, $pageCopy)) ?></span>
|
||||
</div>
|
||||
<div class="small text-secondary d-grid gap-1">
|
||||
<span><?= h($pageCopy['activity_path']) ?>: <span class="text-dark"><?= h((string) ($activity['page_path'] ?? '/')) ?></span></span>
|
||||
<?php if ($activityTitle !== ''): ?>
|
||||
<span><?= h($pageCopy['activity_document']) ?>: <a class="text-decoration-none" href="/document.php?id=<?= (int) $activity['document_id'] ?>"><?= h($activityTitle) ?></a></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
<?php library_render_footer();
|
||||
@ -12,6 +12,12 @@ $publicOnly = $context !== 'admin';
|
||||
|
||||
$document = $documentId > 0 ? library_fetch_document($documentId, $publicOnly) : null;
|
||||
|
||||
if ($document && $context !== 'admin') {
|
||||
library_track_request('reader_opened', (int) $document['id'], [
|
||||
'document_type' => (string) ($document['document_type'] ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$document || empty($document['file_path'])) {
|
||||
http_response_code(404);
|
||||
die('Document not found or no file attached.');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user