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
+1921
View File
File diff suppressed because it is too large Load Diff
+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>
+1249
View File
File diff suppressed because it is too large Load Diff
+501
View File
@@ -0,0 +1,501 @@
<!DOCTYPE html>
<html class="dark" lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>AfakZaid RAG — Sign In</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","error":"#f87171"},fontFamily:{sans:["Manrope","sans-serif"]}}}}</script>
<style>
*{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;display:flex;align-items:center;justify-content:center;padding:20px}
.glass{background:rgba(21,27,45,0.6);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);border:1px solid rgba(78,222,163,0.15)}
.glow{box-shadow:0 0 30px rgba(78,222,163,0.15)}
.msym{font-family:'Material Symbols Outlined';font-variation-settings:'FILL' 0,'wght' 400,'GRAD' 0,'opsz' 24;vertical-align:middle;line-height:1}
:root { --ease-out: cubic-bezier(0.23,1,0.32,1); }
.inp{width:100%;background:#23293c;border:1px solid #3c4a42;border-radius:12px;padding:12px 16px;color:#dce1fb;font-size:14px;outline:none;transition:border-color 180ms ease, box-shadow 180ms ease;font-family:'Manrope',sans-serif}
.inp:focus{border-color:#4edea3;box-shadow:0 0 0 2px rgba(78,222,163,0.1)}
.inp.inp-error{border-color:#f87171;box-shadow:0 0 0 2px rgba(248,113,113,0.1)}
.inp-icon{position:relative}
.inp-icon .msym{position:absolute;left:12px;top:50%;transform:translateY(-50%);color:#bbcabf;font-size:18px;pointer-events:none}
.inp-icon input{padding-left:40px}
/* Primary button — specific transitions, no transition:all */
.btn-primary{width:100%;background:#4edea3;color:#003824;font-weight:700;border:none;border-radius:12px;padding:14px;cursor:pointer;font-size:15px;font-family:'Manrope',sans-serif;position:relative;overflow:hidden;transition:box-shadow 200ms ease, transform 150ms var(--ease-out)}
.btn-primary:disabled{opacity:0.5;cursor:not-allowed}
.btn-primary:active:not(:disabled){transform:scale(0.97);transition-duration:100ms}
@media (hover:hover) and (pointer:fine){.btn-primary:hover:not(:disabled){box-shadow:0 0 24px rgba(78,222,163,0.4)}}
/* Shimmer on btn hover */
.btn-primary::after{content:'';position:absolute;inset:0;background:linear-gradient(90deg,transparent 0%,rgba(255,255,255,0.12) 50%,transparent 100%);transform:translateX(-100%);transition:transform 380ms cubic-bezier(0.77,0,0.175,1);pointer-events:none}
@media (hover:hover) and (pointer:fine){.btn-primary:hover::after{transform:translateX(100%)}}
/* Tabs — specific transitions */
.tab{padding:10px 24px;border-radius:10px;font-size:14px;font-weight:600;cursor:pointer;border:none;background:transparent;color:#bbcabf;font-family:'Manrope',sans-serif;transition:background 150ms var(--ease-out),color 150ms var(--ease-out)}
.tab.active{background:rgba(78,222,163,0.1);color:#4edea3}
.tab:active{transform:scale(0.97)}
/* Field validation */
.field-error{font-size:12px;color:#f87171;margin-top:4px;display:none}
.field-error.visible{display:block}
/* Country dropdown */
#country-dropdown{position:absolute;top:calc(100% + 4px);left:0;right:0;background:#191f31;border:1px solid rgba(78,222,163,0.2);border-radius:12px;z-index:50;max-height:200px;overflow-y:auto;box-shadow:0 8px 32px rgba(0,0,0,0.4)}
#country-dropdown::-webkit-scrollbar{width:4px}
#country-dropdown::-webkit-scrollbar-thumb{background:rgba(78,222,163,0.2);border-radius:4px}
.country-item{display:flex;align-items:center;gap:10px;padding:10px 14px;cursor:pointer;font-size:13px;transition:background 120ms ease}
@media (hover:hover) and (pointer:fine){.country-item:hover{background:rgba(78,222,163,0.08)}}
/* Form entry */
@keyframes fadeUp{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
.auth-card{animation:fadeUp 400ms var(--ease-out) both}
/* Reduced motion */
@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms !important;transition-duration:.01ms !important}}
/* ── 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; }
}
</style>
<script src="/static/analytics.js" defer></script>
</head>
<body>
<div class="w-full max-w-md">
<!-- Logo -->
<div class="text-center mb-8">
<div class="w-16 h-16 bg-primary/10 rounded-2xl flex items-center justify-center mx-auto mb-4 glow">
<span class="msym text-primary text-3xl">auto_awesome</span>
</div>
<h1 id="brand-name" class="text-2xl font-bold text-primary">AfakZaid RAG</h1>
<p id="brand-sub" class="text-on-surface-variant text-sm mt-1">Scholarly Intelligence</p>
</div>
<!-- Card -->
<div class="glass glow rounded-3xl p-8 auth-card">
<!-- Tabs -->
<div class="flex gap-2 bg-surface-container rounded-xl p-1 mb-8">
<button class="tab active flex-1" id="tab-login" onclick="switchTab('login')">Sign In</button>
<button class="tab flex-1" id="tab-register" onclick="switchTab('register')">Create Account</button>
</div>
<!-- LOGIN -->
<form id="form-login" onsubmit="doLogin(event)">
<div class="space-y-4 mb-6">
<div class="inp-icon">
<span class="msym">mail</span>
<input class="inp" type="email" id="login-email" placeholder="Email address" required/>
</div>
<div class="inp-icon">
<span class="msym">lock</span>
<input class="inp" type="password" id="login-pass" placeholder="Password" required/>
</div>
</div>
<p id="login-err" class="text-error text-xs mb-4 hidden"></p>
<button class="btn-primary" type="submit" id="login-btn">
<span class="msym text-lg align-middle mr-1">login</span> Sign In
</button>
</form>
<!-- REGISTER -->
<form id="form-register" class="hidden" onsubmit="doRegister(event)">
<div class="space-y-4 mb-6">
<div class="inp-icon">
<span class="msym">person</span>
<input class="inp" type="text" id="reg-name" placeholder="Full name" required/>
</div>
<div class="inp-icon">
<span class="msym">mail</span>
<input class="inp" type="email" id="reg-email" placeholder="Email address" required/>
</div>
<div class="inp-icon">
<span class="msym">lock</span>
<input class="inp" type="password" id="reg-pass" placeholder="Password (min 6 chars)" required minlength="6"/>
</div>
<!-- Phone + Country -->
<div class="grid grid-cols-2 gap-3">
<!-- Country picker -->
<div class="relative" id="country-wrap">
<div class="inp flex items-center gap-2 cursor-pointer select-none" onclick="toggleCountry()" id="country-display">
<span class="msym text-on-surface-variant text-base">public</span>
<span id="country-flag" class="text-lg hidden"></span>
<span id="country-label" class="text-on-surface-variant text-sm truncate flex-1">Country</span>
<span class="msym text-on-surface-variant text-base">expand_more</span>
</div>
<div id="country-dropdown" class="hidden">
<div class="p-2">
<input id="country-search" class="inp text-xs py-2" placeholder="Search country..." oninput="filterCountries(this.value)"/>
</div>
<div id="country-list"></div>
</div>
<input type="hidden" id="reg-country-code"/>
<input type="hidden" id="reg-country-name"/>
</div>
<!-- Phone -->
<div class="inp-icon">
<span class="msym">phone</span>
<input class="inp" type="tel" id="reg-phone" placeholder="Phone"/>
</div>
</div>
<!-- GPS button -->
<button type="button" onclick="detectLocation()" id="gps-btn"
class="w-full flex items-center justify-center gap-2 glass border border-primary/20 text-on-surface-variant hover:text-primary hover:border-primary/50 rounded-xl py-2.5 text-sm transition-all">
<span class="msym text-base">my_location</span>
<span id="gps-text">Detect my location</span>
</button>
</div>
<p id="reg-err" class="text-error text-xs mb-4 hidden"></p>
<button class="btn-primary" type="submit" id="reg-btn">
<span class="msym text-lg align-middle mr-1">person_add</span> Create Account
</button>
</form>
</div>
<p class="text-center text-on-surface-variant text-xs mt-6">
<a href="/" class="hover:text-primary transition-colors">← Back to library</a>
</p>
</div>
<!-- Background -->
<div class="fixed top-0 right-0 -z-10 w-96 h-96 bg-primary/5 rounded-full blur-[120px] pointer-events-none"></div>
<div class="fixed bottom-0 left-0 -z-10 w-72 h-72 bg-secondary/5 rounded-full blur-[100px] pointer-events-none"></div>
<script>
// ── Branding ───────────────────────────────────────────────────────────────
fetch('/branding').then(r=>r.json()).then(b=>{
if(b.site_name){document.title=b.site_name+' — Sign In';document.getElementById('brand-name').textContent=b.site_name;}
if(b.site_subtitle) document.getElementById('brand-sub').textContent=b.site_subtitle;
}).catch(()=>{});
// Redirect if already logged in
const token = localStorage.getItem('rag_token');
if(token) {
fetch('/auth/me',{headers:{'Authorization':token}}).then(r=>{
if(r.ok) window.location.href='/';
}).catch(()=>{});
}
// ── Tabs ───────────────────────────────────────────────────────────────────
function switchTab(tab) {
document.getElementById('form-login').classList.toggle('hidden', tab!=='login');
document.getElementById('form-register').classList.toggle('hidden', tab!=='register');
document.getElementById('tab-login').classList.toggle('active', tab==='login');
document.getElementById('tab-register').classList.toggle('active', tab==='register');
}
// ── Auth ───────────────────────────────────────────────────────────────────
async function doLogin(e) {
e.preventDefault();
const btn=document.getElementById('login-btn');
if(btn.disabled) return;
const err=document.getElementById('login-err');
btn.disabled=true; btn.textContent='Signing in...'; err.classList.add('hidden');
try {
const r = await fetch('/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({email:document.getElementById('login-email').value,password:document.getElementById('login-pass').value})});
const d = await r.json();
if(!r.ok){err.textContent=d.detail||'Login failed';err.classList.remove('hidden');return;}
localStorage.setItem('rag_token',d.token);
localStorage.setItem('rag_user',JSON.stringify(d.user));
window.location.href = d.user.permissions?.admin ? '/admin' : '/';
} catch(ex){err.textContent='Connection error';err.classList.remove('hidden');}
finally{btn.disabled=false;btn.innerHTML='<span class="msym text-lg align-middle mr-1">login</span> Sign In';}
}
async function doRegister(e) {
e.preventDefault();
const btn=document.getElementById('reg-btn');
if(btn.disabled) return;
const err=document.getElementById('reg-err');
btn.disabled=true;btn.textContent='Creating account...';err.classList.add('hidden');
try {
const r = await fetch('/auth/register',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({
name:document.getElementById('reg-name').value,
email:document.getElementById('reg-email').value,
password:document.getElementById('reg-pass').value,
phone:document.getElementById('reg-phone').value,
country_code:document.getElementById('reg-country-code').value,
country_name:document.getElementById('reg-country-name').value,
})});
const d = await r.json();
if(!r.ok){err.textContent=d.detail||'Registration failed';err.classList.remove('hidden');return;}
localStorage.setItem('rag_token',d.token);
window.location.href='/';
} catch(ex){err.textContent='Connection error';err.classList.remove('hidden');}
finally{btn.disabled=false;btn.innerHTML='<span class="msym text-lg align-middle mr-1">person_add</span> Create Account';}
}
// ── Country Picker ─────────────────────────────────────────────────────────
let allCountries = [];
let selectedCountry = null;
async function loadCountries() {
try {
const r = await fetch('https://restcountries.com/v3.1/all?fields=name,cca2,idd,flag');
const data = await r.json();
allCountries = data.sort((a,b)=>a.name.common.localeCompare(b.name.common));
renderCountries(allCountries);
} catch(e) {
// fallback minimal list
allCountries = [
{name:{common:'Saudi Arabia'},cca2:'SA',flag:'🇸🇦'},
{name:{common:'UAE'},cca2:'AE',flag:'🇦🇪'},
{name:{common:'Kuwait'},cca2:'KW',flag:'🇰🇼'},
{name:{common:'Qatar'},cca2:'QA',flag:'🇶🇦'},
{name:{common:'Bahrain'},cca2:'BH',flag:'🇧🇭'},
{name:{common:'Egypt'},cca2:'EG',flag:'🇪🇬'},
{name:{common:'Jordan'},cca2:'JO',flag:'🇯🇴'},
{name:{common:'United States'},cca2:'US',flag:'🇺🇸'},
{name:{common:'United Kingdom'},cca2:'GB',flag:'🇬🇧'},
];
renderCountries(allCountries);
}
}
function renderCountries(list) {
document.getElementById('country-list').innerHTML = list.slice(0,100).map(c=>`
<div class="country-item" onclick="selectCountry('${c.cca2}','${c.name.common.replace(/'/g,"\\'")}','${c.flag||''}')">
<span class="text-lg">${c.flag||'🌍'}</span>
<span class="text-sm">${c.name.common}</span>
<span class="text-xs text-on-surface-variant ml-auto">${c.cca2}</span>
</div>`).join('');
}
function filterCountries(q) {
const filtered = allCountries.filter(c=>c.name.common.toLowerCase().includes(q.toLowerCase())||c.cca2.toLowerCase().includes(q.toLowerCase()));
renderCountries(filtered);
}
function selectCountry(code, name, flag) {
document.getElementById('reg-country-code').value = code;
document.getElementById('reg-country-name').value = name;
document.getElementById('country-flag').textContent = flag;
document.getElementById('country-flag').classList.remove('hidden');
document.getElementById('country-label').textContent = name;
document.getElementById('country-label').style.color = '#dce1fb';
closeCountry();
}
function toggleCountry() {
const dd = document.getElementById('country-dropdown');
dd.classList.toggle('hidden');
if(!dd.classList.contains('hidden')) {
document.getElementById('country-search').focus();
loadCountries();
}
}
function closeCountry() {
document.getElementById('country-dropdown').classList.add('hidden');
}
document.addEventListener('click', e=>{
if(!document.getElementById('country-wrap').contains(e.target)) closeCountry();
});
// ── GPS Detection ──────────────────────────────────────────────────────────
async function detectLocation() {
const btn = document.getElementById('gps-btn');
const txt = document.getElementById('gps-text');
txt.textContent = 'Detecting...';
btn.disabled = true;
if (!navigator.geolocation) { txt.textContent='GPS not supported'; btn.disabled=false; return; }
navigator.geolocation.getCurrentPosition(async pos => {
try {
const {latitude: lat, longitude: lon} = pos.coords;
const r = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lon}`);
const d = await r.json();
const code = d.address?.country_code?.toUpperCase()||'';
const name = d.address?.country||'';
// Find flag
await loadCountries();
const country = allCountries.find(c=>c.cca2===code);
const flag = country?.flag||'🌍';
selectCountry(code, name, flag);
txt.textContent = '✓ ' + name;
} catch(e){ txt.textContent='Could not detect'; }
btn.disabled=false;
}, ()=>{ txt.textContent='Location denied'; btn.disabled=false; });
}
</script>
</body>
</html>
+434
View File
@@ -0,0 +1,434 @@
<!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</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@400,0&display=swap" rel="stylesheet"/>
<script>tailwind.config={darkMode:"class",theme:{extend:{colors:{"primary":"#4edea3","surface":"#0c1324","surface-high":"#23293c","on-surface":"#dce1fb","on-surface-variant":"#bbcabf","outline-variant":"#3c4a42"},fontFamily:{sans:["Manrope","sans-serif"]}}}}</script>
<style>
:root { --ease-out: cubic-bezier(0.23,1,0.32,1); }
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;overflow:hidden}
body{background:#0c1324;font-family:'Manrope',sans-serif;color:#dce1fb;display:flex;flex-direction:column}
.glass{background:rgba(21,27,45,0.6);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)}
.msym{font-family:'Material Symbols Outlined';font-variation-settings:'FILL' 0,'wght' 400,'GRAD' 0,'opsz' 24;vertical-align:middle;line-height:1}
/* Header buttons — specific transitions only */
.hdr-btn {
transition: background 150ms var(--ease-out), border-color 150ms var(--ease-out), color 150ms var(--ease-out);
border: 1px solid transparent;
}
@media (hover: hover) and (pointer: fine) {
.hdr-btn:hover { background: rgba(78,222,163,0.08); border-color: rgba(78,222,163,0.2); }
}
.hdr-btn:active { transform: scale(0.95); transition-duration: 100ms; }
/* AI btn glow */
.ai-btn {
background: rgba(78,222,163,0.12);
border: 1px solid rgba(78,222,163,0.25);
transition: background 150ms var(--ease-out), box-shadow 200ms ease;
}
@media (hover: hover) and (pointer: fine) {
.ai-btn:hover { background: rgba(78,222,163,0.2); box-shadow: 0 0 20px rgba(78,222,163,0.2); }
}
.ai-btn:active { transform: scale(0.96); transition-duration: 100ms; }
/* Fav btn — love animation */
@keyframes heartBeat {
0% { transform: scale(1); }
30% { transform: scale(1.4); }
60% { transform: scale(0.9); }
100%{ transform: scale(1); }
}
#fav-btn { transition: color 200ms ease, transform 200ms var(--ease-out); }
#fav-btn.faved { animation: heartBeat 400ms var(--ease-out); color: #f87171; }
#fav-btn:active { transform: scale(0.9); }
/* PDF frame */
#pdf-frame { flex:1; width:100%; height:100%; border:none; background:#1a1a2e; display:block; }
/* Loading state */
#loading {
position:absolute; inset:0;
display:flex; align-items:center; justify-content:center; flex-direction:column;
background:#0c1324; gap:16px; z-index:5;
animation: fadeIn 200ms ease both;
}
@keyframes fadeIn { from{opacity:0} to{opacity:1} }
/* Loading spinner */
@keyframes spin { to { transform: rotate(360deg); } }
.loading-ring {
width: 40px; height: 40px;
border: 2px solid rgba(78,222,163,0.15);
border-top-color: #4edea3;
border-radius: 50%;
animation: spin 800ms linear infinite;
}
/* AI panel slide */
#ai-panel {
transition: max-height 300ms var(--ease-out), opacity 250ms ease;
overflow: hidden;
}
#ai-panel.collapsed { max-height: 0; opacity: 0; }
#ai-panel.open { max-height: 400px; opacity: 1; }
/* Ask input */
#ask-input {
transition: border-color 180ms ease, box-shadow 180ms ease;
}
#ask-input:focus { border-color: #4edea3; box-shadow: 0 0 0 2px rgba(78,222,163,0.1); outline: none; }
/* prefers-reduced-motion */
@media (prefers-reduced-motion: reduce) {
*,*::before,*::after { animation-duration:.01ms !important; transition-duration:.01ms !important; }
}
/* ── 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; }
}
</style>
<script src="/static/analytics.js" defer></script>
</head>
<body>
<!-- Top bar -->
<header class="glass border-b border-primary/20 px-4 py-3 flex items-center gap-3 shrink-0">
<a href="/library" class="text-on-surface-variant hover:text-primary transition-colors">
<span class="msym text-2xl">arrow_back</span>
</a>
<div class="flex-1 min-w-0">
<h1 id="book-title" class="text-sm font-bold truncate text-primary">جاري التحميل...</h1>
<p id="book-author" class="text-xs text-on-surface-variant truncate"></p>
</div>
<div class="flex items-center gap-2">
<button id="fav-btn" onclick="toggleFavorite()" class="glass flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs transition-all hover:border-primary/40">
<span class="msym text-lg" id="fav-icon">favorite_border</span>
<span id="fav-text" class="hidden sm:inline">إضافة</span>
</button>
<a id="download-btn" href="#" class="glass flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs text-on-surface-variant hover:text-primary transition-all" download>
<span class="msym text-lg">download</span>
<span class="hidden sm:inline">تحميل</span>
</a>
<button onclick="askAboutBook()" class="bg-primary text-surface font-bold px-3 py-2 rounded-xl text-xs flex items-center gap-1.5 hover:shadow-[0_0_15px_rgba(78,222,163,0.4)] transition-all">
<span class="msym text-lg">auto_awesome</span>
<span class="hidden sm:inline">اسأل</span>
</button>
</div>
</header>
<!-- PDF Viewer -->
<div class="flex-1 relative overflow-hidden" style="height:100%;min-height:0">
<div id="loading">
<div class="spinner mb-4"></div>
<p class="text-on-surface-variant text-sm">جاري تحميل الكتاب...</p>
</div>
<iframe id="pdf-frame" title="PDF Reader"></iframe>
</div>
<!-- Ask modal -->
<div id="ask-modal" class="hidden fixed inset-0 z-50 flex items-end justify-center p-4 bg-black/60">
<div class="glass rounded-3xl p-6 w-full max-w-lg">
<div class="flex items-center justify-between mb-4">
<h3 class="font-bold flex items-center gap-2"><span class="msym text-primary">auto_awesome</span>اسأل عن هذا الكتاب</h3>
<button onclick="document.getElementById('ask-modal').classList.add('hidden')" class="text-on-surface-variant hover:text-primary"><span class="msym text-xl">close</span></button>
</div>
<textarea id="ask-input" rows="3" dir="rtl" placeholder="اكتب سؤالك..."
class="w-full bg-surface-high border border-outline-variant rounded-xl px-4 py-3 text-sm text-on-surface outline-none focus:border-primary transition-colors resize-none mb-3"></textarea>
<button onclick="doAsk()" class="w-full bg-primary text-surface font-bold py-3 rounded-xl text-sm hover:shadow-[0_0_20px_rgba(78,222,163,0.4)] transition-all">
<span class="msym text-lg align-middle mr-1">search</span> بحث
</button>
<div id="ask-answer" class="hidden mt-4 bg-surface-high rounded-xl p-4 text-sm leading-7 max-h-48 overflow-y-auto text-on-surface"></div>
</div>
</div>
<script>
const params = new URLSearchParams(location.search);
const bookId = params.get('id');
const bookTitle = decodeURIComponent(params.get('title') || '');
const bookAuthor = decodeURIComponent(params.get('author') || '');
const userToken = localStorage.getItem('rag_token');
const API_KEY = 'zRf77E_brSEnA2BUi40uxLxT4uofAk661WMZb-cdv4M';
let isFavorite = false;
// Set title
document.getElementById('book-title').textContent = bookTitle || 'كتاب';
document.getElementById('book-author').textContent = bookAuthor;
document.title = (bookTitle || 'كتاب') + ' — AfakZaid';
// Load PDF — public, no auth required
if (bookId) {
const pdfUrl = `/api/library/${bookId}/pdf`;
const titleEl = document.getElementById('book-title-loading');
if (titleEl) titleEl.textContent = bookTitle || '';
// Reading progress — restore last page
const savedPage = localStorage.getItem(`progress_${bookId}`);
if (savedPage && parseInt(savedPage) > 1) {
const resume = confirm(`استئناف القراءة من صفحة ${savedPage}؟`);
if (resume) {
window.history.replaceState({}, '', `?id=${bookId}&title=${encodeURIComponent(bookTitle||'')}&page=${savedPage}`);
}
}
const downloadUrl = `/api/library/${bookId}/download`;
const frame = document.getElementById('pdf-frame');
const isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
document.getElementById('download-btn').href = downloadUrl;
document.getElementById('download-btn').removeAttribute('onclick');
function showPdfError() {
document.getElementById('loading').style.display = 'flex';
document.getElementById('loading').innerHTML = `
<div class="text-center px-8">
<span class="msym text-6xl text-on-surface-variant/30 block mb-4">picture_as_pdf</span>
<p class="text-on-surface-variant font-semibold mb-2">PDF غير متاح حالياً</p>
<p class="text-xs text-on-surface-variant/60 mb-6">يجب إعادة رفع الكتاب لتفعيل القراءة</p>
<a href="${downloadUrl}" class="bg-primary/10 border border-primary/20 text-primary px-6 py-3 rounded-xl text-sm inline-block">
تحميل الكتاب
</a>
</div>`;
}
if (isMobile) {
// Mobile browsers can't render PDFs in iframes — use Google Docs viewer
const fullUrl = window.location.origin + pdfUrl;
frame.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;border:none';
frame.src = `https://docs.google.com/viewer?url=${encodeURIComponent(fullUrl)}&embedded=true`;
document.getElementById('loading').style.display = 'none';
} else {
// Desktop: direct iframe src
frame.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;border:none';
frame.src = pdfUrl;
setTimeout(() => document.getElementById('loading').style.display = 'none', 1000);
}
if (userToken) checkFavorite();
}
async function checkFavorite() {
try {
const r = await fetch(`/api/favorites/check/${bookId}`, {headers:{'Authorization':'Bearer '+userToken}});
const d = await r.json();
isFavorite = d.is_favorite;
updateFavBtn();
} catch(e) {}
}
function updateFavBtn() {
document.getElementById('fav-icon').textContent = isFavorite ? 'favorite' : 'favorite_border';
document.getElementById('fav-icon').style.color = isFavorite ? '#f87171' : '';
document.getElementById('fav-text').textContent = isFavorite ? 'مفضلة' : 'إضافة';
}
async function toggleFavorite() {
if (!userToken) { window.location.href='/login'; return; }
try {
const method = isFavorite ? 'DELETE' : 'POST';
await fetch(`/api/favorites/${bookId}`, {method, headers:{'Authorization':'Bearer '+userToken}});
isFavorite = !isFavorite;
updateFavBtn();
} catch(e) {}
}
function askAboutBook() {
document.getElementById('ask-modal').classList.remove('hidden');
document.getElementById('ask-input').focus();
}
async function doAsk() {
const q = document.getElementById('ask-input').value.trim();
if (!q) return;
const ansEl = document.getElementById('ask-answer');
ansEl.classList.remove('hidden');
ansEl.textContent = '...';
try {
const r = await fetch('/query', {
method:'POST',
headers:{'Content-Type':'application/json','X-API-Key':API_KEY},
body: JSON.stringify({query: q + ' في كتاب: ' + bookTitle, session_id: 'reader-'+(bookId||'guest')})
});
const d = await r.json();
ansEl.textContent = d.answer;
} catch(e) { ansEl.textContent = 'خطأ: ' + e.message; }
}
document.getElementById('ask-input').addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); doAsk(); }
});
</script>
</body>
</html>