v1.8.0: bge-m3 embeddings, qwen3 LLM, retrieval fixes, input validation, memory poisoning fix

This commit is contained in:
2026-05-28 18:54:05 +00:00
parent e87b33f403
commit 7ce5c6f7d0
1433 changed files with 63814 additions and 199 deletions
+39
View File
@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>AfakZaid RAG — Scholarly Intelligence Platform</title>
<style>
body{margin:0;font-family:system-ui,sans-serif;background:#0c1324;color:#dce1fb;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:40px 20px;box-sizing:border-box}
.card{max-width:600px;width:100%;background:rgba(21,27,45,0.8);border:1px solid rgba(78,222,163,0.2);border-radius:20px;padding:48px 40px;text-align:center}
.icon{font-size:48px;margin-bottom:16px}
h1{font-size:28px;font-weight:700;color:#4edea3;margin:0 0 8px}
p.sub{color:#bbcabf;font-size:14px;margin:0 0 24px}
p.desc{color:#dce1fb;font-size:15px;line-height:1.7;margin:0 0 32px}
.btn{display:inline-block;background:#4edea3;color:#0c1324;font-weight:700;padding:12px 32px;border-radius:12px;text-decoration:none;font-size:14px;margin-bottom:32px}
.links{border-top:1px solid rgba(78,222,163,0.1);padding-top:24px;font-size:12px;color:#888}
.links a{color:#4edea3;text-decoration:none;margin:0 12px}
.links a:hover{text-decoration:underline}
</style>
</head>
<body>
<div class="card">
<div class="icon">✦</div>
<h1>AfakZaid RAG</h1>
<p class="sub">Scholarly Intelligence Platform</p>
<p class="desc">
AfakZaid RAG is an AI-powered platform for searching, querying, and exploring
Arabic scholarly books and documents. Users can import PDF books from Google Drive,
index them, and ask questions using natural language. The platform uses Google Drive
read-only access solely to download PDF files selected by the user for indexing.
</p>
<a href="https://rag.afaqzaid.com" class="btn">Open Platform</a>
<div class="links">
<a href="https://rag.afaqzaid.com/privacy" rel="privacy-policy">Privacy Policy</a>
<a href="https://rag.afaqzaid.com/terms">Terms of Service</a>
<span style="margin:0 12px">© 2026 AfakZaid</span>
</div>
</div>
</body>
</html>
+237 -5
View File
@@ -242,6 +242,49 @@ a[role=button]:active {
Sign In
</button>
<p id="login-err" class="text-error text-xs mt-3 hidden">Invalid credentials</p>
<section id="page-pages" class="page hidden">
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-xl font-bold">Pages</h2>
<p class="text-sm text-on-surface-variant mt-1">Edit site pages content</p>
</div>
</div>
<!-- Page Tabs -->
<div class="flex gap-2 mb-6">
<button id="pages-tab-privacy" onclick="switchPageTab('privacy')"
class="px-4 py-2 rounded-xl text-sm font-semibold bg-primary text-surface transition-all">
سياسة الخصوصية
</button>
<button id="pages-tab-terms" onclick="switchPageTab('terms')"
class="px-4 py-2 rounded-xl text-sm font-semibold glass text-on-surface-variant hover:text-primary transition-all">
شروط الخدمة
</button>
</div>
<!-- Editor -->
<div class="glass rounded-2xl p-5 space-y-4" style="border:1px solid rgba(78,222,163,0.1)">
<div class="flex items-center justify-between">
<p class="text-sm text-on-surface-variant">تعديل محتوى HTML للصفحة</p>
<div class="flex gap-2">
<a id="pages-preview-btn" href="/privacy" target="_blank"
class="glass px-3 py-1.5 rounded-lg text-xs text-on-surface-variant hover:text-primary transition-colors flex items-center gap-1">
<span class="msym text-sm">open_in_new</span> معاينة
</a>
<button onclick="savePageContent()" id="pages-save-btn"
class="bg-primary text-surface font-bold px-4 py-1.5 rounded-lg text-xs flex items-center gap-1 hover:shadow-[0_0_15px_rgba(78,222,163,0.4)] transition-all">
<span class="msym text-sm">save</span> حفظ
</button>
</div>
</div>
<textarea id="pages-editor"
class="w-full bg-surface/60 border border-white/10 rounded-xl p-4 text-xs font-mono text-on-surface focus:outline-none focus:border-primary/50 transition-colors resize-none"
style="height:420px;direction:ltr"></textarea>
<p id="pages-save-status" class="text-xs text-primary hidden">✅ تم الحفظ بنجاح</p>
</div>
</section>
</div>
</div>
@@ -292,6 +335,9 @@ a[role=button]:active {
<button class="nav-btn w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold transition-all" onclick="showPage('settings',this)">
<span class="msym text-lg">settings</span><span>Settings</span>
</button>
<button class="nav-btn w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold transition-all" onclick="showPage('pages',this)">
<span class="msym text-lg">description</span><span>Pages</span>
</button>
<button class="nav-btn w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold transition-all" onclick="showPage('branding',this)">
<span class="msym text-lg">palette</span><span>Branding</span>
</button>
@@ -516,6 +562,7 @@ curl -X POST https://rag.afaqzaid.com/ingest-url \
<div id="settings-ai" class="glass rounded-2xl p-6"></div>
<div id="settings-system" class="glass rounded-2xl p-6"></div>
</div>
<div id="settings-llm" class="glass rounded-2xl p-6 mt-6"></div>
<div id="settings-r2" class="glass rounded-2xl p-6 mt-6"></div>
</section>
@@ -805,7 +852,7 @@ curl -X POST https://rag.afaqzaid.com/ingest-url \
</div>
<script>
let ADMIN_KEY = 'afakadmin-Ihb7kflFA682Ax3EICnV2ymr4KuNZSo7';
// ADMIN_KEY removed — JWT-only auth
let JWT_TOKEN = localStorage.getItem('admin_jwt') || '';
const RAG_KEY = 'zRf77E_brSEnA2BUi40uxLxT4uofAk661WMZb-cdv4M';
let keyVisible = false;
@@ -815,7 +862,7 @@ if (!JWT_TOKEN) document.getElementById('login').style.display = 'flex';
// ── Core API ──────────────────────────────────────────────────────────────────
async function api(path, opts={}) {
const headers = {'Content-Type':'application/json', 'X-API-Key': ADMIN_KEY};
const headers = {'Content-Type':'application/json', 'Authorization':'Bearer '+JWT_TOKEN};
if (JWT_TOKEN) headers['Authorization'] = 'Bearer ' + JWT_TOKEN;
const r = await fetch(path, {...opts, headers:{...headers, ...(opts.headers||{})}});
if (r.status === 401) {
@@ -967,7 +1014,7 @@ async function deleteBook(id, filename, btn) {
if (!confirm(`حذف الكتاب؟\n"${filename}"\n\nسيُحذف ملف PDF والمتجهات والسجل نهائياً.`)) return;
btn.disabled = true; btn.innerHTML = '<span class="msym text-sm animate-spin">autorenew</span>';
try {
const r = await fetch(`/api/library/${id}`, {method:'DELETE', headers:{'X-API-Key':ADMIN_KEY,'Authorization':'Bearer '+JWT_TOKEN}});
const r = await fetch(`/api/library/${id}`, {method:'DELETE', headers:{'Authorization':'Bearer '+JWT_TOKEN}});
const d = await r.json();
if (r.ok) { document.getElementById('book-row-'+id)?.remove(); loadOverview(); toast('Book deleted','success'); }
else { alert('فشل الحذف: '+(d.detail||'خطأ')); btn.disabled=false; btn.innerHTML='<span class="msym text-sm">delete</span>'; }
@@ -1078,6 +1125,7 @@ async function loadResources() {
}
async function loadSettings() {
loadLLMConfig();
try {
const d = await api('/admin/settings');
document.getElementById('settings-ai').innerHTML = `
@@ -1236,7 +1284,7 @@ async function loadBranding() {
async function saveBranding() {
try {
const r = await fetch('/admin/branding', {method:'POST', headers:{'Content-Type':'application/json','X-API-Key':ADMIN_KEY,'Authorization':'Bearer '+JWT_TOKEN}, body:JSON.stringify({site_name:document.getElementById('b-site-name').value, site_subtitle:document.getElementById('b-subtitle').value, logo_url:document.getElementById('b-logo').value, primary_color:document.getElementById('b-primary-hex').value, secondary_color:document.getElementById('b-secondary-hex').value})});
const r = await fetch('/admin/branding', {method:'POST', headers:{'Content-Type':'application/json','Authorization':'Bearer '+JWT_TOKEN}, body:JSON.stringify({site_name:document.getElementById('b-site-name').value, site_subtitle:document.getElementById('b-subtitle').value, logo_url:document.getElementById('b-logo').value, primary_color:document.getElementById('b-primary-hex').value, secondary_color:document.getElementById('b-secondary-hex').value})});
if (r.ok) toast('Branding saved!','success'); else alert('Save failed');
} catch(e) { alert('Error: '+e.message); }
}
@@ -1338,7 +1386,7 @@ async function saveClaudeKey() {
const key = document.getElementById('claude-key-input').value.trim();
if (!key) return;
try {
const r = await fetch('/admin/claude/key', {method:'POST', headers:{'Content-Type':'application/json','X-API-Key':ADMIN_KEY,'Authorization':'Bearer '+JWT_TOKEN}, body:JSON.stringify({api_key:key})});
const r = await fetch('/admin/claude/key', {method:'POST', headers:{'Content-Type':'application/json','Authorization':'Bearer '+JWT_TOKEN}, body:JSON.stringify({api_key:key})});
if (r.ok) { toast('Claude key saved!','success'); loadClaude(); document.getElementById('claude-key-input').value=''; }
else toast('Failed to save key');
} catch(e) { toast('Error: '+e.message); }
@@ -1777,6 +1825,122 @@ function applyScanResult(item) {
}
async function loadLLMConfig() {
try {
const d = await api('/admin/llm-config');
const isLocal = d.provider !== 'cloudflare';
const isCF = d.provider === 'cloudflare';
window._llmProvider = d.provider || 'local';
document.getElementById('settings-llm').innerHTML = `
<h3 class="text-primary text-xs font-bold uppercase tracking-widest mb-5 flex items-center gap-2">
<span class="msym text-base">hub</span>LLM Provider
<span class="ml-auto text-[10px] font-normal glass px-2 py-0.5 rounded-full ${isCF ? 'text-blue-400' : 'text-emerald-400'}">
${isCF ? '⚡ Cloudflare GPU' : '🖥 Local Ollama'}
</span>
</h3>
<div class="flex gap-2 mb-5">
<button onclick="llmSetProvider('local')" id="llm-btn-local"
class="flex-1 py-2.5 rounded-xl text-xs font-bold transition-all flex items-center justify-center gap-2 ${isLocal ? 'bg-primary text-surface' : 'glass glass-hover text-on-surface-variant'}">
<span class="msym text-base">computer</span> Local (Ollama)
</button>
<button onclick="llmSetProvider('cloudflare')" id="llm-btn-cf"
class="flex-1 py-2.5 rounded-xl text-xs font-bold transition-all flex items-center justify-center gap-2 ${isCF ? 'bg-blue-500 text-white' : 'glass glass-hover text-on-surface-variant'}">
<span class="msym text-base">cloud</span> Cloudflare GPU
</button>
</div>
<div id="llm-local-info" class="${isLocal ? '' : 'hidden'} glass rounded-xl p-4 mb-4 text-xs text-on-surface-variant space-y-1">
<p class="flex items-center gap-2"><span class="msym text-base text-emerald-400">check_circle</span>Running qwen2:7b on your VPS CPU</p>
<p class="flex items-center gap-2"><span class="msym text-base text-yellow-400">schedule</span>~30-50s per query — streaming masks latency</p>
<p class="flex items-center gap-2"><span class="msym text-base text-primary">lock</span>Fully private — no external requests</p>
</div>
<div id="llm-cf-fields" class="${isCF ? '' : 'hidden'} space-y-3 mb-4">
<div class="glass rounded-xl p-4 text-xs text-on-surface-variant space-y-1 mb-3">
<p class="flex items-center gap-2"><span class="msym text-base text-blue-400">bolt</span>GPU inference — 3-5s per query</p>
<p class="flex items-center gap-2"><span class="msym text-base text-primary">all_inclusive</span>~900 free queries/day on free tier</p>
<p class="flex items-center gap-2"><span class="msym text-base text-on-surface-variant">storage</span>Your PDFs stay local — only text prompt sent</p>
</div>
<div>
<label class="text-xs text-on-surface-variant block mb-1.5">Account ID</label>
<input id="llm-cf-account" type="text" dir="ltr" value="${d.cf_account_id||''}"
placeholder="Find in dash.cloudflare.com URL bar"
class="w-full bg-surface-high border border-outline-variant rounded-xl px-3 py-2 text-xs font-mono outline-none focus:border-primary transition-colors"/>
</div>
<div>
<label class="text-xs text-on-surface-variant block mb-1.5">API Token
${d.cf_api_token_configured ? '<span class="text-emerald-400 text-[10px] ml-2">✓ Saved ('+d.cf_api_token_masked+')</span>' : ''}
</label>
<input id="llm-cf-token" type="password" dir="ltr"
placeholder="${d.cf_api_token_configured ? 'Leave blank to keep saved token' : 'Workers AI API token'}"
class="w-full bg-surface-high border border-outline-variant rounded-xl px-3 py-2 text-xs font-mono outline-none focus:border-primary transition-colors"/>
</div>
<div>
<label class="text-xs text-on-surface-variant block mb-1.5">Model</label>
<select id="llm-cf-model" class="w-full bg-surface-high border border-outline-variant rounded-xl px-3 py-2 text-xs outline-none focus:border-primary transition-colors">
<option value="@cf/meta/llama-3.1-8b-instruct-fp8-fast" ${(d.cf_model||'').includes('fp8-fast')?'selected':''}>Llama 3.1 8B FP8 Fast ⚡ (Recommended)</option>
<option value="@cf/meta/llama-3.1-8b-instruct" ${d.cf_model==='@cf/meta/llama-3.1-8b-instruct'?'selected':''}>Llama 3.1 8B Standard</option>
<option value="@cf/meta/llama-3.2-3b-instruct" ${(d.cf_model||'').includes('3.2-3b')?'selected':''}>Llama 3.2 3B (Faster)</option>
<option value="@cf/zai-org/glm-4.7-flash" ${(d.cf_model||'').includes('glm')?'selected':''}>GLM-4.7 Flash (100+ languages)</option>
<option value="@cf/qwen/qwen3-30b-a3b-fp8" ${(d.cf_model||'').includes('qwen3-30b')?'selected':''}>Qwen 3 30B ⭐ (Best Arabic)</option>
<option value="@cf/qwen/qwq-32b" ${(d.cf_model||'').includes('qwq-32b')?'selected':''}>Qwen QwQ 32B (Reasoning)</option>
<option value="@cf/deepseek-ai/deepseek-r1-distill-qwen-32b" ${(d.cf_model||'').includes('deepseek-r1')?'selected':''}>DeepSeek R1 32B (Reasoning)</option>
</select>
</div>
</div>
<div class="flex gap-2">
<button onclick="saveLLMConfig()" class="flex-1 bg-primary text-surface font-bold py-2.5 rounded-xl text-sm flex items-center justify-center gap-2 hover:shadow-[0_0_20px_rgba(78,222,163,0.4)] transition-all">
<span class="msym text-base">save</span> Save
</button>
<button onclick="testLLMConfig()" class="glass glass-hover px-4 py-2.5 rounded-xl text-sm text-on-surface-variant flex items-center gap-2">
<span class="msym text-base">wifi_tethering</span> Test
</button>
</div>
<p id="llm-status" class="text-xs text-center mt-3 hidden"></p>`;
} catch(e) { showErr('settings-llm', e.message); }
}
function llmSetProvider(p) {
window._llmProvider = p;
const isLocal = p !== 'cloudflare', isCF = p === 'cloudflare';
document.getElementById('llm-btn-local').className = `flex-1 py-2.5 rounded-xl text-xs font-bold transition-all flex items-center justify-center gap-2 ${isLocal?'bg-primary text-surface':'glass glass-hover text-on-surface-variant'}`;
document.getElementById('llm-btn-cf').className = `flex-1 py-2.5 rounded-xl text-xs font-bold transition-all flex items-center justify-center gap-2 ${isCF?'bg-blue-500 text-white':'glass glass-hover text-on-surface-variant'}`;
document.getElementById('llm-local-info').classList.toggle('hidden', isCF);
document.getElementById('llm-cf-fields').classList.toggle('hidden', isLocal);
}
async function saveLLMConfig() {
const status = document.getElementById('llm-status');
const payload = {
provider: window._llmProvider || 'local',
cf_account_id: (document.getElementById('llm-cf-account')?.value||'').trim(),
cf_api_token: (document.getElementById('llm-cf-token')?.value||'').trim(),
cf_model: document.getElementById('llm-cf-model')?.value || '@cf/meta/llama-3.1-8b-instruct-fp8-fast',
};
status.textContent = 'Saving...'; status.classList.remove('hidden'); status.style.color = '';
try {
await api('/admin/llm-config', {method:'POST', body:JSON.stringify(payload)});
status.textContent = '✅ Saved — restart API container to apply';
status.style.color = '#4edea3';
setTimeout(loadLLMConfig, 1500);
} catch(e) { status.textContent = '❌ '+e.message; status.style.color = '#f87171'; }
}
async function testLLMConfig() {
const status = document.getElementById('llm-status');
const payload = {
provider: window._llmProvider || 'local',
cf_account_id: (document.getElementById('llm-cf-account')?.value||'').trim(),
cf_api_token: (document.getElementById('llm-cf-token')?.value||'').trim(),
cf_model: document.getElementById('llm-cf-model')?.value || '@cf/meta/llama-3.1-8b-instruct-fp8-fast',
};
status.textContent = 'Testing...'; status.classList.remove('hidden'); status.style.color = '';
try {
const r = await api('/admin/llm-test', {method:'POST', body:JSON.stringify(payload)});
status.textContent = r.ok ? '✅ '+r.detail : '❌ '+r.detail;
status.style.color = r.ok ? '#4edea3' : '#f87171';
} catch(e) { status.textContent = '❌ '+e.message; status.style.color = '#f87171'; }
}
// design-taste-frontend: Spotlight border cards
document.querySelectorAll('.spotlight-card').forEach(card => {
card.addEventListener('mousemove', e => {
@@ -1917,5 +2081,73 @@ async function retryMetaExtraction() {
</div>
</div>
<script>
// ── Pages Editor ─────────────────────────────────────────────────────────────
let _currentPage = 'privacy';
function switchPageTab(page) {
_currentPage = page;
['privacy','terms'].forEach(p => {
const btn = document.getElementById('pages-tab-' + p);
if (p === page) {
btn.className = 'px-4 py-2 rounded-xl text-sm font-semibold bg-primary text-surface transition-all';
} else {
btn.className = 'px-4 py-2 rounded-xl text-sm font-semibold glass text-on-surface-variant hover:text-primary transition-all';
}
});
document.getElementById('pages-preview-btn').href = '/' + page;
document.getElementById('pages-save-status').classList.add('hidden');
loadPageContent(page);
}
async function loadPageContent(page) {
const editor = document.getElementById('pages-editor');
editor.value = 'جاري التحميل...';
try {
const res = await fetch('/admin/pages/' + page, {
headers: { 'Authorization': 'Bearer ' + JWT_TOKEN }
});
const data = await res.json();
editor.value = data.content || '';
} catch(e) {
editor.value = 'خطأ في التحميل: ' + e.message;
}
}
async function savePageContent() {
const btn = document.getElementById('pages-save-btn');
const status = document.getElementById('pages-save-status');
const content = document.getElementById('pages-editor').value;
btn.disabled = true;
btn.innerHTML = '<span class="msym text-sm">progress_activity</span> جاري الحفظ...';
try {
const res = await fetch('/admin/pages/' + _currentPage, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + JWT_TOKEN },
body: JSON.stringify({ content })
});
if (res.ok) {
status.classList.remove('hidden');
setTimeout(() => status.classList.add('hidden'), 3000);
}
} catch(e) {
alert('فشل الحفظ: ' + e.message);
}
btn.disabled = false;
btn.innerHTML = '<span class="msym text-sm">save</span> حفظ';
}
document.addEventListener('DOMContentLoaded', () => {
const origShowPage = window.showPage;
if (origShowPage) {
window.showPage = function(page, btn) {
origShowPage(page, btn);
if (page === 'pages') loadPageContent(_currentPage);
};
}
});
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+175
View File
@@ -0,0 +1,175 @@
/**
* AfakZaid Analytics Bridge — GA4 + Pixel Tracking
* Loads full config from /analytics/full-config
* Boots GA4, injects pixels, wires dataLayer event rules
*/
(function () {
'use strict';
async function init() {
let config;
try {
const r = await fetch('/analytics/full-config');
config = await r.json();
} catch (e) { return; }
bootGA(config.ga);
bootPixels(config.pixels || []);
}
// ── GA4 ──────────────────────────────────────────────────────────────────
function bootGA(ga) {
if (!ga || !ga.enabled || !ga.measurement_id) return;
window.dataLayer = window.dataLayer || [];
window.gtag = function () { window.dataLayer.push(arguments); };
gtag('js', new Date());
gtag('config', ga.measurement_id, { send_page_view: true });
const s = document.createElement('script');
s.async = true;
s.src = `https://www.googletagmanager.com/gtag/js?id=${ga.measurement_id}`;
document.head.appendChild(s);
if (Array.isArray(ga.events)) {
const page = detectPage();
ga.events
.filter(ev => ev.active !== false)
.filter(ev => !ev.pages || ev.pages === 'all' || ev.pages === page)
.forEach(wireEvent);
}
}
// ── Pixels ────────────────────────────────────────────────────────────────
function bootPixels(pixels) {
pixels.filter(p => p.enabled !== false).forEach(p => {
try {
switch (p.platform) {
case 'meta': bootMetaPixel(p.pixel_id); break;
case 'tiktok': bootTikTokPixel(p.pixel_id); break;
case 'snapchat': bootSnapchatPixel(p.pixel_id); break;
case 'pinterest': bootPinterestTag(p.pixel_id); break;
case 'twitter': bootTwitterPixel(p.pixel_id); break;
case 'custom': bootCustomPixel(p.code); break;
}
} catch (e) { /* silent fail per pixel */ }
});
}
function bootMetaPixel(id) {
if (!id) return;
!function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;
n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}(window,
document,'script','https://connect.facebook.net/en_US/fbevents.js');
window.fbq('init', id);
window.fbq('track', 'PageView');
}
function bootTikTokPixel(id) {
if (!id) return;
!function(w,d,t){w.TiktokAnalyticsObject=t;var ttq=w[t]=w[t]||[];
ttq.methods=['page','track','identify','instances','debug','on','off','once','ready','alias','group','enableCookie','disableCookie'];
ttq.setAndDefer=function(t,e){t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}};
for(var i=0;i<ttq.methods.length;i++)ttq.setAndDefer(ttq,ttq.methods[i]);
ttq.instance=function(t){for(var e=ttq._i[t]||[],n=0;n<ttq.methods.length;n++)ttq.setAndDefer(e,ttq.methods[n]);return e};
ttq.load=function(e,n){var i="https://analytics.tiktok.com/i18n/pixel/events.js";ttq._i=ttq._i||{};ttq._i[e]=[];
ttq._i[e]._u=i;ttq._t=ttq._t||{};ttq._t[e]=+new Date;ttq._o=ttq._o||{};ttq._o[e]=n||{};
var o=document.createElement("script");o.type="text/javascript";o.async=!0;o.src=i+"?sdkid="+e+"&lib="+t;
var a=document.getElementsByTagName("script")[0];a.parentNode.insertBefore(o,a)};
ttq.load(id); ttq.page();}(window, document, 'ttq');
}
function bootSnapchatPixel(id) {
if (!id) return;
(function(e,t,n){if(e.snaptr)return;var a=e.snaptr=function(){a.handleRequest?
a.handleRequest.apply(a,arguments):a.queue.push(arguments)};a.queue=[];
var s='script';r=t.createElement(s);r.async=!0;r.src=n;
var u=t.getElementsByTagName(s)[0];u.parentNode.insertBefore(r,u);
}(window,document,'https://sc-static.net/scevent.min.js'));
window.snaptr('init', id, {});
window.snaptr('track', 'PAGE_VIEW');
}
function bootPinterestTag(id) {
if (!id) return;
!function(e){if(!window.pintrk){window.pintrk=function(){window.pintrk.queue.push(Array.prototype.slice.call(arguments))};
var n=window.pintrk;n.queue=[],n.version="3.0";var t=document.createElement("script");
t.async=!0,t.src=e;var r=document.getElementsByTagName("script")[0];
r.parentNode.insertBefore(t,r)}}("https://s.pinimg.com/ct/core.js");
window.pintrk('load', id);
window.pintrk('page');
}
function bootTwitterPixel(id) {
if (!id) return;
!function(e,t,n,s,u,a){e.twq||(s=e.twq=function(){s.exe?s.exe.apply(s,arguments):s.queue.push(arguments)},
s.version='1.1',s.queue=[],u=t.createElement(n),u.async=!0,u.src='https://static.ads-twitter.com/uwt.js',
a=t.getElementsByTagName(n)[0],a.parentNode.insertBefore(u,a))}(window,document,'script');
window.twq('config', id);
}
function bootCustomPixel(code) {
if (!code) return;
const div = document.createElement('div');
div.innerHTML = code;
[...div.querySelectorAll('script')].forEach(orig => {
const s = document.createElement('script');
if (orig.src) { s.src = orig.src; s.async = true; }
else s.textContent = orig.textContent;
document.head.appendChild(s);
});
[...div.querySelectorAll('noscript,img')].forEach(el => document.body.appendChild(el.cloneNode(true)));
}
// ── GA4 Event Wiring ──────────────────────────────────────────────────────
function detectPage() {
const p = window.location.pathname;
if (p.startsWith('/library')) return 'library';
if (p.startsWith('/reader')) return 'reader';
if (p.startsWith('/login')) return 'login';
if (p.startsWith('/admin')) return 'admin';
return 'home';
}
function wireEvent(ev) {
const trigger = ev.trigger || 'click';
if (trigger === 'pageview') { pushEvent(ev, document.body); return; }
if (trigger === 'scroll_depth') { wireScrollDepth(ev); return; }
document.addEventListener(trigger, function (e) {
const target = e.target.closest(ev.selector);
if (target) pushEvent(ev, target);
}, { passive: true });
}
function pushEvent(ev, el) {
if (!window.gtag) return;
const params = { event_category: ev.category || 'engagement' };
(ev.params || []).forEach(p => {
let val = '';
if (p.source === 'static') val = p.value || '';
if (p.source === 'attribute') val = el.getAttribute(p.attribute) || '';
if (p.source === 'text') val = (el.querySelector(p.selector) || el).textContent.trim().slice(0, 100);
if (p.source === 'url_param') val = new URLSearchParams(window.location.search).get(p.param) || '';
if (p.source === 'page_url') val = window.location.href;
if (p.source === 'page_path') val = window.location.pathname;
params[p.key] = val;
});
gtag('event', ev.event_name, params);
if (window.__afak_analytics_debug) console.log('[GA4]', ev.event_name, params);
}
function wireScrollDepth(ev) {
const reached = new Set();
window.addEventListener('scroll', function () {
const pct = Math.round((window.scrollY / (document.body.scrollHeight - window.innerHeight)) * 100);
[25, 50, 75, 90].forEach(d => {
if (pct >= d && !reached.has(d)) {
reached.add(d);
if (window.gtag) gtag('event', ev.event_name || 'scroll_depth', { scroll_depth: d, page: window.location.pathname });
}
});
}, { passive: true });
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

+147
View File
@@ -0,0 +1,147 @@
// ── Sticky footer ─────────────────────────────────────────────────────────
(function() {
const b = document.body;
b.style.minHeight = '100vh';
b.style.display = 'flex';
b.style.flexDirection = 'column';
const main = document.querySelector('main');
if (main) main.style.flex = '1';
})();
// ── Favicon ───────────────────────────────────────────────────────────────
(function() {
if (!document.querySelector('link[rel="icon"]')) {
const l = document.createElement('link');
l.rel = 'icon'; l.type = 'image/x-icon'; l.href = '/favicon.ico';
document.head.appendChild(l);
}
if (!document.querySelector('link[rel="apple-touch-icon"]')) {
const l = document.createElement('link');
l.rel = 'apple-touch-icon'; l.href = '/apple-touch-icon.png';
document.head.appendChild(l);
}
})();
// ── Universal Header & Footer ─────────────────────────────────────────────
// Include this script in every page. It injects the header and footer,
// loads branding, and handles auth nav automatically.
(function() {
const HEADER_HTML = `
<header class="sticky top-0 z-20 glass border-b border-primary/20">
<div class="max-w-6xl mx-auto px-4 lg:px-8 py-4 flex items-center justify-between">
<div class="flex items-center gap-4">
<a href="/" class="flex items-center gap-3">
<div class="w-9 h-9 bg-primary/15 rounded-xl flex items-center justify-center glow-sm">
<span class="msym text-primary text-lg">auto_awesome</span>
</div>
<div class="hidden sm:block">
<h1 class="text-base font-bold text-primary leading-tight" id="brand-name">AfakZaid RAG</h1>
<p class="text-[10px] text-on-surface-variant/60 uppercase tracking-widest" id="brand-sub">Scholarly Intelligence</p>
</div>
</a>
<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-2 px-3 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">
<div id="nav-auth" class="flex items-center gap-2"></div>
</nav>
</div>
</header>`;
const FOOTER_HTML = `
<footer class="text-center py-6 mt-8 text-xs text-on-surface-variant border-t border-white/5">
<div class="flex items-center justify-center gap-4 flex-wrap">
<a href="/privacy" class="hover:text-primary transition-colors">سياسة الخصوصية</a>
<span class="opacity-30">•</span>
<a href="/terms" class="hover:text-primary transition-colors">شروط الخدمة</a>
<span class="opacity-30">•</span>
<span id="footer-brand">© 2026 أفاق زيد</span>
</div>
</footer>`;
// Inject header
const headerMount = document.getElementById('universal-header');
if (headerMount) headerMount.outerHTML = HEADER_HTML;
else document.body.insertAdjacentHTML('afterbegin', HEADER_HTML);
// Inject footer
const footerMount = document.getElementById('universal-footer');
if (footerMount) footerMount.outerHTML = FOOTER_HTML;
else document.body.insertAdjacentHTML('beforeend', FOOTER_HTML);
// ── Branding ──────────────────────────────────────────────────────────────
fetch('/branding').then(r => r.json()).then(b => {
const n = document.getElementById('brand-name');
const s = document.getElementById('brand-sub');
const f = document.getElementById('footer-brand');
if (n && b.app_name) n.textContent = b.app_name;
if (s && b.tagline) s.textContent = b.tagline;
if (f && b.app_name) f.textContent = '© 2026 ' + b.app_name;
}).catch(() => {});
// ── Auth Nav ──────────────────────────────────────────────────────────────
const userToken = localStorage.getItem('rag_token');
async function initHeaderAuth() {
const nav = document.getElementById('nav-auth');
if (!nav) return;
if (!userToken) {
nav.innerHTML = `<a href="/login" class="glass glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-xs text-on-surface-variant hover:text-primary transition-colors">
<span class="msym text-base">login</span>
<span class="hidden sm:inline">دخول</span>
</a>`;
return;
}
try {
const r = await fetch('/auth/me', { headers: { 'Authorization': 'Bearer ' + userToken } });
if (!r.ok) { localStorage.removeItem('rag_token'); initHeaderAuth(); return; }
const user = await r.json();
const perms = user.permissions || [];
const isAdmin = user.role === 'admin';
nav.innerHTML = `
<div class="relative" id="uh-user-wrap">
<button onclick="document.getElementById('uh-drop').classList.toggle('hidden')"
class="glass flex items-center gap-2 px-3 py-2 rounded-xl text-xs hover:border-primary/40 transition-colors">
<span class="msym text-base text-primary">person</span>
<span class="hidden sm:inline max-w-[80px] truncate">${user.name}</span>
<span class="msym text-sm text-on-surface-variant">expand_more</span>
</button>
<div id="uh-drop" class="hidden absolute right-0 top-full mt-1 glass rounded-xl p-2 min-w-[160px] z-50 shadow-xl">
<div class="px-3 py-2 border-b border-outline-variant/30 mb-1">
<p class="text-xs font-bold">${user.name}</p>
<p class="text-[10px] text-primary">${user.role_display || user.role}</p>
</div>
${isAdmin ? '<a href="/admin" class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-on-surface-variant hover:text-primary hover:bg-primary/5"><span class="msym text-sm">admin_panel_settings</span>Admin</a>' : ''}
<button onclick="uhLogout()" class="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-error hover:bg-red-900/10">
<span class="msym text-sm">logout</span>خروج
</button>
</div>
</div>`;
document.addEventListener('click', e => {
const w = document.getElementById('uh-user-wrap');
if (w && !w.contains(e.target)) document.getElementById('uh-drop')?.classList.add('hidden');
});
} catch(e) {}
}
window.uhLogout = function() {
fetch('/auth/logout', { method: 'POST', headers: { 'Authorization': 'Bearer ' + userToken } });
localStorage.removeItem('rag_token');
window.location.href = '/';
};
initHeaderAuth();
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

+1
View File
@@ -0,0 +1 @@
google-site-verification: google1d7bb3283b75009e.html
+82 -10
View File
@@ -224,8 +224,25 @@ header { animation:slideDown 350ms var(--ease-out) both; }
}
.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>
@@ -462,19 +479,65 @@ async function doQuery() {
const box = document.getElementById('answer-box');
const meta = document.getElementById('answer-meta');
wrap.classList.remove('hidden');
box.textContent = '...'; meta.innerHTML = '';
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',{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; }
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> بحث';
@@ -544,5 +607,14 @@ loadStats();
loadBooks();
setInterval(loadStats, 30000);
</script>
<footer id="sticky-footer" style="text-align:center;padding:20px;margin-top:16px;font-size:12px;color:#888;border-top:1px solid rgba(255,255,255,0.06)">
<a href="https://rag.afaqzaid.com/privacy" rel="privacy-policy" style="color:#888;text-decoration:none;margin:0 8px">سياسة الخصوصية</a>
•
<a href="https://rag.afaqzaid.com/terms" style="color:#888;text-decoration:none;margin:0 8px">شروط الخدمة</a>
•
<span style="margin:0 8px">© 2026 أفاق زيد</span>
</footer>
</body>
</html>
+370 -10
View File
@@ -329,6 +329,9 @@ main {
flex-direction: column;
gap: 12px;
}
/* Sticky footer — no layout impact */
#sticky-footer { position: sticky; top: 100vh; }
</style>
<script src="/static/analytics.js" defer></script>
</head>
@@ -366,7 +369,7 @@ main {
</div>
</header>
<main class="max-w-6xl mx-auto px-4 lg:px-8 py-8">
<main class="max-w-6xl" mx-auto px-4 lg:px-8 py-8">
<!-- Search + Filters -->
<div class="glass rounded-3xl p-6 mb-6">
@@ -387,6 +390,7 @@ main {
class="glass border border-primary/30 text-primary font-bold px-4 py-2.5 rounded-xl text-sm flex items-center gap-2 hover:bg-primary/10 transition-all whitespace-nowrap">
<span class="msym text-lg">library_add</span> رفع مجموعة
</button>
</div>
</div>
@@ -425,6 +429,20 @@ main {
</div>
</div>
<!-- Active import banner -->
<div id="import-banner" class="hidden mb-4 glass rounded-2xl p-4 border border-primary/20">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2 text-sm font-medium">
<span class="msym text-primary animate-spin text-base">progress_activity</span>
<span id="import-banner-txt">جاري استيراد الكتب...</span>
</div>
<span id="import-banner-count" class="text-xs text-primary font-bold"></span>
</div>
<div class="w-full bg-white/5 rounded-full h-1.5 overflow-hidden">
<div id="import-banner-bar" class="h-full bg-primary rounded-full transition-all duration-700" style="width:0%"></div>
</div>
</div>
<!-- Books -->
<div id="books-container"></div>
@@ -500,13 +518,55 @@ main {
<button onclick="closeBulkModal()" class="text-on-surface-variant hover:text-primary"><span class="msym text-xl">close</span></button>
</div>
<!-- Drop Zone -->
<div id="bulk-drop" onclick="document.getElementById('bulk-file-input').click()"
class="border-2 border-dashed border-primary/20 rounded-2xl p-8 text-center cursor-pointer hover:border-primary/50 hover:bg-primary/5 transition-all">
<span class="msym text-primary text-4xl block mb-3">cloud_upload</span>
<p class="text-sm font-semibold mb-1">اسحب ملفات PDF هنا أو اضغط للاختيار</p>
<p class="text-xs text-on-surface-variant">يمكنك اختيار عدة ملفات في آن واحد</p>
<input type="file" id="bulk-file-input" accept=".pdf" multiple class="hidden" onchange="onBulkFilesSelected(this.files)"/>
<!-- Tabs -->
<div class="flex gap-2 p-1 bg-surface-high rounded-xl">
<button id="bulk-tab-local" onclick="switchBulkTab('local')"
class="flex-1 py-2 rounded-lg text-sm font-bold flex items-center justify-center gap-2 transition-all bg-primary text-surface">
<span class="msym text-base">upload_file</span> من الجهاز
</button>
<button id="bulk-tab-drive" onclick="switchBulkTab('drive')"
class="flex-1 py-2 rounded-lg text-sm font-bold flex items-center justify-center gap-2 transition-all text-on-surface-variant hover:text-primary">
<svg class="w-4 h-4" viewBox="0 0 87.3 78" xmlns="http://www.w3.org/2000/svg"><path d="m6.6 66.85 3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3l13.75-23.8h-27.5c0 1.55.4 3.1 1.2 4.5z" fill="#0066da"/><path d="m43.65 25-13.75-23.8c-1.35.8-2.5 1.9-3.3 3.3l-25.4 44a9.06 9.06 0 0 0 -1.2 4.5h27.5z" fill="#00ac47"/><path d="m73.55 76.8c1.35-.8 2.5-1.9 3.3-3.3l1.6-2.75 7.65-13.25c.8-1.4 1.2-2.95 1.2-4.5h-27.502l5.852 11.5z" fill="#ea4335"/><path d="m43.65 25 13.75-23.8c-1.35-.8-2.9-1.2-4.5-1.2h-18.5c-1.6 0-3.15.45-4.5 1.2z" fill="#00832d"/><path d="m59.8 53h-32.3l-13.75 23.8c1.35.8 2.9 1.2 4.5 1.2h50.8c1.6 0 3.15-.45 4.5-1.2z" fill="#2684fc"/><path d="m73.4 26.5-12.7-22c-.8-1.4-1.95-2.5-3.3-3.3l-13.75 23.8 16.15 28h27.45c0-1.55-.4-3.1-1.2-4.5z" fill="#ffba00"/></svg>
Google Drive
</button>
</div>
<!-- Tab: Local Upload -->
<div id="bulk-panel-local">
<!-- Drop Zone -->
<div id="bulk-drop" onclick="document.getElementById('bulk-file-input').click()"
class="border-2 border-dashed border-primary/20 rounded-2xl p-8 text-center cursor-pointer hover:border-primary/50 hover:bg-primary/5 transition-all">
<span class="msym text-primary text-4xl block mb-3">cloud_upload</span>
<p class="text-sm font-semibold mb-1">اسحب ملفات PDF هنا أو اضغط للاختيار</p>
<p class="text-xs text-on-surface-variant">يمكنك اختيار عدة ملفات في آن واحد</p>
<input type="file" id="bulk-file-input" accept=".pdf" multiple class="hidden" onchange="onBulkFilesSelected(this.files)"/>
</div>
</div>
<!-- Tab: Google Drive -->
<div id="bulk-panel-drive" class="hidden space-y-4">
<p class="text-sm text-on-surface-variant">الصق رابط مجلد Google Drive ثم اربط حسابك — يعمل مع أي مجلد مشترك.</p>
<input id="gdrive-url-input" type="url" dir="ltr"
placeholder="https://drive.google.com/drive/folders/..."
class="w-full bg-surface/60 border border-white/10 rounded-xl px-4 py-3 text-sm focus:outline-none focus:border-blue-400/60 transition-colors"/>
<div id="gdrive-status" class="hidden space-y-3">
<div class="flex justify-between text-sm">
<span id="gdrive-status-text" class="text-on-surface-variant">جاري المعالجة...</span>
<span id="gdrive-count" class="text-primary font-mono font-bold"></span>
</div>
<div class="h-2 bg-surface rounded-full overflow-hidden">
<div id="gdrive-bar" class="h-full bg-blue-400 rounded-full transition-all duration-500" style="width:0%"></div>
</div>
<div id="gdrive-skipped-list" class="hidden text-xs text-on-surface-variant space-y-1 max-h-32 overflow-y-auto"></div>
</div>
<button onclick="startGdriveOAuth()" id="gdrive-import-btn"
class="w-full bg-blue-500 hover:bg-blue-600 text-white font-bold py-3 rounded-xl text-sm flex items-center justify-center gap-2 transition-all">
<svg class="w-4 h-4" viewBox="0 0 87.3 78" xmlns="http://www.w3.org/2000/svg"><path d="m6.6 66.85 3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3l13.75-23.8h-27.5c0 1.55.4 3.1 1.2 4.5z" fill="#fff"/><path d="m43.65 25-13.75-23.8c-1.35.8-2.5 1.9-3.3 3.3l-25.4 44a9.06 9.06 0 0 0 -1.2 4.5h27.5z" fill="#fff"/><path d="m73.55 76.8c1.35-.8 2.5-1.9 3.3-3.3l1.6-2.75 7.65-13.25c.8-1.4 1.2-2.95 1.2-4.5h-27.502l5.852 11.5z" fill="#fff"/><path d="m43.65 25 13.75-23.8c-1.35-.8-2.9-1.2-4.5-1.2h-18.5c-1.6 0-3.15.45-4.5 1.2z" fill="#fff"/><path d="m59.8 53h-32.3l-13.75 23.8c1.35.8 2.9 1.2 4.5 1.2h50.8c1.6 0 3.15-.45 4.5-1.2z" fill="#fff"/><path d="m73.4 26.5-12.7-22c-.8-1.4-1.95-2.5-3.3-3.3l-13.75 23.8 16.15 28h27.45c0-1.55-.4-3.1-1.2-4.5z" fill="#fff"/></svg>
ربط Google Drive واستيراد
</button>
</div>
<!-- Selected Files Preview -->
@@ -605,6 +665,7 @@ async function initAuth() {
const r = await fetch('/auth/me', {headers:{'Authorization':'Bearer '+userToken}});
if (!r.ok) { localStorage.removeItem('rag_token'); initAuth(); return; }
currentUser = await r.json();
checkActiveImport();
nav.innerHTML = `<div class="relative" id="user-menu-wrap">
<button onclick="document.getElementById('udrop').classList.toggle('hidden')" class="glass flex items-center gap-2 px-3 py-2 rounded-xl text-xs hover:border-primary/40 transition-colors">
<span class="msym text-base text-primary">person</span>
@@ -685,6 +746,77 @@ function debounceSearch(val) {
}
// ── Load Books ─────────────────────────────────────────────────────────────
// ── Persistent import banner ──────────────────────────────────────────────
let _bannerPoll = null;
let _activeBannerJobId = null;
async function checkActiveImport() {
if (!currentUser || !['admin','librarian'].includes(currentUser.role)) return;
try {
const r = await fetch('/api/library/bulk-jobs/active', {
headers: { 'Authorization': 'Bearer ' + userToken }
});
if (!r.ok) return;
const job = await r.json();
if (!job || !job.job_id) { hideBanner(); return; }
_activeBannerJobId = job.job_id;
const total = job.total || 0;
const done = job.done || 0;
const status = job.status || '';
const banner = document.getElementById('import-banner');
const txt = document.getElementById('import-banner-txt');
const count = document.getElementById('import-banner-count');
const bar = document.getElementById('import-banner-bar');
banner.classList.remove('hidden');
const downloaded = job.downloaded || 0;
const skipped = job.skipped || 0;
const processed = done + skipped;
if (status === 'downloading' && total === 0) {
txt.textContent = '🔍 جاري جلب قائمة الملفات من Google Drive...';
count.textContent = '';
bar.style.width = '5%';
} else if (status === 'downloading') {
txt.textContent = '⬇️ جاري تنزيل الكتب...';
count.textContent = downloaded + ' / ' + total;
bar.style.width = Math.min(40, total > 0 ? Math.round((downloaded/total)*40) : 5) + '%';
} else if (status === 'processing' || status === 'pending') {
txt.textContent = '⚙️ جاري فهرسة الكتب...';
count.textContent = processed + ' / ' + total;
const pct = total > 0 ? Math.min(95, 40 + Math.round((processed/total)*55)) : 40;
bar.style.width = pct + '%';
} else if (status === 'done') {
txt.textContent = '✅ اكتمل الاستيراد';
count.textContent = done + ' كتاب';
bar.style.width = '100%';
bar.className = 'h-full bg-green-400 rounded-full transition-all duration-700';
setTimeout(() => { hideBanner(); loadBooks(); }, 4000);
stopBannerPoll();
return;
}
if (!_bannerPoll) startBannerPoll();
} catch(e) {}
}
function startBannerPoll() {
if (_bannerPoll) return;
_bannerPoll = setInterval(() => { checkActiveImport(); loadBooks(); }, 8000);
}
function stopBannerPoll() {
if (_bannerPoll) { clearInterval(_bannerPoll); _bannerPoll = null; }
}
function hideBanner() {
document.getElementById('import-banner').classList.add('hidden');
stopBannerPoll();
}
async function loadBooks() {
let url = `/api/library?page=${currentPage}&limit=24`;
if (filters.search) url += '&search='+encodeURIComponent(filters.search);
@@ -711,7 +843,21 @@ async function loadFilters() {
// Extract unique authors from books
try {
const d = await (await fetch('/api/library?limit=200')).json();
const authors = [...new Set((d.books||[]).map(b=>b.author).filter(Boolean))];
// Normalize Arabic chars before dedup (ي vs ی, ك vs ک, alef variants)
function normalizeAr(s) {
return (s||'')
.replace(/\u06cc/g,'\u064a').replace(/\u06a9/g,'\u0643')
.replace(/[\u0622\u0623\u0625]/g,'\u0627')
.replace(/\u0629/g,'\u0647')
.replace(/\s+/g,' ').trim();
}
const authorMap = {};
(d.books||[]).forEach(b => {
if (!b.author) return;
const key = normalizeAr(b.author);
if (!authorMap[key]) authorMap[key] = b.author;
});
const authors = Object.values(authorMap);
if (authors.length) {
document.getElementById('author-section').classList.remove('hidden');
document.getElementById('author-chips').innerHTML =
@@ -1195,7 +1341,7 @@ async function pollBulkStatus() {
const d = await r.json();
if (!r.ok) return;
const processed = (d.done||0) + (d.failed||0);
const processed = (d.done||0) + (d.failed||0) + (d.skipped||0);
const pct = d.total > 0 ? Math.round(processed / d.total * 100) : 0;
document.getElementById('bulk-progress-bar').style.width = pct + '%';
document.getElementById('bulk-progress-count').textContent = `${processed} / ${d.total}`;
@@ -1216,7 +1362,35 @@ async function pollBulkStatus() {
} catch(e) { /* keep polling */ }
}
function switchBulkTab(tab) {
const isLocal = tab === 'local';
// Panels
document.getElementById('bulk-panel-local').classList.toggle('hidden', !isLocal);
document.getElementById('bulk-panel-drive').classList.toggle('hidden', isLocal);
// Tab styles
const activeClass = ['bg-primary','text-surface'];
const inactiveClass = ['text-on-surface-variant','hover:text-primary'];
const localBtn = document.getElementById('bulk-tab-local');
const driveBtn = document.getElementById('bulk-tab-drive');
if (isLocal) {
localBtn.classList.add(...activeClass); localBtn.classList.remove(...inactiveClass);
driveBtn.classList.remove(...activeClass); driveBtn.classList.add(...inactiveClass);
} else {
driveBtn.classList.add(...activeClass); driveBtn.classList.remove(...inactiveClass);
localBtn.classList.remove(...activeClass); localBtn.classList.add(...inactiveClass);
// reset drive state
document.getElementById('gdrive-status').classList.add('hidden');
document.getElementById('gdrive-bar').style.width = '0%';
document.getElementById('gdrive-bar').className = 'h-full bg-blue-400 rounded-full transition-all duration-500';
document.getElementById('gdrive-import-btn').disabled = false;
document.getElementById('gdrive-import-btn').innerHTML = '<svg class="w-4 h-4" viewBox="0 0 87.3 78" xmlns="http://www.w3.org/2000/svg"><path d="m6.6 66.85 3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3l13.75-23.8h-27.5c0 1.55.4 3.1 1.2 4.5z" fill="#fff"/><path d="m43.65 25-13.75-23.8c-1.35.8-2.5 1.9-3.3 3.3l-25.4 44a9.06 9.06 0 0 0 -1.2 4.5h27.5z" fill="#fff"/><path d="m73.55 76.8c1.35-.8 2.5-1.9 3.3-3.3l1.6-2.75 7.65-13.25c.8-1.4 1.2-2.95 1.2-4.5h-27.502l5.852 11.5z" fill="#fff"/><path d="m43.65 25 13.75-23.8c-1.35-.8-2.9-1.2-4.5-1.2h-18.5c-1.6 0-3.15.45-4.5 1.2z" fill="#fff"/><path d="m59.8 53h-32.3l-13.75 23.8c1.35.8 2.9 1.2 4.5 1.2h50.8c1.6 0 3.15-.45 4.5-1.2z" fill="#fff"/><path d="m73.4 26.5-12.7-22c-.8-1.4-1.95-2.5-3.3-3.3l-13.75 23.8 16.15 28h27.45c0-1.55-.4-3.1-1.2-4.5z" fill="#fff"/></svg> استيراد من Google Drive';
}
}
function closeBulkModal() {
const hadActiveJob = !!bulkJobId;
switchBulkTab('local');
document.getElementById('bulk-modal').classList.add('hidden');
if (bulkPollTimer) clearInterval(bulkPollTimer);
// Reset state for next use
@@ -1232,9 +1406,182 @@ function closeBulkModal() {
if (warn) warn.remove();
const btn = document.getElementById('bulk-upload-btn');
if (btn) { btn.disabled=false; btn.innerHTML='<span class="msym text-lg">rocket_launch</span> رفع ومعالجة الكل'; }
// Show banner immediately if a job was running
if (hadActiveJob) { setTimeout(checkActiveImport, 100); }
}
// ── Google Drive OAuth Import ────────────────────────────────────────────────
let _gdriveState = null;
let _gdriveFolderUrl = null;
function startGdriveOAuth() {
const url = (document.getElementById('gdrive-url-input').value || '').trim();
if (!url) { alert('الرجاء إدخال رابط المجلد أولاً'); return; }
_gdriveFolderUrl = url;
_gdriveState = null;
const btn = document.getElementById('gdrive-import-btn');
btn.disabled = true;
btn.innerHTML = '<span class="msym text-lg" style="display:inline-block;animation:spin 1s linear infinite">progress_activity</span>&nbsp;جاري فتح Google...';
// Open OAuth popup
const state = Math.random().toString(36).slice(2);
const authUrl = `/auth/google/drive?folder_url=${encodeURIComponent(url)}&state=${state}`;
const popup = window.open(authUrl, 'gdrive_oauth',
'width=500,height=650,scrollbars=yes,resizable=yes');
// Listen for postMessage from callback page
function onMessage(e) {
if (!e.data) return;
let data = e.data;
if (typeof data === 'string') { try { data = JSON.parse(data); } catch(ex) { return; } }
if (!data || !('ok' in data)) return;
window.removeEventListener('message', onMessage);
if (data.ok) {
_gdriveState = data.state;
_gdriveFolderUrl = data.folder_url || url;
_startGdriveImportOAuth();
} else {
_gdriveError(data.error || 'فشل التفويض');
}
}
window.addEventListener('message', onMessage);
// Detect if popup was closed without completing
const check = setInterval(() => {
if (popup && popup.closed) {
clearInterval(check);
window.removeEventListener('message', onMessage);
if (!_gdriveState) {
_gdriveError('تم إغلاق نافذة Google قبل إتمام التفويض');
}
}
}, 1000);
}
function _gdriveError(msg) {
const btn = document.getElementById('gdrive-import-btn');
btn.disabled = false;
btn.innerHTML = '<svg class="w-4 h-4" viewBox="0 0 87.3 78" xmlns="http://www.w3.org/2000/svg"><path d="m6.6 66.85 3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3l13.75-23.8h-27.5c0 1.55.4 3.1 1.2 4.5z" fill="#fff"/><path d="m43.65 25-13.75-23.8c-1.35.8-2.5 1.9-3.3 3.3l-25.4 44a9.06 9.06 0 0 0 -1.2 4.5h27.5z" fill="#fff"/><path d="m73.55 76.8c1.35-.8 2.5-1.9 3.3-3.3l1.6-2.75 7.65-13.25c.8-1.4 1.2-2.95 1.2-4.5h-27.502l5.852 11.5z" fill="#fff"/><path d="m43.65 25 13.75-23.8c-1.35-.8-2.9-1.2-4.5-1.2h-18.5c-1.6 0-3.15.45-4.5 1.2z" fill="#fff"/><path d="m59.8 53h-32.3l-13.75 23.8c1.35.8 2.9 1.2 4.5 1.2h50.8c1.6 0 3.15-.45 4.5-1.2z" fill="#fff"/><path d="m73.4 26.5-12.7-22c-.8-1.4-1.95-2.5-3.3-3.3l-13.75 23.8 16.15 28h27.45c0-1.55-.4-3.1-1.2-4.5z" fill="#fff"/></svg> ربط Google Drive واستيراد';
const statusBox = document.getElementById('gdrive-status');
const statusTxt = document.getElementById('gdrive-status-text');
const bar = document.getElementById('gdrive-bar');
statusBox.classList.remove('hidden');
statusTxt.textContent = '❌ ' + msg;
bar.style.width = '100%';
bar.className = 'h-full bg-red-500 rounded-full transition-all duration-500';
}
async function _startGdriveImportOAuth() {
const btn = document.getElementById('gdrive-import-btn');
const statusBox = document.getElementById('gdrive-status');
const statusTxt = document.getElementById('gdrive-status-text');
const countEl = document.getElementById('gdrive-count');
const bar = document.getElementById('gdrive-bar');
const skipList = document.getElementById('gdrive-skipped-list');
btn.innerHTML = '<span class="msym text-lg" style="display:inline-block;animation:spin 1s linear infinite">progress_activity</span>&nbsp;جاري الاستيراد...';
statusBox.classList.remove('hidden');
statusTxt.textContent = '⬇️ جاري جلب الملفات من Google Drive...';
bar.style.width = '10%';
bar.className = 'h-full bg-blue-400 rounded-full transition-all duration-500';
skipList.classList.add('hidden');
let data;
try {
const res = await fetch('/api/library/gdrive-import-oauth', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + userToken },
body: JSON.stringify({ state: _gdriveState, folder_url: _gdriveFolderUrl })
});
data = await res.json();
if (!res.ok) {
_gdriveError((typeof data.detail === 'object' ? data.detail.message : data.detail) || 'فشل الاستيراد');
return;
}
} catch(e) {
_gdriveError(e.message);
return;
}
const jobId = data.job_id;
bar.style.width = '20%';
// Poll progress
const poll = setInterval(async () => {
try {
const pr = await fetch('/api/library/bulk-status/' + jobId, {
headers: { 'Authorization': 'Bearer ' + userToken }
});
const pd = await pr.json();
const status = pd.status || '';
const done = (pd.files || []).filter(f => f.status === 'done').length;
const errFile = (pd.files || []).find(f => f.status === 'error' && f.filename === '_');
if (errFile) {
clearInterval(poll);
_gdriveError(errFile.error || 'فشل الاستيراد');
return;
}
const total = pd.total || 0;
const dlDone = (pd.files || []).filter(f => f.status !== 'pending' && f.filename !== '_').length;
if (status === 'downloading') {
if (total === 0) {
bar.style.width = '8%';
bar.className = 'h-full bg-blue-400 rounded-full transition-all duration-500';
statusTxt.textContent = '🔍 جاري جلب قائمة الملفات...';
countEl.textContent = '';
} else {
const pct = Math.min(45, 10 + Math.round((dlDone / total) * 35));
bar.style.width = pct + '%';
bar.className = 'h-full bg-blue-400 rounded-full transition-all duration-500';
statusTxt.textContent = '⬇️ جاري التنزيل...';
countEl.textContent = dlDone + ' / ' + total;
}
return;
}
const safetotal = total || 1;
const inProgress = (pd.files || []).filter(f => ['queued','processing','ingesting','compressing','extracting','downloading'].includes(f.status)).length;
const downloaded = done + inProgress;
const pct = Math.min(95, 10 + Math.round((downloaded / safetotal) * 85));
bar.style.width = pct + '%';
bar.className = 'h-full bg-primary rounded-full transition-all duration-500';
countEl.textContent = done + ' / ' + safetotal;
if (inProgress > 0)
statusTxt.textContent = '⚙️ تم تحميل ' + downloaded + ' — فهرسة ' + done + ' من ' + safetotal;
else
statusTxt.textContent = '⚙️ جاري الفهرسة... ' + done + ' من ' + safetotal;
const skipped = (pd.files || []).filter(f => f.status === 'skipped');
if (skipped.length) {
skipList.classList.remove('hidden');
skipList.innerHTML = '<p class="font-medium mb-1">تم تخطي (' + skipped.length + '):</p>' +
skipped.slice(0,5).map(s => '<p>• ' + s.filename + ' — ' + (s.error||'') + '</p>').join('');
}
if (status === 'done' || (status !== 'downloading' && done >= total && total > 0)) {
clearInterval(poll);
bar.style.width = '100%';
bar.className = 'h-full bg-green-400 rounded-full transition-all duration-500';
statusTxt.textContent = '✅ اكتمل — تمت فهرسة ' + done + ' كتاب';
countEl.textContent = done + ' / ' + total;
btn.disabled = false;
btn.innerHTML = '<span class="msym text-lg">check_circle</span>&nbsp;تم';
setTimeout(() => { loadBooks(); loadFilters(); }, 1500);
}
} catch(pe) { console.warn('poll error', pe); }
}, 3000);
}
// Keep old startGdriveImport as no-op (unused now)
function startGdriveImport() { startGdriveOAuth(); }
// design-taste-frontend: Spotlight border cards
document.querySelectorAll('.spotlight-card').forEach(card => {
card.addEventListener('mousemove', e => {
@@ -1245,5 +1592,18 @@ document.querySelectorAll('.spotlight-card').forEach(card => {
});
</script>
<footer id="sticky-footer" class="text-center py-6 mt-8 text-xs text-on-surface-variant border-t border-white/5">
<div class="flex items-center justify-center gap-4">
<a href="/privacy" target="_blank" class="hover:text-primary transition-colors">سياسة الخصوصية</a>
<span class="opacity-30">•</span>
<a href="/terms" target="_blank" class="hover:text-primary transition-colors">شروط الخدمة</a>
<span class="opacity-30">•</span>
<span>© 2026 أفاق زيد</span>
</div>
</footer>
</body>
</html>
File diff suppressed because it is too large Load Diff
+9
View File
@@ -497,5 +497,14 @@ async function detectLocation() {
}, ()=>{ txt.textContent='Location denied'; btn.disabled=false; });
}
</script>
<footer style="text-align:center;padding:20px;margin-top:16px;font-size:12px;color:#888;border-top:1px solid rgba(255,255,255,0.06)">
<a href="/privacy" target="_blank" style="color:#888;text-decoration:none;margin:0 8px">سياسة الخصوصية</a>
•
<a href="/terms" target="_blank" style="color:#888;text-decoration:none;margin:0 8px">شروط الخدمة</a>
•
<span style="margin:0 8px">© 2026 أفاق زيد</span>
</footer>
</body>
</html>
+374
View File
@@ -0,0 +1,374 @@
<!DOCTYPE html>
<html class="dark" lang="ar" dir="rtl">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>المكتبة — AfakZaid RAG</title>
<script src="https://cdn.tailwindcss.com?plugins=forms"></script>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
<script>tailwind.config={darkMode:"class",safelist:["grid","grid-cols-2","grid-cols-3","grid-cols-4","md:grid-cols-3","lg:grid-cols-4","gap-4","space-y-3","col-span-4"],theme:{extend:{colors:{"primary":"#4edea3","surface":"#0c1324","surface-low":"#151b2d","surface-container":"#191f31","surface-high":"#23293c","on-surface":"#dce1fb","on-surface-variant":"#bbcabf","outline-variant":"#3c4a42","secondary":"#95d3ba","tertiary":"#45dfa4","error":"#f87171"},fontFamily:{sans:["Manrope","sans-serif"]}}}}</script>
<style>
:root {
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
}
*{box-sizing:border-box}
body{background:radial-gradient(circle at 50% -20%,#151b2d 0%,#0c1324 100%);font-family:'Manrope',sans-serif;color:#dce1fb;min-height:100vh}
/* Glass: purposeful, not default — liquid glass per design-taste */
.glass{
background:rgba(21,27,45,0.5);
backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);
border:1px solid rgba(78,222,163,0.12);
box-shadow:inset 0 1px 0 rgba(255,255,255,0.04);
}
.glass-hover { transition: background 180ms ease, border-color 180ms ease; }
@media (hover: hover) and (pointer: fine) {
.glass-hover:hover { background:rgba(78,222,163,0.06); border-color:rgba(78,222,163,0.22); }
}
.glow{box-shadow:0 0 20px rgba(78,222,163,0.15)}
.glow-sm{box-shadow:0 0 10px rgba(78,222,163,0.1)}
.msym{font-family:'Material Symbols Outlined';font-variation-settings:'FILL' 0,'wght' 400,'GRAD' 0,'opsz' 24;vertical-align:middle;line-height:1}
/* Filter chips — specific transitions */
.filter-chip {
padding:6px 14px; border-radius:999px; font-size:12px; font-weight:600; cursor:pointer;
border:1px solid rgba(78,222,163,0.15); background:rgba(21,27,45,0.5); color:#bbcabf;
transition: background 150ms var(--ease-out), border-color 150ms var(--ease-out), color 150ms var(--ease-out);
will-change: transform;
}
.filter-chip.active { background:rgba(78,222,163,0.12); border-color:rgba(78,222,163,0.4); color:#4edea3; }
@media (hover: hover) and (pointer: fine) {
.filter-chip:hover { background:rgba(78,222,163,0.08); border-color:rgba(78,222,163,0.3); color:#4edea3; }
}
.filter-chip:active { transform: scale(0.97); }
.scrollbar::-webkit-scrollbar{width:4px}
.scrollbar::-webkit-scrollbar-thumb{background:rgba(78,222,163,0.2);border-radius:4px}
.inp{
background:#23293c; border:1px solid #3c4a42; border-radius:12px;
padding:10px 14px; color:#dce1fb; font-size:13px; outline:none;
transition: border-color 180ms ease, box-shadow 180ms ease;
font-family:'Manrope',sans-serif; width:100%;
}
.inp:focus { border-color:#4edea3; box-shadow:0 0 0 2px rgba(78,222,163,0.1); }
/* Book card — purposeful hover, only pointer devices */
.book-card {
transition: transform 200ms var(--ease-out), box-shadow 200ms ease, border-color 200ms ease;
will-change: transform;
}
@media (hover: hover) and (pointer: fine) {
.book-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(78,222,163,0.1);
border-color: rgba(78,222,163,0.28) !important;
}
}
.book-card:active { transform: scale(0.98); }
/* Skeleton loading — design-taste: layout-matching shimmer */
@keyframes shimmer {
0% { background-position: -400px 0; }
100% { background-position: 400px 0; }
}
.skeleton {
background: linear-gradient(90deg, #1a2035 25%, #23293c 50%, #1a2035 75%);
background-size: 800px 100%;
animation: shimmer 1.4s ease-in-out infinite;
border-radius: 8px;
}
/* Upload zone */
.upload-zone {
border: 2px dashed rgba(78,222,163,0.2);
transition: border-color 200ms ease, background 200ms ease;
}
@media (hover: hover) and (pointer: fine) {
.upload-zone:hover { border-color: rgba(78,222,163,0.45); background: rgba(78,222,163,0.03); }
}
.upload-zone.dragover { border-color:rgba(78,222,163,0.6); background:rgba(78,222,163,0.05); }
/* Fav button pulse on add */
@keyframes favPulse {
0% { transform: scale(1); }
40% { transform: scale(1.35); }
100% { transform: scale(1); }
}
.fav-btn.added { animation: favPulse 350ms var(--ease-out); color: #f87171 !important; }
/* Page entry */
@keyframes fadeUp {
from { opacity:0; transform:translateY(8px); }
to { opacity:1; transform:translateY(0); }
}
header { animation: fadeUp 350ms var(--ease-out) both; }
.search-section { animation: fadeUp 400ms var(--ease-out) 80ms both; }
.books-section { animation: fadeUp 400ms var(--ease-out) 160ms both; }
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .01ms !important;
animation-iteration-count: 1 !important;
transition-duration: .01ms !important;
}
.skeleton { animation: none; background: #1a2035; }
}
/* ── Mobile-visible improvements ─────────────────────────────────────────── */
/* Larger touch targets — 44px minimum per Apple HIG */
button, a, .filter-chip, .book-card, .stat-card, .nav-btn {
-webkit-tap-highlight-color: transparent;
}
/* Tap feedback — visible on ALL devices including mobile */
.book-card:active,
.stat-card:active,
.filter-chip:active {
transform: scale(0.97) !important;
transition: transform 100ms ease !important;
}
/* Buttons get a visible press state on tap */
button:active:not(:disabled) {
filter: brightness(0.9);
transform: scale(0.97);
transition: transform 100ms ease, filter 100ms ease !important;
}
/* Restore .glow — ensure it stacks correctly */
.glow {
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.06),
0 0 20px rgba(78,222,163,0.15) !important;
}
.glow-sm {
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.04),
0 0 10px rgba(78,222,163,0.1) !important;
}
/* Mobile: book cards get a more visible pressed state */
@media (max-width: 768px) {
.book-card {
transition: transform 150ms ease, opacity 150ms ease !important;
}
.book-card:active {
transform: scale(0.97) !important;
opacity: 0.85 !important;
}
/* Ensure skeleton is visible on mobile */
.skeleton {
background: linear-gradient(90deg, #1a2035 25%, #2a3348 50%, #1a2035 75%) !important;
background-size: 800px 100% !important;
animation: shimmer 1.2s ease-in-out infinite !important;
}
/* Better spacing on small screens */
.filter-chip {
padding: 8px 16px !important;
font-size: 13px !important;
}
}
/* ── design-taste-frontend: Liquid Glass + Spotlight ─────────────────────── */
/* True liquid glass — inner refraction border */
/* NOTE: does NOT define outer shadow — .glow still wins */
.glass {
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.06),
inset 0 -1px 0 rgba(0,0,0,0.2);
}
/* Combined: liquid glass + green glow */
.glass.glow {
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.06),
inset 0 -1px 0 rgba(0,0,0,0.2),
0 0 20px rgba(78,222,163,0.18),
0 4px 24px rgba(0,0,0,0.15);
}
.glass.glow-sm {
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.06),
inset 0 -1px 0 rgba(0,0,0,0.2),
0 0 10px rgba(78,222,163,0.12);
}
/* .glow overrides naturally on top — no !important needed */
/* Spotlight border card — JS-driven cursor tracking */
.spotlight-card {
position: relative;
overflow: hidden;
}
.spotlight-card::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(
400px circle at var(--mx, 50%) var(--my, 50%),
rgba(78,222,163,0.06),
transparent 60%
);
opacity: 0;
transition: opacity 300ms ease;
pointer-events: none;
border-radius: inherit;
z-index: 0;
}
@media (hover: hover) and (pointer: fine) {
.spotlight-card:hover::before { opacity: 1; }
}
/* Ensure content above spotlight overlay */
.spotlight-card > * { position: relative; z-index: 1; }
/* Tactile button press — design-taste rule */
button:not(:disabled):active,
a[role=button]:active {
transform: translateY(-1px) scale(0.98) !important;
transition-duration: 80ms !important;
}
/* Hardware acceleration on animated elements */
.stat-card, .book-card, .query-card, .books-card, .upload-card-wrap {
transform: translateZ(0);
backface-visibility: hidden;
}
/* Directional hover-aware CTA — shimmer from left on hover */
.cta-primary {
position: relative; overflow: hidden;
}
.cta-primary::before {
content: '';
position: absolute;
top: 0; left: -100%; bottom: 0; width: 50%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.1), transparent);
transition: left 400ms cubic-bezier(0.77,0,0.175,1);
pointer-events: none;
}
@media (hover: hover) and (pointer: fine) {
.cta-primary:hover::before { left: 200%; }
}
/* ── Mobile improvements (actually visible on mobile) ────────────────────── */
/* Larger touch targets — 44px minimum per Apple HIG */
@media (max-width: 768px) {
/* Bigger tap areas on buttons */
button, a { min-height: 44px; }
.filter-chip { padding: 8px 16px; font-size: 13px; }
/* Book cards: more breathing room on mobile */
.book-card { padding: 14px !important; }
/* Stats cards: larger numbers on mobile */
.stat-card .stat-num, #s-books, #s-vectors, #s-convos, #s-memory {
font-size: 1.75rem;
}
/* Header: tighter on mobile */
header { padding-top: 10px; padding-bottom: 10px; }
/* Search input: larger font so iOS doesn't zoom */
#search-input, .inp, textarea, input[type="text"], input[type="email"], input[type="password"] {
font-size: 16px !important; /* prevents iOS zoom on focus */
}
/* Bottom action bar spacing */
main { padding-bottom: env(safe-area-inset-bottom, 16px); }
}
/* Reader: full height on mobile */
@media (max-width: 768px) {
#pdf-frame { position: absolute; inset: 0; }
.reader-header { padding: 8px 12px; }
}
/* FORCE LAYOUT — overrides everything */
main {
display: block !important;
width: 100% !important;
max-width: 72rem !important;
margin-left: auto !important;
margin-right: auto !important;
box-sizing: border-box !important;
}
/* Grid view */
#books-container {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
@media (max-width: 768px) {
#books-container { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 480px) {
#books-container { grid-template-columns: 1fr; }
}
/* List view — overrides grid completely */
#books-container.books-list {
display: flex;
flex-direction: column;
gap: 12px;
}
/* Sticky footer — no layout impact */
#sticky-footer { position: sticky; top: 100vh; }
</style>
<script src="/static/analytics.js" defer></script>
<link rel="privacy-policy" href="https://rag.afaqzaid.com/privacy"/>
</head>
<body>
<div id="universal-header"></div>
<main class="max-w-3xl" mx-auto px-4 lg:px-8 py-10">
<div class="glass rounded-2xl p-8" style="border:1px solid rgba(78,222,163,0.12)">
<div class="flex items-center gap-3 mb-8 pb-6 border-b border-white/5">
<div class="w-10 h-10 bg-primary/15 rounded-xl flex items-center justify-center shrink-0">
<span class="msym text-primary text-xl">shield</span>
</div>
<div>
<h1 class="text-xl font-bold">سياسة الخصوصية</h1>
<p class="text-xs text-on-surface-variant mt-1">آخر تحديث: مايو 2026</p>
</div>
</div>
<div class="space-y-6 text-on-surface-variant leading-relaxed text-sm">
<div><h2 class="text-primary font-bold mb-2">١. المعلومات التي نجمعها</h2>
<p>عند استخدامك ميزة استيراد Google Drive، نطلب إذن القراءة فقط للوصول إلى الملفات التي تحددها. لا نخزن رمز الوصول بعد اكتمال عملية الاستيراد.</p></div>
<div><h2 class="text-primary font-bold mb-2">٢. كيف نستخدم المعلومات</h2>
<p>نستخدم الوصول إلى Google Drive فقط لتنزيل ملفات PDF التي تختارها وفهرستها في مكتبتك. لا نشارك بياناتك مع أطراف ثالثة ولا نستخدمها لأي غرض آخر.</p></div>
<div><h2 class="text-primary font-bold mb-2">٣. تخزين البيانات</h2>
<p>يتم تخزين الملفات التي ترفعها على خوادمنا الخاصة. رموز OAuth مؤقتة ولا تُحفظ بعد انتهاء جلسة الاستيراد.</p></div>
<div><h2 class="text-primary font-bold mb-2">٤. حقوقك</h2>
<p>يمكنك في أي وقت حذف ملفاتك من المكتبة أو التواصل معنا لحذف بياناتك بالكامل.</p></div>
<div><h2 class="text-primary font-bold mb-2">٥. التواصل</h2>
<p>لأي استفسار: <a href="mailto:admin@afaqzaid.com" class="text-primary hover:underline">admin@afaqzaid.com</a></p></div>
</div>
</div>
</main>
<div id="universal-footer"></div>
<script src="/components.js"></script>
</body>
</html>
+24 -5
View File
@@ -331,7 +331,7 @@ document.title = (bookTitle || 'كتاب') + ' — AfakZaid';
// Load PDF — public, no auth required
if (bookId) {
const pdfUrl = `/api/library/${bookId}/pdf`;
const pdfUrl = `/api/library/${bookId}/read`;
const titleEl = document.getElementById('book-title-loading');
if (titleEl) titleEl.textContent = bookTitle || '';
@@ -370,10 +370,29 @@ if (bookId) {
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);
// Desktop: check book is accessible first then load
fetch(pdfUrl, {method:'HEAD'}).then(r => {
document.getElementById('loading').style.display = 'none';
if (r.ok) {
frame.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;border:none';
frame.src = pdfUrl;
} else {
frame.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;border:none;display:flex;align-items:center;justify-content:center';
frame.srcdoc = `<html dir="rtl"><body style="font-family:sans-serif;display:flex;height:100vh;align-items:center;justify-content:center;background:#0c1324;color:#dce1fb;flex-direction:column;gap:12px">
<span style="font-size:48px">📚</span>
<p style="font-size:18px;font-weight:bold">الكتاب قيد المعالجة</p>
<p style="font-size:14px;color:#888">يتم فهرسة هذا الكتاب حالياً، يرجى المحاولة بعد قليل</p>
<a href="/library" style="color:#4edea3;font-size:14px">← العودة للمكتبة</a>
</body></html>`;
}
}).catch(() => {
document.getElementById('loading').style.display = 'none';
frame.srcdoc = `<html dir="rtl"><body style="font-family:sans-serif;display:flex;height:100vh;align-items:center;justify-content:center;background:#0c1324;color:#dce1fb;flex-direction:column;gap:12px">
<span style="font-size:48px">⚠️</span>
<p style="font-size:18px;font-weight:bold">تعذّر تحميل الكتاب</p>
<a href="/library" style="color:#4edea3;font-size:14px">← العودة للمكتبة</a>
</body></html>`;
});
}
if (userToken) checkFavorite();
+374
View File
@@ -0,0 +1,374 @@
<!DOCTYPE html>
<html class="dark" lang="ar" dir="rtl">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>المكتبة — AfakZaid RAG</title>
<script src="https://cdn.tailwindcss.com?plugins=forms"></script>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
<script>tailwind.config={darkMode:"class",safelist:["grid","grid-cols-2","grid-cols-3","grid-cols-4","md:grid-cols-3","lg:grid-cols-4","gap-4","space-y-3","col-span-4"],theme:{extend:{colors:{"primary":"#4edea3","surface":"#0c1324","surface-low":"#151b2d","surface-container":"#191f31","surface-high":"#23293c","on-surface":"#dce1fb","on-surface-variant":"#bbcabf","outline-variant":"#3c4a42","secondary":"#95d3ba","tertiary":"#45dfa4","error":"#f87171"},fontFamily:{sans:["Manrope","sans-serif"]}}}}</script>
<style>
:root {
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
}
*{box-sizing:border-box}
body{background:radial-gradient(circle at 50% -20%,#151b2d 0%,#0c1324 100%);font-family:'Manrope',sans-serif;color:#dce1fb;min-height:100vh}
/* Glass: purposeful, not default — liquid glass per design-taste */
.glass{
background:rgba(21,27,45,0.5);
backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);
border:1px solid rgba(78,222,163,0.12);
box-shadow:inset 0 1px 0 rgba(255,255,255,0.04);
}
.glass-hover { transition: background 180ms ease, border-color 180ms ease; }
@media (hover: hover) and (pointer: fine) {
.glass-hover:hover { background:rgba(78,222,163,0.06); border-color:rgba(78,222,163,0.22); }
}
.glow{box-shadow:0 0 20px rgba(78,222,163,0.15)}
.glow-sm{box-shadow:0 0 10px rgba(78,222,163,0.1)}
.msym{font-family:'Material Symbols Outlined';font-variation-settings:'FILL' 0,'wght' 400,'GRAD' 0,'opsz' 24;vertical-align:middle;line-height:1}
/* Filter chips — specific transitions */
.filter-chip {
padding:6px 14px; border-radius:999px; font-size:12px; font-weight:600; cursor:pointer;
border:1px solid rgba(78,222,163,0.15); background:rgba(21,27,45,0.5); color:#bbcabf;
transition: background 150ms var(--ease-out), border-color 150ms var(--ease-out), color 150ms var(--ease-out);
will-change: transform;
}
.filter-chip.active { background:rgba(78,222,163,0.12); border-color:rgba(78,222,163,0.4); color:#4edea3; }
@media (hover: hover) and (pointer: fine) {
.filter-chip:hover { background:rgba(78,222,163,0.08); border-color:rgba(78,222,163,0.3); color:#4edea3; }
}
.filter-chip:active { transform: scale(0.97); }
.scrollbar::-webkit-scrollbar{width:4px}
.scrollbar::-webkit-scrollbar-thumb{background:rgba(78,222,163,0.2);border-radius:4px}
.inp{
background:#23293c; border:1px solid #3c4a42; border-radius:12px;
padding:10px 14px; color:#dce1fb; font-size:13px; outline:none;
transition: border-color 180ms ease, box-shadow 180ms ease;
font-family:'Manrope',sans-serif; width:100%;
}
.inp:focus { border-color:#4edea3; box-shadow:0 0 0 2px rgba(78,222,163,0.1); }
/* Book card — purposeful hover, only pointer devices */
.book-card {
transition: transform 200ms var(--ease-out), box-shadow 200ms ease, border-color 200ms ease;
will-change: transform;
}
@media (hover: hover) and (pointer: fine) {
.book-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(78,222,163,0.1);
border-color: rgba(78,222,163,0.28) !important;
}
}
.book-card:active { transform: scale(0.98); }
/* Skeleton loading — design-taste: layout-matching shimmer */
@keyframes shimmer {
0% { background-position: -400px 0; }
100% { background-position: 400px 0; }
}
.skeleton {
background: linear-gradient(90deg, #1a2035 25%, #23293c 50%, #1a2035 75%);
background-size: 800px 100%;
animation: shimmer 1.4s ease-in-out infinite;
border-radius: 8px;
}
/* Upload zone */
.upload-zone {
border: 2px dashed rgba(78,222,163,0.2);
transition: border-color 200ms ease, background 200ms ease;
}
@media (hover: hover) and (pointer: fine) {
.upload-zone:hover { border-color: rgba(78,222,163,0.45); background: rgba(78,222,163,0.03); }
}
.upload-zone.dragover { border-color:rgba(78,222,163,0.6); background:rgba(78,222,163,0.05); }
/* Fav button pulse on add */
@keyframes favPulse {
0% { transform: scale(1); }
40% { transform: scale(1.35); }
100% { transform: scale(1); }
}
.fav-btn.added { animation: favPulse 350ms var(--ease-out); color: #f87171 !important; }
/* Page entry */
@keyframes fadeUp {
from { opacity:0; transform:translateY(8px); }
to { opacity:1; transform:translateY(0); }
}
header { animation: fadeUp 350ms var(--ease-out) both; }
.search-section { animation: fadeUp 400ms var(--ease-out) 80ms both; }
.books-section { animation: fadeUp 400ms var(--ease-out) 160ms both; }
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .01ms !important;
animation-iteration-count: 1 !important;
transition-duration: .01ms !important;
}
.skeleton { animation: none; background: #1a2035; }
}
/* ── Mobile-visible improvements ─────────────────────────────────────────── */
/* Larger touch targets — 44px minimum per Apple HIG */
button, a, .filter-chip, .book-card, .stat-card, .nav-btn {
-webkit-tap-highlight-color: transparent;
}
/* Tap feedback — visible on ALL devices including mobile */
.book-card:active,
.stat-card:active,
.filter-chip:active {
transform: scale(0.97) !important;
transition: transform 100ms ease !important;
}
/* Buttons get a visible press state on tap */
button:active:not(:disabled) {
filter: brightness(0.9);
transform: scale(0.97);
transition: transform 100ms ease, filter 100ms ease !important;
}
/* Restore .glow — ensure it stacks correctly */
.glow {
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.06),
0 0 20px rgba(78,222,163,0.15) !important;
}
.glow-sm {
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.04),
0 0 10px rgba(78,222,163,0.1) !important;
}
/* Mobile: book cards get a more visible pressed state */
@media (max-width: 768px) {
.book-card {
transition: transform 150ms ease, opacity 150ms ease !important;
}
.book-card:active {
transform: scale(0.97) !important;
opacity: 0.85 !important;
}
/* Ensure skeleton is visible on mobile */
.skeleton {
background: linear-gradient(90deg, #1a2035 25%, #2a3348 50%, #1a2035 75%) !important;
background-size: 800px 100% !important;
animation: shimmer 1.2s ease-in-out infinite !important;
}
/* Better spacing on small screens */
.filter-chip {
padding: 8px 16px !important;
font-size: 13px !important;
}
}
/* ── design-taste-frontend: Liquid Glass + Spotlight ─────────────────────── */
/* True liquid glass — inner refraction border */
/* NOTE: does NOT define outer shadow — .glow still wins */
.glass {
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.06),
inset 0 -1px 0 rgba(0,0,0,0.2);
}
/* Combined: liquid glass + green glow */
.glass.glow {
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.06),
inset 0 -1px 0 rgba(0,0,0,0.2),
0 0 20px rgba(78,222,163,0.18),
0 4px 24px rgba(0,0,0,0.15);
}
.glass.glow-sm {
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.06),
inset 0 -1px 0 rgba(0,0,0,0.2),
0 0 10px rgba(78,222,163,0.12);
}
/* .glow overrides naturally on top — no !important needed */
/* Spotlight border card — JS-driven cursor tracking */
.spotlight-card {
position: relative;
overflow: hidden;
}
.spotlight-card::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(
400px circle at var(--mx, 50%) var(--my, 50%),
rgba(78,222,163,0.06),
transparent 60%
);
opacity: 0;
transition: opacity 300ms ease;
pointer-events: none;
border-radius: inherit;
z-index: 0;
}
@media (hover: hover) and (pointer: fine) {
.spotlight-card:hover::before { opacity: 1; }
}
/* Ensure content above spotlight overlay */
.spotlight-card > * { position: relative; z-index: 1; }
/* Tactile button press — design-taste rule */
button:not(:disabled):active,
a[role=button]:active {
transform: translateY(-1px) scale(0.98) !important;
transition-duration: 80ms !important;
}
/* Hardware acceleration on animated elements */
.stat-card, .book-card, .query-card, .books-card, .upload-card-wrap {
transform: translateZ(0);
backface-visibility: hidden;
}
/* Directional hover-aware CTA — shimmer from left on hover */
.cta-primary {
position: relative; overflow: hidden;
}
.cta-primary::before {
content: '';
position: absolute;
top: 0; left: -100%; bottom: 0; width: 50%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.1), transparent);
transition: left 400ms cubic-bezier(0.77,0,0.175,1);
pointer-events: none;
}
@media (hover: hover) and (pointer: fine) {
.cta-primary:hover::before { left: 200%; }
}
/* ── Mobile improvements (actually visible on mobile) ────────────────────── */
/* Larger touch targets — 44px minimum per Apple HIG */
@media (max-width: 768px) {
/* Bigger tap areas on buttons */
button, a { min-height: 44px; }
.filter-chip { padding: 8px 16px; font-size: 13px; }
/* Book cards: more breathing room on mobile */
.book-card { padding: 14px !important; }
/* Stats cards: larger numbers on mobile */
.stat-card .stat-num, #s-books, #s-vectors, #s-convos, #s-memory {
font-size: 1.75rem;
}
/* Header: tighter on mobile */
header { padding-top: 10px; padding-bottom: 10px; }
/* Search input: larger font so iOS doesn't zoom */
#search-input, .inp, textarea, input[type="text"], input[type="email"], input[type="password"] {
font-size: 16px !important; /* prevents iOS zoom on focus */
}
/* Bottom action bar spacing */
main { padding-bottom: env(safe-area-inset-bottom, 16px); }
}
/* Reader: full height on mobile */
@media (max-width: 768px) {
#pdf-frame { position: absolute; inset: 0; }
.reader-header { padding: 8px 12px; }
}
/* FORCE LAYOUT — overrides everything */
main {
display: block !important;
width: 100% !important;
max-width: 72rem !important;
margin-left: auto !important;
margin-right: auto !important;
box-sizing: border-box !important;
}
/* Grid view */
#books-container {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
@media (max-width: 768px) {
#books-container { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 480px) {
#books-container { grid-template-columns: 1fr; }
}
/* List view — overrides grid completely */
#books-container.books-list {
display: flex;
flex-direction: column;
gap: 12px;
}
/* Sticky footer — no layout impact */
#sticky-footer { position: sticky; top: 100vh; }
</style>
<script src="/static/analytics.js" defer></script>
<link rel="privacy-policy" href="https://rag.afaqzaid.com/privacy"/>
</head>
<body>
<div id="universal-header"></div>
<main class="max-w-3xl" mx-auto px-4 lg:px-8 py-10">
<div class="glass rounded-2xl p-8" style="border:1px solid rgba(78,222,163,0.12)">
<div class="flex items-center gap-3 mb-8 pb-6 border-b border-white/5">
<div class="w-10 h-10 bg-primary/15 rounded-xl flex items-center justify-center shrink-0">
<span class="msym text-primary text-xl">gavel</span>
</div>
<div>
<h1 class="text-xl font-bold">شروط الخدمة</h1>
<p class="text-xs text-on-surface-variant mt-1">آخر تحديث: مايو 2026</p>
</div>
</div>
<div class="space-y-6 text-on-surface-variant leading-relaxed text-sm">
<div><h2 class="text-primary font-bold mb-2">١. الاستخدام المقبول</h2>
<p>يُسمح باستخدام المنصة لأغراض تعليمية وبحثية مشروعة. يُمنع رفع محتوى مخالف للحقوق أو القوانين المعمول بها.</p></div>
<div><h2 class="text-primary font-bold mb-2">٢. ربط Google Drive</h2>
<p>عند ربط حسابك بـ Google Drive، فإنك تمنح المنصة صلاحية القراءة فقط للملفات التي تختار استيرادها. يمكنك إلغاء هذا الإذن في أي وقت من إعدادات حسابك في Google.</p></div>
<div><h2 class="text-primary font-bold mb-2">٣. المسؤولية</h2>
<p>أنت مسؤول عن المحتوى الذي ترفعه والتأكد من امتلاك حقوق استخدامه.</p></div>
<div><h2 class="text-primary font-bold mb-2">٤. تعديل الشروط</h2>
<p>نحتفظ بحق تعديل هذه الشروط في أي وقت مع إشعار المستخدمين.</p></div>
<div><h2 class="text-primary font-bold mb-2">٥. التواصل</h2>
<p>لأي استفسار: <a href="mailto:admin@afaqzaid.com" class="text-primary hover:underline">admin@afaqzaid.com</a></p></div>
</div>
</div>
</main>
<div id="universal-footer"></div>
<script src="/components.js"></script>
</body>
</html>