Files
afakzaid-rag/static/library.html
T

1572 lines
72 KiB
HTML

<!DOCTYPE html>
<html class="dark" lang="ar" dir="rtl">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>المكتبة — AfakZaid RAG</title>
<script src="https://cdn.tailwindcss.com?plugins=forms"></script>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
<script>tailwind.config={darkMode:"class",safelist:["grid","grid-cols-2","grid-cols-3","grid-cols-4","md:grid-cols-3","lg:grid-cols-4","gap-4","space-y-3","col-span-4"],theme:{extend:{colors:{"primary":"#4edea3","surface":"#0c1324","surface-low":"#151b2d","surface-container":"#191f31","surface-high":"#23293c","on-surface":"#dce1fb","on-surface-variant":"#bbcabf","outline-variant":"#3c4a42","secondary":"#95d3ba","tertiary":"#45dfa4","error":"#f87171"},fontFamily:{sans:["Manrope","sans-serif"]}}}}</script>
<style>
:root {
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
}
*{box-sizing:border-box}
body{background:radial-gradient(circle at 50% -20%,#151b2d 0%,#0c1324 100%);font-family:'Manrope',sans-serif;color:#dce1fb;min-height:100vh}
/* Glass: purposeful, not default — liquid glass per design-taste */
.glass{
background:rgba(21,27,45,0.5);
backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);
border:1px solid rgba(78,222,163,0.12);
box-shadow:inset 0 1px 0 rgba(255,255,255,0.04);
}
.glass-hover { transition: background 180ms ease, border-color 180ms ease; }
@media (hover: hover) and (pointer: fine) {
.glass-hover:hover { background:rgba(78,222,163,0.06); border-color:rgba(78,222,163,0.22); }
}
.glow{box-shadow:0 0 20px rgba(78,222,163,0.15)}
.glow-sm{box-shadow:0 0 10px rgba(78,222,163,0.1)}
.msym{font-family:'Material Symbols Outlined';font-variation-settings:'FILL' 0,'wght' 400,'GRAD' 0,'opsz' 24;vertical-align:middle;line-height:1}
/* Filter chips — specific transitions */
.filter-chip {
padding:6px 14px; border-radius:999px; font-size:12px; font-weight:600; cursor:pointer;
border:1px solid rgba(78,222,163,0.15); background:rgba(21,27,45,0.5); color:#bbcabf;
transition: background 150ms var(--ease-out), border-color 150ms var(--ease-out), color 150ms var(--ease-out);
will-change: transform;
}
.filter-chip.active { background:rgba(78,222,163,0.12); border-color:rgba(78,222,163,0.4); color:#4edea3; }
@media (hover: hover) and (pointer: fine) {
.filter-chip:hover { background:rgba(78,222,163,0.08); border-color:rgba(78,222,163,0.3); color:#4edea3; }
}
.filter-chip:active { transform: scale(0.97); }
.scrollbar::-webkit-scrollbar{width:4px}
.scrollbar::-webkit-scrollbar-thumb{background:rgba(78,222,163,0.2);border-radius:4px}
.inp{
background:#23293c; border:1px solid #3c4a42; border-radius:12px;
padding:10px 14px; color:#dce1fb; font-size:13px; outline:none;
transition: border-color 180ms ease, box-shadow 180ms ease;
font-family:'Manrope',sans-serif; width:100%;
}
.inp:focus { border-color:#4edea3; box-shadow:0 0 0 2px rgba(78,222,163,0.1); }
/* Book card — purposeful hover, only pointer devices */
.book-card {
transition: transform 200ms var(--ease-out), box-shadow 200ms ease, border-color 200ms ease;
will-change: transform;
}
@media (hover: hover) and (pointer: fine) {
.book-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(78,222,163,0.1);
border-color: rgba(78,222,163,0.28) !important;
}
}
.book-card:active { transform: scale(0.98); }
/* Skeleton loading — design-taste: layout-matching shimmer */
@keyframes shimmer {
0% { background-position: -400px 0; }
100% { background-position: 400px 0; }
}
.skeleton {
background: linear-gradient(90deg, #1a2035 25%, #23293c 50%, #1a2035 75%);
background-size: 800px 100%;
animation: shimmer 1.4s ease-in-out infinite;
border-radius: 8px;
}
/* Upload zone */
.upload-zone {
border: 2px dashed rgba(78,222,163,0.2);
transition: border-color 200ms ease, background 200ms ease;
}
@media (hover: hover) and (pointer: fine) {
.upload-zone:hover { border-color: rgba(78,222,163,0.45); background: rgba(78,222,163,0.03); }
}
.upload-zone.dragover { border-color:rgba(78,222,163,0.6); background:rgba(78,222,163,0.05); }
/* Fav button pulse on add */
@keyframes favPulse {
0% { transform: scale(1); }
40% { transform: scale(1.35); }
100% { transform: scale(1); }
}
.fav-btn.added { animation: favPulse 350ms var(--ease-out); color: #f87171 !important; }
/* Page entry */
@keyframes fadeUp {
from { opacity:0; transform:translateY(8px); }
to { opacity:1; transform:translateY(0); }
}
header { animation: fadeUp 350ms var(--ease-out) both; }
.search-section { animation: fadeUp 400ms var(--ease-out) 80ms both; }
.books-section { animation: fadeUp 400ms var(--ease-out) 160ms both; }
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .01ms !important;
animation-iteration-count: 1 !important;
transition-duration: .01ms !important;
}
.skeleton { animation: none; background: #1a2035; }
}
/* ── Mobile-visible improvements ─────────────────────────────────────────── */
/* Larger touch targets — 44px minimum per Apple HIG */
button, a, .filter-chip, .book-card, .stat-card, .nav-btn {
-webkit-tap-highlight-color: transparent;
}
/* Tap feedback — visible on ALL devices including mobile */
.book-card:active,
.stat-card:active,
.filter-chip:active {
transform: scale(0.97) !important;
transition: transform 100ms ease !important;
}
/* Buttons get a visible press state on tap */
button:active:not(:disabled) {
filter: brightness(0.9);
transform: scale(0.97);
transition: transform 100ms ease, filter 100ms ease !important;
}
/* Restore .glow — ensure it stacks correctly */
.glow {
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.06),
0 0 20px rgba(78,222,163,0.15) !important;
}
.glow-sm {
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.04),
0 0 10px rgba(78,222,163,0.1) !important;
}
/* Mobile: book cards get a more visible pressed state */
@media (max-width: 768px) {
.book-card {
transition: transform 150ms ease, opacity 150ms ease !important;
}
.book-card:active {
transform: scale(0.97) !important;
opacity: 0.85 !important;
}
/* Ensure skeleton is visible on mobile */
.skeleton {
background: linear-gradient(90deg, #1a2035 25%, #2a3348 50%, #1a2035 75%) !important;
background-size: 800px 100% !important;
animation: shimmer 1.2s ease-in-out infinite !important;
}
/* Better spacing on small screens */
.filter-chip {
padding: 8px 16px !important;
font-size: 13px !important;
}
}
/* ── design-taste-frontend: Liquid Glass + Spotlight ─────────────────────── */
/* True liquid glass — inner refraction border */
/* NOTE: does NOT define outer shadow — .glow still wins */
.glass {
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.06),
inset 0 -1px 0 rgba(0,0,0,0.2);
}
/* Combined: liquid glass + green glow */
.glass.glow {
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.06),
inset 0 -1px 0 rgba(0,0,0,0.2),
0 0 20px rgba(78,222,163,0.18),
0 4px 24px rgba(0,0,0,0.15);
}
.glass.glow-sm {
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.06),
inset 0 -1px 0 rgba(0,0,0,0.2),
0 0 10px rgba(78,222,163,0.12);
}
/* .glow overrides naturally on top — no !important needed */
/* Spotlight border card — JS-driven cursor tracking */
.spotlight-card {
position: relative;
overflow: hidden;
}
.spotlight-card::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(
400px circle at var(--mx, 50%) var(--my, 50%),
rgba(78,222,163,0.06),
transparent 60%
);
opacity: 0;
transition: opacity 300ms ease;
pointer-events: none;
border-radius: inherit;
z-index: 0;
}
@media (hover: hover) and (pointer: fine) {
.spotlight-card:hover::before { opacity: 1; }
}
/* Ensure content above spotlight overlay */
.spotlight-card > * { position: relative; z-index: 1; }
/* Tactile button press — design-taste rule */
button:not(:disabled):active,
a[role=button]:active {
transform: translateY(-1px) scale(0.98) !important;
transition-duration: 80ms !important;
}
/* Hardware acceleration on animated elements */
.stat-card, .book-card, .query-card, .books-card, .upload-card-wrap {
transform: translateZ(0);
backface-visibility: hidden;
}
/* Directional hover-aware CTA — shimmer from left on hover */
.cta-primary {
position: relative; overflow: hidden;
}
.cta-primary::before {
content: '';
position: absolute;
top: 0; left: -100%; bottom: 0; width: 50%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.1), transparent);
transition: left 400ms cubic-bezier(0.77,0,0.175,1);
pointer-events: none;
}
@media (hover: hover) and (pointer: fine) {
.cta-primary:hover::before { left: 200%; }
}
/* ── Mobile improvements (actually visible on mobile) ────────────────────── */
/* Larger touch targets — 44px minimum per Apple HIG */
@media (max-width: 768px) {
/* Bigger tap areas on buttons */
button, a { min-height: 44px; }
.filter-chip { padding: 8px 16px; font-size: 13px; }
/* Book cards: more breathing room on mobile */
.book-card { padding: 14px !important; }
/* Stats cards: larger numbers on mobile */
.stat-card .stat-num, #s-books, #s-vectors, #s-convos, #s-memory {
font-size: 1.75rem;
}
/* Header: tighter on mobile */
header { padding-top: 10px; padding-bottom: 10px; }
/* Search input: larger font so iOS doesn't zoom */
#search-input, .inp, textarea, input[type="text"], input[type="email"], input[type="password"] {
font-size: 16px !important; /* prevents iOS zoom on focus */
}
/* Bottom action bar spacing */
main { padding-bottom: env(safe-area-inset-bottom, 16px); }
}
/* Reader: full height on mobile */
@media (max-width: 768px) {
#pdf-frame { position: absolute; inset: 0; }
.reader-header { padding: 8px 12px; }
}
/* FORCE LAYOUT — overrides everything */
main {
display: block !important;
width: 100% !important;
max-width: 72rem !important;
margin-left: auto !important;
margin-right: auto !important;
box-sizing: border-box !important;
}
/* Grid view */
#books-container {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
@media (max-width: 768px) {
#books-container { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 480px) {
#books-container { grid-template-columns: 1fr; }
}
/* List view — overrides grid completely */
#books-container.books-list {
display: flex;
flex-direction: column;
gap: 12px;
}
/* Sticky footer — no layout impact */
#sticky-footer { position: sticky; top: 100vh; }
</style>
<script src="/static/analytics.js" defer></script>
</head>
<body>
<!-- Header -->
<div id="universal-header"></div>
<main class="max-w-6xl" mx-auto px-4 lg:px-8 py-8">
<!-- Search + Filters -->
<div class="glass rounded-3xl p-6 mb-6">
<div class="flex flex-col lg:flex-row gap-4 mb-5">
<!-- Search -->
<div class="relative flex-1">
<span class="msym absolute right-3 top-1/2 -translate-y-1/2 text-on-surface-variant text-lg">search</span>
<input id="search-input" type="text" dir="rtl" placeholder="ابحث في الكتب..." class="inp pr-10"
oninput="debounceSearch(this.value)"/>
</div>
<!-- Upload button (librarians only) -->
<div id="upload-btn-wrap" class="hidden flex items-center gap-2">
<button onclick="document.getElementById('upload-modal').classList.remove('hidden')"
class="bg-primary text-surface font-bold px-4 py-2.5 rounded-xl text-sm flex items-center gap-2 hover:shadow-[0_0_20px_rgba(78,222,163,0.4)] transition-all whitespace-nowrap">
<span class="msym text-lg">upload_file</span> رفع كتاب
</button>
<button onclick="document.getElementById('bulk-modal').classList.remove('hidden')"
class="glass border border-primary/30 text-primary font-bold px-4 py-2.5 rounded-xl text-sm flex items-center gap-2 hover:bg-primary/10 transition-all whitespace-nowrap">
<span class="msym text-lg">library_add</span> رفع مجموعة
</button>
</div>
</div>
<!-- Filter rows -->
<div class="space-y-3">
<!-- Categories -->
<div>
<p class="text-[10px] text-on-surface-variant uppercase tracking-widest font-bold mb-2 flex items-center gap-1"><span class="msym text-xs">category</span>التصنيف</p>
<div id="cat-chips" class="flex flex-wrap gap-2">
<button class="filter-chip active" onclick="setFilter('category','',this)">الكل</button>
</div>
</div>
<!-- Authors -->
<div id="author-section" class="hidden">
<p class="text-[10px] text-on-surface-variant uppercase tracking-widest font-bold mb-2 flex items-center gap-1"><span class="msym text-xs">person</span>المؤلف</p>
<div id="author-chips" class="flex flex-wrap gap-2">
<button class="filter-chip active" onclick="setFilter('author','',this)">الكل</button>
</div>
</div>
</div>
</div>
<!-- Stats row -->
<div class="flex items-center justify-between mb-5">
<p class="text-on-surface-variant text-sm"><span id="result-count" class="text-primary font-bold">-</span> كتاب</p>
<div class="flex items-center gap-2">
<select id="sort-select" onchange="setSort(this.value)" class="filter-chip text-xs px-3 py-1.5 outline-none" style="background:rgba(21,27,45,0.5);cursor:pointer">
<option value="newest">الأحدث</option>
<option value="oldest">الأقدم</option>
<option value="title_az">العنوان أ-ي</option>
<option value="title_za">العنوان ي-أ</option>
<option value="chunks_desc">الأطول</option>
</select>
<button onclick="setView('grid')" id="view-grid" class="filter-chip active p-2"><span class="msym text-base">grid_view</span></button>
<button onclick="setView('list')" id="view-list" class="filter-chip p-2"><span class="msym text-base">view_list</span></button>
</div>
</div>
<!-- Active import banner -->
<div id="import-banner" class="hidden mb-4 glass rounded-2xl p-4 border border-primary/20">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2 text-sm font-medium">
<span class="msym text-primary animate-spin text-base">progress_activity</span>
<span id="import-banner-txt">جاري استيراد الكتب...</span>
</div>
<span id="import-banner-count" class="text-xs text-primary font-bold"></span>
</div>
<div class="w-full bg-white/5 rounded-full h-1.5 overflow-hidden">
<div id="import-banner-bar" class="h-full bg-primary rounded-full transition-all duration-700" style="width:0%"></div>
</div>
</div>
<!-- Books -->
<div id="books-container"></div>
<!-- Favorites section -->
<div id="fav-section" class="hidden mb-8">
<h3 class="text-lg font-bold flex items-center gap-2 mb-5"><span class="msym text-red-400">favorite</span>المفضلة</h3>
<div id="fav-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"></div>
</div>
<!-- Pagination -->
<div id="pagination" class="flex justify-center gap-2 mt-8"></div>
</main>
<!-- Upload Modal -->
<div id="upload-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60">
<div class="glass glow rounded-3xl p-8 w-full max-w-lg">
<div class="flex items-center justify-between mb-6">
<h2 class="text-lg font-bold flex items-center gap-2"><span class="msym text-primary">upload_file</span>رفع كتاب PDF</h2>
<button onclick="document.getElementById('upload-modal').classList.add('hidden')" class="text-on-surface-variant hover:text-primary">
<span class="msym text-2xl">close</span>
</button>
</div>
<div class="space-y-3">
<!-- File drop -->
<div id="modal-drop" class="border-2 border-dashed border-primary/20 rounded-2xl p-6 text-center cursor-pointer relative hover:border-primary/50 hover:bg-primary/5 transition-all">
<input type="file" accept=".pdf" class="absolute inset-0 opacity-0 cursor-pointer w-full h-full" onchange="handleUploadFile(this.files[0])"/>
<span class="msym text-primary text-3xl">cloud_upload</span>
<p class="text-sm text-on-surface-variant mt-2">اختر ملف PDF أو اسحبه هنا</p>
<p id="selected-file" class="text-xs text-primary mt-1 hidden"></p>
</div>
<!-- Or URL -->
<div class="flex items-center gap-3">
<div class="flex-1 h-px bg-outline-variant/30"></div>
<span class="text-xs text-on-surface-variant">أو رابط مباشر</span>
<div class="flex-1 h-px bg-outline-variant/30"></div>
</div>
<input type="text" id="upload-url" dir="ltr" placeholder="https://example.com/book.pdf" class="inp text-xs"/>
<!-- Metadata -->
<div class="grid grid-cols-2 gap-3">
<input type="text" id="upload-title" dir="rtl" placeholder="عنوان الكتاب" class="inp text-xs"/>
<input type="text" id="upload-author" dir="rtl" placeholder="اسم المؤلف" class="inp text-xs"/>
</div>
<div class="grid grid-cols-2 gap-3">
<input type="text" id="upload-category" dir="rtl" placeholder="التصنيف" class="inp text-xs"/>
<select id="upload-lang" class="inp text-xs">
<option value="ar">عربي</option>
<option value="en">English</option>
<option value="mixed">Mixed</option>
</select>
</div>
<textarea id="upload-desc" dir="rtl" rows="2" placeholder="وصف مختصر (اختياري)" class="inp text-xs resize-none"></textarea>
<!-- Progress -->
<div id="upload-progress" class="hidden">
<div class="bg-surface-high rounded-full h-1.5 overflow-hidden mb-2">
<div id="progress-fill" class="h-1.5 bg-primary rounded-full transition-all duration-500" style="width:0%"></div>
</div>
<p id="progress-text" class="text-xs text-on-surface-variant"></p>
</div>
<button onclick="doUpload()" class="w-full bg-primary text-surface font-bold py-3 rounded-xl hover:shadow-[0_0_20px_rgba(78,222,163,0.4)] transition-all text-sm">
<span class="msym text-lg align-middle mr-1">upload</span> رفع وفهرسة
</button>
</div>
</div>
</div>
<!-- ── Bulk Upload Modal ─────────────────────────────────────────────── -->
<div id="bulk-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70">
<div class="glass rounded-3xl w-full max-w-2xl max-h-[90vh] overflow-y-auto p-6 space-y-5" style="border:1px solid rgba(78,222,163,0.15)">
<!-- Header -->
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold flex items-center gap-2"><span class="msym text-primary text-xl">library_add</span>رفع مجموعة كتب</h2>
<button onclick="closeBulkModal()" class="text-on-surface-variant hover:text-primary"><span class="msym text-xl">close</span></button>
</div>
<!-- Tabs -->
<div class="flex gap-2 p-1 bg-surface-high rounded-xl">
<button id="bulk-tab-local" onclick="switchBulkTab('local')"
class="flex-1 py-2 rounded-lg text-sm font-bold flex items-center justify-center gap-2 transition-all bg-primary text-surface">
<span class="msym text-base">upload_file</span> من الجهاز
</button>
<button id="bulk-tab-drive" onclick="switchBulkTab('drive')"
class="flex-1 py-2 rounded-lg text-sm font-bold flex items-center justify-center gap-2 transition-all text-on-surface-variant hover:text-primary">
<svg class="w-4 h-4" viewBox="0 0 87.3 78" xmlns="http://www.w3.org/2000/svg"><path d="m6.6 66.85 3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3l13.75-23.8h-27.5c0 1.55.4 3.1 1.2 4.5z" fill="#0066da"/><path d="m43.65 25-13.75-23.8c-1.35.8-2.5 1.9-3.3 3.3l-25.4 44a9.06 9.06 0 0 0 -1.2 4.5h27.5z" fill="#00ac47"/><path d="m73.55 76.8c1.35-.8 2.5-1.9 3.3-3.3l1.6-2.75 7.65-13.25c.8-1.4 1.2-2.95 1.2-4.5h-27.502l5.852 11.5z" fill="#ea4335"/><path d="m43.65 25 13.75-23.8c-1.35-.8-2.9-1.2-4.5-1.2h-18.5c-1.6 0-3.15.45-4.5 1.2z" fill="#00832d"/><path d="m59.8 53h-32.3l-13.75 23.8c1.35.8 2.9 1.2 4.5 1.2h50.8c1.6 0 3.15-.45 4.5-1.2z" fill="#2684fc"/><path d="m73.4 26.5-12.7-22c-.8-1.4-1.95-2.5-3.3-3.3l-13.75 23.8 16.15 28h27.45c0-1.55-.4-3.1-1.2-4.5z" fill="#ffba00"/></svg>
Google Drive
</button>
</div>
<!-- Tab: Local Upload -->
<div id="bulk-panel-local">
<!-- Drop Zone -->
<div id="bulk-drop" onclick="document.getElementById('bulk-file-input').click()"
class="border-2 border-dashed border-primary/20 rounded-2xl p-8 text-center cursor-pointer hover:border-primary/50 hover:bg-primary/5 transition-all">
<span class="msym text-primary text-4xl block mb-3">cloud_upload</span>
<p class="text-sm font-semibold mb-1">اسحب ملفات PDF هنا أو اضغط للاختيار</p>
<p class="text-xs text-on-surface-variant">يمكنك اختيار عدة ملفات في آن واحد</p>
<input type="file" id="bulk-file-input" accept=".pdf" multiple class="hidden" onchange="onBulkFilesSelected(this.files)"/>
</div>
</div>
<!-- Tab: Google Drive -->
<div id="bulk-panel-drive" class="hidden space-y-4">
<p class="text-sm text-on-surface-variant">الصق رابط مجلد Google Drive ثم اربط حسابك — يعمل مع أي مجلد مشترك.</p>
<input id="gdrive-url-input" type="url" dir="ltr"
placeholder="https://drive.google.com/drive/folders/..."
class="w-full bg-surface/60 border border-white/10 rounded-xl px-4 py-3 text-sm focus:outline-none focus:border-blue-400/60 transition-colors"/>
<div id="gdrive-status" class="hidden space-y-3">
<div class="flex justify-between text-sm">
<span id="gdrive-status-text" class="text-on-surface-variant">جاري المعالجة...</span>
<span id="gdrive-count" class="text-primary font-mono font-bold"></span>
</div>
<div class="h-2 bg-surface rounded-full overflow-hidden">
<div id="gdrive-bar" class="h-full bg-blue-400 rounded-full transition-all duration-500" style="width:0%"></div>
</div>
<div id="gdrive-skipped-list" class="hidden text-xs text-on-surface-variant space-y-1 max-h-32 overflow-y-auto"></div>
</div>
<button onclick="startGdriveOAuth()" id="gdrive-import-btn"
class="w-full bg-blue-500 hover:bg-blue-600 text-white font-bold py-3 rounded-xl text-sm flex items-center justify-center gap-2 transition-all">
<svg class="w-4 h-4" viewBox="0 0 87.3 78" xmlns="http://www.w3.org/2000/svg"><path d="m6.6 66.85 3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3l13.75-23.8h-27.5c0 1.55.4 3.1 1.2 4.5z" fill="#fff"/><path d="m43.65 25-13.75-23.8c-1.35.8-2.5 1.9-3.3 3.3l-25.4 44a9.06 9.06 0 0 0 -1.2 4.5h27.5z" fill="#fff"/><path d="m73.55 76.8c1.35-.8 2.5-1.9 3.3-3.3l1.6-2.75 7.65-13.25c.8-1.4 1.2-2.95 1.2-4.5h-27.502l5.852 11.5z" fill="#fff"/><path d="m43.65 25 13.75-23.8c-1.35-.8-2.9-1.2-4.5-1.2h-18.5c-1.6 0-3.15.45-4.5 1.2z" fill="#fff"/><path d="m59.8 53h-32.3l-13.75 23.8c1.35.8 2.9 1.2 4.5 1.2h50.8c1.6 0 3.15-.45 4.5-1.2z" fill="#fff"/><path d="m73.4 26.5-12.7-22c-.8-1.4-1.95-2.5-3.3-3.3l-13.75 23.8 16.15 28h27.45c0-1.55-.4-3.1-1.2-4.5z" fill="#fff"/></svg>
ربط Google Drive واستيراد
</button>
</div>
<!-- Selected Files Preview -->
<div id="bulk-file-list" class="hidden space-y-2"></div>
<!-- Action Buttons -->
<div id="bulk-actions" class="hidden flex gap-3 justify-end">
<button onclick="clearBulkFiles()" class="glass px-4 py-2 rounded-xl text-sm text-on-surface-variant hover:text-primary transition-colors">
<span class="msym text-base align-middle">delete_sweep</span> مسح
</button>
<button onclick="startBulkUpload()" id="bulk-upload-btn"
class="bg-primary text-surface font-bold px-6 py-2.5 rounded-xl text-sm flex items-center gap-2 hover:shadow-[0_0_20px_rgba(78,222,163,0.4)] transition-all">
<span class="msym text-lg">rocket_launch</span> رفع ومعالجة الكل
</button>
</div>
<!-- Progress Panel (shown after upload starts) -->
<div id="bulk-progress" class="hidden">
<div class="flex items-center justify-between mb-4">
<p class="text-sm font-semibold flex items-center gap-2">
<span class="msym text-primary">sync</span>
<span id="bulk-progress-label">جارٍ المعالجة…</span>
</p>
<span id="bulk-progress-count" class="text-xs text-on-surface-variant glass px-3 py-1 rounded-full">0 / 0</span>
</div>
<!-- Overall progress bar -->
<div class="bg-surface-high rounded-full h-2 mb-5 overflow-hidden">
<div id="bulk-progress-bar" class="h-2 rounded-full bg-primary transition-all duration-500" style="width:0%"></div>
</div>
<!-- Per-file rows -->
<div id="bulk-file-rows" class="space-y-3"></div>
<!-- Done state -->
<div id="bulk-done-bar" class="hidden mt-5 p-4 rounded-2xl bg-primary/10 border border-primary/20 text-center">
<p class="text-primary font-bold mb-1">✅ تمت المعالجة</p>
<p class="text-xs text-on-surface-variant mb-3" id="bulk-done-summary"></p>
<button onclick="closeBulkModal(); loadBooks(); loadFilters();"
class="bg-primary text-surface font-bold px-6 py-2 rounded-xl text-sm">
عرض المكتبة
</button>
</div>
</div>
</div>
</div>
<div class="fixed top-0 left-0 -z-10 w-96 h-96 bg-primary/5 rounded-full blur-[120px] pointer-events-none"></div>
<div class="fixed bottom-0 right-0 -z-10 w-72 h-72 bg-tertiary/5 rounded-full blur-[100px] pointer-events-none"></div>
<script>
const API_KEY = 'zRf77E_brSEnA2BUi40uxLxT4uofAk661WMZb-cdv4M';
let userToken = localStorage.getItem('rag_token');
let currentUser = null;
let currentView = 'grid';
let filters = {search:'', category:'', author:'', sort:'newest'};
let currentPage = 1;
let uploadFile = null;
let searchTimer = null;
let favIds = new Set();
async function loadFavIds() {
if (!userToken) return;
try {
const r = await fetch('/api/library/my-favorites', {headers:{'Authorization':'Bearer '+userToken}});
const d = await r.json();
favIds = new Set(d.ids||[]);
} catch(e) {}
}
async function toggleFav(bookId, btn, e) {
e.stopPropagation();
if (!userToken) { window.location.href='/login'; return; }
try {
const r = await fetch(`/api/library/${bookId}/favorite`, {
method:'POST', headers:{'Authorization':'Bearer '+userToken}
});
const d = await r.json();
if (d.status === 'added') { favIds.add(bookId); btn.innerHTML='<span class="msym text-base" style="color:#f87171">favorite</span>'; }
else { favIds.delete(bookId); btn.innerHTML='<span class="msym text-base">favorite_border</span>'; }
} catch(e) {}
}
// ── Auth Nav ───────────────────────────────────────────────────────────────
return; }
currentUser = await r.json();
checkActiveImport();
nav.innerHTML = `<div class="relative" id="user-menu-wrap">
<button onclick="document.getElementById('udrop').classList.toggle('hidden')" class="glass flex items-center gap-2 px-3 py-2 rounded-xl text-xs hover:border-primary/40 transition-colors">
<span class="msym text-base text-primary">person</span>
<span class="hidden sm:inline max-w-[80px] truncate">${currentUser.name}</span>
<span class="msym text-sm text-on-surface-variant">expand_more</span>
</button>
<div id="udrop" class="hidden absolute right-0 top-full mt-1 glass rounded-xl p-2 min-w-[160px] z-50 shadow-xl">
<div class="px-3 py-2 border-b border-outline-variant/30 mb-1">
<p class="text-xs font-bold">${currentUser.name}</p>
<p class="text-[10px] text-primary">${currentUser.role_display||currentUser.role}</p>
</div>
${currentUser.role==='admin'?'<a href="/admin" class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-on-surface-variant hover:text-primary hover:bg-primary/5"><span class="msym text-sm">admin_panel_settings</span>Admin</a>':''}
<button onclick="doLogout()" class="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-error hover:bg-red-900/10">
<span class="msym text-sm">logout</span>خروج
</button>
</div>
</div>`;
document.addEventListener('click',e=>{const w=document.getElementById('user-menu-wrap');if(w&&!w.contains(e.target))document.getElementById('udrop')?.classList.add('hidden')});
// Show upload if permitted
const perms = currentUser.permissions || [];
const canUpload = Array.isArray(perms)
? perms.includes('upload')
: (perms.upload === true);
const isAdmin = currentUser.role === 'admin' || currentUser.role === 'librarian';
if(canUpload || isAdmin) {
document.getElementById('upload-btn-wrap').classList.remove('hidden');
}
} catch(e) {}
}
function doLogout() {
fetch('/auth/logout',{method:'POST',headers:{'Authorization':'Bearer '+userToken}});
localStorage.removeItem('rag_token');
window.location.reload();
}
// ── Filters ────────────────────────────────────────────────────────────────
function setFilter(type, value, btn) {
filters[type] = value;
const group = type === 'category' ? 'cat-chips' : 'author-chips';
document.querySelectorAll('#'+group+' .filter-chip').forEach(b=>b.classList.remove('active'));
btn.classList.add('active');
currentPage = 1;
loadBooks();
}
function setSort(val) {
filters.sort = val;
currentPage = 1;
loadBooks();
}
function clearAllFilters() {
filters.search = ''; filters.category = ''; filters.author = '';
document.getElementById('search-input').value = '';
document.querySelectorAll('.filter-chip').forEach(c => c.classList.remove('active'));
document.querySelectorAll('#cat-chips .filter-chip, #author-chips .filter-chip').forEach((c,i) => { if(i===0) c.classList.add('active'); });
currentPage = 1;
loadBooks();
}
function setView(v) {
currentView = v;
document.getElementById('view-grid').classList.toggle('active', v==='grid');
document.getElementById('view-list').classList.toggle('active', v==='list');
const container = document.getElementById('books-container');
if (v === 'grid') {
container.className = '';
} else {
container.className = 'books-list';
}
loadBooks();
}
function debounceSearch(val) {
clearTimeout(searchTimer);
searchTimer = setTimeout(()=>{ filters.search = val; currentPage = 1; loadBooks(); }, 400);
}
// ── Load Books ─────────────────────────────────────────────────────────────
// ── Persistent import banner ──────────────────────────────────────────────
let _bannerPoll = null;
let _activeBannerJobId = null;
async function checkActiveImport() {
if (!currentUser || !['admin','librarian'].includes(currentUser.role)) return;
try {
const r = await fetch('/api/library/bulk-jobs/active', {
headers: { 'Authorization': 'Bearer ' + userToken }
});
if (!r.ok) return;
const job = await r.json();
if (!job || !job.job_id) { hideBanner(); return; }
_activeBannerJobId = job.job_id;
const total = job.total || 0;
const done = job.done || 0;
const status = job.status || '';
const banner = document.getElementById('import-banner');
const txt = document.getElementById('import-banner-txt');
const count = document.getElementById('import-banner-count');
const bar = document.getElementById('import-banner-bar');
banner.classList.remove('hidden');
const downloaded = job.downloaded || 0;
const skipped = job.skipped || 0;
const processed = done + skipped;
if (status === 'downloading' && total === 0) {
txt.textContent = '🔍 جاري جلب قائمة الملفات من Google Drive...';
count.textContent = '';
bar.style.width = '5%';
} else if (status === 'downloading') {
txt.textContent = '⬇️ جاري تنزيل الكتب...';
count.textContent = downloaded + ' / ' + total;
bar.style.width = Math.min(40, total > 0 ? Math.round((downloaded/total)*40) : 5) + '%';
} else if (status === 'processing' || status === 'pending') {
txt.textContent = '⚙️ جاري فهرسة الكتب...';
count.textContent = processed + ' / ' + total;
const pct = total > 0 ? Math.min(95, 40 + Math.round((processed/total)*55)) : 40;
bar.style.width = pct + '%';
} else if (status === 'done') {
txt.textContent = '✅ اكتمل الاستيراد';
count.textContent = done + ' كتاب';
bar.style.width = '100%';
bar.className = 'h-full bg-green-400 rounded-full transition-all duration-700';
setTimeout(() => { hideBanner(); loadBooks(); }, 4000);
stopBannerPoll();
return;
}
if (!_bannerPoll) startBannerPoll();
} catch(e) {}
}
function startBannerPoll() {
if (_bannerPoll) return;
_bannerPoll = setInterval(() => { checkActiveImport(); loadBooks(); }, 8000);
}
function stopBannerPoll() {
if (_bannerPoll) { clearInterval(_bannerPoll); _bannerPoll = null; }
}
function hideBanner() {
document.getElementById('import-banner').classList.add('hidden');
stopBannerPoll();
}
async function loadBooks() {
let url = `/api/library?page=${currentPage}&limit=24`;
if (filters.search) url += '&search='+encodeURIComponent(filters.search);
if (filters.category) url += '&category='+encodeURIComponent(filters.category);
if (filters.author) url += '&author='+encodeURIComponent(filters.author);
if (filters.sort) url += '&sort='+encodeURIComponent(filters.sort);
try {
const d = await (await fetch(url)).json();
document.getElementById('result-count').textContent = d.total||d.books?.length||0;
renderBooks(d.books||[]);
renderPagination(d.page||1, d.pages||1);
} catch(e) {}
}
async function loadFilters() {
try {
const d = await (await fetch('/api/library/categories')).json();
const cats = d.categories||[];
const chips = document.getElementById('cat-chips');
chips.innerHTML = '<button class="filter-chip active" onclick="setFilter(\'category\',\'\',this)">الكل</button>' +
cats.map(c=>`<button class="filter-chip" onclick="setFilter('category','${c.name}',this)">${c.name} <span class="opacity-50 text-[10px]">${c.count}</span></button>`).join('');
} catch(e) {}
// Extract unique authors from books
try {
const d = await (await fetch('/api/library?limit=200')).json();
// Normalize Arabic chars before dedup (ي vs ی, ك vs ک, alef variants)
function normalizeAr(s) {
return (s||'')
.replace(/\u06cc/g,'\u064a').replace(/\u06a9/g,'\u0643')
.replace(/[\u0622\u0623\u0625]/g,'\u0627')
.replace(/\u0629/g,'\u0647')
.replace(/\s+/g,' ').trim();
}
const authorMap = {};
(d.books||[]).forEach(b => {
if (!b.author) return;
const key = normalizeAr(b.author);
if (!authorMap[key]) authorMap[key] = b.author;
});
const authors = Object.values(authorMap);
if (authors.length) {
document.getElementById('author-section').classList.remove('hidden');
document.getElementById('author-chips').innerHTML =
'<button class="filter-chip active" onclick="setFilter(\'author\',\'\',this)">الكل</button>' +
authors.map(a=>`<button class="filter-chip" onclick="setFilter('author','${a.replace(/'/g,"\\'")}',this)">${a}</button>`).join('');
}
} catch(e) {}
}
// ── Render ─────────────────────────────────────────────────────────────────
function renderBooks(books) {
const container = document.getElementById('books-container');
if (currentView === 'grid') {
container.className = '';
} else {
container.className = 'books-list';
}
if (!books.length) {
container.innerHTML = '<p class="text-on-surface-variant text-center py-12 col-span-4">لا توجد كتب مطابقة</p>';
return;
}
if (currentView === 'grid') {
container.innerHTML = books.map(b => {
const title = b.title || b.filename.replace(/\.pdf$/i,'').replace(/^\d{4}-\d{2}-\d{2}\s*/,'');
const progress = localStorage.getItem('progress_'+b.id);
const pct = progress && parseInt(progress) > 1 ? Math.min(parseInt(progress)/10, 100) : 0;
const progressBar = pct > 0 ? '<div style="height:2px;background:rgba(78,222,163,0.1)"><div style="height:100%;background:#4edea3;width:'+pct+'%;transition:width .3s ease"></div></div>' : '';
return `<div class="glass rounded-2xl overflow-hidden flex flex-col transition-all hover:border-primary/30 cursor-pointer" onclick="readBook(${b.id})">
<div class="relative w-full bg-primary/5 overflow-hidden" style="aspect-ratio:3/4;max-height:200px">
<img src="${b.cover_url || '/library/'+b.id+'/cover'}"
class="w-full h-full object-cover object-top absolute inset-0"
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'"
loading="lazy"/>
<div class="w-full h-full absolute inset-0 items-center justify-center bg-primary/5" style="display:none">
<span class="msym text-primary/40 text-4xl">menu_book</span>
</div>
</div>
<div class="p-3 flex-1">
<h4 class="font-semibold text-sm leading-snug mb-1 line-clamp-2">${title}</h4>
${b.author?`<p class="text-xs text-primary/80 mb-1 flex items-center gap-1"><span class="msym text-xs">person</span>${b.author}</p>`:''}
${b.description?`<p class="text-xs text-on-surface-variant/70 line-clamp-2 mt-1">${b.description}</p>`:''}
<div class="flex items-center justify-between text-xs text-on-surface-variant mt-3 pt-3 border-t border-outline-variant/20">
${b.category?`<span class="glass px-2 py-0.5 rounded-full text-primary text-[10px] font-semibold">${b.category}</span>`:'<span></span>'}
<span class="flex items-center gap-0.5"><span class="msym text-[10px]">layers</span>${b.total_chunks||0}</span>
</div>
</div>
<div class="flex border-t border-outline-variant/20">
<button onclick="event.stopPropagation();readBook(${b.id})" class="flex-1 flex items-center justify-center gap-1.5 py-2 text-xs text-on-surface-variant hover:text-primary hover:bg-primary/5 transition-colors">
<span class="msym text-sm">menu_book</span>قراءة
</button>
<button onclick="toggleFav(${b.id},this)" class="flex items-center justify-center gap-1 px-4 py-2.5 text-xs text-on-surface-variant hover:text-red-400 hover:bg-red-400/5 transition-colors border-x border-outline-variant/20" title="المفضلة">
<span class="msym text-sm fav-icon">favorite_border</span>
</button>
<button onclick="downloadBook(${b.id})" class="flex items-center justify-center gap-1 px-4 py-2.5 text-xs text-on-surface-variant hover:text-primary hover:bg-primary/5 transition-colors" title="تحميل">
<span class="msym text-sm">download</span>
</button>
</div>${progressBar}</div>`;
}).join('');
} else {
container.innerHTML = books.map(b => {
const title = b.title || b.filename.replace(/\.pdf$/i,'').replace(/^\d{4}-\d{2}-\d{2}\s*/,'');
return `<div class="glass rounded-2xl p-4 flex items-center gap-4 transition-all hover:border-primary/30">
<div class="w-10 h-10 bg-primary/10 rounded-xl flex items-center justify-center shrink-0 glow-sm cursor-pointer" onclick="readBook(${b.id})">
<img src="${b.cover_url || '/library/'+b.id+'/cover'}" class="w-full h-full object-cover rounded-xl" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" style="border-radius:12px"/><span class="msym text-primary" style="display:none">menu_book</span>
</div>
<div class="flex-1 min-w-0 cursor-pointer" onclick="readBook(${b.id})">
<h4 class="font-semibold text-sm truncate">${title}</h4>
<div class="flex items-center gap-3 text-xs text-on-surface-variant mt-1">
${b.author?`<span class="flex items-center gap-1 text-primary/80"><span class="msym text-xs">person</span>${b.author}</span>`:''}
${b.category?`<span class="glass px-2 py-0.5 rounded-full text-[10px]">${b.category}</span>`:''}
</div>
</div>
<div class="flex items-center gap-1 shrink-0">
<button onclick="readBook(${b.id})" class="flex items-center gap-1 px-3 py-2 rounded-xl text-xs text-primary bg-primary/10 hover:bg-primary/20 transition-colors font-semibold">
<span class="msym text-sm">menu_book</span>
</button>
<button onclick="toggleFav(${b.id},this)" class="p-2 rounded-xl text-on-surface-variant hover:text-red-400 transition-colors">
<span class="msym text-base fav-icon">favorite_border</span>
</button>
<button onclick="downloadBook(${b.id})" class="p-2 rounded-xl text-on-surface-variant hover:text-primary transition-colors">
<span class="msym text-base">download</span>
</button>
</div>
</div>`;
}).join('');
}
}
function renderPagination(page, pages) {
if (pages <= 1) { document.getElementById('pagination').innerHTML=''; return; }
let html = '';
if (page > 1) html += `<button onclick="goPage(${page-1})" class="filter-chip px-4">السابق</button>`;
for (let i = Math.max(1,page-2); i <= Math.min(pages,page+2); i++) {
html += `<button onclick="goPage(${i})" class="filter-chip px-4 ${i===page?'active':''}">${i}</button>`;
}
if (page < pages) html += `<button onclick="goPage(${page+1})" class="filter-chip px-4">التالي</button>`;
document.getElementById('pagination').innerHTML = html;
}
function goPage(p) { currentPage = p; loadBooks(); window.scrollTo({top:0,behavior:'smooth'}); }
function readBook(id) {
window.location.href = '/reader?id=' + id;
}
async function toggleFav(id, btn) {
if (!userToken) { window.location.href='/login'; return; }
const icon = btn.querySelector('.fav-icon');
const isFav = icon.textContent === 'favorite';
try {
const r = await fetch(`/api/library/${id}/favorite`, {method:'POST',headers:{'Authorization':'Bearer '+userToken}});
if (r.ok) {
icon.textContent = isFav ? 'favorite_border' : 'favorite';
icon.style.color = isFav ? '' : '#f87171';
}
} catch(e) {}
}
async function downloadBook(id) {
if (!userToken) { window.location.href='/login'; return; }
try {
const r = await fetch(`/api/library/${id}/download`, {headers:{'Authorization':'Bearer '+userToken}});
if (!r.ok) { document.getElementById('progress-text').textContent = '❌ ' + (await r.json()).detail; return; }
const blob = await r.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url;
a.download = 'book.pdf';
document.body.appendChild(a); a.click();
document.body.removeChild(a); URL.revokeObjectURL(url);
} catch(e) { document.getElementById('progress-text').textContent = '❌ ' + e.message; }
}
// ── Upload ─────────────────────────────────────────────────────────────────
async function handleUploadFile(file) {
if (!file) return;
uploadFile = file;
document.getElementById('selected-file').textContent = '⏳ جاري استخراج المعلومات...';
document.getElementById('selected-file').classList.remove('hidden');
// Show spinner on fields
['upload-title','upload-author','upload-category'].forEach(id => {
document.getElementById(id).placeholder = '...';
document.getElementById(id).disabled = true;
});
try {
const fd = new FormData();
fd.append('file', file);
const r = await fetch('/api/library/extract-meta', {
method: 'POST',
headers: {'Authorization': 'Bearer ' + userToken},
body: fd
});
if (r.ok) {
const d = await r.json();
const m = d.meta || {};
if (m.title) document.getElementById('upload-title').value = m.title;
if (m.author) document.getElementById('upload-author').value = m.author;
if (m.category) document.getElementById('upload-category').value = m.category;
if (m.description) document.getElementById('upload-desc').value = m.description;
document.getElementById('selected-file').textContent = '✅ ' + file.name;
} else {
document.getElementById('upload-title').value = file.name.replace(/\.pdf$/i,'').replace(/^\d{4}-\d{2}-\d{2}\s*/,'');
document.getElementById('selected-file').textContent = '✓ ' + file.name;
}
} catch(e) {
document.getElementById('upload-title').value = file.name.replace(/\.pdf$/i,'').replace(/^\d{4}-\d{2}-\d{2}\s*/,'');
document.getElementById('selected-file').textContent = '✓ ' + file.name;
}
['upload-title','upload-author','upload-category'].forEach(id => {
document.getElementById(id).disabled = false;
document.getElementById(id).placeholder = id === 'upload-title' ? 'عنوان الكتاب' : id === 'upload-author' ? 'اسم المؤلف' : 'التصنيف';
});
}
async function doUpload() {
if (!userToken) { window.location.href='/login'; return; }
const title = document.getElementById('upload-title').value;
const author = document.getElementById('upload-author').value;
const category = document.getElementById('upload-category').value || '';
const language = document.getElementById('upload-lang').value;
const description = document.getElementById('upload-desc').value;
const url = document.getElementById('upload-url').value.trim();
const prog = document.getElementById('upload-progress');
const fill = document.getElementById('progress-fill');
const txt = document.getElementById('progress-text');
prog.classList.remove('hidden');
try {
let r;
if (url) {
txt.textContent = 'جاري جلب الملف...'; fill.style.width='40%';
r = await fetch('/ingest-url', {
method:'POST',
headers:{'Content-Type':'application/json','X-API-Key':API_KEY,'Authorization':'Bearer '+userToken},
body: JSON.stringify({url, title, author, category, language, description})
});
} else if (uploadFile) {
txt.textContent = 'جاري الرفع...'; fill.style.width='30%';
const fd = new FormData();
fd.append('file', uploadFile);
fd.append('title', title);
fd.append('author', author);
fd.append('category', category);
fd.append('language', language);
fd.append('description', description);
r = await fetch('/api/library/upload', {
method:'POST',
headers:{'Authorization':'Bearer '+userToken},
body: fd
});
} else {
txt.textContent = 'اختر ملفاً أو أدخل رابطاً'; return;
}
fill.style.width = '100%';
const d = await r.json();
if (!r.ok) {
if (r.status === 409 && d.detail && d.detail.error === 'duplicate') {
const book = d.detail.book;
fill.style.width = '100%';
fill.style.background = '#f87171';
txt.innerHTML = '⚠️ هذا الكتاب موجود مسبقاً: <strong>' + (book.title||book.filename) + '</strong>' +
(book.author ? ' · ' + book.author : '');
setTimeout(() => { fill.style.background = ''; fill.style.width = '0%'; prog.classList.add('hidden'); }, 5000);
return;
}
txt.textContent = '❌ ' + (d.detail || 'خطأ في الرفع');
return;
}
// Job dispatched to Celery — poll until done
if (d.job_id) {
txt.textContent = 'في الانتظار...';
fill.style.width = '60%';
const _stageLabels = {
queued: 'في الانتظار...',
compressing: 'ضغط PDF...',
ingesting: 'استخراج النصوص...',
extracting: 'استخراج البيانات...',
done: '✅ اكتملت المعالجة',
error: '❌ خطأ'
};
const _stageWidth = { compressing:'72%', ingesting:'84%', extracting:'94%' };
const _pollTimer = setInterval(async () => {
try {
const sr = await fetch('/api/library/bulk-status/' + d.job_id, {
headers: { 'X-API-Key': API_KEY, 'Authorization': 'Bearer ' + userToken }
});
const sd = await sr.json();
const f = sd.files && sd.files[0];
if (!f) return;
txt.textContent = _stageLabels[f.status] || f.status;
if (_stageWidth[f.status]) fill.style.width = _stageWidth[f.status];
if (f.status === 'done' || f.status === 'error') {
clearInterval(_pollTimer);
fill.style.width = '100%';
if (f.status === 'error') { fill.style.background = '#f87171'; }
setTimeout(() => {
fill.style.background = '';
fill.style.width = '0%';
document.getElementById('upload-modal').classList.add('hidden');
loadBooks(); loadFilters();
}, 2000);
}
} catch(_e) { clearInterval(_pollTimer); }
}, 2500);
return;
}
// Fallback: legacy sync response
txt.textContent = '✅ تم بنجاح!';
setTimeout(()=>{ document.getElementById('upload-modal').classList.add('hidden'); loadBooks(); loadFilters(); }, 1500);
} catch(e) { txt.textContent = '❌ ' + e.message; }
}
// ── Init ───────────────────────────────────────────────────────────────────
// Apply pre-set query from main page
const preQuery = sessionStorage.getItem('rag_prefill');
if (preQuery) { sessionStorage.removeItem('rag_prefill'); }
// Toast
function showToast(msg, isError=false) {
let t = document.getElementById('toast');
if (!t) {
t = document.createElement('div');
t.id = 'toast';
t.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);padding:10px 20px;border-radius:12px;font-size:13px;font-weight:600;z-index:999;transition:opacity .3s;backdrop-filter:blur(20px)';
document.body.appendChild(t);
}
t.textContent = msg;
t.style.background = isError ? 'rgba(248,113,113,0.2)' : 'rgba(78,222,163,0.2)';
t.style.border = isError ? '1px solid rgba(248,113,113,0.4)' : '1px solid rgba(78,222,163,0.4)';
t.style.color = isError ? '#f87171' : '#4edea3';
t.style.opacity = '1';
clearTimeout(t._timer);
t._timer = setTimeout(() => t.style.opacity = '0', 2500);
}
initAuth();
loadFilters();
loadFavIds().then(()=>loadBooks());
async function showFavorites() {
if (!userToken) { window.location.href='/login'; return; }
try {
const r = await fetch('/api/library/favorites/list', {headers:{'Authorization':'Bearer '+userToken}});
const d = await r.json();
filters = {search:'', category:'', author:''};
allBooks = d.favorites || [];
renderBooks(allBooks);
document.getElementById('result-count').textContent = allBooks.length;
} catch(e) {}
}
// ── Bulk Upload ──────────────────────────────────────────────────────────────
let bulkFiles = [];
let bulkJobId = null;
let bulkPollTimer = null;
// Drag & drop wiring
(function() {
const drop = document.getElementById('bulk-drop');
if (!drop) return;
drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('border-primary/60','bg-primary/10'); });
drop.addEventListener('dragleave', () => drop.classList.remove('border-primary/60','bg-primary/10'));
drop.addEventListener('drop', e => {
e.preventDefault();
drop.classList.remove('border-primary/60','bg-primary/10');
const files = [...e.dataTransfer.files].filter(f=>f.name.endsWith('.pdf'));
if (files.length) onBulkFilesSelected(files);
});
})();
function onBulkFilesSelected(fileList) {
bulkFiles = [...fileList].filter(f=>f.name.endsWith('.pdf'));
const list = document.getElementById('bulk-file-list');
const actions = document.getElementById('bulk-actions');
if (!bulkFiles.length) { list.classList.add('hidden'); actions.classList.add('hidden'); return; }
list.classList.remove('hidden');
actions.classList.remove('hidden');
list.innerHTML = bulkFiles.map((f,i) => `
<div class="glass rounded-xl px-4 py-3 flex items-center gap-3">
<span class="msym text-primary text-lg shrink-0">picture_as_pdf</span>
<div class="flex-1 min-w-0">
<p class="text-sm truncate">${f.name}</p>
<p class="text-xs text-on-surface-variant">${(f.size/1024).toFixed(0)} KB</p>
</div>
<button onclick="removeBulkFile(${i})" class="text-on-surface-variant hover:text-red-400 transition-colors">
<span class="msym text-base">close</span>
</button>
</div>`).join('');
}
function removeBulkFile(idx) {
bulkFiles.splice(idx, 1);
onBulkFilesSelected(bulkFiles);
}
function clearBulkFiles() {
bulkFiles = [];
document.getElementById('bulk-file-list').classList.add('hidden');
document.getElementById('bulk-actions').classList.add('hidden');
document.getElementById('bulk-file-input').value = '';
}
async function startBulkUpload() {
if (!bulkFiles.length) return;
if (!userToken) { window.location.href='/login'; return; }
const btn = document.getElementById('bulk-upload-btn');
btn.disabled = true;
btn.innerHTML = '<span class="msym text-base animate-spin">autorenew</span> جارٍ الرفع…';
const formData = new FormData();
bulkFiles.forEach(f => formData.append('files', f));
try {
const r = await fetch('/api/library/bulk-upload', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + userToken },
body: formData
});
const d = await r.json();
if (!r.ok) throw new Error(d.detail || 'Upload failed');
bulkJobId = d.job_id;
showBulkProgress(d.queued, d.skipped);
startBulkPolling();
} catch(e) {
btn.disabled = false;
btn.innerHTML = '<span class="msym text-lg">rocket_launch</span> رفع ومعالجة الكل';
alert('خطأ: ' + e.message);
}
}
function showBulkProgress(total, skipped) {
document.getElementById('bulk-file-list').classList.add('hidden');
document.getElementById('bulk-actions').classList.add('hidden');
document.getElementById('bulk-progress').classList.remove('hidden');
document.getElementById('bulk-progress-count').textContent = `0 / ${total}`;
if (skipped && skipped.length) {
const warn = document.createElement('p');
warn.className = 'text-xs text-yellow-400 mb-2';
warn.textContent = `⚠️ تجاهل ${skipped.length} ملف (ليس PDF)`;
document.getElementById('bulk-progress').prepend(warn);
}
}
const statusIcons = {
queued: { icon:'schedule', color:'text-on-surface-variant', label:'في الانتظار' },
ingesting: { icon:'hub', color:'text-blue-400', label:'فهرسة…' },
extracting: { icon:'psychology', color:'text-yellow-400', label:'استخراج البيانات…' },
done: { icon:'check_circle', color:'text-emerald-400', label:'تمّ' },
error: { icon:'error', color:'text-red-400', label:'خطأ' },
};
function renderBulkRows(files) {
return files.map(f => {
const s = statusIcons[f.status] || statusIcons.queued;
const meta = f.meta || {};
const isSpinning = ['ingesting','extracting'].includes(f.status);
return `
<div class="glass rounded-xl p-4" id="bfile-${f.id}">
<div class="flex items-center gap-3">
<span class="msym ${s.color} text-xl shrink-0 ${isSpinning?'animate-spin':''}">${s.icon}</span>
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold truncate">${meta.title || f.filename}</p>
<p class="text-xs text-on-surface-variant truncate">${meta.author || f.filename}</p>
</div>
<span class="text-xs ${s.color} shrink-0">${s.label}</span>
</div>
${f.status === 'done' && meta.title ? `
<div class="mt-3 grid grid-cols-2 gap-2 text-xs">
<div class="glass rounded-lg px-3 py-1.5">
<span class="text-on-surface-variant">العنوان: </span><span class="text-primary">${meta.title||'—'}</span>
</div>
<div class="glass rounded-lg px-3 py-1.5">
<span class="text-on-surface-variant">المؤلف: </span><span class="text-primary">${meta.author||'—'}</span>
</div>
<div class="glass rounded-lg px-3 py-1.5 col-span-2">
<span class="text-on-surface-variant">التصنيف: </span><span class="text-primary">${meta.category||'—'}</span>
</div>
</div>` : ''}
${f.status === 'error' ? `<p class="text-xs text-red-400 mt-2">${f.error||'خطأ غير معروف'}</p>` : ''}
${f.status === 'skipped' ? `<p class="text-xs text-yellow-400 mt-2">⚠️ مكرر — موجود مسبقاً${f.duplicate_of ? ': ' + f.duplicate_of : ''}</p>` : ''}
</div>`;
}).join('');
}
// ── Notifications ────────────────────────────────────────────────────────────
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission();
}
function showUploadNotification(msg) {
const el = document.createElement('div');
el.style.cssText = 'position:fixed;bottom:24px;right:16px;z-index:9999;background:#4edea3;color:#003824;padding:12px 18px;border-radius:14px;font-size:13px;font-weight:700;box-shadow:0 4px 24px rgba(78,222,163,0.4);max-width:300px;direction:rtl';
el.textContent = '✓ ' + msg;
document.body.appendChild(el);
setTimeout(() => { el.style.opacity='0'; el.style.transition='opacity .3s'; setTimeout(()=>el.remove(),300); }, 4500);
if ('Notification' in window && Notification.permission === 'granted') {
new Notification('AfakZaid RAG', {body: msg});
}
}
function startBulkPolling() {
if (bulkPollTimer) clearInterval(bulkPollTimer);
bulkPollTimer = setInterval(pollBulkStatus, 2500);
pollBulkStatus();
}
async function pollBulkStatus() {
if (!bulkJobId) return;
try {
const r = await fetch(`/api/library/bulk-status/${bulkJobId}`, {
headers: { 'Authorization': 'Bearer ' + userToken }
});
const d = await r.json();
if (!r.ok) return;
const processed = (d.done||0) + (d.failed||0) + (d.skipped||0);
const pct = d.total > 0 ? Math.round(processed / d.total * 100) : 0;
document.getElementById('bulk-progress-bar').style.width = pct + '%';
document.getElementById('bulk-progress-count').textContent = `${processed} / ${d.total}`;
document.getElementById('bulk-progress-label').textContent =
d.status === 'done' ? 'اكتملت المعالجة' : 'جارٍ المعالجة…';
document.getElementById('bulk-file-rows').innerHTML = renderBulkRows(d.files || []);
if (d.status === 'done') {
clearInterval(bulkPollTimer);
bulkPollTimer = null;
const dupes = (d.files||[]).filter(f=>f.status==='skipped'&&f.duplicate_of).length;
const summary = `${d.done} كتاب تمّ بنجاح${d.failed ? ' · ' + d.failed + ' فشل' : ''}${dupes ? ' · ' + dupes + ' مكرر' : ''}`;
document.getElementById('bulk-done-bar').classList.remove('hidden');
document.getElementById('bulk-done-summary').textContent = summary;
showUploadNotification(summary);
loadBooks();
}
} catch(e) { /* keep polling */ }
}
function switchBulkTab(tab) {
const isLocal = tab === 'local';
// Panels
document.getElementById('bulk-panel-local').classList.toggle('hidden', !isLocal);
document.getElementById('bulk-panel-drive').classList.toggle('hidden', isLocal);
// Tab styles
const activeClass = ['bg-primary','text-surface'];
const inactiveClass = ['text-on-surface-variant','hover:text-primary'];
const localBtn = document.getElementById('bulk-tab-local');
const driveBtn = document.getElementById('bulk-tab-drive');
if (isLocal) {
localBtn.classList.add(...activeClass); localBtn.classList.remove(...inactiveClass);
driveBtn.classList.remove(...activeClass); driveBtn.classList.add(...inactiveClass);
} else {
driveBtn.classList.add(...activeClass); driveBtn.classList.remove(...inactiveClass);
localBtn.classList.remove(...activeClass); localBtn.classList.add(...inactiveClass);
// reset drive state
document.getElementById('gdrive-status').classList.add('hidden');
document.getElementById('gdrive-bar').style.width = '0%';
document.getElementById('gdrive-bar').className = 'h-full bg-blue-400 rounded-full transition-all duration-500';
document.getElementById('gdrive-import-btn').disabled = false;
document.getElementById('gdrive-import-btn').innerHTML = '<svg class="w-4 h-4" viewBox="0 0 87.3 78" xmlns="http://www.w3.org/2000/svg"><path d="m6.6 66.85 3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3l13.75-23.8h-27.5c0 1.55.4 3.1 1.2 4.5z" fill="#fff"/><path d="m43.65 25-13.75-23.8c-1.35.8-2.5 1.9-3.3 3.3l-25.4 44a9.06 9.06 0 0 0 -1.2 4.5h27.5z" fill="#fff"/><path d="m73.55 76.8c1.35-.8 2.5-1.9 3.3-3.3l1.6-2.75 7.65-13.25c.8-1.4 1.2-2.95 1.2-4.5h-27.502l5.852 11.5z" fill="#fff"/><path d="m43.65 25 13.75-23.8c-1.35-.8-2.9-1.2-4.5-1.2h-18.5c-1.6 0-3.15.45-4.5 1.2z" fill="#fff"/><path d="m59.8 53h-32.3l-13.75 23.8c1.35.8 2.9 1.2 4.5 1.2h50.8c1.6 0 3.15-.45 4.5-1.2z" fill="#fff"/><path d="m73.4 26.5-12.7-22c-.8-1.4-1.95-2.5-3.3-3.3l-13.75 23.8 16.15 28h27.45c0-1.55-.4-3.1-1.2-4.5z" fill="#fff"/></svg> استيراد من Google Drive';
}
}
function closeBulkModal() {
const hadActiveJob = !!bulkJobId;
switchBulkTab('local');
document.getElementById('bulk-modal').classList.add('hidden');
if (bulkPollTimer) clearInterval(bulkPollTimer);
// Reset state for next use
bulkFiles = []; bulkJobId = null; bulkPollTimer = null;
document.getElementById('bulk-file-list').classList.add('hidden');
document.getElementById('bulk-actions').classList.add('hidden');
document.getElementById('bulk-progress').classList.add('hidden');
document.getElementById('bulk-done-bar').classList.add('hidden');
document.getElementById('bulk-progress-bar').style.width = '0%';
document.getElementById('bulk-file-rows').innerHTML = '';
document.getElementById('bulk-file-input').value = '';
const warn = document.querySelector('#bulk-progress > p.text-yellow-400');
if (warn) warn.remove();
const btn = document.getElementById('bulk-upload-btn');
if (btn) { btn.disabled=false; btn.innerHTML='<span class="msym text-lg">rocket_launch</span> رفع ومعالجة الكل'; }
// Show banner immediately if a job was running
if (hadActiveJob) { setTimeout(checkActiveImport, 100); }
}
// ── Google Drive OAuth Import ────────────────────────────────────────────────
let _gdriveState = null;
let _gdriveFolderUrl = null;
function startGdriveOAuth() {
const url = (document.getElementById('gdrive-url-input').value || '').trim();
if (!url) { alert('الرجاء إدخال رابط المجلد أولاً'); return; }
_gdriveFolderUrl = url;
_gdriveState = null;
const btn = document.getElementById('gdrive-import-btn');
btn.disabled = true;
btn.innerHTML = '<span class="msym text-lg" style="display:inline-block;animation:spin 1s linear infinite">progress_activity</span>&nbsp;جاري فتح Google...';
// Open OAuth popup
const state = Math.random().toString(36).slice(2);
const authUrl = `/auth/google/drive?folder_url=${encodeURIComponent(url)}&state=${state}`;
const popup = window.open(authUrl, 'gdrive_oauth',
'width=500,height=650,scrollbars=yes,resizable=yes');
// Listen for postMessage from callback page
function onMessage(e) {
if (!e.data) return;
let data = e.data;
if (typeof data === 'string') { try { data = JSON.parse(data); } catch(ex) { return; } }
if (!data || !('ok' in data)) return;
window.removeEventListener('message', onMessage);
if (data.ok) {
_gdriveState = data.state;
_gdriveFolderUrl = data.folder_url || url;
_startGdriveImportOAuth();
} else {
_gdriveError(data.error || 'فشل التفويض');
}
}
window.addEventListener('message', onMessage);
// Detect if popup was closed without completing
const check = setInterval(() => {
if (popup && popup.closed) {
clearInterval(check);
window.removeEventListener('message', onMessage);
if (!_gdriveState) {
_gdriveError('تم إغلاق نافذة Google قبل إتمام التفويض');
}
}
}, 1000);
}
function _gdriveError(msg) {
const btn = document.getElementById('gdrive-import-btn');
btn.disabled = false;
btn.innerHTML = '<svg class="w-4 h-4" viewBox="0 0 87.3 78" xmlns="http://www.w3.org/2000/svg"><path d="m6.6 66.85 3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3l13.75-23.8h-27.5c0 1.55.4 3.1 1.2 4.5z" fill="#fff"/><path d="m43.65 25-13.75-23.8c-1.35.8-2.5 1.9-3.3 3.3l-25.4 44a9.06 9.06 0 0 0 -1.2 4.5h27.5z" fill="#fff"/><path d="m73.55 76.8c1.35-.8 2.5-1.9 3.3-3.3l1.6-2.75 7.65-13.25c.8-1.4 1.2-2.95 1.2-4.5h-27.502l5.852 11.5z" fill="#fff"/><path d="m43.65 25 13.75-23.8c-1.35-.8-2.9-1.2-4.5-1.2h-18.5c-1.6 0-3.15.45-4.5 1.2z" fill="#fff"/><path d="m59.8 53h-32.3l-13.75 23.8c1.35.8 2.9 1.2 4.5 1.2h50.8c1.6 0 3.15-.45 4.5-1.2z" fill="#fff"/><path d="m73.4 26.5-12.7-22c-.8-1.4-1.95-2.5-3.3-3.3l-13.75 23.8 16.15 28h27.45c0-1.55-.4-3.1-1.2-4.5z" fill="#fff"/></svg> ربط Google Drive واستيراد';
const statusBox = document.getElementById('gdrive-status');
const statusTxt = document.getElementById('gdrive-status-text');
const bar = document.getElementById('gdrive-bar');
statusBox.classList.remove('hidden');
statusTxt.textContent = '❌ ' + msg;
bar.style.width = '100%';
bar.className = 'h-full bg-red-500 rounded-full transition-all duration-500';
}
async function _startGdriveImportOAuth() {
const btn = document.getElementById('gdrive-import-btn');
const statusBox = document.getElementById('gdrive-status');
const statusTxt = document.getElementById('gdrive-status-text');
const countEl = document.getElementById('gdrive-count');
const bar = document.getElementById('gdrive-bar');
const skipList = document.getElementById('gdrive-skipped-list');
btn.innerHTML = '<span class="msym text-lg" style="display:inline-block;animation:spin 1s linear infinite">progress_activity</span>&nbsp;جاري الاستيراد...';
statusBox.classList.remove('hidden');
statusTxt.textContent = '⬇️ جاري جلب الملفات من Google Drive...';
bar.style.width = '10%';
bar.className = 'h-full bg-blue-400 rounded-full transition-all duration-500';
skipList.classList.add('hidden');
let data;
try {
const res = await fetch('/api/library/gdrive-import-oauth', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + userToken },
body: JSON.stringify({ state: _gdriveState, folder_url: _gdriveFolderUrl })
});
data = await res.json();
if (!res.ok) {
_gdriveError((typeof data.detail === 'object' ? data.detail.message : data.detail) || 'فشل الاستيراد');
return;
}
} catch(e) {
_gdriveError(e.message);
return;
}
const jobId = data.job_id;
bar.style.width = '20%';
// Poll progress
const poll = setInterval(async () => {
try {
const pr = await fetch('/api/library/bulk-status/' + jobId, {
headers: { 'Authorization': 'Bearer ' + userToken }
});
const pd = await pr.json();
const status = pd.status || '';
const done = (pd.files || []).filter(f => f.status === 'done').length;
const errFile = (pd.files || []).find(f => f.status === 'error' && f.filename === '_');
if (errFile) {
clearInterval(poll);
_gdriveError(errFile.error || 'فشل الاستيراد');
return;
}
const total = pd.total || 0;
const dlDone = (pd.files || []).filter(f => f.status !== 'pending' && f.filename !== '_').length;
if (status === 'downloading') {
if (total === 0) {
bar.style.width = '8%';
bar.className = 'h-full bg-blue-400 rounded-full transition-all duration-500';
statusTxt.textContent = '🔍 جاري جلب قائمة الملفات...';
countEl.textContent = '';
} else {
const pct = Math.min(45, 10 + Math.round((dlDone / total) * 35));
bar.style.width = pct + '%';
bar.className = 'h-full bg-blue-400 rounded-full transition-all duration-500';
statusTxt.textContent = '⬇️ جاري التنزيل...';
countEl.textContent = dlDone + ' / ' + total;
}
return;
}
const safetotal = total || 1;
const inProgress = (pd.files || []).filter(f => ['queued','processing','ingesting','compressing','extracting','downloading'].includes(f.status)).length;
const downloaded = done + inProgress;
const pct = Math.min(95, 10 + Math.round((downloaded / safetotal) * 85));
bar.style.width = pct + '%';
bar.className = 'h-full bg-primary rounded-full transition-all duration-500';
countEl.textContent = done + ' / ' + safetotal;
if (inProgress > 0)
statusTxt.textContent = '⚙️ تم تحميل ' + downloaded + ' — فهرسة ' + done + ' من ' + safetotal;
else
statusTxt.textContent = '⚙️ جاري الفهرسة... ' + done + ' من ' + safetotal;
const skipped = (pd.files || []).filter(f => f.status === 'skipped');
if (skipped.length) {
skipList.classList.remove('hidden');
skipList.innerHTML = '<p class="font-medium mb-1">تم تخطي (' + skipped.length + '):</p>' +
skipped.slice(0,5).map(s => '<p>• ' + s.filename + ' — ' + (s.error||'') + '</p>').join('');
}
if (status === 'done' || (status !== 'downloading' && done >= total && total > 0)) {
clearInterval(poll);
bar.style.width = '100%';
bar.className = 'h-full bg-green-400 rounded-full transition-all duration-500';
statusTxt.textContent = '✅ اكتمل — تمت فهرسة ' + done + ' كتاب';
countEl.textContent = done + ' / ' + total;
btn.disabled = false;
btn.innerHTML = '<span class="msym text-lg">check_circle</span>&nbsp;تم';
setTimeout(() => { loadBooks(); loadFilters(); }, 1500);
}
} catch(pe) { console.warn('poll error', pe); }
}, 3000);
}
// Keep old startGdriveImport as no-op (unused now)
function startGdriveImport() { startGdriveOAuth(); }
// design-taste-frontend: Spotlight border cards
document.querySelectorAll('.spotlight-card').forEach(card => {
card.addEventListener('mousemove', e => {
const rect = card.getBoundingClientRect();
card.style.setProperty('--mx', ((e.clientX - rect.left) / rect.width * 100) + '%');
card.style.setProperty('--my', ((e.clientY - rect.top) / rect.height * 100) + '%');
});
});
</script>
<footer id="sticky-footer" class="text-center py-6 mt-8 text-xs text-on-surface-variant border-t border-white/5">
<div class="flex items-center justify-center gap-4">
<a href="/privacy" target="_blank" class="hover:text-primary transition-colors">سياسة الخصوصية</a>
<span class="opacity-30">•</span>
<a href="/terms" target="_blank" class="hover:text-primary transition-colors">شروط الخدمة</a>
<span class="opacity-30">•</span>
<span>© 2026 أفاق زيد</span>
</div>
</footer>
<div id="universal-footer"></div>
<script src="/components.js"></script>
</body>
</html>