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
+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>