Files
afakzaid-rag/static/admin.html
T

2697 lines
153 KiB
HTML

<!DOCTYPE html>
<html class="dark" lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>AfakZaid RAG — Admin</title>
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate"/>
<meta http-equiv="Pragma" content="no-cache"/>
<script src="https://cdn.tailwindcss.com"></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 id="tw-config">
tailwind.config = {
darkMode: "class",
theme: { extend: { colors: {
"primary": "#4edea3", "primary-dim": "#3bc48e",
"surface": "#0c1324", "surface-low": "#151b2d",
"surface-container": "#191f31", "surface-high": "#23293c",
"surface-highest": "#2e3447", "surface-bright": "#33394c",
"on-surface": "#dce1fb", "on-surface-variant": "#bbcabf",
"outline-variant": "#3c4a42", "secondary": "#95d3ba",
"tertiary": "#45dfa4", "error": "#f87171",
}, fontFamily: { sans: ["Manrope", "sans-serif"] } } }
}
</script>
<style>
* { box-sizing: border-box; }
body { background: radial-gradient(circle at 50% -20%, #151b2d 0%, #0c1324 100%); font-family: 'Manrope', sans-serif; color: #dce1fb; min-height: 100vh; }
.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); }
.glass-hover:hover { background: rgba(78,222,163,0.06); border-color: rgba(78,222,163,0.25); }
.glow { box-shadow: 0 0 20px rgba(78,222,163,0.15); }
.glow-sm { box-shadow: 0 0 10px rgba(78,222,163,0.1); }
.msym { font-family: 'Material Symbols Outlined'; font-variation-settings: 'FILL' 0,'wght' 400,'GRAD' 0,'opsz' 24; vertical-align: middle; line-height: 1; }
.scrollbar::-webkit-scrollbar { width: 4px; }
.scrollbar::-webkit-scrollbar-track { background: transparent; }
.scrollbar::-webkit-scrollbar-thumb { background: rgba(78,222,163,0.2); border-radius: 10px; }
/* sidebar overlay */
#sidebar-overlay { display: none; }
@media(max-width:1023px) {
#sidebar { transform: translateX(-100%); transition: transform 0.3s; }
#sidebar.open { transform: translateX(0); }
#sidebar-overlay.show { display: block; }
#main { margin-left: 0 !important; }
}
@media (prefers-reduced-motion: reduce) {
*,*::before,*::after { animation-duration:.01ms !important; transition-duration:.01ms !important; animation-iteration-count:1 !important; }
.skeleton { animation:none !important; 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; }
}
</style>
</head>
<body>
<!-- Login -->
<div id="login" class="fixed inset-0 flex items-center justify-center z-50 bg-surface" style="display:none">
<div class="glass glow rounded-3xl p-10 w-full max-w-sm text-center">
<div class="w-16 h-16 bg-primary/10 rounded-2xl flex items-center justify-center mx-auto mb-6 glow">
<span class="msym text-primary text-3xl">lock</span>
</div>
<h1 class="text-2xl font-bold text-primary mb-1">RAG Admin</h1>
<p class="text-on-surface-variant text-sm mb-8">AfakZaid Scholarly System</p>
<input id="admin-email" type="email" placeholder="Email"
class="w-full bg-surface-high border border-outline-variant rounded-xl px-4 py-3 text-on-surface text-sm mb-3 outline-none focus:border-primary transition-colors"
onkeydown="if(event.key==='Enter')document.getElementById('admin-pass').focus()"/>
<input id="admin-pass" type="password" placeholder="Password"
class="w-full bg-surface-high border border-outline-variant rounded-xl px-4 py-3 text-on-surface text-sm mb-4 outline-none focus:border-primary transition-colors"
onkeydown="if(event.key==='Enter')doLogin()"/>
<button type="button" onclick="doLogin()" style="cursor:pointer;opacity:1" class="w-full bg-primary text-surface font-bold py-3 rounded-xl hover:shadow-[0_0_20px_rgba(78,222,163,0.4)] transition-all">
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">
<!-- Header -->
<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">Frontend builder — edit any page visually</p>
</div>
<div class="flex gap-2">
<a id="pb-preview-btn" href="/privacy" target="_blank"
class="glass glass-hover px-4 py-2 rounded-xl text-sm flex items-center gap-2">
<span class="msym text-base">open_in_new</span> Preview
</a>
<button onclick="pbSave()" id="pb-save-btn"
class="bg-primary text-surface font-bold px-5 py-2 rounded-xl text-sm flex items-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>
</div>
</div>
<!-- Page selector -->
<div class="flex flex-wrap gap-2 mb-6">
<button onclick="pbSwitch('privacy')" id="pb-tab-privacy" class="pb-tab px-4 py-2 rounded-xl text-xs font-bold bg-primary text-surface transition-all">Privacy</button>
<button onclick="pbSwitch('terms')" id="pb-tab-terms" class="pb-tab px-4 py-2 rounded-xl text-xs font-bold glass text-on-surface-variant hover:text-primary transition-all">Terms</button>
<button onclick="pbSwitch('about')" id="pb-tab-about" class="pb-tab px-4 py-2 rounded-xl text-xs font-bold glass text-on-surface-variant hover:text-primary transition-all">About</button>
<button onclick="pbSwitch('index')" id="pb-tab-index" class="pb-tab px-4 py-2 rounded-xl text-xs font-bold glass text-on-surface-variant hover:text-primary transition-all">Home</button>
</div>
<!-- Mode toggle -->
<div class="flex gap-2 mb-4">
<button onclick="pbSetMode('visual')" id="pb-mode-visual"
class="px-3 py-1.5 rounded-lg text-xs font-bold bg-primary/20 text-primary border border-primary/30 flex items-center gap-1">
<span class="msym text-sm">dashboard</span> Visual Builder
</button>
<button onclick="pbSetMode('html')" id="pb-mode-html"
class="px-3 py-1.5 rounded-lg text-xs font-bold glass text-on-surface-variant flex items-center gap-1">
<span class="msym text-sm">code</span> HTML Editor
</button>
</div>
<!-- VISUAL BUILDER -->
<div id="pb-visual" class="space-y-3">
<div id="pb-blocks" class="space-y-3"></div>
<!-- Add block toolbar -->
<div class="glass rounded-2xl p-4">
<p class="text-xs text-on-surface-variant mb-3 font-bold uppercase tracking-widest">Add Block</p>
<div class="flex flex-wrap gap-2">
<button onclick="pbAddBlock('heading')" class="glass glass-hover px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-1">
<span class="msym text-sm">title</span> Heading
</button>
<button onclick="pbAddBlock('paragraph')" class="glass glass-hover px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-1">
<span class="msym text-sm">notes</span> Paragraph
</button>
<button onclick="pbAddBlock('list')" class="glass glass-hover px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-1">
<span class="msym text-sm">format_list_bulleted</span> List
</button>
<button onclick="pbAddBlock('divider')" class="glass glass-hover px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-1">
<span class="msym text-sm">horizontal_rule</span> Divider
</button>
<button onclick="pbAddBlock('hero')" class="glass glass-hover px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-1 text-primary">
<span class="msym text-sm">auto_awesome</span> Hero
</button>
<button onclick="pbAddBlock('contact')" class="glass glass-hover px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-1">
<span class="msym text-sm">mail</span> Contact
</button>
</div>
</div>
</div>
<!-- HTML EDITOR -->
<div id="pb-html" class="hidden">
<div class="glass rounded-2xl p-4">
<textarea id="pb-html-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:500px;direction:ltr"></textarea>
</div>
</div>
<p id="pb-status" class="text-xs text-primary mt-3 hidden">✅ Saved successfully</p>
</section>
</div>
</div>
<!-- Sidebar overlay -->
<div id="sidebar-overlay" onclick="closeSidebar()" class="fixed inset-0 bg-black/50 z-30"></div>
<!-- Sidebar -->
<aside id="sidebar" class="fixed left-0 top-0 h-full w-64 glass border-r border-primary/20 flex flex-col z-40 shadow-2xl">
<div class="px-6 py-6 border-b border-outline-variant/30">
<div 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>
<h1 class="text-base font-bold text-primary leading-tight">AfakZaid RAG</h1>
<p class="text-[10px] text-on-surface-variant/60 uppercase tracking-widest">Admin Console</p>
</div>
</div>
</div>
<nav class="flex-1 px-3 py-4 space-y-1 overflow-y-auto scrollbar">
<button class="nav-btn active w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold transition-all" onclick="showPage('overview',this)">
<span class="msym text-lg">dashboard</span><span>Overview</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('books',this)">
<span class="msym text-lg">menu_book</span><span>Books</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('users',this)">
<span class="msym text-lg">group</span><span>Sessions</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('members',this)">
<span class="msym text-lg">manage_accounts</span><span>Members</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('conversations',this)">
<span class="msym text-lg">chat</span><span>Conversations</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('resources',this)">
<span class="msym text-lg">monitor_heart</span><span>Resources</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('api',this)">
<span class="msym text-lg">key</span><span>API</span>
</button>
<!-- REMOVED claude nav --><button class="nav-btn w-full hidden flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold transition-all" onclick="showPage('claude',this)">
<span class="msym text-lg">neurology</span><span>Claude AI</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('tracking',this)">
<span class="msym text-lg">troubleshoot</span><span>Tracking</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('settings',this)">
<span class="msym text-lg">smart_toy</span><span>LLM</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>
</nav>
<div class="px-4 py-4 border-t border-outline-variant/30">
<a href="/" class="flex items-center gap-2 text-on-surface-variant text-xs hover:text-primary transition-colors">
<span class="msym text-sm">open_in_new</span> Open RAG UI
</a>
</div>
</aside>
<!-- Main -->
<div id="main" class="lg:ml-64 min-h-screen">
<!-- Top bar (mobile) -->
<header class="lg:hidden sticky top-0 z-20 glass border-b border-primary/20 px-4 py-3 flex items-center gap-3">
<button onclick="openSidebar()" class="text-on-surface-variant hover:text-primary">
<span class="msym text-2xl">menu</span>
</button>
<h1 class="text-primary font-bold">AfakZaid Admin</h1>
</header>
<div class="p-4 lg:p-8 max-w-7xl mx-auto">
<!-- OVERVIEW -->
<section id="page-overview" class="page">
<div class="flex items-center justify-between mb-6">
<div><h2 class="text-2xl font-bold">Overview</h2><p class="text-on-surface-variant text-sm mt-1">System dashboard</p></div>
<button onclick="loadOverview()" class="glass glass-hover px-4 py-2 rounded-xl text-sm flex items-center gap-2">
<span class="msym text-base">refresh</span> Refresh
</button>
</div>
<div id="stats-grid" class="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4 mb-6"></div>
<!-- ── Library Health ─────────────────────────────────────────────── -->
<div class="glass rounded-2xl p-6 spotlight-card mb-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-primary text-xs font-bold uppercase tracking-widest flex items-center gap-2">
<span class="msym text-base">library_books</span> Library Health
</h3>
<div class="flex gap-2">
<button onclick="rebuildBM25()" id="btn-bm25" class="glass glass-hover px-3 py-1.5 rounded-xl text-xs font-bold flex items-center gap-1 text-secondary">
<span class="msym text-sm">refresh</span> Rebuild BM25
</button>
<button onclick="clearCache()" id="btn-cache" class="glass glass-hover px-3 py-1.5 rounded-xl text-xs font-bold flex items-center gap-1 text-yellow-400">
<span class="msym text-sm">delete_sweep</span> Clear Cache
</button>
<button onclick="reingestMissing()" id="btn-reingest" class="glass glass-hover px-3 py-1.5 rounded-xl text-xs font-bold flex items-center gap-1 text-red-400 hidden">
<span class="msym text-sm">sync</span> Re-ingest Missing
</button>
</div>
</div>
<div id="library-health-content">
<div class="text-on-surface-variant text-sm">Loading...</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div class="glass rounded-2xl p-6 spotlight-card">
<h3 class="text-primary text-xs font-bold uppercase tracking-widest mb-4 flex items-center gap-2"><span class="msym text-base">bar_chart</span>Daily Queries</h3>
<div id="daily-chart"></div>
</div>
<div class="glass rounded-2xl p-6 spotlight-card">
<h3 class="text-primary text-xs font-bold uppercase tracking-widest mb-4 flex items-center gap-2"><span class="msym text-base">smart_toy</span>LLM Usage</h3>
<div id="llm-chart"></div>
</div>
</div>
<div class="glass rounded-2xl p-6 spotlight-card">
<h3 class="text-primary text-xs font-bold uppercase tracking-widest mb-4 flex items-center gap-2"><span class="msym text-base">history</span>Recent Queries</h3>
<div id="recent-list" class="overflow-x-auto"></div>
</div>
</section>
<!-- BOOKS -->
<section id="page-books" class="page hidden">
<div class="flex items-center justify-between mb-6">
<div><h2 class="text-2xl font-bold">Books</h2><p class="text-on-surface-variant text-sm mt-1">Ingested knowledge base</p></div>
<button onclick="loadBooks()" class="glass glass-hover px-4 py-2 rounded-xl text-sm flex items-center gap-2">
<span class="msym text-base">refresh</span> Refresh
</button>
</div>
<div id="books-grid" class="space-y-3"></div>
</section>
<!-- USERS -->
<section id="page-users" class="page hidden">
<div class="flex items-center justify-between mb-6">
<div><h2 class="text-2xl font-bold">Users</h2><p class="text-on-surface-variant text-sm mt-1">Session activity</p></div>
<button onclick="loadUsers()" class="glass glass-hover px-4 py-2 rounded-xl text-sm flex items-center gap-2">
<span class="msym text-base">refresh</span> Refresh
</button>
</div>
<div id="users-list" class="space-y-3"></div>
</section>
<!-- CONVERSATIONS -->
<section id="page-conversations" class="page hidden">
<div class="flex items-center justify-between mb-6">
<div><h2 class="text-2xl font-bold">Conversations</h2><p class="text-on-surface-variant text-sm mt-1">Query history</p></div>
<button onclick="loadConversations()" class="glass glass-hover px-4 py-2 rounded-xl text-sm flex items-center gap-2">
<span class="msym text-base">refresh</span> Refresh
</button>
</div>
<div id="convos-list" class="space-y-3"></div>
</section>
<!-- MEMBERS -->
<section id="page-members" class="page hidden">
<div class="flex items-center justify-between mb-6">
<div><h2 class="text-2xl font-bold">Members</h2><p class="text-on-surface-variant text-sm mt-1">Registered users & roles</p></div>
<button onclick="loadMembers()" class="glass glass-hover px-4 py-2 rounded-xl text-sm flex items-center gap-2">
<span class="msym text-base">refresh</span> Refresh
</button>
</div>
<!-- Roles -->
<div class="glass rounded-2xl p-6 mb-6">
<h3 class="text-primary text-xs font-bold uppercase tracking-widest mb-5 flex items-center gap-2"><span class="msym text-base">shield</span>Roles & Permissions</h3>
<div id="roles-grid" class="grid grid-cols-1 lg:grid-cols-3 gap-4"></div>
</div>
<!-- Users table -->
<div class="glass rounded-2xl p-6">
<h3 class="text-primary text-xs font-bold uppercase tracking-widest mb-5 flex items-center gap-2"><span class="msym text-base">group</span>All Members</h3>
<div id="members-list" class="space-y-3"></div>
</div>
</section>
<!-- BRANDING -->
<section id="page-branding" class="page hidden">
<div class="flex items-center justify-between mb-6">
<div><h2 class="text-2xl font-bold">Branding</h2><p class="text-on-surface-variant text-sm mt-1">Logo, colors & identity</p></div>
<button onclick="saveBranding()" class="bg-primary text-surface font-bold px-5 py-2 rounded-xl text-sm flex items-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>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="glass rounded-2xl p-6 space-y-5">
<h3 class="text-primary text-xs font-bold uppercase tracking-widest flex items-center gap-2"><span class="msym text-base">badge</span>Identity</h3>
<div>
<label class="text-xs text-on-surface-variant mb-2 block">Site Name</label>
<input id="b-site-name" class="w-full bg-surface-high border border-outline-variant rounded-xl px-4 py-3 text-sm text-on-surface outline-none focus:border-primary transition-colors"/>
</div>
<div>
<label class="text-xs text-on-surface-variant mb-2 block">Subtitle</label>
<input id="b-subtitle" class="w-full bg-surface-high border border-outline-variant rounded-xl px-4 py-3 text-sm text-on-surface outline-none focus:border-primary transition-colors"/>
</div>
<div>
<label class="text-xs text-on-surface-variant mb-2 block">Logo</label>
<div class="flex gap-2 mb-2">
<input id="b-logo" placeholder="https://example.com/logo.png"
class="flex-1 bg-surface-high border border-outline-variant rounded-xl px-4 py-3 text-sm text-on-surface outline-none focus:border-primary transition-colors"
oninput="updateLogoPreview()"/>
<label class="glass glass-hover px-3 py-2 rounded-xl text-xs text-primary font-bold cursor-pointer flex items-center gap-1 shrink-0">
<span class="msym text-sm">upload</span> Upload
<input type="file" accept="image/*" class="hidden" onchange="uploadLogo(this)"/>
</label>
</div>
<img id="b-logo-preview" class="hidden rounded-xl mb-2 border border-outline-variant" style="max-width:200px"/>
<div class="flex items-center gap-3">
<label class="text-xs text-on-surface-variant shrink-0">Size</label>
<input type="range" id="b-logo-size" min="20" max="120" value="40" step="4"
class="flex-1 accent-primary" oninput="updateLogoPreview()"/>
<span id="b-logo-size-val" class="text-xs text-on-surface-variant w-10 text-right">40px</span>
</div>
</div>
</div>
<div class="glass rounded-2xl p-6 space-y-5">
<h3 class="text-primary text-xs font-bold uppercase tracking-widest flex items-center gap-2"><span class="msym text-base">palette</span>Colors</h3>
<div>
<label class="text-xs text-on-surface-variant mb-2 block">Primary Color</label>
<div class="flex items-center gap-3">
<input type="color" id="b-primary" class="w-12 h-12 rounded-xl border border-outline-variant cursor-pointer bg-transparent" oninput="document.getElementById('b-primary-hex').value=this.value;document.getElementById('preview-primary').style.background=this.value"/>
<input id="b-primary-hex" class="flex-1 bg-surface-high border border-outline-variant rounded-xl px-4 py-3 text-sm font-mono outline-none focus:border-primary transition-colors" oninput="if(this.value.match(/^#[0-9a-fA-F]{6}$/)){document.getElementById('b-primary').value=this.value;document.getElementById('preview-primary').style.background=this.value}"/>
</div>
</div>
<div>
<label class="text-xs text-on-surface-variant mb-2 block">Secondary Color</label>
<div class="flex items-center gap-3">
<input type="color" id="b-secondary" class="w-12 h-12 rounded-xl border border-outline-variant cursor-pointer bg-transparent" oninput="document.getElementById('b-secondary-hex').value=this.value;document.getElementById('preview-secondary').style.background=this.value"/>
<input id="b-secondary-hex" class="flex-1 bg-surface-high border border-outline-variant rounded-xl px-4 py-3 text-sm font-mono outline-none focus:border-primary transition-colors" oninput="if(this.value.match(/^#[0-9a-fA-F]{6}$/)){document.getElementById('b-secondary').value=this.value;document.getElementById('preview-secondary').style.background=this.value}"/>
</div>
</div>
<div class="glass rounded-xl p-4">
<p class="text-xs text-on-surface-variant mb-3">Preview</p>
<div class="flex gap-3">
<div id="preview-primary" class="flex-1 h-10 rounded-lg"></div>
<div id="preview-secondary" class="flex-1 h-10 rounded-lg"></div>
</div>
</div>
<p class="text-xs text-on-surface-variant">Colors apply instantly after saving.</p>
</div>
</div>
</section>
<!-- RESOURCES -->
<section id="page-resources" class="page hidden">
<div class="flex items-center justify-between mb-6">
<div><h2 class="text-2xl font-bold">Resources</h2><p class="text-on-surface-variant text-sm mt-1">System health</p></div>
<button onclick="loadResources()" class="glass glass-hover px-4 py-2 rounded-xl text-sm flex items-center gap-2">
<span class="msym text-base">refresh</span> Refresh
</button>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="glass rounded-2xl p-6"><h3 class="text-primary text-xs font-bold uppercase tracking-widest mb-5 flex items-center gap-2"><span class="msym text-base">storage</span>Storage & Memory</h3><div id="res-storage"></div></div>
<div class="glass rounded-2xl p-6"><h3 class="text-primary text-xs font-bold uppercase tracking-widest mb-5 flex items-center gap-2"><span class="msym text-base">dns</span>Services</h3><div id="res-services"></div></div>
</div>
</section>
<!-- API -->
<section id="page-api" class="page hidden">
<div class="mb-6"><h2 class="text-2xl font-bold">API</h2><p class="text-on-surface-variant text-sm mt-1">Keys & endpoints</p></div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div class="glass rounded-2xl p-6">
<h3 class="text-primary text-xs font-bold uppercase tracking-widest mb-5 flex items-center gap-2"><span class="msym text-base">key</span>API Key</h3>
<div class="bg-surface-high rounded-xl p-3 flex items-center justify-between gap-3 mb-3">
<code id="api-key-display" class="text-primary text-xs truncate">••••••••••••••••••••••</code>
<button onclick="toggleApiKey()" class="text-on-surface-variant hover:text-primary text-xs glass px-3 py-1 rounded-lg transition-colors" id="key-toggle-btn">Show</button>
</div>
<p class="text-on-surface-variant text-xs">Include as <code class="text-primary">X-API-Key</code> header in all requests</p>
</div>
<div class="glass rounded-2xl p-6">
<h3 class="text-primary text-xs font-bold uppercase tracking-widest mb-5 flex items-center gap-2"><span class="msym text-base">api</span>Endpoints</h3>
<div class="space-y-2 text-xs">
<div class="flex justify-between py-2 border-b border-outline-variant/20"><code class="text-secondary">GET /health</code><span class="text-on-surface-variant">Public</span></div>
<div class="flex justify-between py-2 border-b border-outline-variant/20"><code class="text-secondary">GET /stats</code><span class="text-on-surface-variant">Public</span></div>
<div class="flex justify-between py-2 border-b border-outline-variant/20"><code class="text-primary">POST /query</code><span class="text-on-surface-variant">API Key</span></div>
<div class="flex justify-between py-2 border-b border-outline-variant/20"><code class="text-primary">POST /ingest</code><span class="text-on-surface-variant">API Key</span></div>
<div class="flex justify-between py-2 border-b border-outline-variant/20"><code class="text-primary">POST /ingest-url</code><span class="text-on-surface-variant">API Key</span></div>
<div class="flex justify-between py-2"><code class="text-tertiary">GET /admin/*</code><span class="text-on-surface-variant">Admin Key</span></div>
</div>
</div>
</div>
<div class="glass rounded-2xl p-6">
<h3 class="text-primary text-xs font-bold uppercase tracking-widest mb-4 flex items-center gap-2"><span class="msym text-base">code</span>Quick Reference</h3>
<pre class="bg-surface-container-low rounded-xl p-4 text-xs text-primary overflow-x-auto leading-6"><code># Query books
curl -X POST https://rag.afaqzaid.com/query \
-H "X-API-Key: YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "سؤالك هنا", "session_id": "user1"}'
# Ingest PDF by URL
curl -X POST https://rag.afaqzaid.com/ingest-url \
-H "X-API-Key: YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/book.pdf"}'</code></pre>
</div>
</section>
<!-- SETTINGS -->
<section id="page-settings" class="page hidden">
<div class="flex items-center justify-between mb-6">
<div><h2 class="text-2xl font-bold">LLM</h2><p class="text-on-surface-variant text-sm mt-1">AI provider, keys & system configuration</p></div>
<button onclick="loadSettings();loadClaude();" class="glass glass-hover px-4 py-2 rounded-xl text-sm flex items-center gap-2">
<span class="msym text-base">refresh</span> Refresh
</button>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<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>
</section>
<!-- CLAUDE -->
<section id="page-claude" class="page hidden" style="display:none!important">
<div class="flex items-center justify-between mb-6">
<div><h2 class="text-2xl font-bold">Claude AI</h2><p class="text-on-surface-variant text-sm mt-1">API key & usage tracking</p></div>
<button onclick="loadClaude()" class="glass glass-hover px-4 py-2 rounded-xl text-sm flex items-center gap-2">
<span class="msym text-base">refresh</span> Refresh
</button>
</div>
<!-- Key input -->
<div class="glass rounded-2xl p-6 mb-6">
<h3 class="text-primary text-xs font-bold uppercase tracking-widest mb-5 flex items-center gap-2"><span class="msym text-base">key</span>API Key</h3>
<div class="flex gap-3 mb-3">
<input type="password" id="claude-key-input" placeholder="sk-ant-..."
class="flex-1 bg-surface-high border border-outline-variant rounded-xl px-4 py-3 text-sm text-on-surface outline-none focus:border-primary transition-colors font-mono"/>
<button onclick="toggleClaudeKey()" id="claude-key-toggle" class="glass glass-hover px-4 py-2 rounded-xl text-xs text-on-surface-variant">Show</button>
</div>
<div class="flex items-center gap-3 mb-4">
<div id="claude-status" class="flex items-center gap-2 text-xs text-on-surface-variant">
<span class="w-2 h-2 rounded-full bg-outline-variant inline-block"></span> Not configured
</div>
</div>
<button onclick="saveClaudeKey()" class="bg-primary text-surface font-bold px-6 py-2.5 rounded-xl text-sm hover:shadow-[0_0_20px_rgba(78,222,163,0.4)] transition-all flex items-center gap-2">
<span class="msym text-base">save</span> Save Key
</button>
<p class="text-on-surface-variant text-xs mt-3">Key is stored securely in the database and loaded at runtime. Get yours at <span class="text-primary">console.anthropic.com</span></p>
</div>
<!-- Usage stats -->
<!-- Daily usage chart -->
<div class="glass rounded-2xl p-6 mb-6">
<h3 class="text-primary text-xs font-bold uppercase tracking-widest mb-4 flex items-center gap-2"><span class="msym text-base">bar_chart</span>Daily Usage</h3>
<div id="claude-daily-chart"></div>
</div>
<!-- Recent calls -->
<div class="glass rounded-2xl p-6">
<h3 class="text-primary text-xs font-bold uppercase tracking-widest mb-4 flex items-center gap-2"><span class="msym text-base">history</span>Recent Calls</h3>
<div id="claude-usage-list" class="space-y-2"></div>
</div>
</section>
<!-- ── Tracking Page ──────────────────────────────────────────────────── -->
<section id="page-tracking" class="page hidden">
<div class="flex items-center justify-between mb-6">
<div><h2 class="text-2xl font-bold">Tracking</h2><p class="text-on-surface-variant text-sm mt-1">Google Analytics 4 · Pixel Tracking · DataLayer Events</p></div>
<button onclick="loadTracking()" class="glass glass-hover px-4 py-2 rounded-xl text-sm flex items-center gap-2"><span class="msym text-base">refresh</span> Refresh</button>
</div>
<!-- GA4 -->
<div class="glass rounded-2xl p-6 mb-5">
<div class="flex items-center gap-3 mb-5">
<div class="w-9 h-9 rounded-xl bg-blue-900/30 flex items-center justify-center"><span class="msym text-blue-400 text-lg">track_changes</span></div>
<div><h3 class="text-sm font-bold">Google Analytics 4</h3><p class="text-xs text-on-surface-variant">Measurement Protocol + DataLayer Events</p></div>
</div>
<div class="flex gap-3 mb-3">
<input type="text" id="ga-mid" placeholder="G-XXXXXXXXXX" dir="ltr"
class="flex-1 bg-surface-high border border-outline-variant rounded-xl px-4 py-2.5 text-sm font-mono outline-none focus:border-primary transition-colors"/>
<button onclick="toggleGAEnabled()" id="ga-toggle-btn"
class="px-4 py-2 rounded-xl text-xs font-bold transition-colors glass glass-hover text-on-surface-variant">Enable</button>
</div>
<div id="ga-status" class="flex items-center gap-2 text-xs text-on-surface-variant mb-4">
<span class="w-2 h-2 rounded-full bg-outline-variant inline-block"></span> Not configured
</div>
<button onclick="saveGAConfig()"
class="w-full bg-primary/10 border border-primary/20 text-primary font-bold py-2.5 rounded-xl text-sm flex items-center justify-center gap-2 hover:bg-primary/20 transition-all">
<span class="msym text-base">save</span> Save GA4 Settings
</button>
</div>
<!-- DataLayer Events -->
<div class="glass rounded-2xl p-6 mb-5">
<div class="flex items-center justify-between mb-5">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-xl bg-primary/10 flex items-center justify-center"><span class="msym text-primary text-lg">data_object</span></div>
<div><h3 class="text-sm font-bold">DataLayer Events</h3><p class="text-xs text-on-surface-variant">CSS selector-based event rules</p></div>
</div>
<button onclick="showEventBuilder(null)"
class="bg-primary/10 border border-primary/20 text-primary px-4 py-1.5 rounded-xl text-xs font-bold flex items-center gap-1.5 hover:bg-primary/20 transition-colors">
<span class="msym text-base">add</span> Add Event
</button>
</div>
<div id="analytics-events-list"><p class="text-on-surface-variant text-sm">No events configured</p></div>
</div>
<!-- Pixel Tracking -->
<div class="glass rounded-2xl p-6">
<div class="flex items-center justify-between mb-5">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-xl bg-purple-900/30 flex items-center justify-center"><span class="msym text-purple-400 text-lg">ads_click</span></div>
<div><h3 class="text-sm font-bold">Pixel Tracking</h3><p class="text-xs text-on-surface-variant">Meta · TikTok · Snapchat · Pinterest · X · Custom</p></div>
</div>
<button onclick="showPixelBuilder(null)"
class="bg-purple-900/30 border border-purple-400/20 text-purple-400 px-4 py-1.5 rounded-xl text-xs font-bold flex items-center gap-1.5 hover:bg-purple-900/50 transition-colors">
<span class="msym text-base">add</span> Add Pixel
</button>
</div>
<div id="pixels-list"><p class="text-on-surface-variant text-sm">No pixels configured</p></div>
</div>
</section>
</div>
</div>
<!-- ── Scan Picker Modal ─────────────────────────────────────────────────────── -->
<div id="scan-picker" class="hidden fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/80">
<div class="glass rounded-3xl w-full max-w-xl max-h-[85vh] flex flex-col" style="border:1px solid rgba(78,222,163,0.2)">
<!-- Header -->
<div class="flex items-center justify-between p-5 border-b border-outline-variant/30 shrink-0">
<div class="flex items-center gap-3">
<span class="msym text-primary text-xl">radar</span>
<div>
<h3 class="font-bold text-sm">Scan Page Elements</h3>
<p class="text-xs text-on-surface-variant">Click any element to use it as your selector</p>
</div>
</div>
<button onclick="closeScanPicker()" class="text-on-surface-variant hover:text-primary"><span class="msym text-xl">close</span></button>
</div>
<!-- Page selector -->
<div class="flex gap-2 p-4 border-b border-outline-variant/20 shrink-0 flex-wrap">
<button onclick="runScan('library')" id="scan-tab-library" class="scan-tab glass glass-hover px-3 py-1.5 rounded-lg text-xs font-semibold">Library</button>
<button onclick="runScan('reader')" id="scan-tab-reader" class="scan-tab glass glass-hover px-3 py-1.5 rounded-lg text-xs font-semibold">Reader</button>
<button onclick="runScan('home')" id="scan-tab-home" class="scan-tab glass glass-hover px-3 py-1.5 rounded-lg text-xs font-semibold">Home</button>
<button onclick="runScan('login')" id="scan-tab-login" class="scan-tab glass glass-hover px-3 py-1.5 rounded-lg text-xs font-semibold">Login</button>
</div>
<!-- Results -->
<div id="scan-results" class="flex-1 overflow-y-auto p-4">
<div class="flex flex-col items-center justify-center h-32 text-on-surface-variant text-sm gap-2">
<span class="msym text-3xl">radar</span>
Select a page above to scan its elements
</div>
</div>
</div>
</div>
<!-- ── Event Builder Modal ──────────────────────────────────────────────────── -->
<div id="event-builder" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70">
<div class="glass rounded-3xl w-full max-w-2xl max-h-[90vh] overflow-y-auto p-6 space-y-4" style="border:1px solid rgba(78,222,163,0.15)">
<div class="flex items-center justify-between">
<h3 class="font-bold text-lg flex items-center gap-2"><span class="msym text-primary">data_object</span><span id="eb-title">New Event</span></h3>
<button onclick="closeEventBuilder()" class="text-on-surface-variant hover:text-primary"><span class="msym text-xl">close</span></button>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-xs text-on-surface-variant font-bold uppercase tracking-widest block mb-2">Event Name</label>
<input type="text" id="eb-event-name" placeholder="book_view" dir="ltr" oninput="updateEBPreview()"
class="w-full bg-surface-high border border-outline-variant rounded-xl px-3 py-2.5 text-sm font-mono outline-none focus:border-primary transition-colors"/>
</div>
<div>
<label class="text-xs text-on-surface-variant font-bold uppercase tracking-widest block mb-2">Category</label>
<input type="text" id="eb-category" placeholder="engagement" dir="ltr" oninput="updateEBPreview()"
class="w-full bg-surface-high border border-outline-variant rounded-xl px-3 py-2.5 text-sm outline-none focus:border-primary transition-colors"/>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-xs text-on-surface-variant font-bold uppercase tracking-widest block mb-2">Trigger</label>
<select id="eb-trigger" onchange="updateEBPreview()" class="w-full bg-surface-high border border-outline-variant rounded-xl px-3 py-2.5 text-sm outline-none focus:border-primary transition-colors">
<option value="click">Click</option>
<option value="pageview">Page View</option>
<option value="scroll_depth">Scroll Depth</option>
<option value="submit">Form Submit</option>
<option value="change">Input Change</option>
</select>
</div>
<div>
<label class="text-xs text-on-surface-variant font-bold uppercase tracking-widest block mb-2">Pages</label>
<select id="eb-pages" onchange="updateEBPreview()" class="w-full bg-surface-high border border-outline-variant rounded-xl px-3 py-2.5 text-sm outline-none focus:border-primary transition-colors">
<option value="all">All Pages</option>
<option value="library">Library</option>
<option value="reader">Reader</option>
<option value="home">Home</option>
<option value="login">Login</option>
</select>
</div>
</div>
<div>
<div class="flex items-center justify-between mb-2">
<label class="text-xs text-on-surface-variant font-bold uppercase tracking-widest">CSS Selector</label>
<button onclick="openScanPicker()" class="flex items-center gap-1.5 text-xs text-primary glass glass-hover px-3 py-1 rounded-lg transition-colors">
<span class="msym text-base">radar</span> Scan Page
</button>
</div>
<input type="text" id="eb-selector" placeholder=".book-card, #download-btn" dir="ltr" oninput="updateEBPreview()"
class="w-full bg-surface-high border border-outline-variant rounded-xl px-3 py-2.5 text-sm font-mono outline-none focus:border-primary transition-colors mb-2"/>
<div class="flex flex-wrap gap-2">
<p class="text-xs text-on-surface-variant w-full">Quick selectors:</p>
<button onclick="ebSetSel('[data-book-id]')" class="glass glass-hover px-2.5 py-1 rounded-lg text-xs">Book Card</button>
<button onclick="ebSetSel('#download-btn')" class="glass glass-hover px-2.5 py-1 rounded-lg text-xs">Download</button>
<button onclick="ebSetSel('#ask-btn')" class="glass glass-hover px-2.5 py-1 rounded-lg text-xs">AI Ask</button>
<button onclick="ebSetSel('.fav-btn')" class="glass glass-hover px-2.5 py-1 rounded-lg text-xs">Favorite</button>
<button onclick="ebSetSel('a[href]')" class="glass glass-hover px-2.5 py-1 rounded-lg text-xs">Any Link</button>
<button onclick="ebSetSel('button')" class="glass glass-hover px-2.5 py-1 rounded-lg text-xs">Any Button</button>
</div>
</div>
<div>
<div class="flex items-center justify-between mb-2">
<label class="text-xs text-on-surface-variant font-bold uppercase tracking-widest">Parameters</label>
<button onclick="ebAddParam()" class="text-primary text-xs flex items-center gap-1 hover:opacity-80"><span class="msym text-base">add</span> Add</button>
</div>
<div id="eb-params" class="space-y-2"></div>
<div class="flex flex-wrap gap-2 mt-2">
<p class="text-xs text-on-surface-variant w-full">Quick params:</p>
<button onclick="ebAddPreset('book_id','attribute','data-book-id')" class="glass glass-hover px-2.5 py-1 rounded-lg text-xs">book_id</button>
<button onclick="ebAddPreset('book_title','attribute','data-title')" class="glass glass-hover px-2.5 py-1 rounded-lg text-xs">book_title</button>
<button onclick="ebAddPreset('category','attribute','data-category')" class="glass glass-hover px-2.5 py-1 rounded-lg text-xs">category</button>
<button onclick="ebAddPreset('page_url','page_url','')" class="glass glass-hover px-2.5 py-1 rounded-lg text-xs">page_url</button>
<button onclick="ebAddPreset('page_path','page_path','')" class="glass glass-hover px-2.5 py-1 rounded-lg text-xs">page_path</button>
<button onclick="ebAddPreset('element_text','text','')" class="glass glass-hover px-2.5 py-1 rounded-lg text-xs">element_text</button>
</div>
</div>
<div class="flex items-center justify-between glass rounded-xl px-4 py-3">
<span class="text-sm font-semibold">Active</span>
<button onclick="ebToggleActive(this)" id="eb-active-btn" data-active="true" class="w-12 h-6 rounded-full relative transition-colors bg-primary">
<span class="absolute top-1 right-1 w-4 h-4 bg-surface rounded-full transition-all"></span>
</button>
</div>
<div class="glass rounded-xl p-4">
<p class="text-xs text-on-surface-variant uppercase tracking-widest font-bold mb-2">Preview</p>
<pre id="eb-preview" class="text-xs font-mono text-emerald-400 whitespace-pre-wrap leading-5"></pre>
</div>
<input type="hidden" id="eb-edit-idx" value=""/>
<button onclick="ebSave()" class="w-full bg-primary text-surface font-bold py-3 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-lg">save</span> Save Event
</button>
</div>
</div>
<!-- ── Pixel Builder Modal ───────────────────────────────────────────────────── -->
<div id="pixel-builder" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70">
<div class="glass rounded-3xl w-full max-w-xl max-h-[90vh] overflow-y-auto p-6 space-y-4" style="border:1px solid rgba(168,85,247,0.2)">
<div class="flex items-center justify-between">
<h3 class="font-bold text-lg flex items-center gap-2"><span class="msym text-purple-400">ads_click</span><span id="pb-title">Add Pixel</span></h3>
<button onclick="pbClose()" class="text-on-surface-variant hover:text-primary"><span class="msym text-xl">close</span></button>
</div>
<div>
<label class="text-xs text-on-surface-variant font-bold uppercase tracking-widest block mb-3">Platform</label>
<div class="grid grid-cols-3 gap-2" id="pb-grid">
<button onclick="pbSelectPlatform('meta')" data-plat="meta" class="pb-plat glass glass-hover rounded-xl p-3 flex flex-col items-center gap-1.5">
<span class="text-xl">&#128216;</span><span class="text-xs font-semibold">Meta</span>
</button>
<button onclick="pbSelectPlatform('tiktok')" data-plat="tiktok" class="pb-plat glass glass-hover rounded-xl p-3 flex flex-col items-center gap-1.5">
<span class="text-xl">&#127925;</span><span class="text-xs font-semibold">TikTok</span>
</button>
<button onclick="pbSelectPlatform('snapchat')" data-plat="snapchat" class="pb-plat glass glass-hover rounded-xl p-3 flex flex-col items-center gap-1.5">
<span class="text-xl">&#128123;</span><span class="text-xs font-semibold">Snapchat</span>
</button>
<button onclick="pbSelectPlatform('pinterest')" data-plat="pinterest" class="pb-plat glass glass-hover rounded-xl p-3 flex flex-col items-center gap-1.5">
<span class="text-xl">&#128204;</span><span class="text-xs font-semibold">Pinterest</span>
</button>
<button onclick="pbSelectPlatform('twitter')" data-plat="twitter" class="pb-plat glass glass-hover rounded-xl p-3 flex flex-col items-center gap-1.5">
<span class="text-xl">&#128038;</span><span class="text-xs font-semibold">X / Twitter</span>
</button>
<button onclick="pbSelectPlatform('custom')" data-plat="custom" class="pb-plat glass glass-hover rounded-xl p-3 flex flex-col items-center gap-1.5">
<span class="text-xl">&#9889;</span><span class="text-xs font-semibold">Custom</span>
</button>
</div>
</div>
<div id="pb-id-wrap">
<label class="text-xs text-on-surface-variant font-bold uppercase tracking-widest block mb-2" id="pb-id-label">Pixel ID</label>
<input type="text" id="pb-pixel-id" placeholder="Enter pixel / tag ID" dir="ltr"
class="w-full bg-surface-high border border-outline-variant rounded-xl px-4 py-2.5 text-sm font-mono outline-none focus:border-primary transition-colors"/>
<p id="pb-id-hint" class="text-xs text-on-surface-variant mt-1.5"></p>
</div>
<div id="pb-code-wrap" class="hidden">
<label class="text-xs text-on-surface-variant font-bold uppercase tracking-widest block mb-2">Custom Code</label>
<textarea id="pb-code" rows="5" dir="ltr" placeholder="Paste pixel code here"
class="w-full bg-surface-high border border-outline-variant rounded-xl px-4 py-2.5 text-xs font-mono outline-none focus:border-primary transition-colors resize-none"></textarea>
<label class="text-xs text-on-surface-variant font-bold uppercase tracking-widest block mb-2 mt-3">Label</label>
<input type="text" id="pb-name" placeholder="My Custom Pixel"
class="w-full bg-surface-high border border-outline-variant rounded-xl px-4 py-2.5 text-sm outline-none focus:border-primary transition-colors"/>
</div>
<div class="flex items-center justify-between glass rounded-xl px-4 py-3">
<span class="text-sm font-semibold">Active</span>
<button onclick="pbToggleActive(this)" id="pb-active-btn" data-active="true" class="w-12 h-6 rounded-full relative transition-colors bg-primary">
<span class="absolute top-1 right-1 w-4 h-4 bg-surface rounded-full transition-all"></span>
</button>
</div>
<input type="hidden" id="pb-edit-idx" value=""/>
<button onclick="pbSave()" class="w-full bg-purple-900/60 border border-purple-400/30 text-purple-300 font-bold py-3 rounded-xl text-sm flex items-center justify-center gap-2 hover:bg-purple-900/80 transition-all">
<span class="msym text-lg">save</span> Save Pixel
</button>
</div>
</div>
<script>
// -- Branding color override --------------------------------------------------
function applyBrandingColors(p, s) {
p = p || '#4edea3'; s = s || '#95d3ba';
const hr = (hex) => { const r=parseInt(hex.slice(1,3),16),g=parseInt(hex.slice(3,5),16),b=parseInt(hex.slice(5,7),16); return r+','+g+','+b; };
const pr = hr(p), sr = hr(s);
const old = document.getElementById('_bco'); if(old) old.remove();
const st = document.createElement('style'); st.id='_bco';
st.textContent=
'.text-primary{color:'+p+'!important}.bg-primary{background-color:'+p+'!important}.border-primary{border-color:'+p+'!important}'+
'.text-secondary{color:'+s+'!important}.bg-secondary{background-color:'+s+'!important}'+
'.glass{border-color:rgba('+pr+',0.12)!important}'+
'.glass-hover:hover{background:rgba('+pr+',0.06)!important;border-color:rgba('+pr+',0.25)!important}'+
'.glow{box-shadow:0 0 20px rgba('+pr+',0.15)!important}'+
'.glow-sm{box-shadow:0 0 10px rgba('+pr+',0.1)!important}'+
'.glass.glow{box-shadow:inset 0 1px 0 rgba(255,255,255,0.06),0 0 20px rgba('+pr+',0.18),0 4px 24px rgba(0,0,0,0.15)!important}'+
'.upload-zone{border-color:rgba('+pr+',0.2)!important}'+
'.scrollbar::-webkit-scrollbar-thumb{background:rgba('+pr+',0.2)!important}';
document.head.appendChild(st);
document.documentElement.style.setProperty('--primary',p);
document.documentElement.style.setProperty('--secondary',s);
}
// ADMIN_KEY removed - JWT-only auth
let JWT_TOKEN = localStorage.getItem('admin_jwt') || '';
const RAG_KEY = 'zRf77E_brSEnA2BUi40uxLxT4uofAk661WMZb-cdv4M';
let keyVisible = false;
// Hide login immediately if token present -- no flash
if (!JWT_TOKEN) document.getElementById('login').style.display = 'flex';
// -- Core API ------------------------------------------------------------------
async function api(path, opts={}) {
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) {
JWT_TOKEN = ''; localStorage.removeItem('admin_jwt');
document.getElementById('login').style.display = 'flex';
throw new Error('Session expired -- please login again');
}
if (!r.ok) throw new Error(r.status + ' ' + path);
return r.json();
}
function toast(msg, type='error') {
const el = document.createElement('div');
const bg = type==='success'?'#22c55e':type==='warn'?'#f59e0b':'#f87171';
el.style.cssText = `position:fixed;bottom:24px;right:24px;z-index:9999;background:${bg};color:#fff;padding:12px 20px;border-radius:12px;font-size:13px;font-weight:600;box-shadow:0 4px 24px rgba(0,0,0,0.4);max-width:360px;word-break:break-word;`;
el.textContent = msg; document.body.appendChild(el);
setTimeout(()=>el.remove(), 4000);
}
function showErr(id, msg) {
const el = document.getElementById(id);
if (el) el.innerHTML = `<div class="glass border border-red-900/40 rounded-xl p-4 text-red-400 text-sm flex items-center gap-2"><span class="msym">error</span>${msg}</div>`;
}
// -- Auth ----------------------------------------------------------------------
async function doLogin() {
const email = document.getElementById('admin-email').value.trim();
const pass = document.getElementById('admin-pass').value;
const errEl = document.getElementById('login-err');
errEl.classList.add('hidden');
if (!email||!pass) { errEl.textContent='Enter email and password'; errEl.classList.remove('hidden'); return; }
try {
const r = await fetch('/auth/login', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({email, password:pass})});
const d = await r.json();
if (!r.ok) { errEl.textContent=d.detail||'Invalid credentials'; errEl.classList.remove('hidden'); return; }
if (d.user?.role !== 'admin') { errEl.textContent='Admin access required'; errEl.classList.remove('hidden'); return; }
JWT_TOKEN = d.token;
localStorage.setItem('admin_jwt', d.token);
document.getElementById('login').style.display = 'none';
loadOverview();
} catch(e) { errEl.textContent='Connection error'; errEl.classList.remove('hidden'); }
}
// -- Navigation ----------------------------------------------------------------
function showPage(name, btn) {
document.querySelectorAll('.page').forEach(p=>p.classList.add('hidden'));
document.querySelectorAll('.nav-btn').forEach(b=>{
b.classList.remove('active','bg-primary/10','text-primary');
b.classList.add('text-on-surface-variant');
});
document.getElementById('page-'+name).classList.remove('hidden');
if (btn) { btn.classList.add('active','bg-primary/10','text-primary'); btn.classList.remove('text-on-surface-variant'); }
closeSidebar();
const loaders = {overview:loadOverview, books:loadBooks, users:loadUsers,
conversations:loadConversations, resources:loadResources, settings:()=>{loadSettings();loadClaude();},
branding:loadBranding, members:loadMembers, claude:loadClaude, tracking:loadTracking,
pages:()=>pbSwitch(_pbPage)};
if (loaders[name]) loaders[name]();
}
function openSidebar() { document.getElementById('sidebar').classList.add('open'); document.getElementById('sidebar-overlay').classList.add('show'); }
function closeSidebar() { document.getElementById('sidebar').classList.remove('open'); document.getElementById('sidebar-overlay').classList.remove('show'); }
// -- Helpers -------------------------------------------------------------------
function bar(label, val, max, color='#4edea3') {
const pct = max>0 ? Math.round(val/max*100) : 0;
return `<div class="flex items-center gap-3 mb-3">
<div class="w-24 text-right text-xs text-on-surface-variant truncate shrink-0">${label}</div>
<div class="flex-1 bg-surface-high rounded-full h-2 overflow-hidden"><div style="width:${pct}%;background:${color}" class="h-2 rounded-full transition-all duration-700"></div></div>
<div class="w-8 text-xs text-on-surface-variant">${val}</div>
</div>`;
}
function resource(label, used, total, pct) {
const color = pct>80?'#f87171':pct>60?'#fbbf24':'#4edea3';
return `<div class="mb-5">
<div class="flex justify-between text-xs text-on-surface-variant mb-2"><span>${label}</span><span>${used} / ${total}</span></div>
<div class="bg-surface-high rounded-full h-2 overflow-hidden"><div style="width:${pct}%;background:${color}" class="h-2 rounded-full transition-all duration-700"></div></div>
</div>`;
}
function badge(llm) {
const map = {cache:'bg-emerald-900/40 text-emerald-400', claude:'bg-purple-900/40 text-purple-400'};
return `<span class="text-[10px] font-bold px-2 py-0.5 rounded-full ${map[llm]||'bg-blue-900/40 text-blue-400'}">${llm}</span>`;
}
// -- Page Loaders --------------------------------------------------------------
async function loadLibraryHealth() {
try {
const d = await api('/admin/library-health');
const el = document.getElementById('library-health-content');
const btnReingest = document.getElementById('btn-reingest');
// Show/hide reingest button
if (d.missing_chunks > 0) btnReingest.classList.remove('hidden');
else btnReingest.classList.add('hidden');
// Health summary pills
const health = d.missing_chunks === 0 ? 'text-emerald-400' : 'text-red-400';
const healthIcon = d.missing_chunks === 0 ? 'check_circle' : 'warning';
let html = `<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
<div class="bg-surface-high rounded-xl p-3 text-center">
<div class="text-xl font-bold text-primary">${d.total_books}</div>
<div class="text-xs text-on-surface-variant mt-1">Total Books</div>
</div>
<div class="bg-surface-high rounded-xl p-3 text-center">
<div class="text-xl font-bold text-emerald-400">${d.indexed_books}</div>
<div class="text-xs text-on-surface-variant mt-1">Indexed</div>
</div>
<div class="bg-surface-high rounded-xl p-3 text-center">
<div class="text-xl font-bold ${health}">${d.missing_chunks}</div>
<div class="text-xs text-on-surface-variant mt-1">Missing Chunks</div>
</div>
<div class="bg-surface-high rounded-xl p-3 text-center">
<div class="text-xl font-bold text-blue-400">${(d.vector_count||d.total_chunks||0).toLocaleString()}</div>
<div class="text-xs text-on-surface-variant mt-1">Vectors</div>
</div>
</div>`;
// Books with no chunks
if (d.missing_books?.length > 0) {
html += `<div class="mb-4">
<div class="text-xs font-bold text-red-400 uppercase tracking-widest mb-2 flex items-center gap-1">
<span class="msym text-sm">warning</span> Not Indexed (${d.missing_books.length})
</div>
<div class="space-y-1 max-h-60 overflow-y-auto">
${d.missing_books.map(b => `
<div class="bg-red-900/20 border border-red-900/30 rounded-lg px-3 py-2 text-xs flex items-center justify-between gap-2">
<span class="text-on-surface-variant truncate">${b.title || b.filename}</span>
<button onclick="ingestBook(${b.id}, '${b.filename.replace(/'/g,"\'")}', this)"
class="shrink-0 glass glass-hover px-2 py-1 rounded-lg text-xs font-bold text-emerald-400 flex items-center gap-1">
<span class="msym text-sm">sync</span> Ingest Now
</button>
</div>`).join('')}
</div>
</div>`;
}
// Failed files
if (d.failed_files?.length > 0) {
html += `<div class="mb-4">
<div class="text-xs font-bold text-orange-400 uppercase tracking-widest mb-2 flex items-center gap-1">
<span class="msym text-sm">error</span> Failed Files (${d.failed_files.length})
</div>
<div class="space-y-1 max-h-40 overflow-y-auto">
${d.failed_files.map(f => `<div class="bg-orange-900/20 border border-orange-900/30 rounded-lg px-3 py-2 text-xs">
<span class="text-on-surface-variant">${f.filename}</span>
<span class="text-red-400 ml-2">${f.error || 'Unknown error'}</span>
</div>`).join('')}
</div>
</div>`;
}
// Pending jobs
if (d.pending_jobs?.length > 0) {
html += `<div>
<div class="text-xs font-bold text-yellow-400 uppercase tracking-widest mb-2 flex items-center gap-1">
<span class="msym text-sm">pending</span> Active Jobs (${d.pending_jobs.length})
</div>
<div class="space-y-1">
${d.pending_jobs.map(j => `<div class="bg-yellow-900/20 border border-yellow-900/30 rounded-lg px-3 py-2 text-xs flex justify-between">
<span class="text-on-surface-variant">${j.status} -- ${j.done}/${j.total} done</span>
<span class="text-red-400">${j.errors > 0 ? j.errors + ' errors' : ''}</span>
</div>`).join('')}
</div>
</div>`;
}
if (d.missing_chunks === 0 && d.failed_files?.length === 0 && d.pending_jobs?.length === 0) {
html += `<div class="flex items-center gap-2 text-emerald-400 text-sm">
<span class="msym">check_circle</span> All ${d.total_books} books are fully indexed
</div>`;
}
el.innerHTML = html;
} catch(e) {
document.getElementById('library-health-content').innerHTML = `<div class="text-red-400 text-sm">Error loading health: ${e.message}</div>`;
}
}
async function rebuildBM25() {
const btn = document.getElementById('btn-bm25');
btn.disabled = true; btn.innerHTML = '<span class="msym text-sm animate-spin">refresh</span> Rebuilding...';
try {
const r = await api('/admin/rebuild-bm25', {method:'POST'});
toast(`BM25 rebuilt -- ${r.chunks_indexed} chunks indexed`, 'success');
} catch(e) { toast('BM25 rebuild failed: ' + e.message, 'error'); }
btn.disabled = false; btn.innerHTML = '<span class="msym text-sm">refresh</span> Rebuild BM25';
}
async function clearCache() {
const btn = document.getElementById('btn-cache');
btn.disabled = true; btn.innerHTML = '<span class="msym text-sm">delete_sweep</span> Clearing...';
try {
const r = await api('/admin/clear-cache', {method:'POST'});
toast(`Cache cleared -- ${r.cleared} entries removed`, 'success');
loadOverview();
} catch(e) { toast('Cache clear failed: ' + e.message, 'error'); }
btn.disabled = false; btn.innerHTML = '<span class="msym text-sm">delete_sweep</span> Clear Cache';
}
async function ingestBook(bookId, filename, btn) {
btn.disabled = true;
btn.innerHTML = '<span class="msym text-sm animate-spin">sync</span> Ingesting...';
try {
const r = await api(`/admin/ingest-book/${bookId}`, {method:'POST'});
btn.innerHTML = '<span class="msym text-sm">check_circle</span> Done';
btn.classList.replace('text-emerald-400','text-primary');
toast('Ingested: ' + filename.substring(0,40));
setTimeout(loadLibraryHealth, 2000);
} catch(e) {
btn.innerHTML = '<span class="msym text-sm">error</span> Failed';
btn.classList.replace('text-emerald-400','text-red-400');
toast('Error: ' + e.message);
}
}
async function reingestMissing() {
const btn = document.getElementById('btn-reingest');
btn.disabled = true; btn.innerHTML = '<span class="msym text-sm">sync</span> Queuing...';
try {
const r = await api('/admin/reingest-missing', {method:'POST'});
toast(`${r.queued} books queued for re-ingestion`, 'success');
setTimeout(loadLibraryHealth, 2000);
} catch(e) { toast('Re-ingest failed: ' + e.message, 'error'); }
btn.disabled = false; btn.innerHTML = '<span class="msym text-sm">sync</span> Re-ingest Missing';
}
async function loadOverview() {
loadLibraryHealth();
try {
const d = await api('/admin/overview');
document.getElementById('stats-grid').innerHTML = [
['menu_book','Books',d.books,'ingested',''],
['hub','Vectors',(d.vectors||0).toLocaleString(),'embeddings',''],
['chat','Queries',d.conversations,'total','text-secondary'],
['group','Users',d.unique_users,'sessions',''],
['bolt','Cache',d.cache_keys,d.cache_memory,'text-yellow-400'],
['memory','Memories',d.memories,'stored',''],
].map(([icon,label,val,sub,cls])=>`
<div class="glass glow-sm rounded-2xl p-5 glass-hover transition-all">
<div class="flex items-center justify-between mb-3"><span class="text-on-surface-variant text-xs uppercase tracking-widest font-semibold">${label}</span><span class="msym text-primary text-lg">${icon}</span></div>
<div class="text-3xl font-bold ${cls||'text-primary'} mb-1">${val}</div>
<div class="text-on-surface-variant text-xs">${sub}</div>
</div>`).join('');
const daily = d.daily_queries||[], maxQ = Math.max(...daily.map(r=>+r.cnt),1);
document.getElementById('daily-chart').innerHTML = daily.length ? daily.map(r=>bar(r.day,r.cnt,maxQ)).join('') : '<p class="text-on-surface-variant text-sm">No data yet</p>';
const llm = d.llm_usage||[], maxL = Math.max(...llm.map(r=>+r.cnt),1);
const lc = {cache:'#34d399',claude:'#a78bfa'};
document.getElementById('llm-chart').innerHTML = llm.length ? llm.map(r=>bar(r.llm_used,r.cnt,maxL,lc[r.llm_used]||'#7c83fd')).join('') : '<p class="text-on-surface-variant text-sm">No data yet</p>';
const recent = d.recent_queries||[];
document.getElementById('recent-list').innerHTML = recent.length
? `<table class="w-full text-sm"><thead><tr class="border-b border-outline-variant/30"><th class="text-left py-2 text-xs text-on-surface-variant font-semibold">Query</th><th class="text-left py-2 text-xs text-on-surface-variant font-semibold">LLM</th><th class="text-left py-2 text-xs text-on-surface-variant font-semibold hidden lg:table-cell">Time</th></tr></thead><tbody>${recent.map(r=>`<tr class="border-b border-outline-variant/20 hover:bg-primary/5"><td class="py-3 max-w-xs truncate pr-4">${r.user_query}</td><td class="py-3">${badge(r.llm_used)}</td><td class="py-3 text-xs text-on-surface-variant hidden lg:table-cell">${new Date(r.created_at).toLocaleString()}</td></tr>`).join('')}</tbody></table>`
: '<p class="text-on-surface-variant text-sm">No conversations yet</p>';
} catch(e) { toast('Overview: '+e.message); console.error(e); }
}
async function loadBooks() {
document.getElementById('books-grid').innerHTML = Array.from({length:3}).map(()=>`
<div class="rounded-2xl p-5 border border-outline-variant/20 flex gap-4">
<div class="skeleton w-10 h-10 rounded-xl shrink-0"></div>
<div class="flex-1 space-y-2"><div class="skeleton h-4 rounded w-3/4"></div><div class="skeleton h-3 rounded w-2/5 mt-3"></div></div>
</div>`).join('');
try {
const d = await api('/admin/books');
document.getElementById('books-grid').innerHTML = d.books.length
? d.books.map(b=>`
<div class="glass glass-hover rounded-2xl p-5 flex items-center gap-4 transition-all" id="book-row-${b.id}">
<div class="w-10 h-10 bg-primary/10 rounded-xl flex items-center justify-center shrink-0 glow-sm"><span class="msym text-primary">menu_book</span></div>
<div class="flex-1 min-w-0">
<p class="font-semibold text-sm truncate">${b.title || b.filename}${!b.author ? ' <span class="text-yellow-400 text-[10px] font-bold ml-1">⚠ بدون مؤلف</span>' : ''}</p>
<p class="text-on-surface-variant text-xs mt-0.5 truncate">${b.author || '<span class="text-yellow-400/70">اسم المؤلف غير محدد</span>'}</p>
<p class="text-on-surface-variant text-xs mt-0.5">${b.total_chunks} chunks · ${b.category || ''} · ${new Date(b.uploaded_at).toLocaleDateString()}</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="glass px-3 py-1 rounded-full text-xs text-primary font-bold">${b.total_chunks}</span>
<button onclick="openBookEdit(this)"
data-id="${b.id}"
data-title="${(b.title||'').replace(/"/g,'&quot;')}"
data-author="${(b.author||'').replace(/"/g,'&quot;')}"
data-category="${(b.category||'').replace(/"/g,'&quot;')}"
data-description="${(b.description||'').replace(/"/g,'&quot;')}"
class="glass glass-hover px-3 py-1.5 rounded-xl text-xs text-on-surface-variant flex items-center gap-1" title="تعديل"><span class="msym text-sm">edit</span></button>
<button onclick="deleteBook(${b.id},'${b.filename.replace(/'/g,"\\'")}',this)" class="glass border border-red-900/40 text-red-400 px-3 py-1.5 rounded-xl text-xs hover:bg-red-900/20 transition-colors flex items-center gap-1"><span class="msym text-sm">delete</span></button>
</div>
</div>`).join('')
: '<p class="text-on-surface-variant">No books yet</p>';
} catch(e) { showErr('books-grid', e.message); console.error(e); }
}
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:{'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>'; }
} catch(e) { alert('خطأ: '+e.message); btn.disabled=false; btn.innerHTML='<span class="msym text-sm">delete</span>'; }
}
async function loadUsers() {
try {
const d = await api('/admin/conversations?limit=100');
const bySession = {};
(d.conversations||[]).forEach(c => {
if (!bySession[c.session_id]) bySession[c.session_id] = {id:c.session_id, count:0, last:c.created_at, llms:{}};
bySession[c.session_id].count++;
bySession[c.session_id].llms[c.llm_used] = (bySession[c.session_id].llms[c.llm_used]||0)+1;
});
const sessions = Object.values(bySession).sort((a,b)=>b.count-a.count);
document.getElementById('users-list').innerHTML = sessions.length
? sessions.map(s=>`
<div class="glass rounded-2xl p-4 flex items-center gap-4">
<div class="w-10 h-10 bg-primary/10 rounded-xl flex items-center justify-center shrink-0">
<span class="msym text-primary">forum</span>
</div>
<div class="flex-1 min-w-0">
<p class="font-semibold text-sm truncate">${s.id}</p>
<p class="text-xs text-on-surface-variant mt-0.5">Last: ${new Date(s.last).toLocaleString()}</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="glass px-3 py-1 rounded-full text-xs text-primary font-bold">${s.count} queries</span>
${Object.entries(s.llms).map(([l,c])=>`<span class="glass px-2 py-0.5 rounded-full text-[10px] ${l==='claude'?'text-purple-400':'text-emerald-400'}">${l}</span>`).join('')}
</div>
</div>`).join('')
: '<p class="text-on-surface-variant text-sm">No sessions yet</p>';
} catch(e) { showErr('users-list', e.message); console.error(e); }
}
async function loadConversations() {
try {
const d = await api('/admin/conversations?limit=50');
document.getElementById('convos-list').innerHTML = d.conversations.length
? d.conversations.map((c,i)=>`
<div class="glass rounded-2xl overflow-hidden">
<div class="p-5 cursor-pointer select-none" onclick="toggleConvo(${i})">
<div class="flex items-start justify-between gap-3 mb-3">
<p class="text-sm font-semibold">${c.user_query}</p>
<div class="flex items-center gap-2 shrink-0">${badge(c.llm_used)}<span class="msym text-on-surface-variant text-lg" id="chevron-${i}">expand_more</span></div>
</div>
<div class="flex flex-wrap gap-3 text-xs text-on-surface-variant">
<span class="flex items-center gap-1"><span class="msym text-xs">person</span>${c.session_id}</span>
<span class="flex items-center gap-1"><span class="msym text-xs">schedule</span>${new Date(c.created_at).toLocaleString()}</span>
</div>
</div>
<div id="convo-body-${i}" class="hidden border-t border-outline-variant/30 p-5 bg-surface-container/40">
<p class="text-xs text-primary uppercase tracking-widest font-bold mb-3">Answer</p>
<p class="text-sm text-on-surface leading-7 whitespace-pre-wrap">${c.assistant_response||'No response'}</p>
</div>
</div>`).join('')
: '<p class="text-on-surface-variant">No conversations yet</p>';
} catch(e) { showErr('convos-list', e.message); console.error(e); }
}
function toggleConvo(i) {
const body = document.getElementById('convo-body-'+i), ch = document.getElementById('chevron-'+i);
const hidden = body.classList.toggle('hidden');
ch.textContent = hidden ? 'expand_more' : 'expand_less';
}
async function loadResources() {
try {
const d = await api('/admin/overview');
const s = d.system||{};
const diskPct = parseInt(s.disk_pct)||0;
const ramTotal = parseFloat(s.ram_total)||31, ramUsed = parseFloat(s.ram_used)||0;
const ramPct = Math.round(ramUsed/ramTotal*100);
document.getElementById('res-storage').innerHTML = resource('Disk',s.disk_used||'?',s.disk_total||'?',diskPct) + resource('RAM',s.ram_used||'?',s.ram_total||'?',ramPct) + resource('Cache',d.cache_memory||'0B','Redis',20);
// Load live service statuses
const svcData = await api('/admin/services');
const SERVICE_META = {
'Qdrant': {icon:'hub', desc:'Vector DB'},
'PostgreSQL': {icon:'storage', desc:'Metadata & Memory'},
'Redis': {icon:'bolt', desc:'Cache Layer'},
'Ollama': {icon:'smart_toy', desc:'Local LLM'},
'Mem0': {icon:'memory', desc:'Smart Memory'},
'Typesense': {icon:'search', desc:'Full-Text Search'},
'MinIO': {icon:'folder', desc:'Object Storage'},
'Celery': {icon:'task', desc:'Task Queue'},
'RAG API': {icon:'api', desc:'FastAPI Gateway'},
};
document.getElementById('res-services').innerHTML = svcData.services.map(s => {
const m = SERVICE_META[s.name] || {icon:'circle', desc:''};
const ok = s.status === 'running';
const disabled = s.status === 'disabled';
const cls = ok ? 'text-emerald-400' : disabled ? 'text-on-surface-variant/50' : 'text-red-400';
const dot = ok ? 'bg-emerald-400' : disabled ? 'bg-on-surface-variant/30' : 'bg-red-400';
const label = ok ? 'Running' : disabled ? 'Disabled' : 'Error';
return `<div class="flex items-center justify-between py-3 border-b border-outline-variant/20 last:border-0 ${disabled ? 'opacity-50' : ''}">
<div class="flex items-center gap-3">
<span class="msym text-primary/60 text-base">${m.icon}</span>
<div><p class="text-sm font-semibold">${s.name}</p>
<p class="text-xs text-on-surface-variant">${s.detail || m.desc}</p></div>
</div>
<span class="flex items-center gap-1.5 text-xs font-bold ${cls}">
<span class="w-2 h-2 rounded-full ${dot} inline-block"></span>
${label}
</span>
</div>`;
}).join('');
} catch(e) { showErr('res-storage', e.message); console.error(e); }
}
async function loadSettings() {
loadLLMConfig();
try {
const d = await api('/admin/settings');
document.getElementById('settings-ai').innerHTML = `
<h3 class="text-primary text-xs font-bold uppercase tracking-widest mb-5 flex items-center gap-2"><span class="msym text-base">smart_toy</span>AI Configuration</h3>
${[['Local LLM',d.ollama_model,''],['Claude API',d.claude_api_configured?'✓ Configured':'✗ Not set',d.claude_api_configured?'text-emerald-400':'text-red-400'],['Confidence Threshold',d.confidence_threshold,''],['Cache TTL',d.cache_ttl_hours+'h','']].map(([l,v,cls])=>`
<div class="flex justify-between items-center py-3 border-b border-outline-variant/20 last:border-0">
<span class="text-sm text-on-surface-variant">${l}</span><span class="text-xs font-mono glass px-2 py-1 rounded-lg ${cls||'text-primary'}">${v}</span>
</div>`).join('')}`;
document.getElementById('settings-system').innerHTML = `
<h3 class="text-primary text-xs font-bold uppercase tracking-widest mb-5 flex items-center gap-2"><span class="msym text-base">build</span>Ingestion Settings</h3>
${[['Chunk Size',d.chunk_size+' words'],['Chunk Overlap',d.chunk_overlap+' sentences'],['Qdrant URL',d.qdrant_url],['Ollama URL',d.ollama_url]].map(([l,v])=>`
<div class="flex justify-between items-center py-3 border-b border-outline-variant/20 last:border-0">
<span class="text-sm text-on-surface-variant">${l}</span><span class="text-xs font-mono glass px-2 py-1 rounded-lg text-primary truncate max-w-[140px]">${v}</span>
</div>`).join('')}`;
// -- R2 / Storage section --------------------------------------------------
document.getElementById('settings-r2').innerHTML = `
<h3 class="text-primary text-xs font-bold uppercase tracking-widest mb-5 flex items-center gap-2">
<span class="msym text-base">cloud</span>Cloudflare R2 Storage
<span class="ml-auto text-[10px] font-normal glass px-2 py-0.5 rounded-full ${d.r2_configured ? 'text-emerald-400' : 'text-yellow-400'}">
${d.r2_configured ? '✓ Connected' : '✗ Not configured'}
</span>
</h3>
<div class="space-y-3 mb-5">
<div>
<label class="text-xs text-on-surface-variant block mb-1.5">Endpoint URL</label>
<input id="r2-endpoint" type="url" dir="ltr" value="${d.r2_endpoint||''}"
placeholder="https://<account-id>.r2.cloudflarestorage.com"
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 class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-on-surface-variant block mb-1.5">
Access Key ID
<span id="r2-access-status" class="ml-2 text-emerald-400 text-[10px]"></span>
</label>
<input id="r2-access" type="text" dir="ltr"
placeholder="Leave blank to keep existing key"
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">
Secret Key
<span id="r2-secret-status" class="ml-2 text-emerald-400 text-[10px]"></span>
</label>
<input id="r2-secret" type="password" dir="ltr"
placeholder="Leave blank to keep existing key"
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>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-on-surface-variant block mb-1.5">Bucket Name</label>
<input id="r2-bucket" type="text" dir="ltr" value="${d.r2_bucket||''}"
placeholder="rag-media"
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">Public CDN URL</label>
<input id="r2-public" type="url" dir="ltr" value="${d.r2_public_url||''}"
placeholder="https://cdn.afaqzaid.com"
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>
</div>
<div class="flex gap-3">
<button onclick="saveR2Config()"
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>حفظ إعدادات R2
</button>
<button onclick="testR2Connection()"
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>اختبار
</button>
</div>
<p id="r2-status" class="text-xs text-center mt-3 hidden"></p>
<div class="mt-5 pt-4 border-t border-outline-variant/20">
<p class="text-xs text-on-surface-variant mb-3 font-semibold uppercase tracking-widest">Service Endpoints</p>
${[['Typesense',d.typesense_host ? 'http://'+d.typesense_host+':8108' : '--'],
['MinIO',d.minio_endpoint||'--'],
].map(([l,v])=>
'<div class="flex justify-between items-center py-2"><span class="text-xs text-on-surface-variant">'+l+'</span><span class="text-xs font-mono glass px-2 py-1 rounded-lg text-primary">'+v+'</span></div>'
).join('')}
</div>`;
// Update key status badges after render
setTimeout(() => _updateR2KeyStatus(d.r2_configured), 50);
} catch(e) { showErr('settings-ai', e.message); console.error(e); }
}
async function saveR2Config() {
const status = document.getElementById('r2-status');
const raw = {
R2_ENDPOINT: document.getElementById('r2-endpoint').value.trim(),
R2_ACCESS_KEY: document.getElementById('r2-access').value.trim(),
R2_SECRET_KEY: document.getElementById('r2-secret').value.trim(),
R2_BUCKET: document.getElementById('r2-bucket').value.trim(),
R2_PUBLIC_URL: document.getElementById('r2-public').value.trim(),
};
if (!raw.R2_ENDPOINT) { toast('Endpoint URL مطلوب'); return; }
// Only send non-empty values -- never overwrite saved keys with blank
const payload = Object.fromEntries(Object.entries(raw).filter(([,v]) => v !== ''));
status.textContent = 'جاري الحفظ...'; status.classList.remove('hidden');
try {
await api('/admin/r2-config', {method:'POST', body:JSON.stringify(payload)});
status.textContent = '✅ تم الحفظ بشكل دائم';
status.style.color = '#4edea3';
setTimeout(loadSettings, 1500);
} catch(e) { status.textContent = '❌ '+e.message; status.style.color = '#f87171'; }
}
function _updateR2KeyStatus(configured) {
// Show ✓ configured badges next to key fields if keys are stored
const aStatus = document.getElementById('r2-access-status');
const sStatus = document.getElementById('r2-secret-status');
if (aStatus) aStatus.textContent = configured ? '✓ محفوظ' : '';
if (sStatus) sStatus.textContent = configured ? '✓ محفوظ' : '';
}
async function testR2Connection() {
const status = document.getElementById('r2-status');
status.textContent = 'جاري الاختبار...'; status.classList.remove('hidden');
try {
// Test-only -- never saves, never overwrites stored credentials
const r = await api('/admin/r2-test');
if (r.ok) {
status.textContent = '✅ R2 متصل -- ' + (r.detail || '');
status.style.color = '#4edea3';
} else {
status.textContent = '❌ ' + (r.detail || 'فشل الاتصال');
status.style.color = '#f87171';
}
status.classList.remove('hidden');
} catch(e) { status.textContent = '❌ '+e.message; status.style.color = '#f87171'; }
}
function updateLogoPreview() {
const url = document.getElementById('b-logo')?.value;
const size = document.getElementById('b-logo-size')?.value || 40;
document.getElementById('b-logo-size-val').textContent = size + 'px';
const img = document.getElementById('b-logo-preview');
if (url) { img.src = url; img.style.height = size + 'px'; img.classList.remove('hidden'); }
else img.classList.add('hidden');
}
async function uploadLogo(input) {
const file = input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (e) => {
try {
const base64 = e.target.result.split(',')[1];
const mime = file.type;
const r = await fetch('/admin/upload-logo', {
method: 'POST',
headers: {'Content-Type':'application/json','Authorization':'Bearer '+JWT_TOKEN},
body: JSON.stringify({data: base64, mime_type: mime, filename: file.name})
});
const d = await r.json();
if (d.url) {
document.getElementById('b-logo').value = d.url;
updateLogoPreview();
toast('Logo uploaded!', 'success');
}
} catch(e) { toast('Upload failed: '+e.message); }
};
reader.readAsDataURL(file);
}
async function loadBranding() {
try {
const d = await api('/admin/branding');
document.getElementById('b-site-name').value = d.site_name||'';
document.getElementById('b-subtitle').value = d.site_subtitle||'';
document.getElementById('b-logo').value = d.logo_url||'';
document.getElementById('b-primary').value = d.primary_color||'#4edea3';
document.getElementById('b-primary-hex').value = d.primary_color||'#4edea3';
document.getElementById('b-secondary').value = d.secondary_color||'#95d3ba';
document.getElementById('b-secondary-hex').value = d.secondary_color||'#95d3ba';
document.getElementById('preview-primary').style.background = d.primary_color||'#4edea3';
document.getElementById('preview-secondary').style.background = d.secondary_color||'#95d3ba';
applyBrandingColors(d.primary_color, d.secondary_color);
// Logo
if(d.logo_url) document.getElementById('b-logo').value = d.logo_url;
if(d.logo_size) document.getElementById('b-logo-size').value = d.logo_size;
if(d.logo_url) { const img=document.getElementById('b-logo-preview'); img.src=d.logo_url; img.style.height=(d.logo_size||40)+'px'; img.classList.remove('hidden'); }
if (d.logo_url) { document.getElementById('logo-img').src=d.logo_url; document.getElementById('logo-preview').classList.remove('hidden'); }
document.getElementById('b-primary').oninput = function(){ document.getElementById('b-primary-hex').value=this.value; document.getElementById('preview-primary').style.background=this.value; };
document.getElementById('b-secondary').oninput = function(){ document.getElementById('b-secondary-hex').value=this.value; document.getElementById('preview-secondary').style.background=this.value; };
document.getElementById('b-primary-hex').oninput = function(){ if(/^#[0-9A-Fa-f]{6}$/.test(this.value)){ document.getElementById('b-primary').value=this.value; document.getElementById('preview-primary').style.background=this.value; } };
document.getElementById('b-secondary-hex').oninput = function(){ if(/^#[0-9A-Fa-f]{6}$/.test(this.value)){ document.getElementById('b-secondary').value=this.value; document.getElementById('preview-secondary').style.background=this.value; } };
document.getElementById('b-logo').oninput = function(){ if(this.value){ document.getElementById('logo-img').src=this.value; document.getElementById('logo-preview').classList.remove('hidden'); } };
} catch(e) { toast('Branding: '+e.message); console.error(e); }
}
async function saveBranding() {
try {
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, logo_size:document.getElementById('b-logo-size')?.value||'40', primary_color:document.getElementById('b-primary-hex').value, secondary_color:document.getElementById('b-secondary-hex').value})});
if (r.ok) {
toast('Branding saved!','success');
const p=document.getElementById('b-primary-hex')?.value;
const s=document.getElementById('b-secondary-hex')?.value;
if(p||s) applyBrandingColors(p,s);
} else { alert('Save failed'); }
} catch(e) { alert('Error: '+e.message); }
}
async function loadMembers() {
document.getElementById('members-list').innerHTML = Array.from({length:2}).map(()=>`
<div class="rounded-2xl p-4 border border-outline-variant/20 flex gap-4">
<div class="skeleton w-10 h-10 rounded-xl shrink-0"></div>
<div class="flex-1 space-y-2"><div class="skeleton h-4 rounded w-1/2"></div><div class="skeleton h-3 rounded w-1/3 mt-3"></div></div>
</div>`).join('');
try {
const [mRes, rRes] = await Promise.all([api('/admin/users'), api('/admin/roles')]);
const perm_labels = {query:'Query',upload:'Upload',manage_users:'Manage Users',admin:'Admin'};
document.getElementById('roles-grid').innerHTML = rRes.roles.map(role=>`
<div class="glass rounded-xl p-4">
<div class="flex items-center justify-between mb-3"><span class="font-bold text-sm">${role.display_name||role.name}</span><span class="text-xs glass px-2 py-1 rounded-full text-on-surface-variant">${role.name}</span></div>
<div class="space-y-1">${(Array.isArray(role.permissions)?role.permissions:[]).map(p=>`<div class="flex items-center gap-2 text-xs py-1 border-b border-outline-variant/20"><span class="msym text-emerald-400 text-sm">check_circle</span><span class="text-on-surface-variant">${perm_labels[p]||p}</span></div>`).join('')}</div>
</div>`).join('');
document.getElementById('members-list').innerHTML = mRes.users.length
? mRes.users.map(u=>`
<div class="glass rounded-2xl p-4 flex items-center gap-4 transition-all">
<div class="w-10 h-10 bg-primary/10 rounded-xl flex items-center justify-center shrink-0"><span class="msym text-primary">person</span></div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1"><p class="font-semibold text-sm">${u.name}</p>${!u.is_active?'<span class="text-xs bg-red-900/40 text-red-400 px-2 py-0.5 rounded-full">Inactive</span>':''}</div>
<p class="text-xs text-on-surface-variant">${u.email}</p>
${u.phone?`<p class="text-xs text-on-surface-variant">${u.phone} ${u.country_name?'· '+u.country_name:''}</p>`:''}
<p class="text-xs text-on-surface-variant mt-1">Joined ${new Date(u.created_at).toLocaleDateString()}</p>
</div>
<div class="flex flex-col items-end gap-2 shrink-0">
<select onchange="setRole(${u.id},this.value)" class="bg-surface-high border border-outline-variant rounded-lg px-2 py-1 text-xs text-on-surface outline-none focus:border-primary">
${rRes.roles.map(r=>`<option value="${r.name}" ${r.name===u.role?'selected':''}>${r.display_name||r.name}</option>`).join('')}
</select>
<div class="flex gap-2">
<button onclick="setUserStatus(${u.id},this)" class="glass border border-outline-variant text-xs px-3 py-1 rounded-lg hover:border-primary transition-colors">${u.is_active?'Disable':'Enable'}</button>
<button onclick="deleteUser(${u.id})" class="glass border border-red-900/50 text-red-400 text-xs px-3 py-1 rounded-lg hover:bg-red-900/20 transition-colors"><span class="msym text-sm">delete</span></button>
</div>
</div>
</div>`).join('')
: '<p class="text-on-surface-variant text-sm">No members yet</p>';
} catch(e) { showErr('members-list', e.message); console.error(e); }
}
async function setRole(userId, roleName) {
try { await api(`/admin/users/${userId}/role`, {method:'PATCH', body:JSON.stringify({role:roleName})}); toast('Role updated','success'); }
catch(e) { toast('setRole: '+e.message); }
}
async function setUserStatus(userId, btn) {
try {
const d = await api(`/admin/users/${userId}/status`, {method:'PATCH'});
btn.textContent = d.is_active ? 'Disable' : 'Enable';
loadMembers();
} catch(e) { toast('Status: '+e.message); }
}
async function deleteUser(userId) {
if (!confirm('Delete this user permanently?')) return;
try { await api(`/admin/users/${userId}`, {method:'DELETE'}); toast('User deleted','success'); loadMembers(); }
catch(e) { toast('Delete: '+e.message); }
}
async function loadClaude() {
try {
const d = await api('/admin/claude');
document.getElementById('claude-status').innerHTML = d.key_configured
? `<span class="w-2 h-2 rounded-full bg-emerald-400 inline-block"></span> <span class="text-emerald-400 font-semibold">Configured</span> <span class="text-on-surface-variant">(${d.key_masked})</span>`
: '<span class="w-2 h-2 rounded-full bg-red-400 inline-block"></span> <span class="text-red-400">Not configured</span>';
const t = d.totals||{};
document.getElementById('claude-stats').innerHTML = [
['neurology','Total Calls',t.calls||0,''],['token','Input Tokens',(t.ti||0).toLocaleString(),''],
['output','Output Tokens',(t.to_||0).toLocaleString(),''],['payments','Total Cost','$'+(parseFloat(t.tc||0).toFixed(4)),'text-yellow-400'],
].map(([icon,label,val,cls])=>`
<div class="glass rounded-2xl p-4 glow-sm"><div class="flex items-center justify-between mb-2"><span class="text-xs text-on-surface-variant uppercase tracking-widest">${label}</span><span class="msym text-primary text-lg">${icon}</span></div><div class="text-2xl font-bold ${cls||'text-primary'}">${val}</div></div>`).join('');
const daily = d.daily||[], maxC = Math.max(...daily.map(r=>+r.calls),1);
document.getElementById('claude-daily-chart').innerHTML = daily.length
? daily.map(r=>`<div class="flex items-center gap-3 mb-3"><div class="w-24 text-right text-xs text-on-surface-variant shrink-0">${r.day}</div><div class="flex-1 bg-surface-high rounded-full h-2 overflow-hidden"><div style="width:${Math.round(r.calls/maxC*100)}%;background:#a78bfa" class="h-2 rounded-full"></div></div><div class="text-xs text-on-surface-variant w-12">${r.calls} calls</div><div class="text-xs text-yellow-400 w-16">$${parseFloat(r.total_cost_usd||0).toFixed(4)}</div></div>`).join('')
: '<p class="text-on-surface-variant text-sm">No Claude calls yet</p>';
const u = await api('/admin/claude/usage');
document.getElementById('claude-usage-list').innerHTML = u.usage?.length
? u.usage.map(r=>`<div class="glass rounded-xl p-3 flex items-start justify-between gap-3"><div class="flex-1 min-w-0"><p class="text-sm truncate">${r.query}</p><p class="text-xs text-on-surface-variant mt-1">${new Date(r.created_at).toLocaleString()} · ${r.session_id}</p></div><div class="text-right shrink-0 text-xs"><p class="text-on-surface-variant">${r.input_tokens}↑ ${r.output_tokens}↓</p><p class="text-yellow-400">$${parseFloat(r.cost_usd||0).toFixed(5)}</p></div></div>`).join('')
: '<p class="text-on-surface-variant text-sm">No usage yet</p>';
} catch(e) { toast('Claude: '+e.message); console.error(e); }
}
function toggleApiKey() {
const el = document.getElementById('api-key-display'), btn = document.getElementById('key-toggle-btn');
keyVisible = !keyVisible;
el.textContent = keyVisible ? RAG_KEY : '**********************';
btn.textContent = keyVisible ? 'Hide' : 'Show';
}
function toggleClaudeKey() {
const inp = document.getElementById('claude-key-input'), btn = document.getElementById('claude-key-toggle');
inp.type = inp.type==='password' ? 'text' : 'password';
btn.textContent = inp.type==='password' ? 'Show' : 'Hide';
}
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','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); }
}
// -- Auto-login on page load ---------------------------------------------------
if (JWT_TOKEN) {
document.getElementById('login').style.display = 'none';
fetch('/auth/me', {headers:{'Authorization':'Bearer '+JWT_TOKEN}})
.then(r => r.ok ? r.json() : null)
.then(u => {
if (!u || u.role !== 'admin') {
JWT_TOKEN = ''; localStorage.removeItem('admin_jwt');
document.getElementById('login').style.display = 'flex';
} else {
loadOverview();
}
}).catch(() => { loadOverview(); }); // network error -- stay logged in
}
// -- Nav init ------------------------------------------------------------------
document.querySelectorAll('.nav-btn').forEach(b => {
if (!b.classList.contains('active')) b.classList.add('text-on-surface-variant','hover:text-primary','hover:bg-primary/5');
});
document.querySelector('.nav-btn.active')?.classList.add('bg-primary/10','text-primary');
// ══════════════════════════════════════════════════════════════════════════════
// TRACKING -- Google Analytics 4 + DataLayer Events + Pixel Tracking
// ══════════════════════════════════════════════════════════════════════════════
let gaEnabled = false;
let analyticsEvents = [];
let trackingPixels = [];
let pbCurrentPlatform = 'meta';
const PB_META = {
meta: {label:'Meta Pixel ID', hint:'e.g. 1234567890123456', icon:'&#128216;'},
tiktok: {label:'TikTok Pixel ID', hint:'e.g. ABCDE1FG2H3IJ45', icon:'&#127925;'},
snapchat: {label:'Snapchat Pixel ID', hint:'e.g. a1b2c3d4-xxxx', icon:'&#128123;'},
pinterest: {label:'Pinterest Tag ID', hint:'e.g. 123456789', icon:'&#128204;'},
twitter: {label:'X (Twitter) Pixel ID', hint:'e.g. o1234', icon:'&#128038;'},
custom: {label:'Custom Code', hint:'', icon:'&#9889;'},
};
// -- Load all tracking data ----------------------------------------------------
async function loadTracking() {
try {
const [ga, px] = await Promise.all([
api('/admin/analytics'),
api('/admin/analytics/pixels'),
]);
// GA4
gaEnabled = ga.enabled;
analyticsEvents = ga.events || [];
document.getElementById('ga-mid').value = ga.measurement_id || '';
updateGAToggleBtn(gaEnabled);
const statusEl = document.getElementById('ga-status');
if (ga.measurement_id) {
const on = ga.enabled;
statusEl.innerHTML = '<span class="w-2 h-2 rounded-full ' + (on?'bg-emerald-400':'bg-yellow-400') + ' inline-block"></span> <span class="' + (on?'text-emerald-400':'text-yellow-400') + '">' + (on?'Active':'Disabled') + ' -- ' + ga.measurement_id + '</span>';
} else {
statusEl.innerHTML = '<span class="w-2 h-2 rounded-full bg-outline-variant inline-block"></span> Not configured';
}
renderEventsList();
// Pixels
trackingPixels = px.pixels || [];
renderPixelsList();
} catch(e) { toast('Tracking: ' + e.message); console.error(e); }
}
// -- GA4 -----------------------------------------------------------------------
function updateGAToggleBtn(enabled) {
const btn = document.getElementById('ga-toggle-btn');
if (!btn) return;
btn.textContent = enabled ? 'Enabled' : 'Enable';
btn.className = 'px-4 py-2 rounded-xl text-xs font-bold transition-colors ' +
(enabled ? 'bg-emerald-900/40 border border-emerald-400/30 text-emerald-400' : 'glass glass-hover text-on-surface-variant');
}
function toggleGAEnabled() {
gaEnabled = !gaEnabled;
updateGAToggleBtn(gaEnabled);
}
async function saveGAConfig() {
const mid = document.getElementById('ga-mid').value.trim();
if (mid && !mid.startsWith('G-')) { toast('Measurement ID must start with G-'); return; }
try {
await api('/admin/analytics/ga', {method:'POST', body:JSON.stringify({measurement_id:mid, enabled:gaEnabled})});
toast('GA4 settings saved', 'success');
loadTracking();
} catch(e) { toast('Save failed: ' + e.message); }
}
// -- DataLayer Events ----------------------------------------------------------
function renderEventsList() {
const el = document.getElementById('analytics-events-list');
if (!el) return;
if (!analyticsEvents.length) {
el.innerHTML = '<p class="text-on-surface-variant text-sm">No events configured yet.</p>';
return;
}
const icons = {click:'touch_app', pageview:'visibility', scroll_depth:'swipe_down', submit:'send', change:'edit'};
el.innerHTML = analyticsEvents.map((ev, i) => `
<div class="glass rounded-xl p-4 flex items-start gap-3 ${ev.active===false?'opacity-50':''}">
<div class="w-9 h-9 bg-primary/10 rounded-lg flex items-center justify-center shrink-0"><span class="msym text-primary text-lg">${icons[ev.trigger]||'bolt'}</span></div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="font-mono text-sm font-bold text-primary">${ev.event_name}</span>
<span class="glass px-2 py-0.5 rounded-full text-[10px] text-on-surface-variant">${ev.trigger}</span>
<span class="glass px-2 py-0.5 rounded-full text-[10px] text-on-surface-variant">${ev.pages||'all'}</span>
</div>
${ev.selector ? '<p class="text-xs font-mono text-on-surface-variant truncate">' + ev.selector + '</p>' : ''}
</div>
<div class="flex gap-2 shrink-0">
<button onclick="showEventBuilder(${i})" class="glass glass-hover p-1.5 rounded-lg"><span class="msym text-base text-on-surface-variant">edit</span></button>
<button onclick="deleteEvent(${i})" class="glass p-1.5 rounded-lg hover:bg-red-900/20"><span class="msym text-base text-red-400">delete</span></button>
</div>
</div>`).join('');
}
function showEventBuilder(idx) {
const modal = document.getElementById('event-builder');
modal.classList.remove('hidden');
document.getElementById('eb-params').innerHTML = '';
if (idx !== null && analyticsEvents[idx]) {
const ev = analyticsEvents[idx];
document.getElementById('eb-title').textContent = 'Edit Event';
document.getElementById('eb-event-name').value = ev.event_name || '';
document.getElementById('eb-category').value = ev.category || 'engagement';
document.getElementById('eb-trigger').value = ev.trigger || 'click';
document.getElementById('eb-pages').value = ev.pages || 'all';
document.getElementById('eb-selector').value = ev.selector || '';
document.getElementById('eb-edit-idx').value = idx;
const active = ev.active !== false;
const btn = document.getElementById('eb-active-btn');
btn.dataset.active = String(active);
btn.className = 'w-12 h-6 rounded-full relative transition-colors ' + (active ? 'bg-primary' : 'bg-surface-high border border-outline-variant');
(ev.params || []).forEach(p => ebAddParam(p));
} else {
document.getElementById('eb-title').textContent = 'New Event';
document.getElementById('eb-event-name').value = '';
document.getElementById('eb-category').value = 'engagement';
document.getElementById('eb-trigger').value = 'click';
document.getElementById('eb-pages').value = 'all';
document.getElementById('eb-selector').value = '';
document.getElementById('eb-edit-idx').value = '';
const btn = document.getElementById('eb-active-btn');
btn.dataset.active = 'true';
btn.className = 'w-12 h-6 rounded-full relative transition-colors bg-primary';
}
updateEBPreview();
}
function closeEventBuilder() { document.getElementById('event-builder').classList.add('hidden'); }
function ebSetSel(s) { document.getElementById('eb-selector').value = s; updateEBPreview(); }
function ebToggleActive(btn) {
const active = btn.dataset.active === 'true';
btn.dataset.active = String(!active);
btn.className = 'w-12 h-6 rounded-full relative transition-colors ' + (!active ? 'bg-primary' : 'bg-surface-high border border-outline-variant');
}
function ebAddParam(preset) {
const p = preset || {key:'', source:'attribute', attribute:'', value:''};
const row = document.createElement('div');
row.className = 'grid grid-cols-12 gap-2';
row.innerHTML = '<input type="text" placeholder="key" value="' + (p.key||'') + '" oninput="updateEBPreview()" class="col-span-3 bg-surface-high border border-outline-variant rounded-lg px-2 py-1.5 text-xs font-mono outline-none focus:border-primary" dir="ltr"/>' +
'<select onchange="updateEBPreview()" class="col-span-3 bg-surface-high border border-outline-variant rounded-lg px-2 py-1.5 text-xs outline-none focus:border-primary">' +
['attribute','static','text','url_param','page_url','page_path'].map(s => '<option value="' + s + '"' + (s===p.source?' selected':'') + '>' + s + '</option>').join('') + '</select>' +
'<input type="text" placeholder="attr / value" value="' + (p.attribute||p.value||p.param||'') + '" oninput="updateEBPreview()" class="col-span-5 bg-surface-high border border-outline-variant rounded-lg px-2 py-1.5 text-xs font-mono outline-none focus:border-primary" dir="ltr"/>' +
'<button onclick="this.parentElement.remove();updateEBPreview()" class="col-span-1 text-red-400 text-center pt-1"><span class="msym text-base">close</span></button>';
document.getElementById('eb-params').appendChild(row);
updateEBPreview();
}
function ebAddPreset(key, source, attr) { ebAddParam({key, source, attribute: source==='attribute'?attr:'', value: source==='static'?attr:''}); }
function ebGetParams() {
return [...document.getElementById('eb-params').children].map(row => {
const inputs = row.querySelectorAll('input,select');
const key = inputs[0].value.trim(); const src = inputs[1].value; const val = inputs[2].value.trim();
const p = {key, source:src};
if (src==='attribute') p.attribute=val; else if (src==='static') p.value=val; else if (src==='url_param') p.param=val;
return p;
}).filter(p => p.key);
}
function updateEBPreview() {
const name = document.getElementById('eb-event-name').value || 'event_name';
const cat = document.getElementById('eb-category').value || 'engagement';
const preview = {event: name, event_category: cat};
ebGetParams().forEach(p => { preview[p.key] = '<' + p.source + (p.attribute?':'+p.attribute:p.value?':'+p.value:'') + '>'; });
document.getElementById('eb-preview').textContent = 'gtag("event", "' + name + '",\n' + JSON.stringify(preview, null, 2) + ');';
}
async function ebSave() {
const name = document.getElementById('eb-event-name').value.trim();
if (!name) { toast('Event name required'); return; }
const editIdx = document.getElementById('eb-edit-idx').value;
const trigger = document.getElementById('eb-trigger').value;
const ev = {
id: editIdx || String(Date.now()),
event_name: name, category: document.getElementById('eb-category').value.trim() || 'engagement',
trigger, pages: document.getElementById('eb-pages').value,
selector: (trigger==='pageview'||trigger==='scroll_depth') ? '' : document.getElementById('eb-selector').value.trim(),
params: ebGetParams(),
active: document.getElementById('eb-active-btn').dataset.active !== 'false'
};
if (editIdx !== '' && !isNaN(parseInt(editIdx))) analyticsEvents[parseInt(editIdx)] = ev;
else analyticsEvents.push(ev);
try {
await api('/admin/analytics/events', {method:'POST', body:JSON.stringify({events:analyticsEvents})});
toast('Event saved', 'success');
closeEventBuilder();
renderEventsList();
} catch(e) { toast('Save failed: ' + e.message); }
}
async function deleteEvent(idx) {
if (!confirm('Delete this event?')) return;
analyticsEvents.splice(idx, 1);
await api('/admin/analytics/events', {method:'POST', body:JSON.stringify({events:analyticsEvents})});
toast('Event deleted', 'success');
renderEventsList();
}
// -- Pixel Tracking ------------------------------------------------------------
function renderPixelsList() {
const el = document.getElementById('pixels-list');
if (!el) return;
if (!trackingPixels.length) { el.innerHTML = '<p class="text-on-surface-variant text-sm">No pixels configured.</p>'; return; }
el.innerHTML = trackingPixels.map((px, i) => {
const meta = PB_META[px.platform] || PB_META.custom;
const on = px.enabled !== false;
const label = px.platform === 'custom' ? (px.name || 'Custom') : (px.pixel_id || '--');
return '<div class="glass rounded-xl p-4 flex items-center gap-4 ' + (on?'':'opacity-50') + '">' +
'<div class="w-10 h-10 rounded-xl bg-purple-900/30 flex items-center justify-center shrink-0 text-xl">' + meta.icon + '</div>' +
'<div class="flex-1 min-w-0"><div class="flex items-center gap-2 mb-0.5"><span class="text-sm font-bold capitalize">' + px.platform + '</span>' +
'<span class="w-1.5 h-1.5 rounded-full ' + (on?'bg-emerald-400':'bg-yellow-400') + ' inline-block"></span></div>' +
'<p class="text-xs font-mono text-on-surface-variant truncate">' + label + '</p></div>' +
'<div class="flex items-center gap-2 shrink-0">' +
'<button onclick="pbToggleIdx(' + i + ')" class="glass px-3 py-1 rounded-lg text-xs ' + (on?'text-emerald-400':'text-on-surface-variant') + '">' + (on?'On':'Off') + '</button>' +
'<button onclick="showPixelBuilder(' + i + ')" class="glass glass-hover p-1.5 rounded-lg"><span class="msym text-base text-on-surface-variant">edit</span></button>' +
'<button onclick="pbDelete(' + i + ')" class="glass p-1.5 rounded-lg hover:bg-red-900/20"><span class="msym text-base text-red-400">delete</span></button>' +
'</div></div>';
}).join('');
}
function showPixelBuilder(idx) {
document.getElementById('pixel-builder').classList.remove('hidden');
document.getElementById('pb-pixel-id').value = '';
document.getElementById('pb-code').value = '';
document.getElementById('pb-name').value = '';
document.getElementById('pb-edit-idx').value = idx !== null ? String(idx) : '';
if (idx !== null && trackingPixels[idx]) {
const px = trackingPixels[idx];
document.getElementById('pb-title').textContent = 'Edit Pixel';
pbSelectPlatform(px.platform || 'meta');
if (px.platform !== 'custom') document.getElementById('pb-pixel-id').value = px.pixel_id || '';
else { document.getElementById('pb-code').value = px.code || ''; document.getElementById('pb-name').value = px.name || ''; }
const active = px.enabled !== false;
const btn = document.getElementById('pb-active-btn');
btn.dataset.active = String(active);
btn.className = 'w-12 h-6 rounded-full relative transition-colors ' + (active?'bg-primary':'bg-surface-high border border-outline-variant');
} else {
document.getElementById('pb-title').textContent = 'Add Pixel';
pbSelectPlatform('meta');
const btn = document.getElementById('pb-active-btn');
btn.dataset.active = 'true';
btn.className = 'w-12 h-6 rounded-full relative transition-colors bg-primary';
}
}
function pbClose() { document.getElementById('pixel-builder').classList.add('hidden'); }
function pbSelectPlatform(plat) {
pbCurrentPlatform = plat;
document.querySelectorAll('.pb-plat').forEach(btn => {
const active = btn.dataset.plat === plat;
btn.style.borderColor = active ? 'rgba(168,85,247,0.6)' : '';
btn.style.background = active ? 'rgba(88,28,135,0.4)' : '';
});
const isCustom = plat === 'custom';
document.getElementById('pb-id-wrap').classList.toggle('hidden', isCustom);
document.getElementById('pb-code-wrap').classList.toggle('hidden', !isCustom);
if (!isCustom && PB_META[plat]) {
document.getElementById('pb-id-label').textContent = PB_META[plat].label;
document.getElementById('pb-id-hint').textContent = PB_META[plat].hint;
}
}
function pbToggleActive(btn) {
const active = btn.dataset.active === 'true';
btn.dataset.active = String(!active);
btn.className = 'w-12 h-6 rounded-full relative transition-colors ' + (!active?'bg-primary':'bg-surface-high border border-outline-variant');
}
async function pbToggleIdx(idx) {
trackingPixels[idx].enabled = !trackingPixels[idx].enabled;
await api('/admin/analytics/pixels', {method:'POST', body:JSON.stringify({pixels:trackingPixels})});
renderPixelsList();
}
async function pbSave() {
const active = document.getElementById('pb-active-btn').dataset.active !== 'false';
const editIdx = document.getElementById('pb-edit-idx').value;
let px = {id: editIdx || String(Date.now()), platform: pbCurrentPlatform, enabled: active};
if (pbCurrentPlatform === 'custom') {
const code = document.getElementById('pb-code').value.trim();
if (!code) { toast('Paste your pixel code'); return; }
px.code = code; px.name = document.getElementById('pb-name').value.trim() || 'Custom Pixel';
} else {
const pid = document.getElementById('pb-pixel-id').value.trim();
if (!pid) { toast('Enter a Pixel ID'); return; }
px.pixel_id = pid;
}
if (editIdx !== '' && !isNaN(parseInt(editIdx))) trackingPixels[parseInt(editIdx)] = px;
else trackingPixels.push(px);
try {
await api('/admin/analytics/pixels', {method:'POST', body:JSON.stringify({pixels:trackingPixels})});
toast('Pixel saved', 'success');
pbClose();
renderPixelsList();
} catch(e) { toast('Save failed: ' + e.message); }
}
async function pbDelete(idx) {
if (!confirm('Delete this pixel?')) return;
trackingPixels.splice(idx, 1);
await api('/admin/analytics/pixels', {method:'POST', body:JSON.stringify({pixels:trackingPixels})});
toast('Pixel deleted', 'success');
renderPixelsList();
}
// -- Page Scanner -------------------------------------------------------------
let scanCurrentPage = null;
function openScanPicker() {
document.getElementById('scan-picker').classList.remove('hidden');
if (!scanCurrentPage) runScan('library');
}
function closeScanPicker() {
document.getElementById('scan-picker').classList.add('hidden');
}
async function runScan(page) {
scanCurrentPage = page;
// Update tab styles
document.querySelectorAll('.scan-tab').forEach(btn => {
const active = btn.id === 'scan-tab-' + page;
btn.style.background = active ? 'rgba(78,222,163,0.15)' : '';
btn.style.color = active ? 'var(--md-sys-color-primary, #4edea3)' : '';
btn.style.borderColor = active ? 'rgba(78,222,163,0.4)' : '';
});
const resultsEl = document.getElementById('scan-results');
resultsEl.innerHTML = '<div class="flex items-center justify-center h-24 gap-2 text-on-surface-variant text-sm"><span class="msym animate-spin">radar</span> Scanning ' + page + '...</div>';
try {
const d = await api('/admin/analytics/scan?page=' + page);
renderScanResults(d);
} catch(e) {
resultsEl.innerHTML = '<p class="text-red-400 text-sm p-4">Scan failed: ' + e.message + '</p>';
}
}
function renderScanResults(data) {
const el = document.getElementById('scan-results');
if (!data.groups || !data.groups.length) {
el.innerHTML = '<p class="text-on-surface-variant text-sm p-4">No trackable elements found.</p>';
return;
}
const groupIcons = {
'Page Events': 'article',
'Dynamic Elements': 'dynamic_feed',
'Buttons': 'smart_button',
'Inputs': 'input',
'Links': 'link',
'Forms': 'assignment',
'Other': 'widgets',
};
el.innerHTML = data.groups.map(g => `
<div class="mb-4">
<div class="flex items-center gap-2 mb-2 px-1">
<span class="msym text-primary text-base">${groupIcons[g.group] || 'widgets'}</span>
<span class="text-xs font-bold uppercase tracking-widest text-on-surface-variant">${g.group}</span>
<span class="text-xs glass px-2 py-0.5 rounded-full text-on-surface-variant">${g.items.length}</span>
</div>
<div class="space-y-1.5">
${g.items.map(item => `
<button onclick="applyScanResult(${JSON.stringify(item).replace(/"/g, '&quot;')})"
class="w-full glass glass-hover rounded-xl px-4 py-3 flex items-start gap-3 text-left transition-all hover:border-primary/30 group">
<span class="msym text-on-surface-variant group-hover:text-primary text-base shrink-0 mt-0.5">
${item.trigger === 'click' ? 'touch_app' : item.trigger === 'change' ? 'edit' : item.trigger === 'submit' ? 'send' : item.trigger === 'pageview' ? 'visibility' : 'swipe_down'}
</span>
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold group-hover:text-primary transition-colors">${item.label}</p>
<p class="text-xs font-mono text-on-surface-variant truncate">${item.selector}</p>
${item.suggested_params && item.suggested_params.length ? '<p class="text-xs text-on-surface-variant/60 mt-0.5">params: ' + item.suggested_params.map(p => p.key).join(', ') + '</p>' : ''}
</div>
<span class="text-xs glass px-2 py-0.5 rounded-full text-on-surface-variant shrink-0 mt-0.5">${item.trigger}</span>
</button>`).join('')}
</div>
</div>`).join('');
}
function applyScanResult(item) {
// Fill the event builder fields
if (!document.getElementById('eb-event-name').value) {
// Auto-suggest event name from label
const suggested = item.label.toLowerCase()
.replace(/[^a-z0-9\s]/g, '')
.trim().replace(/\s+/g, '_')
.slice(0, 40);
document.getElementById('eb-event-name').value = suggested;
}
document.getElementById('eb-trigger').value = item.trigger || 'click';
document.getElementById('eb-selector').value = item.selector || '';
// Auto-add suggested params (clearing existing ones first)
if (item.suggested_params && item.suggested_params.length) {
document.getElementById('eb-params').innerHTML = '';
item.suggested_params.forEach(p => ebAddParam(p));
}
updateEBPreview();
closeScanPicker();
// Toast to confirm
toast('Element applied -- review and save', 'success');
}
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>
<button onclick="llmSetProvider('claude')" id="llm-btn-claude"
class="flex-1 py-2.5 rounded-xl text-xs font-bold transition-all flex items-center justify-center gap-2 ${d.provider==='claude' ? 'bg-purple-600 text-white' : 'glass glass-hover text-on-surface-variant'}">
<span class="msym text-base">smart_toy</span> Claude AI
</button>
</div>
<div id="llm-claude-fields" class="${d.provider==='claude' ? '' : '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-purple-400">bolt</span>Anthropic Claude -- fast, high quality</p>
<p class="flex items-center gap-2"><span class="msym text-base text-primary">all_inclusive</span>claude-sonnet-4 recommended for Arabic</p>
<p class="flex items-center gap-2"><span class="msym text-base text-on-surface-variant">storage</span>API key required -- console.anthropic.com</p>
</div>
<div id="claude-key-llm-wrap">
<label class="text-xs text-on-surface-variant block mb-1.5">API Key
<span id="claude-key-llm-status" class="text-emerald-400 text-[10px] ml-2"></span>
</label>
<div class="flex gap-2">
<input id="claude-key-llm-input" type="password" dir="ltr" placeholder="sk-ant-..."
class="flex-1 bg-surface-high border border-outline-variant rounded-xl px-3 py-2 text-xs font-mono outline-none focus:border-primary transition-colors"/>
<button onclick="saveClaudeKey()" class="glass glass-hover px-3 py-2 rounded-xl text-xs text-primary font-bold">Save Key</button>
</div>
</div>
<div>
<label class="text-xs text-on-surface-variant block mb-1.5">Model</label>
<select id="llm-claude-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="claude-sonnet-4-20250514">Claude Sonnet 4 ⭐ (Recommended)</option>
<option value="claude-haiku-4-5-20251001">Claude Haiku 4.5 (Faster)</option>
<option value="claude-opus-4-20250514">Claude Opus 4 (Most Powerful)</option>
</select>
</div>
<!-- Usage tracking -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mt-4" id="claude-stats"></div>
<div class="glass rounded-xl p-4 mt-3">
<h4 class="text-primary text-xs font-bold uppercase tracking-widest mb-3 flex items-center gap-2"><span class="msym text-sm">bar_chart</span>Daily Usage</h4>
<div id="claude-daily-chart"></div>
</div>
<div class="glass rounded-xl p-4 mt-3">
<h4 class="text-primary text-xs font-bold uppercase tracking-widest mb-3 flex items-center gap-2"><span class="msym text-sm">history</span>Recent Calls</h4>
<div id="claude-usage-list" class="space-y-2"></div>
</div>
</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 === 'local', isCF = p === 'cloudflare', isClaude = p === 'claude';
const btnLocal = document.getElementById('llm-btn-local');
const btnCF = document.getElementById('llm-btn-cf');
const btnClaude = document.getElementById('llm-btn-claude');
if (btnLocal) btnLocal.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'}`;
if (btnCF) btnCF.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'}`;
if (btnClaude) btnClaude.className = `flex-1 py-2.5 rounded-xl text-xs font-bold transition-all flex items-center justify-center gap-2 ${isClaude?'bg-purple-600 text-white':'glass glass-hover text-on-surface-variant'}`;
const localInfo = document.getElementById('llm-local-info');
const cfFields = document.getElementById('llm-cf-fields');
const claudeFields = document.getElementById('llm-claude-fields');
if (localInfo) localInfo.classList.toggle('hidden', !isLocal);
if (cfFields) cfFields.classList.toggle('hidden', !isCF);
if (claudeFields) claudeFields.classList.toggle('hidden', !isClaude);
}
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 => {
const rect = card.getBoundingClientRect();
card.style.setProperty('--mx', ((e.clientX - rect.left) / rect.width * 100) + '%');
card.style.setProperty('--my', ((e.clientY - rect.top) / rect.height * 100) + '%');
});
});
// -- Book Edit -----------------------------------------------------------------
let _bookEditCache = {}; // id -> book data from last loadBooks()
function openBookEdit(btn) {
const d = btn.dataset;
document.getElementById('be-book-id').value = d.id;
document.getElementById('be-title').value = d.title || '';
document.getElementById('be-author').value = d.author || '';
document.getElementById('be-category').value = d.category || '';
document.getElementById('be-description').value = d.description || '';
const status = document.getElementById('be-status');
status.textContent = '';
status.classList.add('hidden');
document.getElementById('book-edit-modal').classList.remove('hidden');
// Focus first empty important field
setTimeout(() => {
const authorEl = document.getElementById('be-author');
if (!authorEl.value) authorEl.focus();
else document.getElementById('be-title').focus();
}, 50);
}
function closeBookEdit() {
document.getElementById('book-edit-modal').classList.add('hidden');
}
async function saveBookEdit() {
const id = document.getElementById('be-book-id').value;
const title = document.getElementById('be-title').value.trim();
const author= document.getElementById('be-author').value.trim();
const cat = document.getElementById('be-category').value.trim();
const desc = document.getElementById('be-description').value.trim();
const status = document.getElementById('be-status');
if (!title) { toast('العنوان مطلوب'); return; }
status.textContent = 'جاري الحفظ...';
status.classList.remove('hidden');
try {
await api('/api/library/' + id, {
method: 'PATCH',
body: JSON.stringify({ title, author, category: cat, description: desc })
});
status.textContent = '✅ تم الحفظ';
status.style.color = '#4edea3';
// Update cache
if (_bookEditCache[id]) {
Object.assign(_bookEditCache[id], { title, author, category: cat, description: desc });
}
// Refresh books list
setTimeout(() => { closeBookEdit(); loadBooks(); }, 800);
} catch(e) {
status.textContent = '❌ ' + e.message;
status.style.color = '#f87171';
}
}
async function retryMetaExtraction() {
const id = document.getElementById('be-book-id').value;
const status = document.getElementById('be-status');
status.textContent = 'جاري إعادة الاستخراج... (قد يستغرق دقيقة)';
status.style.color = '';
status.classList.remove('hidden');
try {
await api('/admin/books/' + id + '/extract-meta', { method: 'POST' });
status.textContent = '✅ طُلب الاستخراج -- ستُحدَّث البيانات خلال دقيقة';
status.style.color = '#4edea3';
// Reload after 65s
setTimeout(() => { closeBookEdit(); loadBooks(); }, 65000);
} catch(e) {
status.textContent = '❌ ' + e.message;
status.style.color = '#f87171';
}
}
// Book data passed via data attributes -- no cache needed
</script>
<!-- ── Book Edit Modal ───────────────────────────────────────────────────────── -->
<div id="book-edit-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70">
<div class="glass rounded-3xl w-full max-w-lg p-6 space-y-4" style="border:1px solid rgba(78,222,163,0.2);max-height:90vh;overflow-y:auto">
<div class="flex items-center justify-between">
<h3 class="font-bold text-lg flex items-center gap-2">
<span class="msym text-primary">edit</span> تعديل بيانات الكتاب
</h3>
<button onclick="closeBookEdit()" class="text-on-surface-variant hover:text-primary">
<span class="msym text-xl">close</span>
</button>
</div>
<input type="hidden" id="be-book-id"/>
<div>
<label class="text-xs text-on-surface-variant font-bold uppercase tracking-widest block mb-2">العنوان</label>
<input type="text" id="be-title" dir="rtl"
class="w-full bg-surface-high border border-outline-variant rounded-xl px-4 py-2.5 text-sm outline-none focus:border-primary transition-colors"
placeholder="عنوان الكتاب"/>
</div>
<div>
<label class="text-xs text-on-surface-variant font-bold uppercase tracking-widest block mb-2">المؤلف</label>
<input type="text" id="be-author" dir="rtl"
class="w-full bg-surface-high border border-outline-variant rounded-xl px-4 py-2.5 text-sm outline-none focus:border-primary transition-colors"
placeholder="اسم المؤلف"/>
</div>
<div>
<label class="text-xs text-on-surface-variant font-bold uppercase tracking-widest block mb-2">التصنيف</label>
<input type="text" id="be-category" dir="rtl"
class="w-full bg-surface-high border border-outline-variant rounded-xl px-4 py-2.5 text-sm outline-none focus:border-primary transition-colors"
placeholder="فقه / حديث / تفسير / أدب / عقيدة"/>
</div>
<div>
<label class="text-xs text-on-surface-variant font-bold uppercase tracking-widest block mb-2">الوصف</label>
<textarea id="be-description" dir="rtl" rows="3"
class="w-full bg-surface-high border border-outline-variant rounded-xl px-4 py-2.5 text-sm outline-none focus:border-primary transition-colors resize-none"
placeholder="وصف مختصر للكتاب"></textarea>
</div>
<div class="flex gap-3 pt-1">
<button onclick="saveBookEdit()"
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-lg">save</span> حفظ
</button>
<button onclick="retryMetaExtraction()"
class="glass glass-hover px-4 py-2.5 rounded-xl text-sm text-on-surface-variant flex items-center gap-2 transition-colors" title="إعادة استخراج البيانات تلقائياً">
<span class="msym text-base">auto_fix</span> إعادة استخراج
</button>
</div>
<p id="be-status" class="text-xs text-center hidden"></p>
</div>
</div>
<script>
// -- Page Builder -------------------------------------------------------------
let _pbPage = 'privacy';
let _pbMode = 'visual';
let _pbBlocks = [];
let _pbRawHTML = '';
const PB_PAGES = {
privacy: { label: 'Privacy', url: '/privacy' },
terms: { label: 'Terms', url: '/terms' },
about: { label: 'About', url: '/about' },
index: { label: 'Home', url: '/' },
};
// -- Switch page ---------------------------------------------------------------
function pbSwitch(page) {
_pbPage = page;
document.querySelectorAll('.pb-tab').forEach(b => {
b.className = 'pb-tab px-4 py-2 rounded-xl text-xs font-bold glass text-on-surface-variant hover:text-primary transition-all';
});
const active = document.getElementById('pb-tab-' + page);
if (active) active.className = 'pb-tab px-4 py-2 rounded-xl text-xs font-bold bg-primary text-surface transition-all';
document.getElementById('pb-preview-btn').href = PB_PAGES[page]?.url || '/' + page;
pbLoad();
}
// -- Mode toggle ---------------------------------------------------------------
function pbSetMode(mode) {
_pbMode = mode;
const mv = document.getElementById('pb-mode-visual');
const mh = document.getElementById('pb-mode-html');
const vv = document.getElementById('pb-visual');
const vh = document.getElementById('pb-html');
if (mode === 'visual') {
mv.className = 'px-3 py-1.5 rounded-lg text-xs font-bold bg-primary/20 text-primary border border-primary/30 flex items-center gap-1';
mh.className = 'px-3 py-1.5 rounded-lg text-xs font-bold glass text-on-surface-variant flex items-center gap-1';
vv.classList.remove('hidden'); vh.classList.add('hidden');
pbRenderBlocks();
} else {
mh.className = 'px-3 py-1.5 rounded-lg text-xs font-bold bg-primary/20 text-primary border border-primary/30 flex items-center gap-1';
mv.className = 'px-3 py-1.5 rounded-lg text-xs font-bold glass text-on-surface-variant flex items-center gap-1';
vh.classList.remove('hidden'); vv.classList.add('hidden');
// Sync blocks -> HTML
document.getElementById('pb-html-editor').value = _pbRawHTML;
}
}
// -- Load page content ---------------------------------------------------------
async function pbLoad() {
const blocksEl = document.getElementById('pb-blocks');
blocksEl.innerHTML = '<div class="text-on-surface-variant text-sm p-4">Loading...</div>';
try {
const res = await fetch('/admin/pages/' + _pbPage, { headers: { 'Authorization': 'Bearer ' + JWT_TOKEN } });
const data = await res.json();
_pbRawHTML = data.content || '';
_pbBlocks = pbParseHTML(_pbRawHTML);
if (_pbMode === 'visual') pbRenderBlocks();
else document.getElementById('pb-html-editor').value = _pbRawHTML;
} catch(e) {
blocksEl.innerHTML = '<div class="text-red-400 text-sm p-4">Error: ' + e.message + '</div>';
}
}
// -- Parse HTML -> blocks -------------------------------------------------------
function pbParseHTML(html) {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const blocks = [];
// Find main content area
const main = doc.querySelector('main') || doc.querySelector('.space-y-6') || doc.body;
main.querySelectorAll('h1,h2,h3,p,ul,ol,hr,div[class*="hero"]').forEach((el, i) => {
const tag = el.tagName.toLowerCase();
if (tag === 'hr') {
blocks.push({ id: i, type: 'divider', content: '' });
} else if (['h1','h2','h3'].includes(tag)) {
blocks.push({ id: i, type: 'heading', level: tag, content: el.textContent.trim() });
} else if (tag === 'p') {
const txt = el.textContent.trim();
if (txt) blocks.push({ id: i, type: 'paragraph', content: txt });
} else if (tag === 'ul' || tag === 'ol') {
const items = [...el.querySelectorAll('li')].map(li => li.textContent.trim()).join('\n');
if (items) blocks.push({ id: i, type: 'list', ordered: tag === 'ol', content: items });
}
});
// If nothing found, create a single raw block
if (!blocks.length) {
const bodyText = doc.body.innerText?.trim();
if (bodyText) blocks.push({ id: 0, type: 'paragraph', content: bodyText.substring(0, 500) + '...' });
else blocks.push({ id: 0, type: 'paragraph', content: 'This page has no editable content yet.' });
}
return blocks;
}
// -- Render blocks in visual editor -------------------------------------------
function pbRenderBlocks() {
const container = document.getElementById('pb-blocks');
if (!_pbBlocks.length) {
container.innerHTML = '<div class="glass rounded-2xl p-6 text-center text-on-surface-variant text-sm">No blocks yet -- add one below</div>';
return;
}
container.innerHTML = _pbBlocks.map((b, idx) => pbBlockHTML(b, idx)).join('');
}
function pbBlockHTML(b, idx) {
const moveUp = idx > 0 ? `<button onclick="pbMoveBlock(${idx},-1)" class="glass glass-hover p-1 rounded-lg" title="Move up"><span class="msym text-sm">arrow_upward</span></button>` : '';
const moveDown = idx < _pbBlocks.length-1 ? `<button onclick="pbMoveBlock(${idx},1)" class="glass glass-hover p-1 rounded-lg" title="Move down"><span class="msym text-sm">arrow_downward</span></button>` : '';
const controls = `<div class="flex items-center gap-1 shrink-0">
${moveUp}${moveDown}
<button onclick="pbDeleteBlock(${idx})" class="glass glass-hover p-1 rounded-lg text-red-400" title="Delete"><span class="msym text-sm">delete</span></button>
</div>`;
let inner = '';
if (b.type === 'heading') {
inner = `<div class="flex items-center gap-3">
<select onchange="_pbBlocks[${idx}].level=this.value;pbRenderBlocks()" class="bg-surface-high border border-outline-variant rounded-lg px-2 py-1 text-xs">
<option value="h1" ${b.level==='h1'?'selected':''}>H1</option>
<option value="h2" ${b.level==='h2'?'selected':''}>H2</option>
<option value="h3" ${b.level==='h3'?'selected':''}>H3</option>
</select>
<input type="text" value="${b.content.replace(/"/g,'&quot;')}"
oninput="_pbBlocks[${idx}].content=this.value"
class="flex-1 bg-surface-high border border-outline-variant rounded-xl px-3 py-2 text-sm font-bold outline-none focus:border-primary transition-colors"
placeholder="Heading text..."/>
</div>`;
} else if (b.type === 'paragraph') {
inner = `<textarea rows="3" oninput="_pbBlocks[${idx}].content=this.value"
class="w-full bg-surface-high border border-outline-variant rounded-xl px-3 py-2 text-sm outline-none focus:border-primary transition-colors resize-none"
placeholder="Paragraph text...">${b.content.replace(/</g,'&lt;')}</textarea>`;
} else if (b.type === 'list') {
inner = `<div class="flex items-center gap-2 mb-2">
<span class="text-xs text-on-surface-variant">Type:</span>
<label class="text-xs flex items-center gap-1"><input type="radio" name="list-${idx}" value="false" ${!b.ordered?'checked':''} onchange="_pbBlocks[${idx}].ordered=false"> Bullets</label>
<label class="text-xs flex items-center gap-1"><input type="radio" name="list-${idx}" value="true" ${b.ordered?'checked':''} onchange="_pbBlocks[${idx}].ordered=true"> Numbered</label>
</div>
<textarea rows="4" oninput="_pbBlocks[${idx}].content=this.value"
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 resize-none"
placeholder="One item per line...">${b.content.replace(/</g,'&lt;')}</textarea>`;
} else if (b.type === 'divider') {
inner = `<div class="border-t border-outline-variant my-2 opacity-50"></div>`;
} else if (b.type === 'hero') {
inner = `<div class="space-y-2">
<input type="text" value="${(b.title||'').replace(/"/g,'&quot;')}" oninput="_pbBlocks[${idx}].title=this.value"
class="w-full bg-surface-high border border-outline-variant rounded-xl px-3 py-2 text-base font-bold outline-none focus:border-primary transition-colors" placeholder="Hero title..."/>
<input type="text" value="${(b.subtitle||'').replace(/"/g,'&quot;')}" oninput="_pbBlocks[${idx}].subtitle=this.value"
class="w-full bg-surface-high border border-outline-variant rounded-xl px-3 py-2 text-sm outline-none focus:border-primary transition-colors" placeholder="Hero subtitle..."/>
</div>`;
} else if (b.type === 'contact') {
inner = `<input type="email" value="${(b.email||'').replace(/"/g,'&quot;')}" oninput="_pbBlocks[${idx}].email=this.value"
class="w-full bg-surface-high border border-outline-variant rounded-xl px-3 py-2 text-sm outline-none focus:border-primary transition-colors" placeholder="contact@example.com"/>`;
}
const typeLabels = { heading:'Heading', paragraph:'Paragraph', list:'List', divider:'Divider', hero:'Hero', contact:'Contact' };
const typeColors = { heading:'text-primary', paragraph:'text-on-surface-variant', list:'text-yellow-400', divider:'text-outline-variant', hero:'text-purple-400', contact:'text-blue-400' };
return `<div class="glass rounded-2xl p-4 border border-outline-variant/20 hover:border-primary/20 transition-all">
<div class="flex items-center justify-between mb-3">
<span class="text-xs font-bold uppercase tracking-widest ${typeColors[b.type]||''}">${typeLabels[b.type]||b.type}</span>
${controls}
</div>
${inner}
</div>`;
}
// -- Block actions -------------------------------------------------------------
function pbAddBlock(type) {
const newBlock = { id: Date.now(), type, content: '', level: 'h2', ordered: false, title: '', subtitle: '', email: '' };
_pbBlocks.push(newBlock);
pbRenderBlocks();
// Scroll to new block
setTimeout(() => {
const last = document.getElementById('pb-blocks').lastElementChild;
last?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, 100);
}
function pbDeleteBlock(idx) {
_pbBlocks.splice(idx, 1);
pbRenderBlocks();
}
function pbMoveBlock(idx, dir) {
const newIdx = idx + dir;
if (newIdx < 0 || newIdx >= _pbBlocks.length) return;
[_pbBlocks[idx], _pbBlocks[newIdx]] = [_pbBlocks[newIdx], _pbBlocks[idx]];
pbRenderBlocks();
}
// -- Blocks -> HTML -------------------------------------------------------------
function pbBlocksToHTML(blocks) {
return blocks.map(b => {
if (b.type === 'heading') return `<${b.level||'h2'} class="text-primary font-bold mb-2">${b.content}</${b.level||'h2'}>`;
if (b.type === 'paragraph') return `<p>${b.content}</p>`;
if (b.type === 'divider') return `<hr class="border-outline-variant my-4"/>`;
if (b.type === 'list') {
const tag = b.ordered ? 'ol' : 'ul';
const cls = b.ordered ? 'list-decimal' : 'list-disc';
const items = b.content.split('\n').filter(Boolean).map(i => `<li>${i.trim()}</li>`).join('');
return `<${tag} class="${cls} pl-5 space-y-1">${items}</${tag}>`;
}
if (b.type === 'hero') return `<div class="text-center py-8"><h1 class="text-3xl font-bold text-primary mb-2">${b.title||''}</h1><p class="text-on-surface-variant">${b.subtitle||''}</p></div>`;
if (b.type === 'contact') return `<p>Contact: <a href="mailto:${b.email}" class="text-primary hover:underline">${b.email}</a></p>`;
return '';
}).join('\n');
}
// -- Inject blocks into page HTML ----------------------------------------------
function pbInjectBlocks(rawHTML, blocksHTML) {
// Try to inject into existing content area
const parser = new DOMParser();
const doc = parser.parseFromString(rawHTML, 'text/html');
const main = doc.querySelector('main');
if (main) {
// Find inner content div and replace only content sections
const contentDiv = main.querySelector('.space-y-6, .content-area, [id*="content"]');
if (contentDiv) {
contentDiv.innerHTML = blocksHTML;
} else {
// Append at end of main
const existing = main.innerHTML;
const headerMatch = existing.match(/(<div[^>]*flex[^>]*>[\s\S]*?<\/div>\s*<\/div>)/);
if (headerMatch) {
main.innerHTML = headerMatch[0] + `<div class="space-y-4 text-on-surface-variant leading-relaxed text-sm">${blocksHTML}</div>`;
} else {
main.innerHTML += `<div class="space-y-4 text-on-surface-variant leading-relaxed text-sm">${blocksHTML}</div>`;
}
}
const s = new XMLSerializer();
return '<!DOCTYPE html>' + s.serializeToString(doc.documentElement);
}
return rawHTML; // fallback -- unchanged
}
// -- Save ----------------------------------------------------------------------
async function pbSave() {
const btn = document.getElementById('pb-save-btn');
const status = document.getElementById('pb-status');
btn.disabled = true;
btn.innerHTML = '<span class="msym text-base">progress_activity</span> Saving...';
try {
let finalHTML;
if (_pbMode === 'html') {
finalHTML = document.getElementById('pb-html-editor').value;
} else {
const blocksHTML = pbBlocksToHTML(_pbBlocks);
finalHTML = pbInjectBlocks(_pbRawHTML, blocksHTML);
}
const res = await fetch('/admin/pages/' + _pbPage, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + JWT_TOKEN },
body: JSON.stringify({ content: finalHTML })
});
if (res.ok) {
status.textContent = '✅ Saved!';
status.classList.remove('hidden');
setTimeout(() => status.classList.add('hidden'), 3000);
_pbRawHTML = finalHTML;
toast('Page saved!', 'success');
} else {
toast('Save failed', 'error');
}
} catch(e) {
toast('Error: ' + e.message);
}
btn.disabled = false;
btn.innerHTML = '<span class="msym text-base">save</span> Save';
}
// -- Wire into showPage --------------------------------------------------------
document.addEventListener('DOMContentLoaded', () => {
const origShowPage = window.showPage;
if (origShowPage) {
window.showPage = function(page, btn) {
origShowPage(page, btn);
if (page === 'pages') pbSwitch(_pbPage);
};
}
});
</script>
</body>
</html>