1687 lines
79 KiB
HTML
1687 lines
79 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 -->
|
|
<header class="sticky top-0 z-20 glass border-b border-primary/20">
|
|
<div class="max-w-6xl mx-auto px-4 lg:px-8 py-4 flex items-center justify-between">
|
|
<div class="flex items-center gap-4">
|
|
<a href="/" class="flex items-center gap-3">
|
|
<div class="w-9 h-9 bg-primary/15 rounded-xl flex items-center justify-center glow-sm">
|
|
<span class="msym text-primary text-lg">auto_awesome</span>
|
|
</div>
|
|
<div class="hidden sm:block">
|
|
<h1 class="text-base font-bold text-primary leading-tight" id="brand-name">AfakZaid RAG</h1>
|
|
<p class="text-[10px] text-on-surface-variant/60 uppercase tracking-widest">Scholarly Intelligence</p>
|
|
</div>
|
|
</a>
|
|
<div class="h-5 w-px bg-outline-variant/40 hidden sm:block"></div>
|
|
<span class="text-sm font-semibold text-on-surface flex items-center gap-2">
|
|
<span class="msym text-primary text-base">library_books</span> المكتبة
|
|
</span>
|
|
</div>
|
|
<nav class="flex items-center gap-2">
|
|
<a href="/" class="glass glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-xs text-on-surface-variant hover:text-primary transition-colors">
|
|
<span class="msym text-base">search</span>
|
|
<span class="hidden sm:inline">البحث الذكي</span>
|
|
</a>
|
|
<button onclick="showFavorites()" class="glass glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-xs text-on-surface-variant hover:text-red-400 transition-colors">
|
|
<span class="msym text-base">favorite</span>
|
|
<span class="hidden sm:inline">المفضلة</span>
|
|
</button>
|
|
<div id="nav-auth" class="flex items-center gap-2"></div>
|
|
</nav>
|
|
</div>
|
|
</header>
|
|
|
|
<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) {}
|
|
}
|
|
|
|
// ── Branding ───────────────────────────────────────────────────────────────
|
|
|
|
// -- Branding color override --------------------------------------------------
|
|
function applyBrandingColors(p, s, bg, bgImg) {
|
|
p = p || '#4edea3';
|
|
s = s || '#95d3ba';
|
|
bg = bg || '#0c1324';
|
|
const hr = (hex) => {
|
|
hex = hex.replace('#','');
|
|
if(hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
|
|
return parseInt(hex.slice(0,2),16)+','+parseInt(hex.slice(2,4),16)+','+parseInt(hex.slice(4,6),16);
|
|
};
|
|
const pr = hr(p), sr = hr(s), bgr = hr(bg);
|
|
// Background
|
|
if(bgImg) { document.body.style.backgroundImage='url('+bgImg+')'; document.body.style.backgroundSize='cover'; document.body.style.backgroundPosition='center'; }
|
|
else { document.body.style.backgroundImage=''; document.body.style.background='radial-gradient(circle at 50% -20%,'+bg+' 0%,'+bg+' 100%)'; }
|
|
// Remove old style
|
|
const old = document.getElementById('_bco'); if(old) old.remove();
|
|
const st = document.createElement('style'); st.id='_bco';
|
|
st.textContent = [
|
|
// Primary color — text, bg, border
|
|
'.text-primary{color:'+p+'!important}',
|
|
'.bg-primary{background-color:'+p+'!important}',
|
|
'.border-primary{border-color:'+p+'!important}',
|
|
'[class*="text-primary"]{color:'+p+'!important}',
|
|
'[class*="bg-primary\/"]{}',
|
|
// Secondary
|
|
'.text-secondary{color:'+s+'!important}',
|
|
'.bg-secondary{background-color:'+s+'!important}',
|
|
'.border-secondary{border-color:'+s+'!important}',
|
|
'.text-tertiary{color:'+s+'!important}',
|
|
// Hover/focus states
|
|
'.hover\:text-primary:hover{color:'+p+'!important}',
|
|
'.hover\:bg-primary\/5:hover{background-color:rgba('+pr+',0.05)!important}',
|
|
'.focus\:border-primary:focus{border-color:'+p+'!important}',
|
|
// Active nav
|
|
'.active.bg-primary\/10{background-color:rgba('+pr+',0.1)!important}',
|
|
'.active.text-primary{color:'+p+'!important}',
|
|
'button.active{color:'+p+'!important}',
|
|
// Glass
|
|
'.glass{border-color:rgba('+pr+',0.12)!important}',
|
|
'.glass-hover:hover{background:rgba('+pr+',0.06)!important;border-color:rgba('+pr+',0.25)!important}',
|
|
// Glows
|
|
'.glow{box-shadow:0 0 20px rgba('+pr+',0.15)!important}',
|
|
'.glow-sm{box-shadow:0 0 10px rgba('+pr+',0.1)!important}',
|
|
'.glass.glow{box-shadow:inset 0 1px 0 rgba(255,255,255,0.06),0 0 20px rgba('+pr+',0.18),0 4px 24px rgba(0,0,0,0.15)!important}',
|
|
'.glass.glow-sm{box-shadow:inset 0 1px 0 rgba(255,255,255,0.06),0 0 10px rgba('+pr+',0.12)!important}',
|
|
// Spotlight card gradient
|
|
'.spotlight-card::before{background:radial-gradient(400px circle at var(--mx,50%) var(--my,50%),rgba('+pr+',0.06),transparent 60%)!important}',
|
|
// Alpha variants
|
|
'.bg-primary\/15,.bg-primary\/10,.bg-primary\/5{background-color:rgba('+pr+',0.1)!important}',
|
|
'.border-primary\/20{border-color:rgba('+pr+',0.2)!important}',
|
|
'.border-primary\/30{border-color:rgba('+pr+',0.3)!important}',
|
|
// Upload zone
|
|
'.upload-zone{border-color:rgba('+pr+',0.2)!important}',
|
|
'.upload-zone.dragover{border-color:rgba('+pr+',0.6)!important}',
|
|
// Scrollbar
|
|
'.scrollbar::-webkit-scrollbar-thumb{background:rgba('+pr+',0.2)!important}',
|
|
// Input focus
|
|
'.inp:focus,.inp:focus-within{border-color:'+p+'!important;box-shadow:0 0 0 2px rgba('+pr+',0.1)!important}',
|
|
// Filter chips active
|
|
'.filter-chip.active{background:rgba('+pr+',0.12)!important;border-color:rgba('+pr+',0.4)!important;color:'+p+'!important}',
|
|
// Book card hover
|
|
'.book-card:hover{box-shadow:0 8px 24px rgba('+pr+',0.1)!important;border-color:rgba('+pr+',0.28)!important}',
|
|
// Nav active
|
|
'.nav-btn.active{background-color:rgba('+pr+',0.1)!important;color:'+p+'!important}',
|
|
// Sidebar border
|
|
'aside{border-color:rgba('+pr+',0.2)!important}',
|
|
// CTA button shimmer
|
|
'.cta-primary::before{background:linear-gradient(90deg,transparent,rgba('+pr+',0.1),transparent)!important}',
|
|
// Shadow glow on hover
|
|
'[class*="hover:shadow"]:hover{--tw-shadow-color:rgba('+pr+',0.4)!important}',
|
|
].join('
|
|
');
|
|
document.head.appendChild(st);
|
|
document.documentElement.style.setProperty('--primary', p);
|
|
document.documentElement.style.setProperty('--secondary', s);
|
|
}
|
|
fetch('/branding').then(r=>r.json()).then(b=>{
|
|
if(b.site_name){const bn=document.getElementById('brand-name');if(bn)bn.textContent=b.site_name;}
|
|
applyBrandingColors(b.primary_color,b.secondary_color,b.bg_color,b.bg_image);
|
|
}).catch(()=>{});
|
|
|
|
// ── Auth Nav ───────────────────────────────────────────────────────────────
|
|
async function initAuth() {
|
|
const nav = document.getElementById('nav-auth');
|
|
if (!userToken) {
|
|
nav.innerHTML = `<a href="/login" class="glass glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-xs text-on-surface-variant hover:text-primary transition-colors"><span class="msym text-base">login</span><span class="hidden sm:inline">دخول</span></a>`;
|
|
return;
|
|
}
|
|
try {
|
|
const r = await fetch('/auth/me', {headers:{'Authorization':'Bearer '+userToken}});
|
|
if (!r.ok) { localStorage.removeItem('rag_token'); initAuth(); 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> جاري فتح 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> جاري الاستيراد...';
|
|
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> تم';
|
|
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>
|
|
|
|
</body>
|
|
</html> |