feat: complete AfakZaid RAG system v1.7.4

This commit is contained in:
2026-05-26 19:48:12 +00:00
parent 6c8bd9def8
commit cfb2f74e08
19 changed files with 8367 additions and 0 deletions
+548
View File
@@ -0,0 +1,548 @@
<!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",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"},fontFamily:{sans:["Manrope","sans-serif"]}}}}</script>
<style>
/* ── Emil design vars ─────────────────────────────────────────────────────── */
:root {
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:radial-gradient(circle at 50% -20%,#151b2d 0%,#0c1324 100%);font-family:'Manrope',sans-serif;color:#dce1fb;min-height:100vh;overflow-x:hidden}
.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)}
/* Exact properties — never transition:all */
.glass-hover {
transition:background 200ms ease, border-color 200ms ease;
}
@media (hover: hover) and (pointer: fine) {
.glass-hover:hover{background:rgba(78,222,163,0.06);border-color:rgba(78,222,163,0.25)}
}
.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}
/* Upload zone — specific props, no transform on touch */
.upload-zone{
border:2px dashed rgba(78,222,163,0.2);
transition:border-color 200ms ease, background 200ms ease;
}
.upload-zone.dragover{border-color:rgba(78,222,163,0.6);background:rgba(78,222,163,0.05)}
@media (hover: hover) and (pointer: fine) {
.upload-zone:hover{border-color:rgba(78,222,163,0.4);background:rgba(78,222,163,0.03)}
}
.scrollbar::-webkit-scrollbar{width:4px}
.scrollbar::-webkit-scrollbar-thumb{background:rgba(78,222,163,0.2);border-radius:4px}
/* ── Entry keyframes ──────────────────────────────────────────────────────── */
/* Displacement: 8px max — subtle, professional */
@keyframes fadeUp {
from { opacity:0; transform:translateY(8px); }
to { opacity:1; transform:translateY(0); }
}
@keyframes fadeIn {
from { opacity:0; }
to { opacity:1; }
}
@keyframes slideDown {
from { opacity:0; transform:translateY(-8px); }
to { opacity:1; transform:translateY(0); }
}
/* Panels: 16px — noticeable but not theatrical */
@keyframes slideRight {
from { opacity:0; transform:translateX(16px); }
to { opacity:1; transform:translateX(0); }
}
@keyframes slideLeft {
from { opacity:0; transform:translateX(-16px); }
to { opacity:1; transform:translateX(0); }
}
/* Scale from 0.95 not 0.88 — nothing appears from nothing */
@keyframes scalePop {
0% { opacity:0; transform:scale(0.95); }
100% { opacity:1; transform:scale(1); }
}
/* Answer box entry */
@keyframes fadeUpFast {
from { opacity:0; transform:translateY(6px); }
to { opacity:1; transform:translateY(0); }
}
/* ── Ambient orb — slow linear drift, off main thread ────────────────────── */
@keyframes orbDrift {
0%,100% { transform:translate(0,0) scale(1); }
50% { transform:translate(-8px,10px) scale(1.05); }
}
@keyframes orbDrift2 {
0%,100% { transform:translate(0,0) scale(1); }
50% { transform:translate(10px,-8px) scale(0.97); }
}
/* ── Scan line on brand icon — runs once on load, then stops ─────────────── */
@keyframes scanLine {
0% { top:-100%; opacity:.5; }
100% { top:200%; opacity:0; }
}
/* ── Scroll reveal ────────────────────────────────────────────────────────── */
.reveal {
opacity:0;
transform:translateY(8px);
/* ease-out: entering element, starts fast */
transition:opacity 400ms var(--ease-out), transform 400ms var(--ease-out);
}
.reveal.visible { opacity:1; transform:translateY(0); }
/* ── Entry sequencing ────────────────────────────────────────────────────── */
/* Header: instant, just a subtle drop */
header { animation:slideDown 350ms var(--ease-out) both; }
/* Stats: stagger 50ms — Emil says 30-80ms */
.stat-card { animation:scalePop 400ms var(--ease-out) both; }
.stat-card:nth-child(1) { animation-delay: 80ms; }
.stat-card:nth-child(2) { animation-delay:130ms; }
.stat-card:nth-child(3) { animation-delay:180ms; }
.stat-card:nth-child(4) { animation-delay:230ms; }
/* Panels enter slightly later */
.query-card { animation:slideLeft 450ms var(--ease-out) 300ms both; }
.upload-card-wrap { animation:slideRight 450ms var(--ease-out) 340ms both; }
.books-card { animation:fadeUp 400ms var(--ease-out) 440ms both; }
/* ── Stat card — hover only on pointer devices ───────────────────────────── */
.stat-card {
cursor:default;
will-change:transform;
}
.stat-num {
font-variant-numeric:tabular-nums;
transition:color 200ms ease;
}
@media (hover: hover) and (pointer: fine) {
.stat-card {
transition:transform 200ms var(--ease-out), box-shadow 200ms ease;
}
.stat-card:hover {
transform:translateY(-3px) scale(1.02);
box-shadow:0 8px 24px rgba(78,222,163,0.14);
}
.stat-card:hover .stat-num { color:#fff; }
}
/* Active state — works on all devices */
.stat-card:active { transform:scale(0.97); }
/* ── Book cards ──────────────────────────────────────────────────────────── */
.book-card {
will-change:transform;
}
@media (hover: hover) and (pointer: fine) {
.book-card {
transition:transform 200ms var(--ease-out), box-shadow 200ms ease, border-color 200ms ease;
}
.book-card:hover {
transform:translateY(-2px);
box-shadow:0 6px 20px rgba(78,222,163,0.12);
border-color:rgba(78,222,163,0.28) !important;
}
}
.book-card:active { transform:scale(0.98); }
/* ── Query button ────────────────────────────────────────────────────────── */
#query-btn {
/* No constant animation — button is used frequently */
transition:transform 150ms var(--ease-out), box-shadow 200ms ease, filter 200ms ease;
position:relative; overflow:hidden;
}
/* Glow only on focus/hover — not constantly */
@media (hover: hover) and (pointer: fine) {
#query-btn:hover {
transform:scale(1.015);
box-shadow:0 0 28px rgba(78,222,163,0.45);
filter:brightness(1.08);
}
}
#query-btn:focus-visible {
box-shadow:0 0 0 3px rgba(78,222,163,0.4);
outline:none;
}
/* Active: fast press feedback — Emil says 100-160ms */
#query-btn:active { transform:scale(0.97); transition-duration:100ms; }
/* Shimmer on hover */
#query-btn::after {
content:'';
position:absolute; inset:0;
background:linear-gradient(90deg, transparent 0%, rgba(255,255,255,.1) 50%, transparent 100%);
transform:translateX(-100%);
transition:transform 380ms var(--ease-in-out);
pointer-events:none;
}
@media (hover: hover) and (pointer: fine) {
#query-btn:hover::after { transform:translateX(100%); }
}
/* ── Brand icon scan line — fires once on load ───────────────────────────── */
.brand-icon-wrap { position:relative; overflow:hidden; }
.brand-icon-wrap::after {
content:'';
position:absolute; left:0; right:0; height:30%;
background:linear-gradient(to bottom, transparent, rgba(78,222,163,.25), transparent);
animation:scanLine 1.8s var(--ease-in-out) 0.6s 1 forwards;
opacity:0;
}
/* ── Answer reveal ───────────────────────────────────────────────────────── */
#answer-wrap { animation:fadeUpFast 280ms var(--ease-out) both; }
/* ── Textarea ────────────────────────────────────────────────────────────── */
#query-input {
transition:border-color 200ms ease, box-shadow 200ms ease;
}
#query-input:focus { box-shadow:0 0 0 2px rgba(78,222,163,.12); }
/* ── Orbs: slow linear, off main thread ─────────────────────────────────── */
.orb-1 { animation:orbDrift 18s linear infinite; }
.orb-2 { animation:orbDrift2 24s linear infinite 3s; }
/* ── prefers-reduced-motion — keep opacity fades, remove transforms ──────── */
@media (prefers-reduced-motion: reduce) {
*,*::before,*::after {
animation-duration:.01ms !important;
animation-iteration-count:1 !important;
transition-duration:.01ms !important;
}
.reveal { opacity:1; transform:none; }
}
</style>
<script src="/static/analytics.js" defer></script>
</head>
<body>
<!-- Ambient orbs -->
<div class="fixed top-0 right-0 -z-10 w-96 h-96 bg-primary/5 rounded-full blur-[120px] pointer-events-none orb-1"></div>
<div class="fixed bottom-0 left-0 -z-10 w-72 h-72 bg-tertiary/5 rounded-full blur-[100px] pointer-events-none orb-2"></div>
<div class="fixed top-1/2 left-1/2 -z-10 w-64 h-64 bg-secondary/3 rounded-full blur-[140px] pointer-events-none" style="transform:translate(-50%,-50%);animation:orbFloat 16s ease-in-out infinite 4s"></div>
<!-- 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-3 flex items-center justify-between gap-3">
<div class="flex items-center gap-3 shrink-0">
<div class="w-8 h-8 bg-primary/15 rounded-xl flex items-center justify-center shrink-0 brand-icon-wrap">
<span class="msym text-primary text-base">auto_awesome</span>
</div>
<div class="min-w-0 hidden sm:block">
<h1 class="text-sm font-bold text-primary leading-tight truncate" id="brand-name">AfakZaid RAG</h1>
<p class="text-[10px] text-on-surface-variant/60 uppercase tracking-widest" id="brand-sub">Scholarly Intelligence</p>
</div>
<div class="h-5 w-px bg-outline-variant/40 hidden sm:block"></div>
<a href="/library" class="glass glass-hover flex items-center gap-1 px-2.5 py-2 rounded-xl text-xs text-on-surface-variant hover:text-primary transition-colors">
<span class="msym text-base">library_books</span>
<span class="hidden sm:inline">المكتبة</span>
</a>
</div>
<nav class="flex items-center gap-2 shrink-0">
<div id="nav-user" class="shrink-0"></div>
</nav>
</div>
</header>
<main class="max-w-6xl mx-auto px-4 lg:px-8 py-8">
<!-- Stats -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<div class="glass glow rounded-2xl p-4 text-center stat-card">
<div class="text-2xl font-bold text-primary stat-num" id="s-books">0</div>
<div class="text-xs text-on-surface-variant mt-1">كتاب</div>
</div>
<div class="glass glow rounded-2xl p-4 text-center stat-card">
<div class="text-2xl font-bold text-primary stat-num" id="s-vectors">0</div>
<div class="text-xs text-on-surface-variant mt-1">متجه</div>
</div>
<div class="glass glow rounded-2xl p-4 text-center stat-card">
<div class="text-2xl font-bold text-secondary stat-num" id="s-convos">0</div>
<div class="text-xs text-on-surface-variant mt-1">محادثة</div>
</div>
<div class="glass glow rounded-2xl p-4 text-center stat-card">
<div class="text-2xl font-bold text-tertiary stat-num" id="s-memory">0</div>
<div class="text-xs text-on-surface-variant mt-1">ذاكرة</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Query -->
<div class="glass rounded-3xl p-6 query-card">
<h2 class="text-primary text-xs font-bold uppercase tracking-widest mb-4 flex items-center gap-2">
<span class="msym text-base">search</span> اسأل الكتب
</h2>
<textarea id="query-input" rows="3" dir="rtl"
class="w-full bg-surface-high border border-outline-variant rounded-2xl px-4 py-3 text-on-surface text-sm resize-none outline-none focus:border-primary transition-colors mb-3"
placeholder="اكتب سؤالك هنا..."></textarea>
<button onclick="doQuery()" id="query-btn"
class="w-full bg-primary text-surface font-bold py-3 rounded-xl flex items-center justify-center gap-2">
<span class="msym text-lg">search</span> بحث
</button>
<div id="answer-wrap" class="hidden mt-4">
<div id="answer-box" class="bg-surface-high rounded-2xl p-4 text-sm leading-7 text-on-surface whitespace-pre-wrap max-h-64 overflow-y-auto scrollbar"></div>
<div id="answer-meta" class="flex flex-wrap gap-2 mt-3"></div>
</div>
</div>
<!-- Upload -->
<div class="upload-card-wrap">
<div class="glass rounded-3xl p-6 h-full" id="upload-card">
<h2 class="text-primary text-xs font-bold uppercase tracking-widest mb-4 flex items-center gap-2">
<span class="msym text-base">upload_file</span> رفع كتاب PDF
</h2>
<div class="upload-zone rounded-2xl p-8 text-center cursor-pointer relative mb-4" id="drop-zone">
<input type="file" accept=".pdf" multiple class="absolute inset-0 opacity-0 cursor-pointer w-full h-full" onchange="handleFiles(this.files)"/>
<div class="w-14 h-14 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-3 glow" style="transition:transform .3s">
<span class="msym text-primary text-3xl">cloud_upload</span>
</div>
<p class="text-on-surface-variant text-sm">اسحب ملفات PDF وأفلتها هنا</p>
<p class="text-primary text-xs font-bold mt-1">أو انقر للاختيار</p>
</div>
<div class="flex gap-2">
<input type="text" id="url-input" dir="ltr" placeholder="https://example.com/book.pdf"
class="flex-1 bg-surface-high border border-outline-variant rounded-xl px-3 py-2 text-xs text-on-surface outline-none focus:border-primary transition-colors"/>
<button onclick="ingestURL()" class="bg-primary/10 border border-primary/30 text-primary px-3 py-2 rounded-xl text-xs font-bold hover:bg-primary/20 transition-colors flex items-center gap-1">
<span class="msym text-sm">add</span>
</button>
</div>
<div id="upload-progress" class="hidden mt-3">
<div class="bg-surface-high rounded-full h-1.5 overflow-hidden">
<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 mt-2"></p>
</div>
<pre id="log-box" class="hidden mt-2 text-xs text-on-surface-variant bg-surface-high rounded-xl p-3 max-h-24 overflow-y-auto scrollbar font-mono"></pre>
</div>
</div>
</div>
<!-- Books -->
<div class="glass rounded-3xl p-6 books-card reveal" id="books-section">
<div class="flex items-center justify-between mb-5">
<h2 class="text-primary text-xs font-bold uppercase tracking-widest flex items-center gap-2">
<span class="msym text-base">library_books</span> الكتب المُدرجة
</h2>
<a href="/library" class="text-xs text-on-surface-variant hover:text-primary transition-colors flex items-center gap-1">
عرض الكل <span class="msym text-sm">arrow_back</span>
</a>
</div>
<div id="books-list" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3"></div>
</div>
</main>
<script>
const API_KEY = 'zRf77E_brSEnA2BUi40uxLxT4uofAk661WMZb-cdv4M';
let userToken = localStorage.getItem('rag_token');
let currentUser = null;
// ── Branding ──────────────────────────────────────────────────────────────────
fetch('/branding').then(r=>r.json()).then(b=>{
if(b.site_name){document.title=b.site_name;document.getElementById('brand-name').textContent=b.site_name;}
if(b.site_subtitle) document.getElementById('brand-sub').textContent=b.site_subtitle;
}).catch(()=>{});
// ── Counter animation ─────────────────────────────────────────────────────────
function animateCount(el, target, duration=900) {
const start = performance.now();
const from = parseInt(el.textContent.replace(/,/g,'')) || 0;
const isLarge = target > 999;
function tick(now) {
const elapsed = now - start;
const progress = Math.min(elapsed / duration, 1);
// ease out cubic
const eased = 1 - Math.pow(1 - progress, 3);
const current = Math.round(from + (target - from) * eased);
el.textContent = isLarge ? current.toLocaleString() : current;
if (progress < 1) requestAnimationFrame(tick);
else el.textContent = isLarge ? target.toLocaleString() : target;
}
requestAnimationFrame(tick);
}
// ── Scroll reveal ─────────────────────────────────────────────────────────────
const observer = new IntersectionObserver(entries => {
entries.forEach(e => { if(e.isIntersecting) { e.target.classList.add('visible'); observer.unobserve(e.target); } });
}, { threshold: 0.12 });
document.querySelectorAll('.reveal').forEach(el => observer.observe(el));
// ── Auth ──────────────────────────────────────────────────────────────────────
async function initAuth() {
const nav = document.getElementById('nav-user');
if (!userToken) {
nav.innerHTML = `<a href="/login" class="bg-primary text-surface font-bold px-3 py-2 rounded-xl text-xs hover:shadow-[0_0_15px_rgba(78,222,163,0.4)] transition-all flex items-center gap-1.5 whitespace-nowrap" style="animation:scalePop .4s cubic-bezier(.22,1,.36,1) .8s both"><span class="msym text-base">login</span><span>دخول</span></a>`;
document.getElementById('upload-card').classList.add('hidden');
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();
const perms = currentUser.permissions || [];
if (!perms.includes('upload')) document.getElementById('upload-card').classList.add('hidden');
nav.innerHTML = `
<div class="flex items-center gap-2" style="animation:fadeIn .4s both .6s">
<div class="text-right hidden sm:block">
<p class="text-xs font-bold">${currentUser.name}</p>
<p class="text-[10px] text-primary">${currentUser.role}</p>
</div>
${perms.includes('admin') ? '<a href="/admin" class="glass glass-hover p-2 rounded-xl transition-colors"><span class="msym text-base text-on-surface-variant">admin_panel_settings</span></a>' : ''}
<button onclick="logout()" class="glass glass-hover p-2 rounded-xl transition-colors"><span class="msym text-base text-on-surface-variant">logout</span></button>
</div>`;
} catch(e){}
}
function logout() {
if(userToken) fetch('/auth/logout',{method:'POST',headers:{'Authorization':'Bearer '+userToken}});
localStorage.removeItem('rag_token');
window.location.reload();
}
// ── Stats ─────────────────────────────────────────────────────────────────────
async function loadStats() {
try {
const d = await (await fetch('/stats')).json();
animateCount(document.getElementById('s-books'), d.books||0);
animateCount(document.getElementById('s-vectors'), d.vectors||0);
animateCount(document.getElementById('s-convos'), d.conversations||0);
animateCount(document.getElementById('s-memory'), d.memories||0);
} catch(e){}
}
// ── Books ─────────────────────────────────────────────────────────────────────
async function loadBooks() {
try {
const d = await (await fetch('/api/library?limit=6')).json();
const list = document.getElementById('books-list');
if (!d.books||!d.books.length) {
list.innerHTML='<p class="text-on-surface-variant text-sm">لا توجد كتب حتى الآن</p>'; return;
}
list.innerHTML = d.books.map((b,i)=>{
const name = b.title || b.filename.replace(/\.pdf$/i,'').replace(/^\d{4}-\d{2}-\d{2}\s*/,'');
return `<div class="glass glass-hover rounded-2xl p-4 flex items-center gap-3 cursor-pointer book-card"
style="animation:fadeUp .5s cubic-bezier(.22,1,.36,1) ${.85+i*.08}s both"
onclick="window.location='/library'">
<div class="w-9 h-9 bg-primary/10 rounded-xl flex items-center justify-center shrink-0" style="transition:transform .25s">
<span class="msym text-primary">menu_book</span>
</div>
<div class="min-w-0">
<p class="text-sm font-medium truncate">${name}</p>
${b.author?`<p class="text-xs text-on-surface-variant">${b.author}</p>`:''}
</div>
</div>`;
}).join('');
} catch(e){}
}
// ── Query ─────────────────────────────────────────────────────────────────────
async function doQuery() {
const q = document.getElementById('query-input').value.trim();
if(!q) return;
const btn = document.getElementById('query-btn');
btn.disabled = true;
btn.style.animation = 'none';
btn.innerHTML = '<span class="msym text-lg" style="animation:spin .8s linear infinite">autorenew</span> جاري البحث...';
const wrap = document.getElementById('answer-wrap');
const box = document.getElementById('answer-box');
const meta = document.getElementById('answer-meta');
wrap.classList.remove('hidden');
box.textContent = '...'; meta.innerHTML = '';
try {
const headers = {'Content-Type':'application/json','X-API-Key':API_KEY};
if(userToken) headers['Authorization'] = 'Bearer '+userToken;
const r = await fetch('/query',{method:'POST',headers,body:JSON.stringify({query:q,session_id:'web-'+(currentUser?.id||'guest')})});
const d = await r.json();
box.textContent = d.answer;
meta.innerHTML = [
`<span class="glass px-3 py-1 rounded-full text-xs text-primary font-bold">${d.llm}</span>`,
`<span class="glass px-3 py-1 rounded-full text-xs text-secondary">${((d.confidence||0)*100).toFixed(0)}% ثقة</span>`,
...(d.books_used||[]).map(b=>`<span class="glass px-3 py-1 rounded-full text-xs text-on-surface-variant">${b}</span>`)
].join('');
} catch(e){ box.textContent='خطأ: '+e.message; }
btn.disabled = false;
btn.style.animation = '';
btn.innerHTML = '<span class="msym text-lg">search</span> بحث';
}
// ── Upload ────────────────────────────────────────────────────────────────────
async function handleFiles(files){for(const f of files)await uploadFile(f);loadStats();loadBooks();}
async function uploadFile(file){
const prog=document.getElementById('upload-progress'),fill=document.getElementById('progress-fill');
const txt=document.getElementById('progress-text'),log=document.getElementById('log-box');
prog.classList.remove('hidden');log.classList.remove('hidden');
txt.textContent='رفع: '+file.name;fill.style.width='30%';
const fd=new FormData();fd.append('file',file);
try{
fill.style.width='60%';
const headers={'X-API-Key':API_KEY};
if(userToken) headers['Authorization']='Bearer '+userToken;
const r=await fetch('/ingest',{method:'POST',headers,body:fd});
fill.style.width='100%';
const d=await r.json();
txt.textContent='✅ تم: '+file.name;log.textContent=d.log||'Done';
}catch(e){txt.textContent='❌ خطأ: '+e.message;}
}
async function ingestURL(){
const url=document.getElementById('url-input').value.trim();if(!url)return;
const prog=document.getElementById('upload-progress'),fill=document.getElementById('progress-fill');
const txt=document.getElementById('progress-text'),log=document.getElementById('log-box');
prog.classList.remove('hidden');log.classList.remove('hidden');
txt.textContent='جلب الملف...';fill.style.width='40%';
try{
fill.style.width='70%';
const headers={'Content-Type':'application/json','X-API-Key':API_KEY};
if(userToken) headers['Authorization']='Bearer '+userToken;
const r=await fetch('/ingest-url',{method:'POST',headers,body:JSON.stringify({url})});
fill.style.width='100%';
const d=await r.json();
txt.textContent='✅ تم: '+d.file;log.textContent=d.log||'Done';
document.getElementById('url-input').value='';
loadStats();loadBooks();
}catch(e){txt.textContent='❌ خطأ: '+e.message;}
}
// ── Upload zone drag ─────────────────────────────────────────────────────────
const zone = document.getElementById('drop-zone');
const cloudIcon = zone.querySelector('.w-14');
zone.addEventListener('dragover',e=>{e.preventDefault();zone.classList.add('dragover');cloudIcon.style.transform='scale(1.15)';});
zone.addEventListener('dragleave',()=>{zone.classList.remove('dragover');cloudIcon.style.transform='';});
zone.addEventListener('drop',e=>{e.preventDefault();zone.classList.remove('dragover');cloudIcon.style.transform='';handleFiles(e.dataTransfer.files);});
// ── Keyboard shortcut ────────────────────────────────────────────────────────
document.getElementById('query-input').addEventListener('keydown',e=>{if(e.ctrlKey&&e.key==='Enter')doQuery();});
// ── URL pre-fill ─────────────────────────────────────────────────────────────
const urlQ = new URLSearchParams(location.search).get('q');
if(urlQ){document.getElementById('query-input').value=decodeURIComponent(urlQ);setTimeout(doQuery,600);}
// ── Spin keyframe for loading ────────────────────────────────────────────────
const spinStyle = document.createElement('style');
spinStyle.textContent = '@keyframes spin{to{transform:rotate(360deg)}}';
document.head.appendChild(spinStyle);
// ── Init ──────────────────────────────────────────────────────────────────────
initAuth();
loadStats();
loadBooks();
setInterval(loadStats, 30000);
</script>
</body>
</html>