Files
afakzaid-rag/static/index.html
T

473 lines
21 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",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 */
@media (hover: hover) and (pointer: fine) {
}
.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; }
.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; }
}
/* Sticky footer — no layout impact */
#sticky-footer { position: sticky; top: 100vh; }
.stream-cursor {
display: inline-block;
width: 2px;
background: var(--color-primary, #4eded3);
margin-right: 1px;
border-radius: 1px;
animation: blink 0.75s step-end infinite;
vertical-align: text-bottom;
height: 1.1em;
}
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0} }
</style>
<script src="/static/analytics.js" defer></script>
<link rel="privacy-policy" href="https://rag.afaqzaid.com/privacy"/>
<meta name="description" content="AfakZaid RAG — AI-powered scholarly research platform. Search and query Arabic books and documents."/>
</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>
<div id="universal-header"></div>
<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>
<!-- Query -->
<div class="glass rounded-3xl p-6 query-card mb-6">
<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>
</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;
// ── 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));
// ── 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 = '';
// Blinking cursor while streaming
let cursor = document.createElement('span');
cursor.className = 'stream-cursor';
cursor.textContent = '|';
box.appendChild(cursor);
try {
const headers = {'Content-Type':'application/json','X-API-Key':API_KEY};
if(userToken) headers['Authorization'] = 'Bearer '+userToken;
const r = await fetch('/query/stream', {
method: 'POST',
headers,
body: JSON.stringify({query:q, session_id:'web-'+(currentUser?.id||'guest')})
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const reader = r.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const {done, value} = await reader.read();
if (done) break;
buf += decoder.decode(value, {stream: true});
// Process complete SSE lines
const lines = buf.split('\n');
buf = lines.pop(); // keep incomplete last line
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const raw = line.slice(6).trim();
if (raw === '[DONE]') break;
try {
const frame = JSON.parse(raw);
if (frame.t === 'token') {
// Insert token before cursor
cursor.before(document.createTextNode(frame.v));
box.scrollTop = box.scrollHeight;
} else if (frame.t === 'meta') {
const m = frame.v;
meta.innerHTML = [
`<span class="glass px-3 py-1 rounded-full text-xs text-primary font-bold">${m.llm||''}</span>`,
`<span class="glass px-3 py-1 rounded-full text-xs text-secondary">${((m.confidence||0)*100).toFixed(0)}% ثقة</span>`,
...(m.books_used||[]).map(b=>`<span class="glass px-3 py-1 rounded-full text-xs text-on-surface-variant">${b}</span>`)
].join('');
}
} catch(_) {}
}
}
} catch(e) { box.textContent = 'خطأ: ' + e.message; }
// Remove cursor
cursor.remove();
btn.disabled = false;
btn.style.animation = '';
btn.innerHTML = '<span class="msym text-lg">search</span> بحث';
}
// ── 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 ──────────────────────────────────────────────────────────────────────
loadStats();
loadBooks();
setInterval(loadStats, 30000);
</script>
<script src="/components.js"></script>
<div id="universal-footer"></div>
</body>
</html>