Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>AuthorBot — Author Admin Panel</title> | |
| <meta name="description" content="Premium author admin panel for AI-powered book sales chatbot management."> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet"> | |
| <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script> | |
| <script src="/static/auth-client.js"></script> | |
| <style> | |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } | |
| :root { | |
| --bg: #f8f9fc; --white: #ffffff; --surface: #ffffff; --surface2: #f1f3f9; | |
| --border: #e5e7ef; --border-light: #eef0f6; | |
| --accent: #6366f1; --accent-hover: #4f46e5; --accent-soft: rgba(99,102,241,0.08); | |
| --accent2: #8b5cf6; --accent3: #ec4899; | |
| --text: #1e293b; --text2: #334155; --muted: #64748b; --light: #94a3b8; | |
| --green: #10b981; --green-bg: rgba(16,185,129,0.1); | |
| --red: #ef4444; --red-bg: rgba(239,68,68,0.08); | |
| --yellow: #f59e0b; --yellow-bg: rgba(245,158,11,0.1); | |
| --blue: #3b82f6; --blue-bg: rgba(59,130,246,0.08); | |
| --radius: 14px; --radius-sm: 10px; --radius-xs: 6px; | |
| --shadow: 0 1px 3px rgba(0,0,0,0.04), 0 4px 12px rgba(0,0,0,0.03); | |
| --shadow-lg: 0 4px 24px rgba(0,0,0,0.06), 0 12px 48px rgba(0,0,0,0.04); | |
| --shadow-hover: 0 8px 32px rgba(99,102,241,0.12); | |
| --transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1); | |
| --sidebar-w: 260px; | |
| } | |
| body { font-family: 'Inter', -apple-system, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; -webkit-font-smoothing: antialiased; } | |
| /* ── Scrollbar ── */ | |
| ::-webkit-scrollbar { width: 6px; } | |
| ::-webkit-scrollbar-track { background: transparent; } | |
| ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } | |
| ::-webkit-scrollbar-thumb:hover { background: var(--muted); } | |
| /* ── Animations ── */ | |
| @keyframes fadeIn { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } } | |
| @keyframes slideInLeft { from { opacity: 0; transform: translateX(-20px); } to { opacity: 1; transform: translateX(0); } } | |
| @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } | |
| @keyframes shimmer { 0% { background-position: -400px 0; } 100% { background-position: 400px 0; } } | |
| @keyframes scaleIn { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } } | |
| @keyframes ripple { to { transform: scale(4); opacity: 0; } } | |
| @keyframes float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-4px); } } | |
| @keyframes countUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } | |
| @keyframes gradientShift { 0% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } 100% { background-position: 0% 50%; } } | |
| .animate-in { animation: fadeIn 0.4s ease forwards; } | |
| .animate-in-delay { animation: fadeIn 0.4s ease 0.1s forwards; opacity: 0; } | |
| .skeleton { background: linear-gradient(90deg, var(--surface2) 25%, #e8eaf2 50%, var(--surface2) 75%); background-size: 800px 100%; animation: shimmer 1.5s infinite; border-radius: var(--radius-sm); } | |
| /* ── Auth Screen ── */ | |
| .auth-wrap { | |
| min-height: 100vh; display: flex; align-items: center; justify-content: center; | |
| background: linear-gradient(135deg, #f8f9fc 0%, #eef0ff 50%, #f0f0ff 100%); | |
| position: relative; overflow: hidden; | |
| } | |
| .auth-wrap::before { | |
| content: ''; position: absolute; width: 600px; height: 600px; border-radius: 50%; | |
| background: radial-gradient(circle, rgba(99,102,241,0.08) 0%, transparent 70%); | |
| top: -200px; right: -100px; | |
| } | |
| .auth-wrap::after { | |
| content: ''; position: absolute; width: 400px; height: 400px; border-radius: 50%; | |
| background: radial-gradient(circle, rgba(139,92,246,0.06) 0%, transparent 70%); | |
| bottom: -100px; left: -50px; | |
| } | |
| .auth-box { | |
| background: var(--white); border: 1px solid var(--border-light); border-radius: 24px; | |
| padding: 48px 40px; width: 100%; max-width: 440px; | |
| box-shadow: var(--shadow-lg); position: relative; z-index: 1; | |
| animation: scaleIn 0.5s ease; | |
| } | |
| .auth-logo { | |
| font-size: 1.8rem; font-weight: 800; margin-bottom: 6px; | |
| background: linear-gradient(135deg, var(--accent), var(--accent2)); | |
| -webkit-background-clip: text; -webkit-text-fill-color: transparent; | |
| letter-spacing: -0.02em; | |
| } | |
| .auth-sub { color: var(--muted); font-size: 14px; margin-bottom: 36px; line-height: 1.5; } | |
| /* ── Form Controls ── */ | |
| .form-group { margin-bottom: 20px; } | |
| .form-label { | |
| display: block; font-size: 12px; font-weight: 600; color: var(--muted); | |
| margin-bottom: 8px; letter-spacing: 0.05em; text-transform: uppercase; | |
| } | |
| .form-input { | |
| width: 100%; padding: 12px 16px; border-radius: var(--radius-sm); | |
| border: 1.5px solid var(--border); background: var(--bg); | |
| color: var(--text); font-size: 14px; font-family: inherit; | |
| transition: border-color var(--transition), box-shadow var(--transition); | |
| } | |
| .form-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } | |
| .form-input::placeholder { color: var(--light); } | |
| textarea.form-input { resize: vertical; min-height: 80px; } | |
| select.form-input { cursor: pointer; } | |
| /* ── Buttons ── */ | |
| .btn { | |
| display: inline-flex; align-items: center; justify-content: center; gap: 8px; | |
| padding: 12px 24px; border-radius: var(--radius-sm); border: none; | |
| font-weight: 600; font-size: 14px; font-family: inherit; cursor: pointer; | |
| transition: all var(--transition); position: relative; overflow: hidden; | |
| } | |
| .btn-primary { | |
| background: linear-gradient(135deg, var(--accent), var(--accent2)); | |
| color: white; box-shadow: 0 2px 8px rgba(99,102,241,0.25); | |
| } | |
| .btn-primary:hover { transform: translateY(-1px); box-shadow: 0 4px 16px rgba(99,102,241,0.35); } | |
| .btn-primary:active { transform: translateY(0); } | |
| .btn-secondary { background: var(--surface2); color: var(--text2); border: 1px solid var(--border); } | |
| .btn-secondary:hover { background: var(--border-light); border-color: var(--accent); color: var(--accent); } | |
| .btn-danger { background: var(--red-bg); color: var(--red); } | |
| .btn-danger:hover { background: var(--red); color: white; } | |
| .btn-sm { padding: 7px 14px; font-size: 12px; border-radius: var(--radius-xs); } | |
| .btn-icon { width: 36px; height: 36px; padding: 0; border-radius: var(--radius-xs); } | |
| .btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none ; } | |
| .btn-block { width: 100%; } | |
| /* ── Sidebar ── */ | |
| .sidebar { | |
| position: fixed; top: 0; left: 0; bottom: 0; width: var(--sidebar-w); | |
| background: var(--white); border-right: 1px solid var(--border-light); | |
| display: flex; flex-direction: column; padding: 24px 16px; z-index: 100; | |
| transition: transform var(--transition); overflow-y: auto; overflow-x: hidden; | |
| } | |
| .sidebar::-webkit-scrollbar { width: 4px; } | |
| .sidebar::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } | |
| .sidebar-logo { | |
| font-size: 1.25rem; font-weight: 800; padding: 0 12px; margin-bottom: 8px; | |
| background: linear-gradient(135deg, var(--accent), var(--accent2)); | |
| -webkit-background-clip: text; -webkit-text-fill-color: transparent; | |
| } | |
| .sidebar-sub { font-size: 11px; color: var(--light); padding: 0 12px; margin-bottom: 28px; } | |
| .nav-section { font-size: 10px; font-weight: 700; color: var(--light); letter-spacing: 0.1em; text-transform: uppercase; padding: 16px 12px 8px; } | |
| .nav-item { | |
| display: flex; align-items: center; gap: 12px; padding: 10px 12px; | |
| border-radius: var(--radius-sm); color: var(--muted); text-decoration: none; | |
| font-size: 13.5px; font-weight: 500; cursor: pointer; border: none; background: none; | |
| width: 100%; transition: all var(--transition); margin-bottom: 2px; text-align: left; | |
| } | |
| .nav-item .nav-icon { width: 20px; text-align: center; font-size: 15px; } | |
| .nav-item:hover { background: var(--accent-soft); color: var(--accent); } | |
| .nav-item.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; } | |
| .nav-item .nav-badge { | |
| margin-left: auto; background: var(--accent); color: white; | |
| font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: 10px; | |
| } | |
| .sidebar-footer { margin-top: auto; padding-top: 16px; border-top: 1px solid var(--border-light); } | |
| .user-card { | |
| display: flex; align-items: center; gap: 10px; padding: 10px 12px; | |
| border-radius: var(--radius-sm); margin-bottom: 8px; | |
| } | |
| .user-avatar { | |
| width: 36px; height: 36px; border-radius: 50%; | |
| background: linear-gradient(135deg, var(--accent), var(--accent2)); | |
| display: flex; align-items: center; justify-content: center; | |
| color: white; font-weight: 700; font-size: 14px; flex-shrink: 0; | |
| } | |
| .user-name { font-size: 13px; font-weight: 600; color: var(--text); } | |
| .user-email { font-size: 11px; color: var(--light); } | |
| /* ── Main Content ── */ | |
| .main { margin-left: var(--sidebar-w); padding: 32px 40px; min-height: 100vh; } | |
| .page { display: none; } .page.active { display: block; animation: fadeIn 0.3s ease; } | |
| .page-header { margin-bottom: 28px; } | |
| .page-title { font-size: 1.6rem; font-weight: 800; color: var(--text); letter-spacing: -0.02em; margin-bottom: 4px; display: flex; align-items: center; gap: 10px; } | |
| .page-sub { color: var(--muted); font-size: 14px; line-height: 1.5; } | |
| /* Info tooltips */ | |
| .info-tip { position: relative; display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: 50%; background: var(--indigo-soft); color: var(--indigo); font-size: 12px; font-weight: 700; cursor: help; border: 1px solid rgba(99,102,241,0.2); transition: all var(--transition); flex-shrink: 0; } | |
| .info-tip:hover { background: var(--indigo); color: white; transform: scale(1.1); } | |
| .info-tip .tip-text { visibility: hidden; opacity: 0; position: absolute; z-index: 1000; top: calc(100% + 10px); left: 0; transform: translateY(-4px); width: 280px; padding: 12px 16px; background: var(--text); color: #f1f5f9; border-radius: var(--radius-sm); font-size: 12px; font-weight: 400; line-height: 1.6; box-shadow: var(--shadow-lg); transition: all 0.2s ease; pointer-events: none; } | |
| .info-tip .tip-text::after { content: ''; position: absolute; bottom: 100%; left: 16px; border-width: 5px; border-style: solid; border-color: transparent transparent var(--text) transparent; } | |
| .info-tip:hover .tip-text { visibility: visible; opacity: 1; transform: translateY(0); } | |
| /* Connection status dot */ | |
| .conn-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; } | |
| .conn-dot.ok { background: var(--green); box-shadow: 0 0 6px rgba(16,185,129,0.4); } | |
| .conn-dot.err { background: var(--red); box-shadow: 0 0 6px rgba(239,68,68,0.4); animation: pulse 2s infinite; } | |
| /* ── Cards ── */ | |
| .card { | |
| background: var(--white); border: 1px solid var(--border-light); border-radius: var(--radius); | |
| padding: 24px; margin-bottom: 20px; box-shadow: var(--shadow); | |
| transition: box-shadow var(--transition), transform var(--transition); | |
| } | |
| .card:hover { box-shadow: var(--shadow-hover); } | |
| .card-title { font-size: 15px; font-weight: 700; margin-bottom: 4px; } | |
| .card-sub { font-size: 13px; color: var(--muted); margin-bottom: 16px; } | |
| /* ── Stat Cards ── */ | |
| .stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 28px; } | |
| .stat-card { | |
| background: var(--white); border: 1px solid var(--border-light); border-radius: var(--radius); | |
| padding: 24px; box-shadow: var(--shadow); position: relative; overflow: hidden; | |
| transition: all var(--transition); cursor: default; | |
| } | |
| .stat-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-hover); } | |
| .stat-card::after { | |
| content: ''; position: absolute; top: 0; right: 0; width: 80px; height: 80px; | |
| border-radius: 50%; opacity: 0.06; transform: translate(20px, -20px); | |
| } | |
| .stat-card:nth-child(1)::after { background: var(--accent); } | |
| .stat-card:nth-child(2)::after { background: var(--green); } | |
| .stat-card:nth-child(3)::after { background: var(--accent2); } | |
| .stat-card:nth-child(4)::after { background: var(--yellow); } | |
| .stat-icon { font-size: 20px; margin-bottom: 12px; } | |
| .stat-val { font-size: 2rem; font-weight: 800; letter-spacing: -0.02em; animation: countUp 0.5s ease; } | |
| .stat-val.accent { color: var(--accent); } | |
| .stat-val.green { color: var(--green); } | |
| .stat-val.purple { color: var(--accent2); } | |
| .stat-val.yellow { color: var(--yellow); } | |
| .stat-label { font-size: 12px; color: var(--muted); margin-top: 4px; font-weight: 500; } | |
| .stat-change { font-size: 11px; font-weight: 600; margin-top: 8px; display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 20px; } | |
| .stat-change.up { color: var(--green); background: var(--green-bg); } | |
| .stat-change.down { color: var(--red); background: var(--red-bg); } | |
| /* ── Tables ── */ | |
| .table-wrap { overflow-x: auto; border-radius: var(--radius); } | |
| table { width: 100%; border-collapse: collapse; font-size: 13.5px; } | |
| thead { background: var(--surface2); } | |
| th { text-align: left; padding: 12px 16px; color: var(--muted); font-weight: 600; font-size: 11px; letter-spacing: 0.05em; text-transform: uppercase; white-space: nowrap; } | |
| td { padding: 14px 16px; border-bottom: 1px solid var(--border-light); transition: background var(--transition); } | |
| tr:hover td { background: var(--accent-soft); } | |
| tr:last-child td { border-bottom: none; } | |
| /* ── Badges ── */ | |
| .badge { display: inline-flex; align-items: center; gap: 4px; padding: 3px 10px; border-radius: 20px; font-size: 11px; font-weight: 600; } | |
| .badge-active { background: var(--green-bg); color: var(--green); } | |
| .badge-processing { background: var(--yellow-bg); color: var(--yellow); } | |
| .badge-failed { background: var(--red-bg); color: var(--red); } | |
| .badge-draft { background: var(--surface2); color: var(--muted); } | |
| .badge-info { background: var(--blue-bg); color: var(--blue); } | |
| /* ── Toast Notifications ── */ | |
| .toast-container { position: fixed; top: 24px; right: 24px; z-index: 9999; display: flex; flex-direction: column; gap: 8px; } | |
| .toast { | |
| background: var(--white); border: 1px solid var(--border); border-radius: var(--radius-sm); | |
| padding: 14px 20px; box-shadow: var(--shadow-lg); display: flex; align-items: center; gap: 10px; | |
| font-size: 13px; font-weight: 500; animation: slideInLeft 0.3s ease; min-width: 280px; | |
| transition: all 0.3s ease; | |
| } | |
| .toast.success { border-left: 3px solid var(--green); } | |
| .toast.error { border-left: 3px solid var(--red); } | |
| .toast.info { border-left: 3px solid var(--blue); } | |
| .toast-close { margin-left: auto; cursor: pointer; color: var(--light); font-size: 16px; background: none; border: none; } | |
| /* ── Modal ── */ | |
| .modal-overlay { | |
| position: fixed; inset: 0; background: rgba(15,23,42,0.4); backdrop-filter: blur(4px); | |
| z-index: 1000; display: flex; align-items: center; justify-content: center; | |
| opacity: 0; pointer-events: none; transition: opacity 0.2s; | |
| } | |
| .modal-overlay.active { opacity: 1; pointer-events: auto; } | |
| .modal { | |
| background: var(--white); border-radius: 20px; padding: 32px; width: 100%; max-width: 520px; | |
| box-shadow: var(--shadow-lg); transform: scale(0.95); transition: transform 0.2s; | |
| max-height: 90vh; overflow-y: auto; | |
| } | |
| .modal-overlay.active .modal { transform: scale(1); } | |
| .modal-title { font-size: 1.2rem; font-weight: 700; margin-bottom: 8px; } | |
| .modal-sub { font-size: 13px; color: var(--muted); margin-bottom: 24px; } | |
| .modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 24px; } | |
| /* ── Upload Zone ── */ | |
| .upload-zone { | |
| border: 2px dashed var(--border); border-radius: var(--radius); padding: 48px 32px; | |
| text-align: center; cursor: pointer; transition: all var(--transition); | |
| background: var(--surface2); | |
| } | |
| .upload-zone:hover, .upload-zone.dragover { border-color: var(--accent); background: var(--accent-soft); } | |
| .upload-zone .upload-icon { font-size: 40px; margin-bottom: 12px; } | |
| .upload-zone .upload-text { font-size: 14px; color: var(--muted); } | |
| .upload-zone .upload-text strong { color: var(--accent); } | |
| /* ── Progress Bar ── */ | |
| .progress-bar { background: var(--surface2); border-radius: 8px; height: 8px; overflow: hidden; } | |
| .progress-fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent2)); border-radius: 8px; transition: width 0.4s ease; } | |
| /* ── Tabs ── */ | |
| .tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border-light); margin-bottom: 24px; } | |
| .tab { | |
| padding: 10px 18px; font-size: 13px; font-weight: 500; color: var(--muted); | |
| cursor: pointer; border-bottom: 2px solid transparent; transition: all var(--transition); | |
| background: none; border-top: none; border-left: none; border-right: none; | |
| } | |
| .tab:hover { color: var(--text); } | |
| .tab.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; } | |
| /* ── Search ── */ | |
| .search-bar { | |
| display: flex; align-items: center; gap: 8px; padding: 10px 16px; | |
| background: var(--surface2); border: 1px solid var(--border); border-radius: var(--radius-sm); | |
| transition: all var(--transition); max-width: 320px; | |
| } | |
| .search-bar:focus-within { border-color: var(--accent); background: var(--white); box-shadow: 0 0 0 3px var(--accent-soft); } | |
| .search-bar input { border: none; background: none; outline: none; font-size: 13px; color: var(--text); width: 100%; font-family: inherit; } | |
| .search-bar input::placeholder { color: var(--light); } | |
| /* ── Toggle ── */ | |
| .toggle { position: relative; width: 44px; height: 24px; cursor: pointer; } | |
| .toggle input { display: none; } | |
| .toggle-track { | |
| position: absolute; inset: 0; background: var(--border); border-radius: 12px; | |
| transition: background var(--transition); | |
| } | |
| .toggle input:checked + .toggle-track { background: var(--accent); } | |
| .toggle-thumb { | |
| position: absolute; top: 2px; left: 2px; width: 20px; height: 20px; | |
| background: white; border-radius: 50%; box-shadow: 0 1px 3px rgba(0,0,0,0.15); | |
| transition: transform var(--transition); | |
| } | |
| .toggle input:checked ~ .toggle-thumb { transform: translateX(20px); } | |
| /* ── Widget Preview ── */ | |
| .widget-preview { | |
| width: 340px; height: 480px; border-radius: 20px; overflow: hidden; | |
| box-shadow: var(--shadow-lg); border: 1px solid var(--border); | |
| display: flex; flex-direction: column; background: var(--white); | |
| } | |
| /* ── Empty State ── */ | |
| .empty-state { text-align: center; padding: 60px 20px; } | |
| .empty-state .empty-icon { font-size: 48px; margin-bottom: 16px; opacity: 0.5; } | |
| .empty-state .empty-title { font-size: 16px; font-weight: 600; margin-bottom: 6px; } | |
| .empty-state .empty-sub { font-size: 13px; color: var(--muted); margin-bottom: 20px; } | |
| /* ── Toolbar ── */ | |
| .toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; } | |
| .toolbar-spacer { flex: 1; } | |
| /* ── Chart Container ── */ | |
| .chart-container { position: relative; height: 280px; } | |
| /* ── Responsive ── */ | |
| @media (max-width: 768px) { | |
| .sidebar { transform: translateX(-100%); } | |
| .sidebar.open { transform: translateX(0); } | |
| .main { margin-left: 0; padding: 20px; } | |
| .stat-grid { grid-template-columns: 1fr 1fr; } | |
| } | |
| /* ── Misc ── */ | |
| .flex { display: flex; } .flex-col { flex-direction: column; } | |
| .items-center { align-items: center; } .justify-between { justify-content: space-between; } | |
| .gap-2 { gap: 8px; } .gap-3 { gap: 12px; } .gap-4 { gap: 16px; } | |
| .mt-2 { margin-top: 8px; } .mt-4 { margin-top: 16px; } .mb-4 { margin-bottom: 16px; } | |
| .text-sm { font-size: 13px; } .text-xs { font-size: 11px; } | |
| .text-muted { color: var(--muted); } .text-accent { color: var(--accent); } | |
| .fw-600 { font-weight: 600; } .fw-700 { font-weight: 700; } | |
| .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } | |
| .hidden { display: none ; } | |
| /* ── Platform Presence panel ── */ | |
| .ab-pp-card { | |
| background: var(--surface2); | |
| border: 1px solid var(--border-light); | |
| border-radius: 16px; | |
| overflow: hidden; | |
| box-shadow: var(--shadow); | |
| } | |
| .ab-pp-header { | |
| display: grid; | |
| grid-template-columns: auto 1fr; | |
| gap: 20px; | |
| align-items: center; | |
| padding: 22px 24px; | |
| border-bottom: 1px solid var(--border-light); | |
| background: linear-gradient(135deg, rgba(16,185,129,0.05) 0%, transparent 60%); | |
| } | |
| .ab-pp-ring { | |
| --pp-pct: 0; | |
| width: 80px; | |
| height: 80px; | |
| border-radius: 50%; | |
| background: conic-gradient(var(--green) calc(var(--pp-pct) * 1%), rgba(0,0,0,0.06) 0); | |
| display: grid; | |
| place-items: center; | |
| flex-shrink: 0; | |
| } | |
| .ab-pp-ring-inner { | |
| width: 64px; | |
| height: 64px; | |
| border-radius: 50%; | |
| background: var(--surface2); | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| text-align: center; | |
| line-height: 1; | |
| box-shadow: inset 0 1px 2px rgba(0,0,0,0.04); | |
| } | |
| .ab-pp-score-num { | |
| font-size: 20px; | |
| font-weight: 800; | |
| color: var(--text); | |
| letter-spacing: -0.04em; | |
| line-height: 1; | |
| } | |
| .ab-pp-score-denom { | |
| font-size: 10px; | |
| font-weight: 600; | |
| color: var(--muted); | |
| margin-top: 3px; | |
| letter-spacing: 0.04em; | |
| } | |
| .ab-pp-title { | |
| font-size: 15px; | |
| font-weight: 800; | |
| color: var(--text); | |
| letter-spacing: -0.01em; | |
| margin-bottom: 4px; | |
| } | |
| .ab-pp-subtitle { | |
| font-size: 12.5px; | |
| color: var(--muted); | |
| line-height: 1.45; | |
| } | |
| .ab-pp-listed { | |
| font-size: 12px; | |
| color: var(--green); | |
| font-weight: 600; | |
| margin-top: 8px; | |
| } | |
| .ab-pp-table-head { | |
| display: grid; | |
| grid-template-columns: 36px 1fr 64px 92px; | |
| gap: 12px; | |
| align-items: center; | |
| padding: 10px 20px 6px; | |
| font-size: 10px; | |
| font-weight: 700; | |
| letter-spacing: 0.06em; | |
| text-transform: uppercase; | |
| color: var(--light); | |
| border-bottom: 1px solid var(--border-light); | |
| } | |
| .ab-pp-link { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 4px; | |
| margin-top: 4px; | |
| font-size: 11px; | |
| font-weight: 600; | |
| color: var(--accent); | |
| text-decoration: none; | |
| max-width: 100%; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .ab-pp-link:hover { text-decoration: underline; } | |
| .ab-pp-link--search { color: var(--muted); font-weight: 500; } | |
| .ab-pp-price { | |
| font-size: 12px; | |
| font-weight: 600; | |
| color: var(--text2); | |
| margin-top: 3px; | |
| line-height: 1.3; | |
| } | |
| .ab-found-links { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 6px; | |
| margin-top: 10px; | |
| padding-top: 10px; | |
| border-top: 1px dashed var(--border-light); | |
| } | |
| .ab-found-links-label { | |
| width: 100%; | |
| font-size: 10px; | |
| font-weight: 700; | |
| letter-spacing: 0.06em; | |
| text-transform: uppercase; | |
| color: var(--light); | |
| margin-bottom: 2px; | |
| } | |
| .ab-found-link { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 4px; | |
| padding: 3px 10px; | |
| border-radius: 20px; | |
| font-size: 11px; | |
| font-weight: 600; | |
| background: rgba(16,185,129,0.1); | |
| border: 1px solid rgba(16,185,129,0.25); | |
| color: #059669; | |
| text-decoration: none; | |
| transition: background 0.15s; | |
| } | |
| .ab-found-link:hover { background: rgba(16,185,129,0.18); } | |
| .ab-pp-table-head span:nth-child(3), | |
| .ab-pp-table-head span:nth-child(4) { text-align: center; } | |
| .ab-pp-platforms { | |
| max-height: 280px; | |
| overflow-y: auto; | |
| } | |
| .ab-pp-row { | |
| display: grid; | |
| grid-template-columns: 36px 1fr 64px 92px; | |
| gap: 12px; | |
| align-items: center; | |
| padding: 12px 20px; | |
| border-bottom: 1px solid var(--border-light); | |
| transition: background var(--transition); | |
| } | |
| .ab-pp-row:last-child { border-bottom: none; } | |
| .ab-pp-row:hover { background: rgba(99,102,241,0.03); } | |
| .ab-pp-icon { | |
| font-size: 18px; | |
| line-height: 1; | |
| text-align: center; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .ab-pp-info { min-width: 0; } | |
| .ab-pp-name { | |
| font-size: 13px; | |
| font-weight: 600; | |
| color: var(--text); | |
| line-height: 1.3; | |
| } | |
| .ab-pp-benefit { | |
| font-size: 11px; | |
| color: var(--muted); | |
| margin-top: 3px; | |
| line-height: 1.4; | |
| } | |
| .ab-pp-bar-wrap { | |
| height: 5px; | |
| background: rgba(0,0,0,0.06); | |
| border-radius: 99px; | |
| overflow: hidden; | |
| } | |
| .ab-pp-bar { | |
| height: 100%; | |
| border-radius: 99px; | |
| background: linear-gradient(90deg, var(--accent), var(--accent2)); | |
| transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1); | |
| } | |
| .ab-pp-badge { | |
| justify-self: center; | |
| font-size: 10px; | |
| font-weight: 700; | |
| padding: 4px 10px; | |
| border-radius: 99px; | |
| white-space: nowrap; | |
| letter-spacing: 0.03em; | |
| text-transform: uppercase; | |
| } | |
| .ab-pp-badge--verified { color: #059669; background: rgba(16,185,129,0.12); } | |
| .ab-pp-badge--likely { color: var(--accent); background: rgba(99,102,241,0.12); } | |
| .ab-pp-badge--missing { color: var(--muted); background: var(--surface); border: 1px solid var(--border-light); } | |
| .ab-pp-badge--error { color: var(--red); background: var(--red-bg); } | |
| .ab-pp-recs { | |
| padding: 16px 20px 12px; | |
| border-top: 1px solid var(--border-light); | |
| } | |
| .ab-pp-recs-title { | |
| font-size: 10px; | |
| font-weight: 800; | |
| color: var(--accent); | |
| text-transform: uppercase; | |
| letter-spacing: 0.07em; | |
| margin-bottom: 12px; | |
| } | |
| .ab-pp-recs-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); | |
| gap: 10px; | |
| } | |
| .ab-pp-rec-card { | |
| padding: 12px 14px; | |
| border-radius: 12px; | |
| background: var(--surface); | |
| border: 1px solid var(--border-light); | |
| line-height: 1.4; | |
| } | |
| .ab-pp-rec-name { | |
| font-size: 13px; | |
| font-weight: 700; | |
| color: var(--text); | |
| margin-bottom: 4px; | |
| } | |
| .ab-pp-rec-text { | |
| font-size: 11.5px; | |
| color: var(--muted); | |
| } | |
| .ab-pp-recs-help { | |
| margin-top: 14px; | |
| padding-top: 12px; | |
| border-top: 1px dashed var(--border-light); | |
| font-size: 12px; | |
| color: var(--light); | |
| line-height: 1.5; | |
| } | |
| .ab-pp-recs-help a { | |
| color: var(--accent); | |
| font-weight: 600; | |
| text-decoration: none; | |
| } | |
| .ab-pp-recs-help a:hover { text-decoration: underline; } | |
| .ab-pp-footer { | |
| padding: 12px 20px 14px; | |
| font-size: 11px; | |
| color: var(--muted); | |
| border-top: 1px solid var(--border-light); | |
| background: rgba(0,0,0,0.015); | |
| line-height: 1.45; | |
| } | |
| @keyframes ab-pp-danger-pulse { | |
| 0%, 100% { box-shadow: inset 3px 0 0 var(--red), 0 0 0 0 rgba(239,68,68,0); } | |
| 50% { box-shadow: inset 3px 0 0 var(--red), 0 0 0 4px rgba(239,68,68,0.12); } | |
| } | |
| @keyframes ab-pp-danger-glow { | |
| 0%, 100% { border-color: rgba(239,68,68,0.45); } | |
| 50% { border-color: rgba(239,68,68,0.85); } | |
| } | |
| .ab-pp-listed--warn { color: var(--red); } | |
| .ab-pp-missing-badge { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 5px; | |
| margin-left: 8px; | |
| padding: 2px 9px; | |
| border-radius: 99px; | |
| font-size: 11px; | |
| font-weight: 700; | |
| color: var(--red); | |
| background: var(--red-bg); | |
| border: 1px solid rgba(239,68,68,0.25); | |
| vertical-align: middle; | |
| animation: ab-pp-danger-glow 2.4s ease-in-out infinite; | |
| } | |
| .ab-pp-row--missing { | |
| background: linear-gradient(90deg, rgba(239,68,68,0.06) 0%, transparent 55%); | |
| animation: ab-pp-danger-pulse 2.4s ease-in-out infinite; | |
| } | |
| .ab-pp-row--missing:hover { background: linear-gradient(90deg, rgba(239,68,68,0.1) 0%, rgba(239,68,68,0.02) 55%); } | |
| .ab-pp-row--missing .ab-pp-name { color: #b91c1c; } | |
| .ab-pp-row--missing .ab-pp-bar { background: linear-gradient(90deg, #f87171, var(--red)); } | |
| .ab-pp-row--clickable { | |
| cursor: pointer; | |
| outline: none; | |
| } | |
| .ab-pp-row--clickable:focus-visible { | |
| box-shadow: inset 0 0 0 2px var(--red); | |
| } | |
| .ab-pp-badge--danger { | |
| color: #b91c1c; | |
| background: var(--red-bg); | |
| border: 1px solid rgba(239,68,68,0.35); | |
| animation: ab-pp-danger-glow 2.4s ease-in-out infinite; | |
| } | |
| .ab-pp-recs-title-row { | |
| display: flex; | |
| align-items: center; | |
| flex-wrap: wrap; | |
| gap: 8px; | |
| margin-bottom: 12px; | |
| } | |
| .ab-pp-recs-title-row .ab-pp-recs-title { margin-bottom: 0; color: #b91c1c; } | |
| .ab-pp-rec-card--missing { | |
| cursor: pointer; | |
| border: 1.5px solid rgba(239,68,68,0.45); | |
| background: linear-gradient(135deg, rgba(239,68,68,0.05) 0%, var(--surface) 60%); | |
| animation: ab-pp-danger-glow 2.4s ease-in-out infinite; | |
| transition: transform var(--transition), box-shadow var(--transition), border-color var(--transition); | |
| outline: none; | |
| } | |
| .ab-pp-rec-card--missing:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 6px 20px rgba(239,68,68,0.15); | |
| border-color: var(--red); | |
| } | |
| .ab-pp-rec-card--missing:focus-visible { | |
| box-shadow: 0 0 0 3px rgba(239,68,68,0.25); | |
| border-color: var(--red); | |
| } | |
| .ab-pp-rec-card--missing .ab-pp-rec-name { color: #b91c1c; } | |
| .ab-pp-rec-cta { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 4px; | |
| margin-top: 8px; | |
| font-size: 11px; | |
| font-weight: 700; | |
| color: var(--red); | |
| letter-spacing: 0.02em; | |
| } | |
| .ab-pp-modal-icon { | |
| width: 52px; | |
| height: 52px; | |
| border-radius: 14px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 26px; | |
| background: var(--red-bg); | |
| border: 1px solid rgba(239,68,68,0.25); | |
| margin-bottom: 16px; | |
| } | |
| .ab-pp-modal-benefit { | |
| padding: 12px 14px; | |
| border-radius: 12px; | |
| background: var(--surface2); | |
| border: 1px solid var(--border-light); | |
| font-size: 13px; | |
| color: var(--text2); | |
| line-height: 1.5; | |
| margin: 16px 0; | |
| } | |
| .ab-pp-modal-tip { | |
| font-size: 12px; | |
| color: var(--muted); | |
| line-height: 1.5; | |
| margin-top: 12px; | |
| } | |
| .ab-pp-sticky-banner { | |
| display: none; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 16px; | |
| flex-wrap: wrap; | |
| margin: 0 32px 0; | |
| padding: 14px 18px; | |
| border-radius: 12px; | |
| background: linear-gradient(90deg, rgba(239,68,68,0.1), rgba(239,68,68,0.04)); | |
| border: 1.5px solid rgba(239,68,68,0.35); | |
| animation: ab-pp-danger-glow 2.4s ease-in-out infinite; | |
| } | |
| .ab-pp-sticky-banner-text { | |
| font-size: 13px; | |
| font-weight: 600; | |
| color: #b91c1c; | |
| line-height: 1.45; | |
| } | |
| .ab-pp-help-card { | |
| margin-top: 14px; | |
| padding: 16px 18px; | |
| border-radius: 14px; | |
| background: linear-gradient(135deg, rgba(99,102,241,0.08), rgba(99,102,241,0.02)); | |
| border: 1.5px solid rgba(99,102,241,0.25); | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 14px; | |
| flex-wrap: wrap; | |
| } | |
| .ab-pp-help-card-title { | |
| font-size: 14px; | |
| font-weight: 700; | |
| color: var(--text); | |
| margin-bottom: 4px; | |
| } | |
| .ab-pp-help-card-sub { | |
| font-size: 12px; | |
| color: var(--muted); | |
| line-height: 1.45; | |
| } | |
| .ab-missing-pill { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 4px; | |
| margin-left: 8px; | |
| padding: 2px 8px; | |
| border-radius: 99px; | |
| font-size: 10px; | |
| font-weight: 700; | |
| color: #b91c1c; | |
| background: var(--red-bg); | |
| border: 1px solid rgba(239,68,68,0.3); | |
| vertical-align: middle; | |
| } | |
| .ab-pp-help-form .form-group { margin-bottom: 12px; } | |
| .ab-pp-help-readonly { | |
| font-size: 12px; | |
| color: var(--muted); | |
| padding: 8px 10px; | |
| background: var(--surface2); | |
| border-radius: 8px; | |
| border: 1px solid var(--border-light); | |
| } | |
| .ab-pp-checklist { | |
| margin: 10px 0 0; | |
| padding: 10px 12px; | |
| border-radius: 10px; | |
| background: var(--surface2); | |
| border: 1px solid var(--border-light); | |
| font-size: 12px; | |
| color: var(--text2); | |
| line-height: 1.5; | |
| } | |
| .ab-pp-checklist ul { margin: 6px 0 0 18px; padding: 0; } | |
| .ab-gaps-card { | |
| border: 1.5px solid rgba(239,68,68,0.3); | |
| background: linear-gradient(135deg, rgba(239,68,68,0.06), transparent); | |
| } | |
| .ab-gaps-card .stat-val { color: #b91c1c ; } | |
| /* Right-edge support tab — brand accent, restrained */ | |
| .ab-contact-tab { | |
| position: fixed; | |
| right: 0; | |
| top: 50%; | |
| transform: translateY(-50%); | |
| z-index: 850; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 18px 11px; | |
| border: none; | |
| border-radius: 8px 0 0 8px; | |
| background: var(--accent); | |
| color: #fff; | |
| font-family: inherit; | |
| cursor: pointer; | |
| box-shadow: -2px 0 12px rgba(99, 102, 241, 0.35); | |
| transition: background var(--transition), box-shadow var(--transition), transform var(--transition); | |
| } | |
| .ab-contact-tab:hover { | |
| background: var(--accent-hover); | |
| transform: translateY(-50%) translateX(-3px); | |
| box-shadow: -4px 0 18px rgba(99, 102, 241, 0.45); | |
| } | |
| .ab-contact-tab:focus-visible { | |
| outline: none; | |
| box-shadow: -4px 0 18px rgba(99, 102, 241, 0.45), 0 0 0 3px var(--accent-soft); | |
| } | |
| .ab-contact-tab-icon { | |
| width: 15px; | |
| height: 15px; | |
| stroke: currentColor; | |
| fill: none; | |
| stroke-width: 1.75; | |
| stroke-linecap: round; | |
| stroke-linejoin: round; | |
| } | |
| .ab-contact-tab-text { | |
| writing-mode: vertical-rl; | |
| text-orientation: mixed; | |
| font-size: 11px; | |
| font-weight: 600; | |
| letter-spacing: 0.07em; | |
| text-transform: uppercase; | |
| white-space: nowrap; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- Toast Container --> | |
| <div class="toast-container" id="toast-container"></div> | |
| <!-- Modal Overlay --> | |
| <div class="modal-overlay" id="modal-overlay"> | |
| <div class="modal" id="modal-content"></div> | |
| </div> | |
| <!-- AUTH SCREEN --> | |
| <div class="auth-wrap" id="auth-screen"> | |
| <div class="auth-box"> | |
| <div class="auth-logo">AuthorBot</div> | |
| <p class="auth-sub">Welcome back! Sign in to manage your AI-powered book chatbot.</p> | |
| <div class="form-group"> | |
| <label class="form-label">Email</label> | |
| <input type="email" class="form-input" id="email" placeholder="you@example.com" autocomplete="email"> | |
| </div> | |
| <div class="form-group"> | |
| <label class="form-label">Password</label> | |
| <input type="password" class="form-input" id="password" placeholder="••••••••" autocomplete="current-password"> | |
| </div> | |
| <button class="btn btn-primary btn-block" id="login-btn">Sign In</button> | |
| <p class="msg" id="login-msg" style="margin-top:12px;font-size:13px;min-height:18px;"></p> | |
| </div> | |
| </div> | |
| <!-- DASHBOARD --> | |
| <div id="dashboard" style="display:none;"> | |
| <nav class="sidebar" id="sidebar"> | |
| <div class="sidebar-logo">AuthorBot</div> | |
| <div class="sidebar-sub">Author Admin Panel</div> | |
| <div class="nav-section">Main</div> | |
| <button class="nav-item active" data-page="overview"><span class="nav-icon">📊</span> Dashboard</button> | |
| <button class="nav-item" data-page="books"><span class="nav-icon">📚</span> Books</button> | |
| <button class="nav-item" data-page="upload"><span class="nav-icon">⬆️</span> Upload Book</button> | |
| <button class="nav-item" data-page="readers"><span class="nav-icon">👥</span> Readers</button> | |
| <button class="nav-item" data-page="conversations"><span class="nav-icon">💭</span> Conversations</button> | |
| <button class="nav-item" data-page="qa"><span class="nav-icon">❓</span> Custom Q&A</button> | |
| <div class="nav-section">Analytics</div> | |
| <button class="nav-item" data-page="analytics"><span class="nav-icon">📈</span> Analytics</button> | |
| <button class="nav-item" data-page="tokens"><span class="nav-icon">🪙</span> Token Usage</button> | |
| <button class="nav-item" data-page="exports"><span class="nav-icon">📥</span> Export Data</button> | |
| <div class="nav-section">Configuration</div> | |
| <button class="nav-item" data-page="widget"><span class="nav-icon">💬</span> Widget Config</button> | |
| <button class="nav-item" data-page="embed"><span class="nav-icon">🔗</span> Embed Code</button> | |
| <button class="nav-item" data-page="links"><span class="nav-icon">🛒</span> Smart Links</button> | |
| <button class="nav-item" data-page="personality"><span class="nav-icon">🤖</span> Bot Personality</button> | |
| <div class="nav-section">Account</div> | |
| <button class="nav-item" data-page="billing"><span class="nav-icon">💳</span> Billing</button> | |
| <button class="nav-item" data-page="profile"><span class="nav-icon">👤</span> Profile</button> | |
| <button class="nav-item" data-page="notifications"><span class="nav-icon">🔔</span> Notifications</button> | |
| <button class="nav-item" data-page="security"><span class="nav-icon">🔒</span> Security</button> | |
| <div class="sidebar-footer"> | |
| <div class="user-card"> | |
| <div class="user-avatar" id="user-initials">A</div> | |
| <div> | |
| <div class="user-name" id="user-display-name">Author</div> | |
| <div class="user-email" id="user-display-email">—</div> | |
| </div> | |
| </div> | |
| <button class="nav-item" id="logout-btn" style="color:var(--red);"> | |
| <span class="nav-icon">🚪</span> Sign Out | |
| </button> | |
| </div> | |
| </nav> | |
| <main class="main"> | |
| <!-- ═══ DASHBOARD ═══ --> | |
| <div class="page active" id="page-overview"> | |
| <div class="page-header"><h1 class="page-title">Dashboard <span class="info-tip">ℹ<span class="tip-text">Your real-time command center. View chat sessions, book counts, and engagement metrics. Data refreshes automatically when you navigate here.</span></span></h1><p class="page-sub">Your AuthorBot at a glance — real-time overview of your chatbot performance</p></div> | |
| <div class="stat-grid" id="stat-grid"> | |
| <div class="stat-card animate-in"><div class="stat-icon">📚</div><div class="stat-val accent" id="stat-books">—</div><div class="stat-label">Books Indexed</div></div> | |
| <div class="stat-card animate-in-delay"><div class="stat-icon">💬</div><div class="stat-val green" id="stat-sessions">—</div><div class="stat-label">Total Conversations</div></div> | |
| <div class="stat-card animate-in-delay"><div class="stat-icon">✅</div><div class="stat-val purple" id="stat-active">—</div><div class="stat-label">Active Books</div></div> | |
| <div class="stat-card animate-in-delay"><div class="stat-icon">🔥</div><div class="stat-val yellow" id="stat-clicks">—</div><div class="stat-label">Link Clicks</div></div> | |
| <div class="stat-card animate-in-delay ab-gaps-card" id="stat-gaps-card" style="display:none;"><div class="stat-icon">⚠️</div><div class="stat-val" id="stat-gaps">—</div><div class="stat-label">Distribution Gaps</div></div> | |
| </div> | |
| <div class="grid-2"> | |
| <div class="card"><div class="card-title">Sessions — Last 30 Days</div><div class="card-sub">Daily chat activity trend</div><div class="chart-container"><canvas id="chart-sessions"></canvas></div></div> | |
| <div class="card" id="dashboard-gaps-card" style="display:none;"> | |
| <div class="card-title">Distribution Gaps</div> | |
| <div class="card-sub" id="dashboard-gaps-sub">Books missing retail listings</div> | |
| <div style="padding:16px 0 8px;font-size:14px;color:var(--text2);line-height:1.5;" id="dashboard-gaps-detail"></div> | |
| <button class="btn btn-primary btn-sm" onclick="nav('books')">View Books with Gaps →</button> | |
| </div> | |
| <div class="card"><div class="card-title">Quick Actions</div><div class="card-sub">Common tasks at a glance</div> | |
| <div class="flex flex-col gap-3 mt-2"> | |
| <button class="btn btn-secondary" onclick="nav('upload')">⬆️ Upload a New Book</button> | |
| <button class="btn btn-secondary" onclick="nav('widget')">💬 Configure Widget</button> | |
| <button class="btn btn-secondary" onclick="nav('analytics')">📈 View Analytics</button> | |
| <button class="btn btn-secondary" onclick="nav('embed')">🔗 Get Embed Code</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ═══ BOOKS ═══ --> | |
| <div class="page" id="page-books"> | |
| <div class="page-header"><h1 class="page-title">Books <span class="info-tip">ℹ<span class="tip-text">Your indexed book library. Each book is split into chunks and embedded in ChromaDB for RAG. Click a book to edit details or re-index it. Use the search bar to filter.</span></span></h1><p class="page-sub">Manage your book catalog — upload, edit, and organize</p></div> | |
| <div class="toolbar"> | |
| <div class="search-bar"><span>🔍</span><input type="text" placeholder="Search books..." id="book-search" oninput="filterBooks()"></div> | |
| <div class="toolbar-spacer"></div> | |
| <button class="btn btn-primary btn-sm" onclick="nav('upload')">+ Upload Book</button> | |
| </div> | |
| <div class="card" style="padding:0;"><div class="table-wrap"><table><thead><tr><th style="width:56px;">Cover</th><th>Book</th><th>Genre</th><th>Status</th><th>Chunks</th><th>Summary</th><th>Actions</th></tr></thead><tbody id="books-body"><tr><td colspan="7" class="text-muted" style="text-align:center;padding:40px;">Loading...</td></tr></tbody></table></div></div> | |
| </div> | |
| <!-- ═══ UPLOAD BOOK (Add Book) ═══ --> | |
| <div class="page" id="page-upload"> | |
| <div class="page-header" style="margin-bottom: 32px;"> | |
| <h1 class="page-title" style="font-size: 28px; font-weight: 800; letter-spacing: -0.02em; display: flex; align-items: center; gap: 8px;"> | |
| Add Book | |
| <span class="info-tip" style="position: relative; top: -2px;">ℹ<span class="tip-text">Import a book from any major bookstore URL — we auto-fetch title, author, cover, genre, description, ratings, and author bio. Or upload a PDF/EPUB directly.</span></span> | |
| </h1> | |
| <p class="page-sub" style="font-size: 15px; color: var(--muted); margin-top: 6px;">Import by URL from Amazon, Goodreads, Google Books & 7 more — or upload a file directly.</p> | |
| </div> | |
| <!-- Tab switcher --> | |
| <div style="display:inline-flex; padding:6px; background: rgba(255, 255, 255, 0.05); backdrop-filter: blur(12px); border: 1px solid var(--border-light); border-radius: 14px; margin-bottom: 36px; box-shadow: 0 4px 20px rgba(0,0,0,0.03);"> | |
| <button id="ab-tab-url" onclick="abSwitchTab('url')" style="padding: 10px 24px; border: none; border-radius: 10px; font-size: 14px; font-weight: 600; cursor: pointer; background: linear-gradient(135deg, var(--accent), var(--accent2)); color: #fff; box-shadow: 0 4px 15px rgba(99,102,241,0.3); font-family: inherit; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); display: flex; align-items: center; gap: 8px;"> | |
| 🔗 Import by URL | |
| <span style="font-size:10px; background: rgba(255,255,255,0.2); color: #fff; padding: 3px 8px; border-radius: 20px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase;">Recommended</span> | |
| </button> | |
| <button id="ab-tab-file" onclick="abSwitchTab('file')" style="padding: 10px 24px; border: none; border-radius: 10px; font-size: 14px; font-weight: 600; cursor: pointer; background: transparent; color: var(--muted); font-family: inherit; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); display: flex; align-items: center; gap: 8px;"> | |
| 📄 Manual Upload | |
| </button> | |
| </div> | |
| <!-- ─── URL IMPORT TAB ─── --> | |
| <div id="ab-url-tab"> | |
| <div style="display:grid; grid-template-columns: 1fr 380px; gap: 32px; align-items: start;"> | |
| <!-- Left: Import card --> | |
| <div class="card" style="padding: 0; overflow: hidden; border-radius: 20px; background: var(--surface); box-shadow: 0 12px 40px rgba(0,0,0,0.06); border: 1px solid var(--border-light);"> | |
| <!-- Platform pills --> | |
| <div style="padding: 24px 32px 16px; border-bottom: 1px solid var(--border-light); background: rgba(255, 255, 255, 0.02);"> | |
| <div style="font-size: 12px; font-weight: 700; color: var(--light); letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 14px; display: flex; align-items: center; gap: 6px;"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg> | |
| Supported Platforms | |
| </div> | |
| <div style="display: flex; flex-wrap: wrap; gap: 8px;" id="ab-platform-pills"> | |
| <!-- injected by abBuildPills() --> | |
| </div> | |
| </div> | |
| <!-- URL row --> | |
| <div style="padding: 28px 32px 32px;"> | |
| <!-- Alert --> | |
| <div id="ab-alert" style="display:none; align-items: flex-start; gap: 12px; padding: 14px 18px; border-radius: 12px; margin-bottom: 20px; font-size: 13.5px; font-weight: 500;"></div> | |
| <div style="display: flex; gap: 12px; align-items: stretch;"> | |
| <div id="ab-url-field" style="flex: 1; display: flex; align-items: center; gap: 12px; padding: 0 18px; background: var(--surface2); border: 2px solid var(--border); border-radius: 12px; transition: all 0.25s ease;"> | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--muted); flex-shrink: 0;"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg> | |
| <input type="url" id="ab-url" placeholder="Paste URL (e.g., https://amazon.com/dp/...)" autocomplete="off" spellcheck="false" | |
| style="flex: 1; background: transparent; border: none; outline: none; color: var(--text); font-size: 15px; font-family: inherit; height: 52px;" | |
| oninput="abDetectPlatform()" | |
| onfocus="this.parentElement.style.borderColor='var(--accent)'; this.parentElement.style.boxShadow='0 0 0 4px rgba(99,102,241,0.15)'; this.parentElement.style.background='var(--surface)'" | |
| onblur="this.parentElement.style.borderColor='var(--border)'; this.parentElement.style.boxShadow=''; this.parentElement.style.background='var(--surface2)'"> | |
| </div> | |
| <button class="btn btn-primary" id="ab-fetch-btn" onclick="abFetchPreview()" style="white-space: nowrap; padding: 0 24px; height: 56px; border-radius: 12px; font-size: 14.5px; font-weight: 600; display: flex; align-items: center; gap: 8px; box-shadow: 0 6px 20px rgba(99,102,241,0.25); transition: all 0.2s;"> | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg> | |
| Fetch Details | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Loader --> | |
| <div id="ab-loader" style="display:none; align-items: center; gap: 18px; padding: 0 32px 32px;"> | |
| <div style="width: 28px; height: 28px; border: 3px solid rgba(99,102,241,0.15); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite; flex-shrink: 0;"></div> | |
| <div> | |
| <p id="ab-loader-msg" style="font-size: 15px; font-weight: 600; color: var(--text); margin-bottom: 4px;">Detecting platform…</p> | |
| <p style="font-size: 12.5px; color: var(--accent); font-weight: 500; letter-spacing: 0.02em;">API-first · Advanced Extraction</p> | |
| </div> | |
| </div> | |
| <!-- Preview card --> | |
| <div id="ab-preview" style="display:none; padding: 0 32px 32px;"> | |
| <div style="background: var(--surface2); border: 1px solid var(--border-light); border-radius: 16px; overflow: hidden; box-shadow: 0 4px 15px rgba(0,0,0,0.02);"> | |
| <!-- Platform + confidence row --> | |
| <div style="display: flex; align-items: center; gap: 10px; padding: 12px 18px; background: linear-gradient(90deg, rgba(99,102,241,0.08), transparent); border-bottom: 1px solid var(--border-light);"> | |
| <span id="ab-p-platform" style="display: inline-flex; align-items: center; gap: 6px; padding: 4px 12px; border-radius: 20px; background: rgba(99,102,241,0.1); border: 1px solid rgba(99,102,241,0.2); color: var(--accent); font-size: 12px; font-weight: 700; letter-spacing: 0.03em;">Platform</span> | |
| <div style="margin-left: auto; display: flex; align-items: center; gap: 10px;"> | |
| <div style="width: 80px; height: 6px; border-radius: 4px; background: rgba(0,0,0,0.06); overflow: hidden; box-shadow: inset 0 1px 2px rgba(0,0,0,0.05);"><div id="ab-conf-fill" style="height: 100%; border-radius: 4px; background: linear-gradient(90deg, var(--accent), var(--green)); transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1); width: 0%"></div></div> | |
| <span id="ab-conf-label" style="font-size: 12px; font-weight: 600; color: var(--muted);">0% match</span> | |
| </div> | |
| </div> | |
| <!-- Book body --> | |
| <div style="display: flex; gap: 20px; padding: 20px;"> | |
| <div id="ab-cover" style="width: 90px; min-width: 90px; height: 130px; border-radius: 8px; border: 1px solid var(--border); background: var(--surface); display: flex; align-items: center; justify-content: center; font-size: 32px; overflow: hidden; flex-shrink: 0; box-shadow: 0 6px 16px rgba(0,0,0,0.08); transition: transform 0.2s;">📖</div> | |
| <div style="flex: 1; min-width: 0;"> | |
| <div id="ab-title-disp" style="font-size: 18px; font-weight: 800; line-height: 1.25; margin-bottom: 6px; color: var(--text);">—</div> | |
| <div id="ab-author-disp" style="font-size: 14px; font-weight: 500; color: var(--accent); margin-bottom: 12px;"></div> | |
| <div id="ab-desc-disp" style="font-size: 13.5px; color: var(--muted); line-height: 1.6; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; margin-bottom: 14px;"></div> | |
| <div id="ab-chips" style="display: flex; flex-wrap: wrap; gap: 6px;"></div> | |
| <div id="ab-found-links" class="ab-found-links" style="display:none;"></div> | |
| </div> | |
| </div> | |
| <!-- Author bio --> | |
| <div id="ab-bio-panel" style="display:none; margin: 0 20px 16px; padding: 14px 18px; border-radius: 12px; background: linear-gradient(145deg, rgba(99,102,241,0.05), rgba(99,102,241,0.02)); border: 1px solid rgba(99,102,241,0.1); border-left: 4px solid var(--accent);"> | |
| <div style="font-size: 11px; font-weight: 800; color: var(--accent); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px; display: flex; align-items: center; gap: 6px;"> | |
| <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> | |
| About the Author | |
| </div> | |
| <div id="ab-bio-text" style="font-size: 13px; color: var(--muted); line-height: 1.6; max-height: 100px; overflow-y: auto; padding-right: 4px;"></div> | |
| </div> | |
| <!-- Edit toggle --> | |
| <div id="ab-edit-toggle" onclick="abToggleEdit()" style="display: flex; align-items: center; gap: 8px; padding: 12px 20px; cursor: pointer; font-size: 13px; font-weight: 600; color: var(--light); background: rgba(0,0,0,0.02); border-top: 1px solid var(--border-light); transition: all 0.2s;"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg> | |
| Edit / Correct Details | |
| <span id="ab-chevron" style="margin-left: auto; transition: transform 0.2s; color: var(--muted);">▾</span> | |
| </div> | |
| <!-- Edit form --> | |
| <div id="ab-edit-form" style="display:none; padding: 0 20px 20px;"> | |
| <div class="grid-2" style="margin-top: 16px; gap: 16px;"> | |
| <div class="form-group" style="grid-column: 1 / -1;"><label class="form-label">Title</label><input type="text" class="form-input" id="ab-e-title" style="border-radius: 8px;"></div> | |
| <div class="form-group"><label class="form-label">Author</label><input type="text" class="form-input" id="ab-e-author" style="border-radius: 8px;"></div> | |
| <div class="form-group"><label class="form-label">Genre</label><input type="text" class="form-input" id="ab-e-genre" style="border-radius: 8px;"></div> | |
| <div class="form-group"><label class="form-label">ISBN</label><input type="text" class="form-input" id="ab-e-isbn" style="border-radius: 8px;"></div> | |
| <div class="form-group"><label class="form-label">Cover URL</label><input type="text" class="form-input" id="ab-e-cover" style="border-radius: 8px;"></div> | |
| <div class="form-group" style="grid-column: 1 / -1;"><label class="form-label">Description</label><textarea class="form-input" id="ab-e-desc" rows="3" style="border-radius: 8px; resize: vertical;"></textarea></div> | |
| <div class="form-group" style="grid-column: 1 / -1;"><label class="form-label">About the Author</label><textarea class="form-input" id="ab-e-bio" rows="2" style="border-radius: 8px; resize: vertical;"></textarea></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Platform Presence panel --> | |
| <div id="ab-platform-panel" style="display:none; padding: 0 32px 24px;"> | |
| <div class="ab-pp-card"> | |
| <div class="ab-pp-header"> | |
| <div class="ab-pp-ring" id="ab-pp-ring"> | |
| <div class="ab-pp-ring-inner"> | |
| <span class="ab-pp-score-num" id="ab-pp-score-num">—</span> | |
| <span class="ab-pp-score-denom" id="ab-pp-score-denom">/ 10</span> | |
| </div> | |
| </div> | |
| <div> | |
| <div class="ab-pp-title">Platform Presence</div> | |
| <div class="ab-pp-subtitle">Distribution coverage across top retail & discovery channels</div> | |
| <div id="ab-pp-distribution-note" class="text-xs" style="display:none;margin-top:8px;padding:10px 12px;border-radius:8px;background:rgba(99,102,241,0.08);color:var(--muted);line-height:1.45;"></div> | |
| <div class="ab-pp-listed" id="ab-pp-listed"></div> | |
| <span class="ab-pp-missing-badge" id="ab-pp-header-missing-badge" style="display:none; margin-top:8px; margin-left:0;"></span> | |
| <button type="button" class="btn btn-primary btn-sm" id="ab-pp-bulk-help-btn" style="display:none;margin-top:10px;" onclick="abPpRequestAllMissing()">Request help for all missing</button> | |
| </div> | |
| </div> | |
| <div class="ab-pp-table-head"> | |
| <span></span> | |
| <span>Platform</span> | |
| <span>Match</span> | |
| <span>Status</span> | |
| </div> | |
| <div class="ab-pp-platforms" id="ab-pp-platforms"></div> | |
| <div class="ab-pp-recs" id="ab-pp-recs" style="display:none;"> | |
| <div class="ab-pp-recs-title-row"> | |
| <div class="ab-pp-recs-title">Not published yet — tap to fix</div> | |
| <span class="ab-pp-missing-badge" id="ab-pp-missing-badge" style="display:none;"></span> | |
| </div> | |
| <div class="ab-pp-recs-grid" id="ab-pp-rec-cards"></div> | |
| <div class="ab-pp-help-card" id="ab-pp-recs-help"> | |
| <div> | |
| <div class="ab-pp-help-card-title">🤝 Get expert help publishing</div> | |
| <div class="ab-pp-help-card-sub">We'll help you list on missing platforms — reply within 24h.</div> | |
| </div> | |
| <button type="button" class="btn btn-primary btn-sm" onclick="abPpRequestAllMissing()">Request Help</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Sticky missing-platforms banner --> | |
| <div id="ab-pp-sticky-banner" class="ab-pp-sticky-banner" role="status" aria-live="polite"> | |
| <div class="ab-pp-sticky-banner-text" id="ab-pp-sticky-text"></div> | |
| <button type="button" class="btn btn-primary btn-sm" onclick="abPpRequestAllMissing()">Request Help</button> | |
| </div> | |
| <!-- Action row --> | |
| <div id="ab-actions" style="display:none; padding: 20px 32px 32px; justify-content: flex-end; gap: 12px; border-top: 1px solid var(--border-light); background: rgba(0,0,0,0.01);"> | |
| <button class="btn btn-secondary" onclick="abReset()" style="padding: 10px 20px; font-weight: 600; border-radius: 10px;">← Back</button> | |
| <button class="btn btn-primary" id="ab-import-btn" onclick="abConfirmImport()" style="padding: 10px 24px; font-weight: 600; border-radius: 10px; display: flex; align-items: center; gap: 8px; box-shadow: 0 4px 15px rgba(99,102,241,0.3);"> | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M20 6L9 17l-5-5"/></svg> | |
| Add to My Books | |
| </button> | |
| </div> | |
| <!-- Success screen --> | |
| <div id="ab-success" style="display:none; flex-direction: column; align-items: center; text-align: center; padding: 48px 32px; gap: 20px;"> | |
| <div style="width: 80px; height: 80px; border-radius: 50%; background: linear-gradient(135deg, rgba(16,185,129,0.15), rgba(16,185,129,0.05)); border: 3px solid rgba(16,185,129,0.3); display: flex; align-items: center; justify-content: center; font-size: 36px; box-shadow: 0 8px 25px rgba(16,185,129,0.2);">✨</div> | |
| <div> | |
| <div style="font-size: 22px; font-weight: 800; color: var(--text); margin-bottom: 6px;">Book Added Successfully!</div> | |
| <div style="font-size: 14px; color: var(--muted);">Your book is now being processed in the background.</div> | |
| </div> | |
| <div id="ab-success-book" style="background: var(--surface2); border: 1px solid var(--border); border-radius: 12px; padding: 14px 24px; font-weight: 600; font-size: 15px; width: 100%; max-width: 360px; color: var(--accent); box-shadow: inset 0 2px 4px rgba(0,0,0,0.02);"></div> | |
| <div id="ab-success-gaps" style="display:none; width:100%; max-width:420px; padding:16px 18px; border-radius:12px; background:linear-gradient(135deg,rgba(239,68,68,0.08),transparent); border:1.5px solid rgba(239,68,68,0.3); text-align:left;"> | |
| <div style="font-size:14px;font-weight:700;color:#b91c1c;margin-bottom:6px;" id="ab-success-gaps-title"></div> | |
| <div style="font-size:12.5px;color:var(--muted);line-height:1.5;margin-bottom:12px;" id="ab-success-gaps-detail"></div> | |
| <button type="button" class="btn btn-primary btn-sm" onclick="abSuccessRequestHelp()">Request publishing help</button> | |
| </div> | |
| <div style="display: flex; align-items: flex-start; gap: 12px; padding: 16px; background: rgba(99,102,241,0.08); border: 1px solid rgba(99,102,241,0.2); border-radius: 12px; font-size: 13px; color: var(--light); max-width: 400px; text-align: left; line-height: 1.5;"> | |
| <div style="font-size: 18px; line-height: 1;">💡</div> | |
| <div>Want AI to read it? Upload a PDF or EPUB for this book on the <strong>Manual Upload</strong> tab to enable the chat widget.</div> | |
| </div> | |
| <div style="display: flex; gap: 12px; margin-top: 10px;"> | |
| <button class="btn btn-secondary" onclick="abReset()" style="padding: 10px 24px; border-radius: 10px; font-weight: 600;">Import Another</button> | |
| <button class="btn btn-primary" onclick="nav('books')" style="padding: 10px 24px; border-radius: 10px; font-weight: 600; box-shadow: 0 4px 15px rgba(99,102,241,0.3);">Go to My Books</button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Right: My Books mini list --> | |
| <div class="card" style="padding: 0; overflow: hidden; border-radius: 20px; background: var(--surface); box-shadow: 0 12px 40px rgba(0,0,0,0.06); border: 1px solid var(--border-light); display: flex; flex-direction: column; height: 100%; max-height: 600px;"> | |
| <div style="padding: 20px 24px; border-bottom: 1px solid var(--border-light); background: rgba(255, 255, 255, 0.02); display: flex; align-items: center; justify-content: space-between;"> | |
| <span style="font-size: 15px; font-weight: 700; display: flex; align-items: center; gap: 8px;"> | |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="color: var(--accent);"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path></svg> | |
| My Books | |
| </span> | |
| <button class="btn btn-secondary btn-sm" onclick="abLoadSidebar()" style="padding: 6px; border-radius: 8px; border: 1px solid var(--border); background: var(--surface2); color: var(--light); transition: all 0.2s;" onmouseover="this.style.background='var(--border)'" onmouseout="this.style.background='var(--surface2)'"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg> | |
| </button> | |
| </div> | |
| <div id="ab-books-sidebar" style="flex: 1; overflow-y: auto; padding: 8px 12px;"> | |
| <div style="padding: 40px 20px; text-align: center; color: var(--muted); font-size: 14px; font-weight: 500;"> | |
| <div style="width: 24px; height: 24px; border: 2px solid rgba(99,102,241,0.2); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; margin: 0 auto 12px;"></div> | |
| Loading Library… | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ─── MANUAL UPLOAD TAB ─── --> | |
| <div id="ab-file-tab" style="display:none; animation: fadeIn 0.3s ease;"> | |
| <div class="grid-2" style="gap: 32px; align-items: start;"> | |
| <div> | |
| <div class="card" style="border-radius: 20px; padding: 32px; box-shadow: 0 12px 40px rgba(0,0,0,0.06); border: 1px solid var(--border-light);"> | |
| <div class="upload-zone" id="upload-zone" onclick="document.getElementById('file-input').click()" style="border: 2px dashed var(--border); border-radius: 16px; padding: 48px 24px; text-align: center; cursor: pointer; background: var(--surface2); transition: all 0.25s ease;" onmouseover="this.style.borderColor='var(--accent)'; this.style.background='rgba(99,102,241,0.04)'" onmouseout="this.style.borderColor='var(--border)'; this.style.background='var(--surface2)'"> | |
| <div class="upload-icon" style="font-size: 48px; margin-bottom: 16px; transform: translateY(0); transition: transform 0.2s ease;">📄</div> | |
| <div class="upload-text" style="font-size: 16px; font-weight: 500; color: var(--text); margin-bottom: 8px;">Drag & drop your book file here, or <strong style="color: var(--accent);">click to browse</strong></div> | |
| <div class="text-xs text-muted mt-2" style="font-size: 13px; font-weight: 500;">PDF, EPUB, DOCX, TXT — Max 50MB</div> | |
| </div> | |
| <input type="file" id="file-input" accept=".pdf,.epub,.docx,.txt" style="display:none"> | |
| <div id="upload-file-info" class="hidden mt-4" style="margin-top: 24px; padding: 16px; background: rgba(99,102,241,0.08); border: 1px solid rgba(99,102,241,0.2); border-radius: 12px; font-size: 14px; font-weight: 600; color: var(--accent); display: flex; align-items: center; gap: 10px;"></div> | |
| <div id="upload-progress-wrap" class="hidden mt-4" style="margin-top: 24px;"> | |
| <div class="flex items-center justify-between mb-4" style="margin-bottom: 12px;"> | |
| <span class="text-sm fw-600" id="upload-stage" style="font-weight: 700; color: var(--text);">Preparing...</span> | |
| <span class="text-xs text-muted" id="upload-pct" style="font-weight: 600; color: var(--accent);">0%</span> | |
| </div> | |
| <div class="progress-bar" style="height: 8px; border-radius: 4px; background: var(--border); overflow: hidden;"> | |
| <div class="progress-fill" id="upload-fill" style="height: 100%; border-radius: 4px; background: linear-gradient(90deg, var(--accent), var(--accent2)); transition: width 0.3s ease; width: 0%"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div> | |
| <div class="card" style="border-radius: 20px; padding: 32px; box-shadow: 0 12px 40px rgba(0,0,0,0.06); border: 1px solid var(--border-light);"> | |
| <div class="card-title" style="font-size: 20px; font-weight: 800; margin-bottom: 6px; color: var(--text);">Book Details</div> | |
| <div class="card-sub" style="font-size: 14px; color: var(--muted); margin-bottom: 24px; line-height: 1.5;">Provide metadata for your book (optional — can be edited later)</div> | |
| <div style="display: flex; flex-direction: column; gap: 20px;"> | |
| <div class="form-group" style="margin: 0;"> | |
| <label class="form-label" style="font-size: 13px; font-weight: 700; color: var(--light); margin-bottom: 8px; display: block;">Title</label> | |
| <input type="text" class="form-input" id="book-title" placeholder="My Awesome Book" style="height: 48px; border-radius: 10px; font-size: 14.5px;"> | |
| </div> | |
| <div class="form-group" style="margin: 0;"> | |
| <label class="form-label" style="font-size: 13px; font-weight: 700; color: var(--light); margin-bottom: 8px; display: block;">Genre</label> | |
| <input type="text" class="form-input" id="book-genre" placeholder="e.g. Fiction, Self-Help, Sci-Fi" style="height: 48px; border-radius: 10px; font-size: 14.5px;"> | |
| </div> | |
| <div class="grid-2" style="gap: 16px;"> | |
| <div class="form-group" style="margin: 0;"> | |
| <label class="form-label" style="font-size: 13px; font-weight: 700; color: var(--light); margin-bottom: 8px; display: block;">Price ($)</label> | |
| <input type="number" class="form-input" id="book-price" placeholder="9.99" step="0.01" style="height: 48px; border-radius: 10px; font-size: 14.5px;"> | |
| </div> | |
| <div class="form-group" style="margin: 0;"> | |
| <label class="form-label" style="font-size: 13px; font-weight: 700; color: var(--light); margin-bottom: 8px; display: block;">Buy URL</label> | |
| <input type="url" class="form-input" id="book-buy-url" placeholder="https://amazon.com/..." style="height: 48px; border-radius: 10px; font-size: 14.5px;"> | |
| </div> | |
| </div> | |
| <button class="btn btn-primary btn-block" id="upload-btn" disabled style="margin-top: 12px; height: 52px; font-size: 15px; font-weight: 700; border-radius: 12px; box-shadow: 0 6px 20px rgba(99,102,241,0.25); transition: all 0.2s; display: flex; align-items: center; justify-content: center; gap: 8px;"> | |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> | |
| Upload & Index | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ═══ READERS ═══ --> | |
| <div class="page" id="page-readers"> | |
| <div class="page-header"><h1 class="page-title">Readers <span class="info-tip">ℹ<span class="tip-text">Browse all reader chat sessions. Each row shows a reader's conversation with your bot, including message count and last activity. Click to view the full transcript.</span></span></h1><p class="page-sub">View and manage reader chat sessions</p></div> | |
| <div class="toolbar"> | |
| <div class="search-bar"><span>🔍</span><input type="text" placeholder="Search by session..." id="reader-search" oninput="filterSessions()"></div> | |
| <div class="toolbar-spacer"></div> | |
| <span class="text-xs text-muted" id="session-count">— sessions</span> | |
| </div> | |
| <div class="card" style="padding:0;"><div class="table-wrap"><table><thead><tr><th>Session</th><th>Country</th><th>Device</th><th>Rating</th><th>Date</th><th>Status</th><th>Actions</th></tr></thead><tbody id="sessions-body"><tr><td colspan="7" class="text-muted" style="text-align:center;padding:40px;">Loading...</td></tr></tbody></table></div></div> | |
| </div> | |
| <!-- ═══ ANALYTICS ═══ --> | |
| <div class="page" id="page-analytics"> | |
| <div class="page-header"><h1 class="page-title">Analytics <span class="info-tip">ℹ<span class="tip-text">Performance insights across all your books. Charts show sessions over time, most popular books, and engagement trends. Data is aggregated from all reader interactions.</span></span></h1><p class="page-sub">Deep dive into your chatbot performance metrics</p></div> | |
| <div class="toolbar"> | |
| <div class="tabs" id="analytics-tabs"> | |
| <button class="tab active" data-days="7">7 Days</button> | |
| <button class="tab" data-days="30">30 Days</button> | |
| <button class="tab" data-days="90">90 Days</button> | |
| <button class="tab" data-days="365">1 Year</button> | |
| </div> | |
| </div> | |
| <div class="stat-grid" id="analytics-stats"> | |
| <div class="stat-card"><div class="stat-icon">💬</div><div class="stat-val accent" id="a-total-sessions">—</div><div class="stat-label">Total Sessions</div></div> | |
| <div class="stat-card"><div class="stat-icon">👥</div><div class="stat-val green" id="a-unique-visitors">—</div><div class="stat-label">Unique Visitors</div></div> | |
| <div class="stat-card"><div class="stat-icon">🔗</div><div class="stat-val purple" id="a-link-clicks">—</div><div class="stat-label">Link Clicks</div></div> | |
| <div class="stat-card"><div class="stat-icon">⚡</div><div class="stat-val yellow" id="a-avg-turns">—</div><div class="stat-label">Avg Turns/Session</div></div> | |
| </div> | |
| <div class="card"><div class="card-title">Sessions Over Time</div><div class="chart-container"><canvas id="chart-analytics"></canvas></div></div> | |
| </div> | |
| <!-- ═══ TOKEN USAGE ═══ --> | |
| <div class="page" id="page-tokens"> | |
| <div class="page-header"><h1 class="page-title">Token Usage <span class="info-tip">ℹ<span class="tip-text">Tracks your AI token consumption. Each chatbot response uses tokens from your budget. When your budget is exhausted, the chatbot will show a friendly "recharging" message until tokens are replenished.</span></span></h1><p class="page-sub">Monitor your AI token budget and consumption</p></div> | |
| <div id="token-subscription-banner" class="hidden" style="margin-bottom:16px;padding:14px 18px;border-radius:10px;background:var(--yellow-bg);border:1px solid var(--yellow);font-size:13px;color:var(--text2);"> | |
| ⚠️ <strong>No active subscription.</strong> Contact your platform administrator to get a package before using the chatbot. | |
| </div> | |
| <div class="grid-2"> | |
| <div class="card" style="text-align:center;padding:40px;"> | |
| <div style="position:relative;width:180px;height:180px;margin:0 auto 20px;"> | |
| <canvas id="chart-token-gauge" width="180" height="180"></canvas> | |
| <div style="position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;"> | |
| <div style="font-size:2rem;font-weight:800;color:var(--accent);" id="token-pct">—%</div> | |
| <div class="text-xs text-muted">used</div> | |
| </div> | |
| </div> | |
| <div class="text-sm text-muted">Budget: <strong id="token-budget">—</strong> tokens</div> | |
| <div class="text-xs text-muted mt-2">Used: <span id="token-used">—</span> | Remaining: <span id="token-remaining">—</span></div> | |
| </div> | |
| <div class="card"><div class="card-title">Daily Token Consumption</div><div class="chart-container"><canvas id="chart-token-daily"></canvas></div></div> | |
| </div> | |
| </div> | |
| <!-- ═══ WIDGET CONFIG ═══ --> | |
| <div class="page" id="page-widget"> | |
| <div class="page-header"><h1 class="page-title">Widget Config <span class="info-tip">ℹ<span class="tip-text">Customize your chat widget's appearance: theme color, position, welcome message, and auto-open behavior. Changes save automatically and take effect immediately on your website.</span></span></h1><p class="page-sub">Customize how your chat widget looks on your website</p></div> | |
| <div class="grid-2"> | |
| <div> | |
| <div class="card"> | |
| <div class="card-title">Appearance</div> | |
| <div class="form-group"><label class="form-label">Bot Name</label><input type="text" class="form-input" id="w-bot-name" placeholder="BookBot" oninput="updateWidgetPreview()"></div> | |
| <div class="form-group"><label class="form-label">Welcome Message</label><textarea class="form-input" id="w-welcome" placeholder="Hi! I'm here to help..." oninput="updateWidgetPreview()"></textarea></div> | |
| <div class="form-group"><label class="form-label">Theme</label> | |
| <select class="form-input" id="w-theme" onchange="updateWidgetPreview()"> | |
| <option value="pearl">Pearl (Light)</option><option value="midnight">Midnight (Dark)</option><option value="ocean">Ocean</option> | |
| <option value="forest">Forest</option><option value="sunset">Sunset</option><option value="lavender">Lavender</option> | |
| </select> | |
| </div> | |
| <div class="form-group"><label class="form-label">Position</label> | |
| <select class="form-input" id="w-position" onchange="updateWidgetPreview()"> | |
| <option value="bottom-right">Bottom Right</option><option value="bottom-left">Bottom Left</option> | |
| </select> | |
| </div> | |
| <div class="form-group"><label class="form-label">Auto-Open Delay (seconds, 0 = off)</label><input type="number" class="form-input" id="w-delay" value="0" min="0" max="60"></div> | |
| <button class="btn btn-primary btn-block" id="save-widget-btn">Save Configuration</button> | |
| </div> | |
| </div> | |
| <div> | |
| <div class="card"><div class="card-title">Live Preview</div><div class="card-sub">See how your widget will appear</div> | |
| <div class="widget-preview" id="widget-preview"> | |
| <div style="background:var(--accent);color:white;padding:16px;display:flex;align-items:center;gap:10px;"> | |
| <div style="width:32px;height:32px;border-radius:50%;background:rgba(255,255,255,0.2);display:flex;align-items:center;justify-content:center;">🤖</div> | |
| <div><div style="font-weight:600;font-size:14px;" id="wp-name">BookBot</div><div style="font-size:11px;opacity:0.8;">Online</div></div> | |
| </div> | |
| <div style="flex:1;padding:16px;overflow-y:auto;background:var(--bg);"> | |
| <div style="background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:10px 14px;font-size:13px;max-width:80%;color:var(--text);" id="wp-msg">Hi! How can I help you today?</div> | |
| </div> | |
| <div style="padding:12px;border-top:1px solid var(--border);display:flex;gap:8px;"> | |
| <input disabled style="flex:1;border:1px solid var(--border);border-radius:20px;padding:8px 14px;font-size:12px;background:var(--surface2);color:var(--muted);" placeholder="Ask me anything..."> | |
| <div style="width:32px;height:32px;border-radius:50%;background:var(--accent);display:flex;align-items:center;justify-content:center;color:white;font-size:14px;">↑</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ═══ EMBED CODE ═══ --> | |
| <div class="page" id="page-embed"> | |
| <div class="page-header"> | |
| <h1 class="page-title">Add Chatbot to Your Website</h1> | |
| <p class="page-sub">Copy one line, paste it in your website footer — your chatbot goes live instantly</p> | |
| </div> | |
| <!-- Live preview notice --> | |
| <div class="card" style="border:2px solid var(--green);margin-bottom:16px;background:linear-gradient(135deg,rgba(34,197,94,0.08),transparent);"> | |
| <div class="card-title" style="color:var(--green);">✅ See it working right now</div> | |
| <div class="card-sub">Look at the <strong>💬 chat bubble in the bottom corner of this page</strong>. That is exactly what your readers will see on your website.</div> | |
| <div id="embed-live-status" style="margin-top:12px;padding:12px 16px;background:var(--surface2);border-radius:8px;font-size:13px;"> | |
| <span style="color:var(--muted);">🔄 Loading your chatbot preview...</span> | |
| </div> | |
| </div> | |
| <!-- Step 1: Copy code --> | |
| <div class="card" style="margin-bottom:16px;"> | |
| <div class="card-title">Step 1 — Copy this one line</div> | |
| <div class="card-sub">Click the copy button. Your secure access token is already included — nothing else to configure.</div> | |
| <div id="embed-token-status" style="margin-top:14px;padding:12px 16px;background:var(--surface2);border-radius:8px;font-size:13px;"> | |
| <span style="color:var(--muted);">🔄 Checking subscription...</span> | |
| </div> | |
| <div style="position:relative;margin-top:14px;"> | |
| <pre style="background:#0d1117;color:#e6edf3;border:1px solid var(--border);border-radius:10px;padding:20px;font-size:12px;overflow-x:auto;line-height:1.7;white-space:pre-wrap;word-break:break-all;" id="embed-code">⏳ Preparing your embed code...</pre> | |
| <button class="btn btn-primary btn-sm" style="position:absolute;top:12px;right:12px;" onclick="copyEmbedCode()">📋 Copy Code</button> | |
| </div> | |
| <div style="margin-top:10px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;"> | |
| <label style="font-size:13px;font-weight:600;color:var(--muted);">Theme:</label> | |
| <select class="form-input" style="width:auto;margin:0;padding:4px 10px;font-size:13px;" id="embed-theme" onchange="generateEmbedCode()"> | |
| <option value="midnight">Midnight (Dark)</option> | |
| <option value="minimal">Pearl (Light)</option> | |
| <option value="ocean">Ocean</option> | |
| <option value="forest">Forest</option> | |
| <option value="sunset">Sunset</option> | |
| </select> | |
| <label style="font-size:13px;font-weight:600;color:var(--muted);margin-left:8px;">Position:</label> | |
| <select class="form-input" style="width:auto;margin:0;padding:4px 10px;font-size:13px;" id="embed-pos" onchange="generateEmbedCode()"> | |
| <option value="bottom-right">Bottom Right</option> | |
| <option value="bottom-left">Bottom Left</option> | |
| </select> | |
| </div> | |
| </div> | |
| <!-- Step 2: Paste --> | |
| <div class="card" style="margin-bottom:16px;"> | |
| <div class="card-title">Step 2 — Paste it on your website</div> | |
| <div class="card-sub">Paste the code in your website's <strong>footer</strong> section. Every platform has a place for this — pick yours below.</div> | |
| <div style="display:flex;gap:8px;flex-wrap:wrap;margin:14px 0;"> | |
| <button class="btn btn-secondary btn-sm" onclick="showPlatform('wordpress')" id="plat-wordpress">WordPress</button> | |
| <button class="btn btn-secondary btn-sm" onclick="showPlatform('squarespace')" id="plat-squarespace">Squarespace</button> | |
| <button class="btn btn-secondary btn-sm" onclick="showPlatform('webflow')" id="plat-webflow">Webflow</button> | |
| <button class="btn btn-secondary btn-sm" onclick="showPlatform('wix')" id="plat-wix">Wix</button> | |
| <button class="btn btn-secondary btn-sm" onclick="showPlatform('shopify')" id="plat-shopify">Shopify</button> | |
| <button class="btn btn-secondary btn-sm" onclick="showPlatform('html')" id="plat-html">Other / HTML</button> | |
| </div> | |
| <div id="platform-instructions" style="font-size:14px;line-height:1.8;color:var(--text2);padding:14px;background:var(--surface2);border-radius:8px;"></div> | |
| </div> | |
| <!-- Step 3: Done --> | |
| <div class="card"> | |
| <div class="card-title">Step 3 — You're done!</div> | |
| <div class="card-sub">Visit your website and look for the 💬 bubble in the corner. Click it to test a conversation. Come back here anytime to change the theme or copy the code again.</div> | |
| <div style="margin-top:14px;display:flex;gap:12px;flex-wrap:wrap;"> | |
| <button class="btn btn-primary" onclick="openWidgetPreview('midnight')">Open Full-Screen Preview</button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ═══ SMART LINKS ═══ --> | |
| <div class="page" id="page-links"> | |
| <div class="page-header"><h1 class="page-title">Smart Links <span class="info-tip">ℹ<span class="tip-text">Add purchase URLs (Amazon, BookShop, etc.) and preview links for each book. These links appear as clickable cards in bot responses when readers ask about a book, driving sales.</span></span></h1><p class="page-sub">Configure buy/preview URLs for each book — shown to readers during chat</p></div> | |
| <div class="card"><div id="links-content">Loading...</div></div> | |
| </div> | |
| <!-- ═══ BOT PERSONALITY ═══ --> | |
| <div class="page" id="page-personality"> | |
| <div class="page-header"><h1 class="page-title">Bot Personality <span class="info-tip">ℹ<span class="tip-text">Shape your bot's voice: name, avatar, welcome message, fallback response, and communication style (friendly/balanced/concise). These settings define how readers experience your chatbot.</span></span></h1><p class="page-sub">Configure how your AI chatbot communicates with readers</p></div> | |
| <div class="card"> | |
| <div class="form-group"><label class="form-label">Response Style</label> | |
| <select class="form-input" id="p-style"><option value="balanced">Balanced</option><option value="formal">Formal</option><option value="casual">Casual</option><option value="enthusiastic">Enthusiastic</option></select> | |
| </div> | |
| <div class="form-group"><label class="form-label">Fallback Message (when bot can't answer)</label><textarea class="form-input" id="p-fallback" placeholder="I'm not sure about that detail — but I can tell you plenty about the books. What would you like to know?"></textarea></div> | |
| <div class="form-group"><label class="form-label">Out-of-Scope Message (non-book questions)</label><textarea class="form-input" id="p-outofscope" placeholder="I'm specialized in discussing books..."></textarea></div> | |
| <button class="btn btn-primary" id="save-personality-btn">Save Personality Settings</button> | |
| </div> | |
| </div> | |
| <!-- ═══ PROFILE ═══ --> | |
| <div class="page" id="page-profile"> | |
| <div class="page-header"><h1 class="page-title">Profile <span class="info-tip">ℹ<span class="tip-text">Your public author profile: name, website, bio, and logo. This information is shown to readers and used in the chatbot's context to personalize responses about your brand.</span></span></h1><p class="page-sub">Your author profile and public information</p></div> | |
| <div class="card"> | |
| <div class="grid-2"> | |
| <div class="form-group"><label class="form-label">Full Name</label><input type="text" class="form-input" id="prof-name"></div> | |
| <div class="form-group"><label class="form-label">Website</label><input type="url" class="form-input" id="prof-website" placeholder="https://yoursite.com"></div> | |
| </div> | |
| <div class="form-group"><label class="form-label">Bio</label><textarea class="form-input" id="prof-bio" placeholder="Tell readers about yourself..."></textarea></div> | |
| <div class="form-group"><label class="form-label">Timezone</label> | |
| <select class="form-input" id="prof-tz"><option>America/New_York</option><option>America/Chicago</option><option>America/Denver</option><option>America/Los_Angeles</option><option>Europe/London</option><option>Europe/Paris</option><option>Asia/Tokyo</option><option>Asia/Kolkata</option><option>Australia/Sydney</option></select> | |
| </div> | |
| <button class="btn btn-primary" id="save-profile-btn">Save Profile</button> | |
| </div> | |
| </div> | |
| <!-- ═══ NOTIFICATIONS ═══ --> | |
| <div class="page" id="page-notifications"> | |
| <div class="page-header"><h1 class="page-title">Notifications <span class="info-tip">ℹ<span class="tip-text">Configure which email alerts you receive: weekly digest, token usage alerts, new conversation alerts, and subscription expiry reminders. Toggle each individually.</span></span></h1><p class="page-sub">Control which email notifications you receive</p></div> | |
| <div class="card"> | |
| <div class="flex items-center justify-between" style="padding:14px 0;border-bottom:1px solid var(--border-light);"> | |
| <div><div class="fw-600 text-sm">Weekly Digest</div><div class="text-xs text-muted">Summary of your chatbot activity</div></div> | |
| <label class="toggle"><input type="checkbox" id="n-weekly" checked><div class="toggle-track"></div><div class="toggle-thumb"></div></label> | |
| </div> | |
| <div class="flex items-center justify-between" style="padding:14px 0;border-bottom:1px solid var(--border-light);"> | |
| <div><div class="fw-600 text-sm">Token Usage Alerts</div><div class="text-xs text-muted">Get notified at 80% and 95% budget usage</div></div> | |
| <label class="toggle"><input type="checkbox" id="n-tokens" checked><div class="toggle-track"></div><div class="toggle-thumb"></div></label> | |
| </div> | |
| <div class="flex items-center justify-between" style="padding:14px 0;border-bottom:1px solid var(--border-light);"> | |
| <div><div class="fw-600 text-sm">New Conversations</div><div class="text-xs text-muted">Email when a new reader starts chatting</div></div> | |
| <label class="toggle"><input type="checkbox" id="n-newchat"><div class="toggle-track"></div><div class="toggle-thumb"></div></label> | |
| </div> | |
| <div class="flex items-center justify-between" style="padding:14px 0;"> | |
| <div><div class="fw-600 text-sm">Subscription Expiry</div><div class="text-xs text-muted">Reminder before your subscription expires</div></div> | |
| <label class="toggle"><input type="checkbox" id="n-expiry" checked><div class="toggle-track"></div><div class="toggle-thumb"></div></label> | |
| </div> | |
| <button class="btn btn-primary mt-4" id="save-notif-btn">Save Preferences</button> | |
| </div> | |
| </div> | |
| <!-- ═══ SECURITY ═══ --> | |
| <div class="page" id="page-security"> | |
| <div class="page-header"><h1 class="page-title">Security <span class="info-tip">ℹ<span class="tip-text">Change your account password here. Passwords must be at least 8 characters with uppercase, lowercase, and a number. Sessions expire after 15 minutes for security.</span></span></h1><p class="page-sub">Manage your account security and password</p></div> | |
| <div class="card"> | |
| <div class="card-title">Change Password</div> | |
| <div class="card-sub">Use a strong password with uppercase, lowercase, numbers, and special characters</div> | |
| <div class="form-group"><label class="form-label">Current Password</label><input type="password" class="form-input" id="sec-cur-pass" style="max-width:400px;"></div> | |
| <div class="form-group"><label class="form-label">New Password</label><input type="password" class="form-input" id="sec-new-pass" style="max-width:400px;" oninput="checkPasswordStrength()"></div> | |
| <div id="pass-strength" class="text-xs mt-2" style="max-width:400px;"></div> | |
| <div class="form-group"><label class="form-label">Confirm New Password</label><input type="password" class="form-input" id="sec-confirm-pass" style="max-width:400px;"></div> | |
| <button class="btn btn-primary" id="change-pass-btn">Update Password</button> | |
| <p class="msg" id="pass-msg" style="margin-top:12px;font-size:13px;"></p> | |
| </div> | |
| </div> | |
| <!-- ── CONVERSATIONS PAGE ── --> | |
| <div class="page" id="page-conversations"> | |
| <div class="page-header"> | |
| <h1 class="page-title">Conversations</h1> | |
| <p class="page-sub">View full chat transcripts, annotate messages, and search across all conversations</p> | |
| </div> | |
| <div class="card"> | |
| <div style="display:flex;gap:12px;align-items:center;margin-bottom:16px;"> | |
| <input type="text" class="form-input" id="conv-search" placeholder="Search messages..." style="flex:1;margin:0;" onkeydown="if(event.key==='Enter')searchConversations()"> | |
| <button class="btn btn-primary btn-sm" onclick="searchConversations()">Search</button> | |
| </div> | |
| <div id="conv-results" style="margin-bottom:16px;"></div> | |
| </div> | |
| <div style="display:grid;grid-template-columns:320px 1fr;gap:20px;" id="conv-split"> | |
| <div class="card" style="max-height:600px;overflow-y:auto;"> | |
| <div class="card-title">Sessions</div> | |
| <div id="conv-session-list" style="font-size:13px;"></div> | |
| </div> | |
| <div class="card" style="max-height:600px;overflow-y:auto;"> | |
| <div class="card-title" id="conv-transcript-title">Select a session</div> | |
| <div id="conv-transcript"></div> | |
| <div id="conv-pagination" style="margin-top:12px;text-align:center;"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ── CUSTOM Q&A PAGE ── --> | |
| <div class="page" id="page-qa"> | |
| <div class="page-header"> | |
| <h1 class="page-title">Custom Q&A</h1> | |
| <p class="page-sub">Define custom question-answer pairs that override AI responses</p> | |
| </div> | |
| <div class="card" style="margin-bottom:20px;"> | |
| <div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;"> | |
| <button class="btn btn-primary btn-sm" onclick="showAddQAModal()">+ Add Q&A Pair</button> | |
| <label class="btn btn-secondary btn-sm" style="cursor:pointer;">📤 Import CSV <input type="file" accept=".csv" style="display:none;" onchange="importQACSV(this)"></label> | |
| <button class="btn btn-secondary btn-sm" onclick="exportQACSV()">📥 Export CSV</button> | |
| <span class="text-muted text-sm" id="qa-count"></span> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <div id="qa-list"></div> | |
| </div> | |
| </div> | |
| <!-- ── EXPORT PAGE ── --> | |
| <div class="page" id="page-exports"> | |
| <div class="page-header"> | |
| <h1 class="page-title">Export Center</h1> | |
| <p class="page-sub">Download your data in CSV format for analysis or backup</p> | |
| </div> | |
| <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:20px;"> | |
| <div class="card"> | |
| <div class="card-title">📊 Sessions Export</div> | |
| <div class="card-sub">Download all reader session data including device, country, and rating info.</div> | |
| <div class="form-group"><label class="form-label">Time Range (days)</label><input type="number" class="form-input" id="export-sessions-days" value="30" min="1" max="365" style="max-width:120px;"></div> | |
| <button class="btn btn-primary btn-sm" onclick="downloadExport('sessions')">Download Sessions CSV</button> | |
| </div> | |
| <div class="card"> | |
| <div class="card-title">📈 Analytics Export</div> | |
| <div class="card-sub">Download daily analytics with token usage, latency, and link click metrics.</div> | |
| <div class="form-group"><label class="form-label">Time Range (days)</label><input type="number" class="form-input" id="export-analytics-days" value="30" min="1" max="365" style="max-width:120px;"></div> | |
| <button class="btn btn-primary btn-sm" onclick="downloadExport('analytics')">Download Analytics CSV</button> | |
| </div> | |
| <div class="card"> | |
| <div class="card-title">💬 Conversations Export</div> | |
| <div class="card-sub">Download full chat transcripts with intent classification and quality metrics.</div> | |
| <div class="form-group"><label class="form-label">Time Range (days)</label><input type="number" class="form-input" id="export-conv-days" value="7" min="1" max="30" style="max-width:120px;"></div> | |
| <button class="btn btn-primary btn-sm" onclick="downloadExport('conversations')">Download Transcripts CSV</button> | |
| </div> | |
| </div> | |
| </div> | |
| </main> | |
| <button type="button" class="ab-contact-tab" id="ab-contact-tab" onclick="abShowContactForm()" aria-label="Contact support"> | |
| <svg class="ab-contact-tab-icon" viewBox="0 0 24 24" aria-hidden="true"> | |
| <rect x="3" y="5" width="18" height="14" rx="1.5"/> | |
| <path d="M3 7.5 12 13l9-5.5"/> | |
| </svg> | |
| <span class="ab-contact-tab-text">Support</span> | |
| </button> | |
| </div> | |
| <!-- ═══ JAVASCRIPT ═══ --> | |
| <script> | |
| /* ── GLOBALS ── */ | |
| const API = window.location.origin + '/api'; | |
| function handleSessionExpired() { | |
| token = null; | |
| authorSlug = null; | |
| document.getElementById('auth-screen').style.display = ''; | |
| document.getElementById('dashboard').style.display = 'none'; | |
| const msg = document.getElementById('login-msg'); | |
| if (msg) { | |
| msg.textContent = 'Your session expired. Please sign in again.'; | |
| msg.style.color = 'var(--red)'; | |
| } | |
| } | |
| const auth = new AuthorBotAuthClient({ | |
| apiBase: API, | |
| accessKey: 'access_token', | |
| refreshKey: 'refresh_token', | |
| slugKey: 'author_slug', | |
| onSessionExpired: handleSessionExpired, | |
| }); | |
| let token = auth.getAccessToken(); | |
| let authorSlug = auth.getAuthorSlug(); | |
| let allBooks = []; | |
| let allSessions = []; | |
| let chartInstances = {}; | |
| /* ── TOAST SYSTEM ── */ | |
| function toast(msg, type = 'info') { | |
| const c = document.getElementById('toast-container'); | |
| const t = document.createElement('div'); | |
| t.className = `toast ${type}`; | |
| t.innerHTML = `<span>${{success:'✅',error:'❌',info:'ℹ️'}[type]||'ℹ️'}</span><span>${msg}</span><button class="toast-close" onclick="this.parentElement.remove()">×</button>`; | |
| c.appendChild(t); | |
| setTimeout(() => { t.style.opacity = '0'; t.style.transform = 'translateX(100px)'; setTimeout(() => t.remove(), 300); }, 4000); | |
| } | |
| /* ── MODAL SYSTEM ── */ | |
| function showModal(title, body, actions = '') { | |
| const o = document.getElementById('modal-overlay'); | |
| document.getElementById('modal-content').innerHTML = `<div class="modal-title">${title}</div>${body}${actions ? `<div class="modal-actions">${actions}</div>` : ''}`; | |
| o.classList.add('active'); | |
| } | |
| function closeModal() { | |
| document.getElementById('modal-overlay').classList.remove('active'); | |
| if (_abPpModalEscHandler) { | |
| document.removeEventListener('keydown', _abPpModalEscHandler); | |
| _abPpModalEscHandler = null; | |
| } | |
| } | |
| document.getElementById('modal-overlay').addEventListener('click', e => { if (e.target === e.currentTarget) closeModal(); }); | |
| /* ── AUTH ── */ | |
| function clearSuperAdminSessionKeys() { | |
| localStorage.removeItem('sa_token'); | |
| localStorage.removeItem('sa_refresh_token'); | |
| } | |
| function clearAuthorSessionKeys() { | |
| localStorage.removeItem('access_token'); | |
| localStorage.removeItem('refresh_token'); | |
| localStorage.removeItem('author_slug'); | |
| } | |
| auth.restoreSession(function (user) { | |
| return user && user.role === 'author'; | |
| }).then(function (ok) { | |
| if (ok) { | |
| clearSuperAdminSessionKeys(); | |
| token = auth.getAccessToken(); | |
| authorSlug = auth.getAuthorSlug(); | |
| showDashboard(); | |
| } else if (auth.getAccessToken()) { | |
| auth.clearSession(); | |
| token = null; | |
| authorSlug = null; | |
| } | |
| }); | |
| document.getElementById('login-btn').onclick = async () => { | |
| const msg = document.getElementById('login-msg'); | |
| msg.textContent = ''; msg.style.color = ''; | |
| const btn = document.getElementById('login-btn'); | |
| btn.disabled = true; btn.textContent = 'Signing in...'; | |
| try { | |
| const data = await auth.login( | |
| document.getElementById('email').value, | |
| document.getElementById('password').value | |
| ); | |
| if (data.is_superadmin || data.role === 'superadmin') { | |
| auth.clearSession(); | |
| clearSuperAdminSessionKeys(); | |
| window.location.href = '/superadmin'; | |
| return; | |
| } | |
| clearSuperAdminSessionKeys(); | |
| token = auth.getAccessToken(); | |
| authorSlug = data.author_slug || auth.getAuthorSlug() || 'me'; | |
| if (authorSlug) localStorage.setItem('author_slug', authorSlug); | |
| toast('Welcome back!', 'success'); | |
| showDashboard(); | |
| } catch (e) { | |
| msg.textContent = e.message; msg.style.color = 'var(--red)'; | |
| } finally { btn.disabled = false; btn.textContent = 'Sign In'; } | |
| }; | |
| ['email', 'password'].forEach(id => document.getElementById(id).addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('login-btn').click(); })); | |
| document.getElementById('logout-btn').onclick = function () { | |
| auth.logout().finally(function () { location.reload(); }); | |
| }; | |
| function showDashboard() { | |
| document.getElementById('auth-screen').style.display = 'none'; | |
| document.getElementById('dashboard').style.display = 'block'; | |
| loadUserInfo(); | |
| abLoadSupportConfig(); | |
| loadDashboard(); | |
| } | |
| /* ── NAVIGATION ── */ | |
| function nav(page) { | |
| document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active')); | |
| document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); | |
| const btn = document.querySelector(`.nav-item[data-page="${page}"]`); | |
| if (btn) btn.classList.add('active'); | |
| const pg = document.getElementById('page-' + page); | |
| if (pg) pg.classList.add('active'); | |
| // Lazy load | |
| const loaders = { books: loadBooks, readers: loadSessions, analytics: () => loadAnalytics(30), upload: null, tokens: loadTokenUsage, embed: generateEmbedCode, links: loadSmartLinks, widget: loadWidgetConfig, profile: loadProfile, personality: loadPersonality, notifications: loadNotifications, conversations: loadConversations, qa: loadQAPairs, exports: null }; | |
| if (loaders[page]) loaders[page](); | |
| } | |
| document.querySelectorAll('.nav-item[data-page]').forEach(el => { el.onclick = () => nav(el.dataset.page); }); | |
| /* ── API HELPERS (auto-refresh on 401) ── */ | |
| async function apiGet(path) { | |
| const data = await auth.get(path); | |
| token = auth.getAccessToken(); | |
| return data; | |
| } | |
| async function apiPost(path, body) { | |
| const data = await auth.post(path, body); | |
| token = auth.getAccessToken(); | |
| return data; | |
| } | |
| async function apiPut(path, body) { | |
| const data = await auth.put(path, body); | |
| token = auth.getAccessToken(); | |
| return data; | |
| } | |
| async function apiPatch(path, body) { | |
| const data = await auth.patch(path, body); | |
| token = auth.getAccessToken(); | |
| return data; | |
| } | |
| async function apiDelete(path) { | |
| const data = await auth.delete(path); | |
| token = auth.getAccessToken(); | |
| return data; | |
| } | |
| /* ── USER INFO ── */ | |
| async function loadUserInfo() { | |
| try { | |
| const u = await apiGet('/auth/me'); | |
| document.getElementById('user-display-name').textContent = u.full_name || 'Author'; | |
| document.getElementById('user-display-email').textContent = u.email || ''; | |
| document.getElementById('user-initials').textContent = (u.full_name || 'A')[0].toUpperCase(); | |
| } catch {} | |
| } | |
| /* ── DASHBOARD ── */ | |
| async function loadDashboard() { | |
| try { | |
| const data = await apiGet(`/admin/${authorSlug}/dashboard`); | |
| animateNumber('stat-books', data.total_books || 0); | |
| animateNumber('stat-sessions', data.total_sessions || 0); | |
| animateNumber('stat-active', data.active_books || 0); | |
| animateNumber('stat-clicks', data.total_clicks || 0); | |
| const gaps = data.distribution_gaps || {}; | |
| const gapsBooks = gaps.books_with_gaps || 0; | |
| const gapsCard = document.getElementById('stat-gaps-card'); | |
| const dashGaps = document.getElementById('dashboard-gaps-card'); | |
| if (gapsBooks > 0) { | |
| if (gapsCard) gapsCard.style.display = ''; | |
| animateNumber('stat-gaps', gapsBooks); | |
| if (dashGaps) { | |
| dashGaps.style.display = ''; | |
| document.getElementById('dashboard-gaps-detail').textContent = | |
| `${gapsBooks} book${gapsBooks === 1 ? '' : 's'} missing ${gaps.total_missing_platforms || 0} platform listing${(gaps.total_missing_platforms || 0) === 1 ? '' : 's'}.`; | |
| } | |
| } else { | |
| if (gapsCard) gapsCard.style.display = 'none'; | |
| if (dashGaps) dashGaps.style.display = 'none'; | |
| } | |
| } catch(e) { console.error('Dashboard:', e); } | |
| // Load mini chart | |
| try { | |
| const analytics = await apiGet(`/admin/${authorSlug}/analytics?days=30`); | |
| renderLineChart('chart-sessions', analytics.daily_sessions || [], 'Sessions'); | |
| } catch {} | |
| } | |
| function animateNumber(id, target) { | |
| const el = document.getElementById(id); | |
| if (!el) return; | |
| const duration = 600; | |
| const start = performance.now(); | |
| const initial = parseInt(el.textContent) || 0; | |
| function step(now) { | |
| const progress = Math.min((now - start) / duration, 1); | |
| const eased = 1 - Math.pow(1 - progress, 3); | |
| el.textContent = Math.round(initial + (target - initial) * eased).toLocaleString(); | |
| if (progress < 1) requestAnimationFrame(step); | |
| } | |
| requestAnimationFrame(step); | |
| } | |
| /* ── CHARTS ── */ | |
| function renderLineChart(canvasId, data, label) { | |
| const canvas = document.getElementById(canvasId); | |
| if (!canvas) return; | |
| if (chartInstances[canvasId]) chartInstances[canvasId].destroy(); | |
| const ctx = canvas.getContext('2d'); | |
| const gradient = ctx.createLinearGradient(0, 0, 0, 250); | |
| gradient.addColorStop(0, 'rgba(99,102,241,0.15)'); | |
| gradient.addColorStop(1, 'rgba(99,102,241,0)'); | |
| chartInstances[canvasId] = new Chart(ctx, { | |
| type: 'line', | |
| data: { | |
| labels: data.map(d => d.date), | |
| datasets: [{ label, data: data.map(d => d.count), borderColor: '#6366f1', backgroundColor: gradient, fill: true, tension: 0.4, borderWidth: 2, pointRadius: 3, pointHoverRadius: 6, pointBackgroundColor: '#6366f1' }] | |
| }, | |
| options: { | |
| responsive: true, maintainAspectRatio: false, | |
| plugins: { legend: { display: false }, tooltip: { backgroundColor: '#1e293b', titleFont: { family: 'Inter' }, bodyFont: { family: 'Inter' }, cornerRadius: 8, padding: 10 } }, | |
| scales: { x: { grid: { display: false }, ticks: { font: { size: 11, family: 'Inter' }, color: '#94a3b8' } }, y: { grid: { color: '#f1f3f9' }, ticks: { font: { size: 11, family: 'Inter' }, color: '#94a3b8' }, beginAtZero: true } }, | |
| animation: { duration: 800, easing: 'easeOutQuart' } | |
| } | |
| }); | |
| } | |
| /* ── BOOKS ── */ | |
| async function loadBooks() { | |
| const tbody = document.getElementById('books-body'); | |
| // Rich skeleton — mirrors exact table column layout with staggered shimmer rows | |
| const skRow = (delay) => ` | |
| <tr style="animation:fadeIn 0.3s ease ${delay}ms both;"> | |
| <td style="padding:12px 8px 12px 16px;"> | |
| <div style="width:44px;height:62px;border-radius:8px;background:linear-gradient(90deg,var(--surface2) 25%,#e8eaf2 50%,var(--surface2) 75%);background-size:400px 100%;animation:shimmer 1.4s infinite;"></div> | |
| </td> | |
| <td> | |
| <div style="height:13px;width:70%;border-radius:6px;background:linear-gradient(90deg,var(--surface2) 25%,#e8eaf2 50%,var(--surface2) 75%);background-size:400px 100%;animation:shimmer 1.4s infinite;margin-bottom:7px;"></div> | |
| <div style="height:10px;width:40%;border-radius:6px;background:linear-gradient(90deg,var(--surface2) 25%,#e8eaf2 50%,var(--surface2) 75%);background-size:400px 100%;animation:shimmer 1.4s infinite ${delay+100}ms;"></div> | |
| </td> | |
| <td><div style="height:10px;width:60px;border-radius:20px;background:linear-gradient(90deg,var(--surface2) 25%,#e8eaf2 50%,var(--surface2) 75%);background-size:400px 100%;animation:shimmer 1.4s infinite;"></div></td> | |
| <td><div style="height:22px;width:90px;border-radius:20px;background:linear-gradient(90deg,var(--surface2) 25%,#e8eaf2 50%,var(--surface2) 75%);background-size:400px 100%;animation:shimmer 1.4s infinite;"></div></td> | |
| <td><div style="height:10px;width:20px;border-radius:4px;background:linear-gradient(90deg,var(--surface2) 25%,#e8eaf2 50%,var(--surface2) 75%);background-size:400px 100%;animation:shimmer 1.4s infinite;"></div></td> | |
| <td><div style="height:10px;width:80%;border-radius:4px;background:linear-gradient(90deg,var(--surface2) 25%,#e8eaf2 50%,var(--surface2) 75%);background-size:400px 100%;animation:shimmer 1.4s infinite;"></div></td> | |
| <td><div style="display:flex;gap:5px;"> | |
| <div style="height:28px;width:32px;border-radius:6px;background:linear-gradient(90deg,var(--surface2) 25%,#e8eaf2 50%,var(--surface2) 75%);background-size:400px 100%;animation:shimmer 1.4s infinite;"></div> | |
| <div style="height:28px;width:32px;border-radius:6px;background:linear-gradient(90deg,var(--surface2) 25%,#e8eaf2 50%,var(--surface2) 75%);background-size:400px 100%;animation:shimmer 1.4s infinite;"></div> | |
| </div></td> | |
| </tr>`; | |
| tbody.innerHTML = [0,80,160,240,320].map(skRow).join(''); | |
| try { | |
| const data = await apiGet(`/admin/${authorSlug}/books`); | |
| allBooks = data.books || []; | |
| renderBooks(allBooks); | |
| } catch(e) { | |
| if (e.message.includes('Session expired')) return; | |
| if (e.message.includes('Cannot reach server')) { | |
| tbody.innerHTML = `<tr><td colspan="7" style="color:var(--amber);text-align:center;padding:40px;"><div style="font-size:32px;margin-bottom:12px;">⏳</div>Server is waking up<div style="font-size:12px;color:var(--light);margin-top:6px;">${e.message}</div></td></tr>`; | |
| return; | |
| } | |
| tbody.innerHTML = `<tr><td colspan="7" style="color:var(--red);text-align:center;padding:40px;"><div style="font-size:32px;margin-bottom:12px;">⚠️</div>Failed to load books<div style="font-size:12px;color:var(--light);margin-top:6px;">${e.message}</div></td></tr>`; | |
| } | |
| } | |
| function renderBooks(books) { | |
| const tbody = document.getElementById('books-body'); | |
| if (!books.length) { | |
| tbody.innerHTML = '<tr><td colspan="7"><div class="empty-state"><div class="empty-icon">📚</div><div class="empty-title">No books yet</div><div class="empty-sub">Import from a URL or upload a file to get started</div><button class="btn btn-primary btn-sm" onclick="nav(\'upload\')">+ Add Book</button></div></td></tr>'; | |
| return; | |
| } | |
| tbody.innerHTML = books.map(b => { | |
| const isUrlImported = b.status === 'url_imported'; // legacy safety | |
| // Only flag as stalled when status is explicitly 'error' | |
| // Processing/created books are actively working — never mark them stalled | |
| const stuck = b.status === 'error'; | |
| // Cover — prefer local thumbnail, fall back to remote scraped URL, then initials | |
| const localThumb = b.cover_thumbnail | |
| ? '/covers/' + (b.cover_thumbnail.split('/data/covers/')[1] || b.cover_thumbnail.replace(/^\//, '')) | |
| : null; | |
| const remoteCover = b.cover_url || null; | |
| const coverSrc = localThumb || remoteCover; | |
| const coverHtml = coverSrc | |
| ? `<img src="${coverSrc}" style="width:100%;height:100%;object-fit:cover;border-radius:8px;" loading="lazy" onerror="this.parentElement.innerHTML='<span style=font-size:18px;font-weight:700;color:#fff>'+'${esc(b.title.charAt(0))}'+' </span>'">` | |
| : `<span style="font-size:18px;font-weight:700;color:#fff;">${esc(b.title.charAt(0))}</span>`; | |
| const coverBg = coverSrc ? '' : `background:linear-gradient(135deg,hsl(${Math.abs(hashCode(b.title))%360},65%,72%),hsl(${(Math.abs(hashCode(b.title))+60)%360},55%,62%));`; | |
| const statusConfig = { | |
| 'ready': { color:'#10b981', bg:'rgba(16,185,129,0.1)', icon:'✅', label:'Ready' }, | |
| 'active': { color:'#10b981', bg:'rgba(16,185,129,0.1)', icon:'✅', label:'Active' }, | |
| 'url_imported': { color:'#6366f1', bg:'rgba(99,102,241,0.1)', icon:'⏳', label:'Indexing…' }, | |
| 'processing': { color:'#f59e0b', bg:'rgba(245,158,11,0.1)', icon:'⚙️', label:'Processing' }, | |
| 'created': { color:'#94a3b8', bg:'rgba(148,163,184,0.1)', icon:'⏳', label:'Created' }, | |
| 'error': { color:'#ef4444', bg:'rgba(239,68,68,0.1)', icon:'❌', label:'Error' }, | |
| }; | |
| const sc = statusConfig[b.status] || { color:'#94a3b8', bg:'rgba(148,163,184,0.1)', icon:'📄', label: b.status }; | |
| return `<tr style="${stuck ? 'opacity:0.7;' : ''}"> | |
| <td style="padding:12px 8px 12px 16px;"> | |
| <div style="width:44px;height:62px;border-radius:8px;overflow:hidden;flex-shrink:0;${coverBg}display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,0.12);"> | |
| ${coverHtml} | |
| </div> | |
| </td> | |
| <td> | |
| <div class="fw-600" style="margin-bottom:3px;">${esc(b.title)}${b.missing_count > 0 ? `<span class="ab-missing-pill" title="Missing platform listings">${b.missing_count} missing</span>` : ''}</div> | |
| <div class="text-xs text-muted">${esc(b.book_author || b.author_name || '')}</div> | |
| ${stuck ? '<div class="text-xs" style="color:var(--yellow);margin-top:2px;">⚠️ Indexing stalled — delete and re-add</div>' : ''} | |
| </td> | |
| <td><span class="text-sm" style="color:var(--muted);">${esc(b.genre || '—')}</span></td> | |
| <td> | |
| <span style="display:inline-flex;align-items:center;gap:5px;padding:4px 10px;border-radius:20px;font-size:11px;font-weight:600;background:${sc.bg};color:${sc.color};"> | |
| ${sc.icon} ${sc.label} | |
| </span> | |
| </td> | |
| <td><span style="font-size:13px;font-weight:600;color:${b.chunk_count > 0 ? 'var(--green)' : 'var(--muted)'}">${b.chunk_count || 0}</span></td> | |
| <td style="max-width:180px;" class="truncate text-xs text-muted">${esc(b.ai_summary || '—')}</td> | |
| <td> | |
| <div style="display:flex;gap:5px;flex-wrap:wrap;align-items:center;"> | |
| ${stuck | |
| ? `<button class="btn btn-primary btn-sm" onclick="repairBook('${b.id}','${esc(b.title)}')">🔄 Re-add</button>` | |
| : (b.status === 'ready' || b.status === 'active') | |
| ? `<button class="btn btn-secondary btn-sm" onclick="toggleBookActive('${b.id}', ${b.is_active !== false})" title="${b.is_active !== false ? 'Deactivate' : 'Activate'}">${b.is_active !== false ? '⏸' : '▶️'}</button> | |
| <label class="btn btn-secondary btn-sm" style="cursor:pointer;margin:0;" title="Upload cover image"><input type="file" accept="image/*" style="display:none;" onchange="uploadCover('${b.id}',this)">📷</label>` | |
| : '' | |
| } | |
| <button class="btn btn-danger btn-sm" onclick="confirmDeleteBook('${b.id}','${esc(b.title)}')" title="Delete">🗑</button> | |
| </div> | |
| </td> | |
| </tr>`; | |
| }).join(''); | |
| } | |
| function hashCode(s) { let h = 0; for (let i = 0; i < s.length; i++) h = ((h << 5) - h) + s.charCodeAt(i); return h; } | |
| async function uploadCover(bookId, input) { | |
| const file = input.files[0]; | |
| if (!file) return; | |
| if (!file.type.startsWith('image/')) { toast('Please select an image file', 'error'); return; } | |
| if (file.size > 5 * 1024 * 1024) { toast('Image too large (max 5MB)', 'error'); return; } | |
| const fd = new FormData(); | |
| fd.append('file', file); | |
| try { | |
| const res = await auth.upload(`/admin/${authorSlug}/books/${bookId}/cover`, fd); | |
| const data = await res.json(); | |
| if (!res.ok) throw new Error(data.detail || 'Upload failed'); | |
| toast('Cover uploaded!', 'success'); | |
| loadBooks(); | |
| } catch(e) { toast(e.message, 'error'); } | |
| input.value = ''; | |
| } | |
| function filterBooks() { | |
| const q = document.getElementById('book-search').value.toLowerCase(); | |
| renderBooks(allBooks.filter(b => b.title.toLowerCase().includes(q) || (b.genre||'').toLowerCase().includes(q))); | |
| } | |
| function confirmDeleteBook(id, title) { | |
| const isLast = allBooks.filter(b => b.is_active !== false).length <= 1; | |
| const warn = isLast ? '<div class="alert alert-warning" style="margin-top:12px;">⚠️ This is your last active book — deleting it will disable your chatbot.</div>' : ''; | |
| showModal('Delete Book', `<div class="modal-sub">Are you sure you want to delete <strong>${title}</strong>? This will remove all embeddings and cannot be undone.</div>${warn}<div class="form-group"><label class="form-label">Type the book title to confirm</label><input type="text" class="form-input" id="confirm-delete-input" placeholder="${title}"></div>`, | |
| `<button class="btn btn-secondary" onclick="closeModal()">Cancel</button><button class="btn btn-danger" id="confirm-delete-btn" onclick="executeDeleteBook('${id}','${title}')">Delete Forever</button>`); | |
| } | |
| async function executeDeleteBook(id, title) { | |
| const input = document.getElementById('confirm-delete-input'); | |
| if (input.value !== title) { toast('Title does not match', 'error'); return; } | |
| try { | |
| const res = await apiDelete(`/admin/${authorSlug}/books/${id}`); | |
| closeModal(); | |
| if (res && res.chatbot_disabled) toast('Book deleted — chatbot disabled (no active books)', 'warning'); | |
| else toast('Book deleted', 'success'); | |
| loadBooks(); | |
| } catch(e) { toast(e.message, 'error'); } | |
| } | |
| async function toggleBookActive(id, isActive) { | |
| try { | |
| await apiPatch(`/admin/${authorSlug}/books/${id}/active`, { is_active: !isActive }); | |
| toast(isActive ? 'Book deactivated' : 'Book activated', 'success'); | |
| loadBooks(); | |
| } catch(e) { toast(e.message, 'error'); } | |
| } | |
| async function repairBook(id, title) { | |
| showModal('🔄 Re-upload Required', | |
| `<div class="modal-sub">The book <strong>${esc(title)}</strong> got stuck during indexing (the server restarted mid-process).<br><br> | |
| The fix is simple: <strong>delete it and re-upload the file</strong>. Indexing now runs in the background and will complete reliably.</div>`, | |
| `<button class="btn btn-secondary" onclick="closeModal()">Cancel</button> | |
| <button class="btn btn-danger" onclick="closeModal(); executeDeleteBook('${id}','${esc(title)}'); setTimeout(()=>nav('upload'),500);">🗑 Delete & Go to Upload</button>` | |
| ); | |
| } | |
| /* ── UPLOAD ── */ | |
| const uploadZone = document.getElementById('upload-zone'); | |
| const fileInput = document.getElementById('file-input'); | |
| let selectedFile = null; | |
| ['dragover','dragenter'].forEach(e => uploadZone.addEventListener(e, ev => { ev.preventDefault(); uploadZone.classList.add('dragover'); })); | |
| ['dragleave','drop'].forEach(e => uploadZone.addEventListener(e, ev => { ev.preventDefault(); uploadZone.classList.remove('dragover'); })); | |
| uploadZone.addEventListener('drop', ev => { if (ev.dataTransfer.files.length) handleFile(ev.dataTransfer.files[0]); }); | |
| fileInput.addEventListener('change', () => { if (fileInput.files.length) handleFile(fileInput.files[0]); }); | |
| function handleFile(file) { | |
| const maxMB = 50; | |
| const allowed = ['pdf','epub','docx','txt']; | |
| const ext = file.name.split('.').pop().toLowerCase(); | |
| if (!allowed.includes(ext)) { toast('Unsupported file type. Use PDF, EPUB, DOCX, or TXT.', 'error'); return; } | |
| if (file.size > maxMB * 1024 * 1024) { toast(`File too large. Max ${maxMB}MB.`, 'error'); return; } | |
| selectedFile = file; | |
| document.getElementById('upload-file-info').classList.remove('hidden'); | |
| document.getElementById('upload-file-info').innerHTML = `<div class="flex items-center gap-3"><span style="font-size:24px;">📄</span><div><div class="fw-600 text-sm">${esc(file.name)}</div><div class="text-xs text-muted">${(file.size/1024/1024).toFixed(2)} MB · ${ext.toUpperCase()}</div></div></div>`; | |
| document.getElementById('upload-btn').disabled = false; | |
| if (!document.getElementById('book-title').value) document.getElementById('book-title').value = file.name.replace(/\.[^.]+$/, ''); | |
| } | |
| document.getElementById('upload-btn').onclick = async () => { | |
| if (!selectedFile) return; | |
| const btn = document.getElementById('upload-btn'); | |
| btn.disabled = true; btn.textContent = 'Uploading...'; | |
| document.getElementById('upload-progress-wrap').classList.remove('hidden'); | |
| const fd = new FormData(); | |
| fd.append('file', selectedFile); | |
| fd.append('title', document.getElementById('book-title').value || selectedFile.name); | |
| fd.append('genre', document.getElementById('book-genre').value); | |
| fd.append('price', document.getElementById('book-price').value || '0'); | |
| fd.append('buy_url', document.getElementById('book-buy-url').value); | |
| try { | |
| const res = await auth.upload(`/admin/${authorSlug}/books/upload`, fd); | |
| const data = await res.json(); | |
| if (!res.ok) throw new Error(data.detail || 'Upload failed'); | |
| toast('Book uploaded and indexing started!', 'success'); | |
| updateProgress('Indexing...', 100); | |
| setTimeout(() => nav('books'), 2000); | |
| } catch(e) { toast(e.message, 'error'); } | |
| finally { btn.disabled = false; btn.textContent = 'Upload & Index'; } | |
| }; | |
| function updateProgress(stage, pct) { | |
| document.getElementById('upload-stage').textContent = stage; | |
| document.getElementById('upload-pct').textContent = pct + '%'; | |
| document.getElementById('upload-fill').style.width = pct + '%'; | |
| } | |
| /* ── READERS ── */ | |
| async function loadSessions() { | |
| const tbody = document.getElementById('sessions-body'); | |
| tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:40px;"><div class="skeleton" style="height:20px;width:60%;margin:0 auto;"></div></td></tr>'; | |
| try { | |
| const data = await apiGet(`/admin/${authorSlug}/sessions`); | |
| allSessions = data.sessions || []; | |
| document.getElementById('session-count').textContent = `${allSessions.length} sessions`; | |
| renderSessions(allSessions); | |
| } catch(e) { tbody.innerHTML = `<tr><td colspan="7" style="color:var(--red);text-align:center;padding:40px;">${e.message}</td></tr>`; } | |
| } | |
| function renderSessions(sessions) { | |
| const tbody = document.getElementById('sessions-body'); | |
| if (!sessions.length) { | |
| tbody.innerHTML = '<tr><td colspan="7"><div class="empty-state"><div class="empty-icon">👥</div><div class="empty-title">No reader sessions yet</div><div class="empty-sub">Sessions will appear here when readers start chatting with your bot</div></div></td></tr>'; | |
| return; | |
| } | |
| tbody.innerHTML = sessions.map(s => `<tr> | |
| <td><code class="text-xs" style="background:var(--surface2);padding:2px 6px;border-radius:4px;">${s.id.slice(0,8)}…</code></td> | |
| <td>${s.country || '🌍 —'}</td> | |
| <td>${{mobile:'📱',desktop:'💻',tablet:'📟'}[s.device_type] || '—'} ${s.device_type||'—'}</td> | |
| <td>${s.rating ? '⭐'.repeat(Math.min(s.rating,5)) : '<span class="text-muted">—</span>'}</td> | |
| <td class="text-xs text-muted">${s.created_at ? new Date(s.created_at).toLocaleDateString() : '—'}</td> | |
| <td>${s.blocked ? '<span class="badge badge-failed">Blocked</span>' : '<span class="badge badge-active">Active</span>'}</td> | |
| <td>${s.blocked | |
| ? `<button class="btn btn-secondary btn-sm" onclick="toggleBlock('${s.id}', false)">Unblock</button>` | |
| : `<button class="btn btn-danger btn-sm" onclick="toggleBlock('${s.id}', true)">Block</button>`}</td> | |
| </tr>`).join(''); | |
| } | |
| function filterSessions() { | |
| const q = document.getElementById('reader-search').value.toLowerCase(); | |
| renderSessions(allSessions.filter(s => s.id.includes(q) || (s.country||'').toLowerCase().includes(q))); | |
| } | |
| async function toggleBlock(id, block) { | |
| try { await apiPost(`/admin/${authorSlug}/sessions/${id}/${block?'block':'unblock'}`, {}); toast(block ? 'Session blocked' : 'Session unblocked', 'success'); loadSessions(); } | |
| catch(e) { toast(e.message, 'error'); } | |
| } | |
| /* ── ANALYTICS ── */ | |
| document.querySelectorAll('#analytics-tabs .tab').forEach(tab => { | |
| tab.onclick = () => { | |
| document.querySelectorAll('#analytics-tabs .tab').forEach(t => t.classList.remove('active')); | |
| tab.classList.add('active'); | |
| loadAnalytics(parseInt(tab.dataset.days)); | |
| }; | |
| }); | |
| async function loadAnalytics(days = 30) { | |
| try { | |
| const [daily, funnel, visitors, sessionStats] = await Promise.all([ | |
| apiGet(`/admin/${authorSlug}/analytics?days=${days}`), | |
| apiGet(`/admin/${authorSlug}/analytics/funnel?days=${days}`), | |
| apiGet(`/admin/${authorSlug}/analytics/visitors?days=${days}`), | |
| apiGet(`/admin/${authorSlug}/analytics/sessions/stats?days=${days}`), | |
| ]); | |
| const dailySessions = daily.daily_sessions || []; | |
| const total = dailySessions.reduce((s, d) => s + d.count, 0); | |
| const funnelStages = funnel.funnel || []; | |
| const linkClicked = funnelStages.find(s => s.stage === 'Link Clicked'); | |
| animateNumber('a-total-sessions', total); | |
| animateNumber('a-unique-visitors', visitors.total_visitors ?? 0); | |
| animateNumber('a-link-clicks', linkClicked ? linkClicked.count : 0); | |
| const avgTurns = sessionStats.avg_turns_per_session; | |
| document.getElementById('a-avg-turns').textContent = avgTurns != null ? Number(avgTurns).toFixed(1) : '—'; | |
| renderLineChart('chart-analytics', dailySessions, 'Sessions'); | |
| } catch(e) { console.error('Analytics:', e); } | |
| } | |
| /* ── TOKEN USAGE ── */ | |
| async function loadTokenUsage() { | |
| try { | |
| const data = await apiGet(`/admin/${authorSlug}/token-usage`); | |
| const banner = document.getElementById('token-subscription-banner'); | |
| const hasSub = data.has_active_subscription !== false && data.subscription_status === 'active'; | |
| if (banner) { | |
| if (hasSub) banner.classList.add('hidden'); | |
| else banner.classList.remove('hidden'); | |
| } | |
| const budget = hasSub ? (data.budget || 0) : 0; | |
| const used = hasSub ? (data.used || 0) : 0; | |
| const remaining = hasSub ? (data.remaining || 0) : 0; | |
| const pct = hasSub ? (data.percent_used || 0) : 0; | |
| document.getElementById('token-pct').textContent = hasSub ? (pct + '%') : '—'; | |
| document.getElementById('token-budget').textContent = hasSub ? budget.toLocaleString() : 'No plan'; | |
| document.getElementById('token-used').textContent = used.toLocaleString(); | |
| document.getElementById('token-remaining').textContent = hasSub ? remaining.toLocaleString() : '—'; | |
| // Gauge | |
| const canvas = document.getElementById('chart-token-gauge'); | |
| if (canvas) { | |
| const ctx = canvas.getContext('2d'); | |
| const cx = 90, cy = 90, r = 75, lw = 12; | |
| ctx.clearRect(0, 0, 180, 180); | |
| ctx.beginPath(); ctx.arc(cx, cy, r, 0.75 * Math.PI, 2.25 * Math.PI); ctx.strokeStyle = '#f1f3f9'; ctx.lineWidth = lw; ctx.lineCap = 'round'; ctx.stroke(); | |
| if (hasSub && pct > 0) { | |
| ctx.beginPath(); ctx.arc(cx, cy, r, 0.75 * Math.PI, (0.75 + 1.5 * pct / 100) * Math.PI); | |
| ctx.strokeStyle = pct > 90 ? '#ef4444' : pct > 75 ? '#f59e0b' : '#6366f1'; ctx.lineWidth = lw; ctx.lineCap = 'round'; ctx.stroke(); | |
| } | |
| } | |
| } catch(e) { console.error('Token usage:', e); } | |
| } | |
| /* ── EMBED CODE ── */ | |
| let _cachedEmbedToken = null; | |
| function buildEmbedSnippet(origin, token, slug, theme, pos) { | |
| return ( | |
| '<scr' + 'ipt src="' + origin + '/static/widget.js" defer\n' + | |
| ' data-authorbot-token="' + token + '"\n' + | |
| ' data-authorbot-slug="' + slug + '"\n' + | |
| ' data-authorbot-api="' + origin + '/api"\n' + | |
| ' data-authorbot-theme="' + theme + '"\n' + | |
| ' data-authorbot-position="' + pos + '"><\/scr' + 'ipt>' | |
| ); | |
| } | |
| function mountLiveWidgetPreview(origin, token, theme, pos) { | |
| document.getElementById('ab-root')?.remove(); | |
| window.__authorBotMounted = false; | |
| document.getElementById('authorbot-preview-script')?.remove(); | |
| const script = document.createElement('script'); | |
| script.id = 'authorbot-preview-script'; | |
| script.src = origin + '/static/widget.js'; | |
| script.defer = true; | |
| script.setAttribute('data-authorbot-token', token); | |
| script.setAttribute('data-authorbot-slug', authorSlug); | |
| script.setAttribute('data-authorbot-api', origin + '/api'); | |
| script.setAttribute('data-authorbot-theme', theme); | |
| script.setAttribute('data-authorbot-position', pos); | |
| document.body.appendChild(script); | |
| const liveStatus = document.getElementById('embed-live-status'); | |
| if (liveStatus) { | |
| liveStatus.innerHTML = '<span style="color:var(--green);font-weight:600;">✅ Your chatbot is live on this page — click the 💬 bubble to try it!</span>'; | |
| } | |
| } | |
| async function generateEmbedCode() { | |
| const origin = window.location.origin; | |
| const theme = document.getElementById('embed-theme')?.value || 'midnight'; | |
| const pos = document.getElementById('embed-pos')?.value || 'bottom-right'; | |
| const codeEl = document.getElementById('embed-code'); | |
| const statusEl = document.getElementById('embed-token-status'); | |
| const liveStatus = document.getElementById('embed-live-status'); | |
| if (!_cachedEmbedToken) { | |
| if (codeEl) codeEl.textContent = '⏳ Preparing your embed code...'; | |
| try { | |
| const data = await apiGet(`/admin/${authorSlug}/embed-token`); | |
| // No active subscription — show friendly UI, not an error | |
| if (!data.active || !data.token) { | |
| if (statusEl) statusEl.innerHTML = ` | |
| <div style="padding:14px;background:var(--yellow-bg);border:1px solid var(--yellow);border-radius:8px;"> | |
| <strong style="color:var(--yellow-dark);">⚠️ No active subscription</strong><br> | |
| <span style="font-size:12px;color:var(--text2);">Your chatbot is ready but needs a subscription plan to go live. | |
| Contact your administrator to activate your account.</span> | |
| </div>`; | |
| if (codeEl) codeEl.textContent = '⚠️ Chatbot not active yet — contact your administrator to get a subscription plan.'; | |
| if (liveStatus) liveStatus.innerHTML = `<span style="color:var(--yellow-dark);">⚠️ Chatbot preview unavailable — no active subscription</span>`; | |
| return; | |
| } | |
| _cachedEmbedToken = data.token; | |
| if (statusEl) { | |
| const d = data.days_remaining || 0; | |
| const t = (data.tokens_remaining || 0).toLocaleString(); | |
| statusEl.innerHTML = `<span style="color:var(--green);font-weight:600;">✅ Your chatbot is active</span> — ${d} days remaining, ${t} tokens left`; | |
| } | |
| } catch(e) { | |
| if (codeEl) codeEl.textContent = '❌ Failed to load embed code: ' + (e.message || 'Unknown error'); | |
| if (statusEl) statusEl.innerHTML = `<span style="color:var(--red);">❌ ${e.message || 'Could not load subscription info'}</span>`; | |
| if (liveStatus) liveStatus.innerHTML = `<span style="color:var(--red);">❌ Preview unavailable</span>`; | |
| return; | |
| } | |
| } | |
| const tok = _cachedEmbedToken; | |
| if (!tok) return; | |
| if (codeEl) codeEl.textContent = buildEmbedSnippet(origin, tok, authorSlug, theme, pos); | |
| mountLiveWidgetPreview(origin, tok, theme, pos); | |
| if (!document.getElementById('platform-instructions')?.innerHTML) showPlatform('wordpress'); | |
| } | |
| function copyEmbedCode() { | |
| const text = document.getElementById('embed-code').textContent; | |
| if (text.startsWith('⏳') || text.startsWith('❌')) { | |
| toast('Please wait for your embed code to load first', 'error'); | |
| return; | |
| } | |
| navigator.clipboard.writeText(text); | |
| toast('Copied! Now paste it in your website footer.', 'success'); | |
| } | |
| async function openWidgetPreview(theme) { | |
| if (!_cachedEmbedToken) await generateEmbedCode(); | |
| if (!_cachedEmbedToken) return; | |
| const pos = document.getElementById('embed-pos')?.value || 'bottom-right'; | |
| const url = `${window.location.origin}/api/widget/${authorSlug}?token=${encodeURIComponent(_cachedEmbedToken)}&slug=${encodeURIComponent(authorSlug)}&theme=${theme}&position=${pos}&autoOpen=1`; | |
| window.open(url, '_blank', 'width=500,height=720,resizable=yes'); | |
| } | |
| const PLATFORM_GUIDES = { | |
| wordpress: `<strong>WordPress — 2 minutes:</strong><br> | |
| 1. Install the free plugin <strong>"WPCode"</strong> or <strong>"Insert Headers and Footers"</strong><br> | |
| 2. Go to the plugin settings → <strong>Footer</strong> section<br> | |
| 3. Paste your copied code → click <strong>Save</strong><br> | |
| 4. Visit your website — the 💬 bubble appears immediately`, | |
| squarespace: `<strong>Squarespace — 2 minutes:</strong><br> | |
| 1. Go to <strong>Settings → Advanced → Code Injection</strong><br> | |
| 2. Paste your copied code in the <strong>Footer</strong> box<br> | |
| 3. Click <strong>Save</strong> — done!`, | |
| webflow: `<strong>Webflow — 2 minutes:</strong><br> | |
| 1. Open <strong>Project Settings → Custom Code</strong><br> | |
| 2. Paste your copied code in <strong>Footer Code</strong><br> | |
| 3. Click <strong>Save</strong>, then <strong>Publish</strong> your site`, | |
| wix: `<strong>Wix — 2 minutes:</strong><br> | |
| 1. Go to <strong>Settings → Custom Code</strong><br> | |
| 2. Click <strong>+ Add Custom Code</strong><br> | |
| 3. Paste your copied code, set placement to <strong>Body - end</strong><br> | |
| 4. Apply to <strong>All Pages</strong> → click <strong>Publish</strong>`, | |
| shopify: `<strong>Shopify — 3 minutes:</strong><br> | |
| 1. Go to <strong>Online Store → Themes → Edit Code</strong><br> | |
| 2. Open <strong>theme.liquid</strong><br> | |
| 3. Paste your copied code just before <code></body></code><br> | |
| 4. Click <strong>Save</strong>`, | |
| html: `<strong>Any website — paste before the closing body tag:</strong><br> | |
| 1. Open your website's HTML editor<br> | |
| 2. Find <code></body></code> near the bottom<br> | |
| 3. Paste your copied code on the line above it<br> | |
| 4. Save and refresh your page`, | |
| }; | |
| function showPlatform(name) { | |
| document.querySelectorAll('[id^="plat-"]').forEach(b => b.classList.remove('btn-primary')); | |
| document.getElementById('plat-' + name).classList.add('btn-primary'); | |
| document.getElementById('platform-instructions').innerHTML = PLATFORM_GUIDES[name] || ''; | |
| } | |
| /* ── SMART LINKS ── */ | |
| async function loadSmartLinks() { | |
| try { | |
| const data = await apiGet(`/admin/${authorSlug}/books`); | |
| const books = data.books || []; | |
| if (!books.length) { document.getElementById('links-content').innerHTML = '<div class="empty-state"><div class="empty-icon">🛒</div><div class="empty-title">No books to configure</div><div class="empty-sub">Upload books first to set up smart links</div></div>'; return; } | |
| document.getElementById('links-content').innerHTML = books.map(b => ` | |
| <div style="padding:16px 0;border-bottom:1px solid var(--border-light);" class="flex items-center gap-4"> | |
| <div style="flex:1;"><div class="fw-600 text-sm">${esc(b.title)}</div><div class="text-xs text-muted">${esc(b.genre||'—')}</div></div> | |
| <input type="url" class="form-input" style="max-width:300px;margin:0;" placeholder="Buy URL" id="link-${b.id}" value=""> | |
| <button class="btn btn-secondary btn-sm" onclick="saveLink('${b.id}')">Save</button> | |
| </div> | |
| `).join(''); | |
| } catch(e) { document.getElementById('links-content').innerHTML = `<p style="color:var(--red)">${e.message}</p>`; } | |
| } | |
| async function saveLink(bookId) { | |
| const url = document.getElementById('link-' + bookId).value; | |
| try { | |
| await apiPut(`/admin/${authorSlug}/smart-links/${bookId}`, { buy_url: url }); | |
| toast('Link saved!', 'success'); | |
| } catch(e) { toast(e.message, 'error'); } | |
| } | |
| /* ── WIDGET PREVIEW ── */ | |
| function updateWidgetPreview() { | |
| document.getElementById('wp-name').textContent = document.getElementById('w-bot-name').value || 'BookBot'; | |
| document.getElementById('wp-msg').textContent = document.getElementById('w-welcome').value || 'Hi! How can I help you today?'; | |
| } | |
| /* ── PASSWORD STRENGTH ── */ | |
| function checkPasswordStrength() { | |
| const pw = document.getElementById('sec-new-pass').value; | |
| const el = document.getElementById('pass-strength'); | |
| let score = 0; | |
| if (pw.length >= 8) score++; | |
| if (pw.length >= 12) score++; | |
| if (/[A-Z]/.test(pw)) score++; | |
| if (/[0-9]/.test(pw)) score++; | |
| if (/[^A-Za-z0-9]/.test(pw)) score++; | |
| const labels = ['Very Weak', 'Weak', 'Fair', 'Strong', 'Very Strong']; | |
| const colors = ['#ef4444', '#f59e0b', '#eab308', '#22c55e', '#10b981']; | |
| el.innerHTML = pw ? `<div class="progress-bar" style="height:4px;max-width:200px;"><div class="progress-fill" style="width:${score*20}%;background:${colors[score-1]||colors[0]};"></div></div><span style="color:${colors[score-1]||colors[0]};">${labels[score-1]||labels[0]}</span>` : ''; | |
| } | |
| /* ── CHANGE PASSWORD ── */ | |
| document.getElementById('change-pass-btn').onclick = async () => { | |
| const cur = document.getElementById('sec-cur-pass').value; | |
| const nw = document.getElementById('sec-new-pass').value; | |
| const conf = document.getElementById('sec-confirm-pass').value; | |
| const msg = document.getElementById('pass-msg'); | |
| if (nw !== conf) { msg.textContent = 'Passwords do not match'; msg.style.color = 'var(--red)'; return; } | |
| if (nw.length < 8) { msg.textContent = 'Password must be at least 8 characters'; msg.style.color = 'var(--red)'; return; } | |
| try { | |
| await apiPost(`/admin/${authorSlug}/password`, { current_password: cur, new_password: nw }); | |
| msg.textContent = 'Password updated!'; msg.style.color = 'var(--green)'; | |
| toast('Password changed successfully', 'success'); | |
| } catch(e) { msg.textContent = e.message; msg.style.color = 'var(--red)'; } | |
| }; | |
| /* ── WIDGET CONFIG SAVE/LOAD ── */ | |
| async function loadWidgetConfig() { | |
| try { | |
| const data = await apiGet(`/admin/${authorSlug}/widget-config`); | |
| document.getElementById('w-bot-name').value = data.bot_name || ''; | |
| document.getElementById('w-welcome').value = data.welcome_message || ''; | |
| document.getElementById('w-theme').value = data.theme || 'pearl'; | |
| document.getElementById('w-position').value = data.position || 'bottom-right'; | |
| document.getElementById('w-delay').value = data.auto_open_delay || 0; | |
| updateWidgetPreview(); | |
| } catch(e) { console.error('Widget config:', e); } | |
| } | |
| document.getElementById('save-widget-btn').onclick = async () => { | |
| try { | |
| await apiPut(`/admin/${authorSlug}/widget-config`, { | |
| bot_name: document.getElementById('w-bot-name').value, | |
| welcome_message: document.getElementById('w-welcome').value, | |
| theme: document.getElementById('w-theme').value, | |
| position: document.getElementById('w-position').value, | |
| auto_open_delay: parseInt(document.getElementById('w-delay').value) || 0, | |
| }); | |
| toast('Widget configuration saved!', 'success'); | |
| } catch(e) { toast(e.message, 'error'); } | |
| }; | |
| /* ── PROFILE SAVE/LOAD ── */ | |
| async function loadProfile() { | |
| try { | |
| const data = await apiGet(`/admin/${authorSlug}/profile`); | |
| document.getElementById('prof-name').value = data.full_name || ''; | |
| document.getElementById('prof-website').value = data.website || ''; | |
| document.getElementById('prof-bio').value = data.bio || ''; | |
| document.getElementById('prof-tz').value = data.timezone || 'America/New_York'; | |
| } catch(e) { console.error('Profile:', e); } | |
| } | |
| document.getElementById('save-profile-btn').onclick = async () => { | |
| try { | |
| await apiPut(`/admin/${authorSlug}/profile`, { | |
| full_name: document.getElementById('prof-name').value, | |
| website: document.getElementById('prof-website').value, | |
| bio: document.getElementById('prof-bio').value, | |
| timezone: document.getElementById('prof-tz').value, | |
| }); | |
| toast('Profile saved!', 'success'); | |
| } catch(e) { toast(e.message, 'error'); } | |
| }; | |
| /* ── PERSONALITY SAVE/LOAD ── */ | |
| async function loadPersonality() { | |
| try { | |
| const data = await apiGet(`/admin/${authorSlug}/personality`); | |
| document.getElementById('p-style').value = data.response_style || 'balanced'; | |
| document.getElementById('p-fallback').value = data.fallback_message || ''; | |
| } catch(e) { console.error('Personality:', e); } | |
| } | |
| document.getElementById('save-personality-btn').onclick = async () => { | |
| try { | |
| await apiPut(`/admin/${authorSlug}/personality`, { | |
| response_style: document.getElementById('p-style').value, | |
| fallback_message: document.getElementById('p-fallback').value, | |
| out_of_scope_message: document.getElementById('p-outofscope').value, | |
| }); | |
| toast('Personality settings saved!', 'success'); | |
| } catch(e) { toast(e.message, 'error'); } | |
| }; | |
| /* ── NOTIFICATIONS SAVE/LOAD ── */ | |
| async function loadNotifications() { | |
| try { | |
| const data = await apiGet(`/admin/${authorSlug}/notifications`); | |
| document.getElementById('n-weekly').checked = data.weekly_digest ?? true; | |
| document.getElementById('n-tokens').checked = data.token_alerts ?? true; | |
| document.getElementById('n-newchat').checked = data.new_conversation ?? false; | |
| document.getElementById('n-expiry').checked = data.subscription_expiry ?? true; | |
| } catch(e) { console.error('Notifications:', e); } | |
| } | |
| document.getElementById('save-notif-btn').onclick = async () => { | |
| try { | |
| await apiPut(`/admin/${authorSlug}/notifications`, { | |
| weekly_digest: document.getElementById('n-weekly').checked, | |
| token_alerts: document.getElementById('n-tokens').checked, | |
| new_conversation: document.getElementById('n-newchat').checked, | |
| subscription_expiry: document.getElementById('n-expiry').checked, | |
| }); | |
| toast('Notification preferences saved!', 'success'); | |
| } catch(e) { toast(e.message, 'error'); } | |
| }; | |
| /* ── UTILS ── */ | |
| function esc(s) { const d = document.createElement('div'); d.textContent = s||''; return d.innerHTML; } | |
| /* ── KEYBOARD SHORTCUTS ── */ | |
| document.addEventListener('keydown', e => { | |
| if (e.key === 'Escape') closeModal(); | |
| }); | |
| /* ══════════════════════════════════════════════════════════════════════════ */ | |
| /* CONVERSATIONS VIEWER */ | |
| /* ══════════════════════════════════════════════════════════════════════════ */ | |
| let convCurrentSession = null; | |
| async function loadConversations() { | |
| const list = document.getElementById('conv-session-list'); | |
| list.innerHTML = '<div style="padding:20px;text-align:center;"><div class="skeleton" style="height:16px;width:80%;margin:8px auto;"></div><div class="skeleton" style="height:16px;width:60%;margin:8px auto;"></div></div>'; | |
| try { | |
| const data = await apiGet(`/admin/${authorSlug}/sessions?limit=50`); | |
| const sessions = data.sessions || []; | |
| if (!sessions.length) { list.innerHTML = '<div class="empty-state"><div class="empty-icon">💭</div><div class="empty-title">No conversations yet</div></div>'; return; } | |
| list.innerHTML = sessions.map(s => ` | |
| <div class="conv-session-item" onclick="loadTranscript('${s.id}')" style="padding:10px 12px;border-bottom:1px solid var(--border-light);cursor:pointer;border-radius:8px;margin-bottom:2px;transition:background 0.15s;" onmouseover="this.style.background='var(--bg-hover)'" onmouseout="this.style.background=''"> | |
| <div style="display:flex;justify-content:space-between;align-items:center;"> | |
| <span class="fw-600 text-sm">${esc(s.visitor_name || s.visitor_fingerprint?.slice(0,8) || 'Visitor')}</span> | |
| ${s.rating ? `<span style="color:#f59e0b;font-size:12px;">${'★'.repeat(s.rating)}</span>` : ''} | |
| </div> | |
| <div class="text-xs text-muted" style="margin-top:2px;">${esc(s.country || '')} · ${esc(s.device_type || '')} · ${s.created_at ? new Date(s.created_at).toLocaleDateString() : ''}</div> | |
| ${s.blocked ? '<span class="badge badge-failed" style="font-size:10px;margin-top:4px;">Blocked</span>' : ''} | |
| </div> | |
| `).join(''); | |
| } catch(e) { list.innerHTML = `<div style="padding:16px;color:var(--red);">${e.message}</div>`; } | |
| } | |
| async function loadTranscript(sessionId) { | |
| convCurrentSession = sessionId; | |
| const container = document.getElementById('conv-transcript'); | |
| const title = document.getElementById('conv-transcript-title'); | |
| container.innerHTML = '<div style="padding:40px;text-align:center;"><div class="skeleton" style="height:16px;width:80%;margin:8px auto;"></div></div>'; | |
| try { | |
| const data = await apiGet(`/admin/${authorSlug}/sessions/${sessionId}/transcript`); | |
| const s = data.session; | |
| title.textContent = `${s.visitor_name || s.visitor_fingerprint} — ${s.country || ''} · ${s.device_type || ''}`; | |
| const msgs = data.messages || []; | |
| if (!msgs.length) { container.innerHTML = '<div class="empty-state"><div class="empty-icon">📭</div><div class="empty-title">No messages</div></div>'; return; } | |
| container.innerHTML = msgs.map(m => ` | |
| <div style="display:flex;gap:12px;padding:12px 0;border-bottom:1px solid var(--border-light);${m.hallucination_detected ? 'background:rgba(239,68,68,0.05);border-radius:8px;padding:12px;' : ''}"> | |
| <div style="width:32px;height:32px;border-radius:50%;background:${m.role==='user' ? 'var(--primary)' : 'linear-gradient(135deg,#6366f1,#8b5cf6)'};display:flex;align-items:center;justify-content:center;color:#fff;font-size:14px;flex-shrink:0;"> | |
| ${m.role === 'user' ? '👤' : '🤖'} | |
| </div> | |
| <div style="flex:1;min-width:0;"> | |
| <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;"> | |
| <span class="fw-600 text-sm" style="color:${m.role==='user'?'var(--primary)':'var(--purple)'};">${m.role === 'user' ? 'Visitor' : 'Bot'}</span> | |
| <div style="display:flex;gap:6px;align-items:center;"> | |
| ${m.intent ? `<span class="badge" style="font-size:10px;background:rgba(99,102,241,0.1);color:#6366f1;">${esc(m.intent)}</span>` : ''} | |
| ${m.response_ms ? `<span class="text-xs text-muted">${m.response_ms}ms</span>` : ''} | |
| ${m.flag_type ? `<span class="badge badge-failed" style="font-size:10px;">🚩 ${esc(m.flag_type)}</span>` : ''} | |
| <span class="text-xs text-muted">${m.created_at ? new Date(m.created_at).toLocaleTimeString() : ''}</span> | |
| </div> | |
| </div> | |
| <div style="font-size:13px;line-height:1.6;white-space:pre-wrap;">${esc(m.content)}</div> | |
| ${m.annotation ? `<div style="margin-top:8px;padding:8px 12px;background:rgba(251,191,36,0.1);border-radius:6px;font-size:12px;border-left:3px solid #f59e0b;"><strong>📝 Note:</strong> ${esc(m.annotation)}</div>` : ''} | |
| <div style="display:flex;gap:8px;margin-top:6px;"> | |
| <button class="btn btn-secondary" style="font-size:11px;padding:2px 8px;" onclick="annotateMessage('${m.id}','${sessionId}')">📝 Note</button> | |
| <button class="btn btn-secondary" style="font-size:11px;padding:2px 8px;" onclick="flagMessage('${m.id}','${sessionId}','quality')">🚩 Flag</button> | |
| </div> | |
| </div> | |
| </div> | |
| `).join(''); | |
| // Pagination | |
| const p = data.pagination; | |
| if (p.total_pages > 1) { | |
| document.getElementById('conv-pagination').innerHTML = `<span class="text-sm text-muted">Page ${p.page} of ${p.total_pages} (${p.total} messages)</span>`; | |
| } | |
| } catch(e) { container.innerHTML = `<div style="padding:16px;color:var(--red);">${e.message}</div>`; } | |
| } | |
| async function annotateMessage(msgId, sessionId) { | |
| const note = prompt('Add a note to this message:'); | |
| if (!note) return; | |
| try { | |
| await apiPost(`/admin/${authorSlug}/sessions/${sessionId}/messages/${msgId}/annotate`, { annotation: note }); | |
| toast('Annotation saved', 'success'); | |
| loadTranscript(sessionId); | |
| } catch(e) { toast(e.message, 'error'); } | |
| } | |
| async function flagMessage(msgId, sessionId, flagType) { | |
| try { | |
| await apiPost(`/admin/${authorSlug}/sessions/${sessionId}/messages/${msgId}/flag`, { flag_type: flagType }); | |
| toast('Message flagged', 'success'); | |
| loadTranscript(sessionId); | |
| } catch(e) { toast(e.message, 'error'); } | |
| } | |
| async function searchConversations() { | |
| const q = document.getElementById('conv-search').value.trim(); | |
| if (!q) return; | |
| const container = document.getElementById('conv-results'); | |
| container.innerHTML = '<div class="text-sm text-muted">Searching...</div>'; | |
| try { | |
| const data = await apiGet(`/admin/${authorSlug}/sessions/search?q=${encodeURIComponent(q)}`); | |
| const sessions = data.sessions || []; | |
| if (!sessions.length) { container.innerHTML = '<div class="text-sm text-muted">No results found</div>'; return; } | |
| container.innerHTML = `<div class="text-sm text-muted" style="margin-bottom:8px;">${data.total} session(s) match "${esc(q)}"</div>` + | |
| sessions.map(s => `<span class="badge" style="cursor:pointer;margin:2px;" onclick="loadTranscript('${s.id}')">${esc(s.visitor_name || s.visitor_fingerprint?.slice(0,8) || 'Visitor')} (${s.turn_count || 0} turns)</span>`).join(''); | |
| } catch(e) { container.innerHTML = `<div style="color:var(--red);">${e.message}</div>`; } | |
| } | |
| /* ══════════════════════════════════════════════════════════════════════════ */ | |
| /* CUSTOM Q&A MANAGER */ | |
| /* ══════════════════════════════════════════════════════════════════════════ */ | |
| let allQAPairs = []; | |
| async function loadQAPairs() { | |
| const list = document.getElementById('qa-list'); | |
| list.innerHTML = '<div style="padding:40px;text-align:center;"><div class="skeleton" style="height:16px;width:60%;margin:8px auto;"></div></div>'; | |
| try { | |
| const data = await apiGet(`/admin/${authorSlug}/qa`); | |
| allQAPairs = data.qa_pairs || []; | |
| document.getElementById('qa-count').textContent = `${allQAPairs.length} / 500 pairs`; | |
| renderQAPairs(allQAPairs); | |
| } catch(e) { list.innerHTML = `<div style="padding:16px;color:var(--red);">${e.message}</div>`; } | |
| } | |
| function renderQAPairs(pairs) { | |
| const list = document.getElementById('qa-list'); | |
| if (!pairs.length) { | |
| list.innerHTML = '<div class="empty-state"><div class="empty-icon">❓</div><div class="empty-title">No Q&A pairs yet</div><div class="empty-sub">Add custom Q&A pairs to override AI responses for specific questions</div></div>'; | |
| return; | |
| } | |
| list.innerHTML = pairs.map(qa => ` | |
| <div style="padding:16px 0;border-bottom:1px solid var(--border-light);"> | |
| <div style="display:flex;justify-content:space-between;align-items:start;gap:12px;"> | |
| <div style="flex:1;min-width:0;"> | |
| <div class="fw-600 text-sm" style="color:var(--primary);margin-bottom:4px;">Q: ${esc(qa.question)}</div> | |
| <div class="text-sm" style="color:var(--text-secondary);line-height:1.5;">A: ${esc(qa.answer?.slice(0, 200))}${qa.answer?.length > 200 ? '...' : ''}</div> | |
| <div style="display:flex;gap:8px;margin-top:6px;flex-wrap:wrap;"> | |
| ${qa.category ? `<span class="badge" style="font-size:10px;">${esc(qa.category)}</span>` : ''} | |
| <span class="text-xs text-muted">Priority: ${qa.priority}</span> | |
| <span class="text-xs text-muted">Matched: ${qa.match_count}x</span> | |
| <span class="badge ${qa.is_active ? 'badge-active' : 'badge-failed'}" style="font-size:10px;">${qa.is_active ? 'Active' : 'Inactive'}</span> | |
| </div> | |
| </div> | |
| <div style="display:flex;gap:6px;flex-shrink:0;"> | |
| <button class="btn btn-secondary btn-sm" onclick="editQA('${qa.id}')">Edit</button> | |
| <button class="btn btn-danger btn-sm" onclick="deleteQA('${qa.id}')">Delete</button> | |
| </div> | |
| </div> | |
| </div> | |
| `).join(''); | |
| } | |
| function showAddQAModal() { | |
| showModal('Add Q&A Pair', | |
| `<div class="form-group"><label class="form-label">Question</label><textarea class="form-input" id="qa-new-q" rows="2" placeholder="What is the main theme of the book?" maxlength="500"></textarea></div> | |
| <div class="form-group"><label class="form-label">Answer</label><textarea class="form-input" id="qa-new-a" rows="4" placeholder="The main theme explores..." maxlength="2000"></textarea></div> | |
| <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;"> | |
| <div class="form-group"><label class="form-label">Category</label><input type="text" class="form-input" id="qa-new-cat" placeholder="General"></div> | |
| <div class="form-group"><label class="form-label">Priority (0-100)</label><input type="number" class="form-input" id="qa-new-pri" value="0" min="0" max="100"></div> | |
| </div>`, | |
| `<button class="btn btn-secondary" onclick="closeModal()">Cancel</button><button class="btn btn-primary" onclick="saveNewQA()">Save Q&A</button>` | |
| ); | |
| } | |
| async function saveNewQA() { | |
| const question = document.getElementById('qa-new-q').value.trim(); | |
| const answer = document.getElementById('qa-new-a').value.trim(); | |
| if (!question || !answer) { toast('Question and answer are required', 'error'); return; } | |
| try { | |
| await apiPost(`/admin/${authorSlug}/qa`, { | |
| question, answer, | |
| category: document.getElementById('qa-new-cat').value.trim() || null, | |
| priority: parseInt(document.getElementById('qa-new-pri').value) || 0, | |
| }); | |
| closeModal(); | |
| toast('Q&A pair created!', 'success'); | |
| loadQAPairs(); | |
| } catch(e) { toast(e.message, 'error'); } | |
| } | |
| async function editQA(id) { | |
| const qa = allQAPairs.find(q => q.id === id); | |
| if (!qa) return; | |
| showModal('Edit Q&A Pair', | |
| `<div class="form-group"><label class="form-label">Question</label><textarea class="form-input" id="qa-edit-q" rows="2" maxlength="500">${esc(qa.question)}</textarea></div> | |
| <div class="form-group"><label class="form-label">Answer</label><textarea class="form-input" id="qa-edit-a" rows="4" maxlength="2000">${esc(qa.answer)}</textarea></div> | |
| <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;"> | |
| <div class="form-group"><label class="form-label">Category</label><input type="text" class="form-input" id="qa-edit-cat" value="${esc(qa.category||'')}"></div> | |
| <div class="form-group"><label class="form-label">Priority</label><input type="number" class="form-input" id="qa-edit-pri" value="${qa.priority}" min="0" max="100"></div> | |
| <div class="form-group"><label class="form-label">Active</label><select class="form-input" id="qa-edit-active"><option value="true" ${qa.is_active?'selected':''}>Active</option><option value="false" ${!qa.is_active?'selected':''}>Inactive</option></select></div> | |
| </div>`, | |
| `<button class="btn btn-secondary" onclick="closeModal()">Cancel</button><button class="btn btn-primary" onclick="saveEditQA('${id}')">Save Changes</button>` | |
| ); | |
| } | |
| async function saveEditQA(id) { | |
| try { | |
| await apiPut(`/admin/${authorSlug}/qa/${id}`, { | |
| question: document.getElementById('qa-edit-q').value.trim(), | |
| answer: document.getElementById('qa-edit-a').value.trim(), | |
| category: document.getElementById('qa-edit-cat').value.trim() || null, | |
| priority: parseInt(document.getElementById('qa-edit-pri').value) || 0, | |
| is_active: document.getElementById('qa-edit-active').value === 'true', | |
| }); | |
| closeModal(); | |
| toast('Q&A pair updated!', 'success'); | |
| loadQAPairs(); | |
| } catch(e) { toast(e.message, 'error'); } | |
| } | |
| async function deleteQA(id) { | |
| if (!confirm('Delete this Q&A pair?')) return; | |
| try { | |
| await apiDelete(`/admin/${authorSlug}/qa/${id}`); | |
| toast('Q&A pair deleted', 'success'); | |
| loadQAPairs(); | |
| } catch(e) { toast(e.message, 'error'); } | |
| } | |
| async function importQACSV(input) { | |
| const file = input.files[0]; | |
| if (!file) return; | |
| const fd = new FormData(); | |
| fd.append('file', file); | |
| try { | |
| const res = await auth.upload(`/admin/${authorSlug}/qa/import`, fd); | |
| const data = await res.json(); | |
| if (!res.ok) throw new Error(data.detail || 'Import failed'); | |
| toast(`Imported ${data.imported} pairs (${data.skipped} skipped)`, 'success'); | |
| loadQAPairs(); | |
| } catch(e) { toast(e.message, 'error'); } | |
| input.value = ''; | |
| } | |
| async function exportQACSV() { | |
| toast('Preparing Q&A export...', 'success'); | |
| try { | |
| const res = await auth.download(`/admin/${authorSlug}/qa/export`); | |
| if (!res.ok) { const e = await res.json(); throw new Error(e.detail || 'Export failed'); } | |
| const blob = await res.blob(); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = 'qa_pairs.csv'; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| toast('Download started!', 'success'); | |
| } catch(e) { toast(e.message, 'error'); } | |
| } | |
| /* ══════════════════════════════════════════════════════════════════════════ */ | |
| /* EXPORT CENTER */ | |
| /* ══════════════════════════════════════════════════════════════════════════ */ | |
| async function downloadExport(type) { | |
| let days; | |
| if (type === 'sessions') days = document.getElementById('export-sessions-days').value; | |
| else if (type === 'analytics') days = document.getElementById('export-analytics-days').value; | |
| else days = document.getElementById('export-conv-days').value; | |
| toast(`Preparing ${type} export...`, 'success'); | |
| try { | |
| const res = await auth.download(`/admin/${authorSlug}/export/${type}?days=${days}`); | |
| if (!res.ok) { const e = await res.json(); throw new Error(e.detail || 'Export failed'); } | |
| const blob = await res.blob(); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `${type}_${days}d.csv`; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| toast('Download started!', 'success'); | |
| } catch(e) { toast(e.message, 'error'); } | |
| } | |
| /* ═══════════════════════════════════════════════════════════════ | |
| ADD BOOK — URL IMPORT (ab* namespace) | |
| ═══════════════════════════════════════════════════════════════ */ | |
| const AB_PLATFORMS = [ | |
| { id:'amazon', name:'Amazon', icon:'🛒', re:/amazon\.(com|co\.|ca|com\.au|de|fr|it|es|in)/ }, | |
| { id:'barnes_noble', name:'Barnes & Noble', icon:'📕', re:/barnesandnoble\.com/ }, | |
| { id:'goodreads', name:'Goodreads', icon:'⭐', re:/goodreads\.com/ }, | |
| { id:'google_books', name:'Google Books', icon:'🔍', re:/books\.google\.|play\.google\.com\/store\/books/ }, | |
| { id:'apple_books', name:'Apple Books', icon:'🍎', re:/books\.apple\.com/ }, | |
| { id:'kobo', name:'Kobo', icon:'📖', re:/kobo\.com/ }, | |
| { id:'thriftbooks', name:'ThriftBooks', icon:'💸', re:/thriftbooks\.com/ }, | |
| { id:'abebooks', name:'AbeBooks', icon:'🏛️', re:/abebooks\./ }, | |
| { id:'bookshop', name:'Bookshop.org', icon:'📚', re:/bookshop\.org/ }, | |
| { id:'open_library', name:'Open Library', icon:'🏛', re:/openlibrary\.org/ }, | |
| ]; | |
| let _abMeta = null; | |
| let _abPpModalEscHandler = null; | |
| function abBuildPills() { | |
| const c = document.getElementById('ab-platform-pills'); | |
| if (!c || c.children.length) return; | |
| AB_PLATFORMS.forEach(p => { | |
| c.insertAdjacentHTML('beforeend', | |
| `<span class="ab-pill" data-pid="${p.id}" style="display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:20px;font-size:11px;font-weight:600;background:var(--surface2);border:1px solid var(--border);color:var(--muted);transition:all .18s;cursor:default;"> | |
| <span class="ab-dot" style="width:5px;height:5px;border-radius:50%;background:var(--border);display:inline-block;transition:all .18s;"></span>${p.icon} ${p.name} | |
| </span>` | |
| ); | |
| }); | |
| } | |
| function abDetectPlatform() { | |
| const url = (document.getElementById('ab-url')?.value||'').toLowerCase(); | |
| document.querySelectorAll('.ab-pill').forEach(pill => { | |
| const p = AB_PLATFORMS.find(x => x.id === pill.dataset.pid); | |
| const match = p && p.re.test(url); | |
| pill.style.background = match ? 'var(--accent-soft)' : 'var(--surface2)'; | |
| pill.style.border = match ? '1px solid rgba(99,102,241,.4)' : '1px solid var(--border)'; | |
| pill.style.color = match ? 'var(--accent)' : 'var(--muted)'; | |
| pill.querySelector('.ab-dot').style.background = match ? 'var(--accent)' : 'var(--border)'; | |
| pill.querySelector('.ab-dot').style.boxShadow = match ? '0 0 5px var(--accent)' : 'none'; | |
| }); | |
| } | |
| function abSwitchTab(tab) { | |
| const urlTab = document.getElementById('ab-url-tab'); | |
| const fileTab = document.getElementById('ab-file-tab'); | |
| const btnUrl = document.getElementById('ab-tab-url'); | |
| const btnFile = document.getElementById('ab-tab-file'); | |
| if (!urlTab || !fileTab) return; | |
| const isUrl = tab === 'url'; | |
| urlTab.style.display = isUrl ? '' : 'none'; | |
| fileTab.style.display = isUrl ? 'none' : ''; | |
| btnUrl.style.background = isUrl ? 'linear-gradient(135deg,var(--accent),var(--accent2))' : 'none'; | |
| btnUrl.style.color = isUrl ? '#fff' : 'var(--muted)'; | |
| btnUrl.style.boxShadow = isUrl ? '0 2px 12px rgba(99,102,241,.4)' : 'none'; | |
| btnFile.style.background = !isUrl ? 'linear-gradient(135deg,var(--accent),var(--accent2))' : 'none'; | |
| btnFile.style.color = !isUrl ? '#fff' : 'var(--muted)'; | |
| btnFile.style.boxShadow = !isUrl ? '0 2px 12px rgba(99,102,241,.4)' : 'none'; | |
| if (isUrl) abBuildPills(); | |
| } | |
| async function abFetchPreview() { | |
| const url = (document.getElementById('ab-url')?.value||'').trim(); | |
| if (!url) { abShowAlert('error', 'Please paste a book URL first.'); return; } | |
| if (!url.startsWith('http')) { abShowAlert('error', 'Enter a full URL starting with https://'); return; } | |
| abSetState('loading'); | |
| const steps = ['Detecting platform…','Querying Google Books + Open Library APIs…','Enriching metadata…','Scanning platform presence…','Checking retailers…']; | |
| let si = 0; | |
| const intv = setInterval(() => { const el = document.getElementById('ab-loader-msg'); if(el) el.textContent = steps[++si % steps.length]; }, 1400); | |
| try { | |
| const res = await auth.request('POST', `/admin/${authorSlug}/books/import-url/preview`, { url, force_refresh: true }); | |
| clearInterval(intv); | |
| const data = await res.json(); | |
| if (!res.ok) { abSetState('input'); abShowAlert('error', data.detail || 'Failed to fetch book details.'); return; } | |
| _abMeta = data; | |
| abRenderPreview(data); | |
| } catch(e) { | |
| clearInterval(intv); | |
| abSetState('input'); | |
| abShowAlert('error', 'Network error: ' + e.message); | |
| } | |
| } | |
| function abSetState(state) { | |
| const loader = document.getElementById('ab-loader'); | |
| const preview = document.getElementById('ab-preview'); | |
| const ppPanel = document.getElementById('ab-platform-panel'); | |
| const actions = document.getElementById('ab-actions'); | |
| const success = document.getElementById('ab-success'); | |
| if (!loader) return; | |
| loader.style.display = state==='loading' ? 'flex' : 'none'; | |
| preview.style.display = state==='preview' ? 'block': 'none'; | |
| if (ppPanel && state !== 'preview') ppPanel.style.display = 'none'; | |
| const sticky = document.getElementById('ab-pp-sticky-banner'); | |
| if (sticky && state !== 'preview') sticky.style.display = 'none'; | |
| actions.style.display = state==='preview' ? 'flex' : 'none'; | |
| success.style.display = state==='success' ? 'flex' : 'none'; | |
| abHideAlert(); | |
| } | |
| const AB_PP_STATUS = { | |
| verified: { label: 'Listed', cls: 'ab-pp-badge--verified' }, | |
| not_found: { label: 'Not listed', cls: 'ab-pp-badge--danger' }, | |
| error: { label: 'Error', cls: 'ab-pp-badge--error' }, | |
| skipped: { label: 'Skipped', cls: 'ab-pp-badge--danger' }, | |
| likely: { label: 'Not listed', cls: 'ab-pp-badge--danger' }, | |
| }; | |
| const AB_PP_CHECKLISTS = { | |
| amazon: ['ISBN or ASIN', 'Cover JPG (2560×1600 min)', 'KDP account & bank details', 'Book description & categories'], | |
| apple_books: ['EPUB file', 'ISBN (recommended)', 'Apple ID & iTunes Connect', 'Cover image'], | |
| google_books: ['EPUB or PDF', 'Cover image', 'Google Books partner account', 'Book metadata'], | |
| barnes_noble: ['EPUB file', 'ISBN', 'B&N Press account', 'Cover & description'], | |
| kobo: ['EPUB file', 'ISBN (recommended)', 'Kobo Writing Life account', 'Cover image'], | |
| bookshop: ['ISBN', 'Distributor or direct upload path', 'Book metadata'], | |
| walmart: ['ISBN', 'Seller Center account', 'Product listing details'], | |
| goodreads: ['Author profile', 'Book ISBN for linking', 'Cover & description'], | |
| thriftbooks: ['ISBN', 'Inventory listing', 'Condition & pricing'], | |
| abebooks: ['ISBN', 'Seller account', 'Book condition & pricing'], | |
| }; | |
| let _abSupportConfig = { support_email: null }; | |
| let _abLastImportGaps = null; | |
| let _abLastImportBook = null; | |
| async function abLoadSupportConfig() { | |
| try { | |
| _abSupportConfig = await apiGet(`/admin/${authorSlug}/support/config`); | |
| } catch (e) { /* optional */ } | |
| } | |
| function abPpIsMissing(status) { | |
| return status === 'not_found' || status === 'skipped' || status === 'likely'; | |
| } | |
| function abPpGetMissingPlatforms(pp) { | |
| if (!pp) return []; | |
| const fromPlatforms = (pp.platforms || []).filter(p => abPpIsMissing(p.status)); | |
| const fromRecs = (pp.recommendations || []).filter(r => !fromPlatforms.some(p => p.platform_id === r.platform_id)); | |
| return [...fromPlatforms, ...fromRecs]; | |
| } | |
| function abPpBuildHelpContext(overrides = {}) { | |
| const pp = _abMeta?.platform_presence; | |
| const missing = overrides.platforms || abPpGetMissingPlatforms(pp); | |
| const platformIds = overrides.platform_ids || missing.map(p => p.platform_id); | |
| return { | |
| book_title: overrides.book_title || document.getElementById('ab-e-title')?.value?.trim() || _abMeta?.title || '', | |
| book_id: overrides.book_id || _abLastImportBook?.book_id || null, | |
| author: overrides.author || document.getElementById('ab-e-author')?.value?.trim() || _abMeta?.author || '', | |
| isbn: overrides.isbn || document.getElementById('ab-e-isbn')?.value?.trim() || _abMeta?.isbn || '', | |
| source_url: overrides.source_url || _abMeta?.source_url || '', | |
| platform_ids: platformIds, | |
| platforms: missing, | |
| }; | |
| } | |
| function abPpHelpDedupKey(ctx) { | |
| return `abHelp:${ctx.book_id || ctx.book_title}:${(ctx.platform_ids || []).slice().sort().join(',')}`; | |
| } | |
| function abPpHelpAlreadySent(ctx) { | |
| try { return sessionStorage.getItem(abPpHelpDedupKey(ctx)) === '1'; } catch { return false; } | |
| } | |
| function abPpMarkHelpSent(ctx) { | |
| try { sessionStorage.setItem(abPpHelpDedupKey(ctx), '1'); } catch {} | |
| } | |
| function abPpHelpSummaryText(ctx) { | |
| const names = (ctx.platforms || []).map(p => p.display_name || p.platform_id).join(', '); | |
| return [ | |
| `Book: ${ctx.book_title}`, | |
| ctx.author ? `Author: ${ctx.author}` : '', | |
| ctx.isbn ? `ISBN: ${ctx.isbn}` : '', | |
| ctx.source_url ? `URL: ${ctx.source_url}` : '', | |
| `Platforms: ${names}`, | |
| ].filter(Boolean).join('\n'); | |
| } | |
| function abPpRenderHelpFormFields(ctx, opts = {}) { | |
| const platformLabels = (ctx.platforms || []).map(p => esc(p.display_name || p.platform_id)).join(', ') || '—'; | |
| const checklistPid = (ctx.platform_ids || [])[0]; | |
| const checklist = checklistPid && AB_PP_CHECKLISTS[checklistPid] | |
| ? `<div class="ab-pp-checklist"><strong>Checklist for ${esc((ctx.platforms||[])[0]?.display_name || checklistPid)}:</strong><ul>${AB_PP_CHECKLISTS[checklistPid].map(i => `<li>${esc(i)}</li>`).join('')}</ul></div>` | |
| : '📖'; | |
| const supportHint = _abSupportConfig.support_email | |
| ? `<div class="text-xs text-muted" style="margin-top:8px;">Support: ${esc(_abSupportConfig.support_email)}</div>` | |
| : '📖'; | |
| return ` | |
| <div class="ab-pp-help-form"> | |
| <div class="form-group"><label class="form-label">Book</label><div class="ab-pp-help-readonly">${esc(ctx.book_title || '—')}</div></div> | |
| <div class="grid-2" style="gap:12px;"> | |
| <div class="form-group"><label class="form-label">Author</label><div class="ab-pp-help-readonly">${esc(ctx.author || '—')}</div></div> | |
| <div class="form-group"><label class="form-label">ISBN</label><div class="ab-pp-help-readonly">${esc(ctx.isbn || '—')}</div></div> | |
| </div> | |
| <div class="form-group"><label class="form-label">Platforms</label><div class="ab-pp-help-readonly">${platformLabels}</div></div> | |
| ${checklist} | |
| <div class="form-group" style="margin-top:12px;"><label class="form-label">Message (optional)</label> | |
| <textarea class="form-input" id="ab-help-message" rows="3" maxlength="2000" placeholder="e.g. I need help with KDP formatting">${esc(opts.message || '')}</textarea> | |
| </div> | |
| ${supportHint} | |
| </div>`; | |
| } | |
| function abShowPublishHelpForm(context = {}) { | |
| const ctx = abPpBuildHelpContext(context); | |
| if (!ctx.platform_ids.length) { | |
| toast('Select at least one platform', 'error'); | |
| return; | |
| } | |
| if (abPpHelpAlreadySent(ctx)) { | |
| toast('Request already sent for this book and platforms', 'info'); | |
| return; | |
| } | |
| const body = abPpRenderHelpFormFields(ctx) + `<div id="ab-help-error" class="text-xs" style="color:var(--red);display:none;margin-top:8px;"></div>`; | |
| const actions = ` | |
| <button type="button" class="btn btn-secondary btn-sm" onclick="abPpCopyHelpSummary()">Copy summary</button> | |
| <button type="button" class="btn btn-secondary" onclick="closeModal()">Cancel</button> | |
| <button type="button" class="btn btn-primary" id="ab-help-submit-btn" onclick="abSubmitPublishHelp()">Submit request</button>`; | |
| showModal('Request publishing help', body, actions); | |
| window._abHelpContext = ctx; | |
| const modal = document.getElementById('modal-content'); | |
| if (modal) { | |
| modal.setAttribute('role', 'dialog'); | |
| modal.setAttribute('aria-modal', 'true'); | |
| modal.setAttribute('aria-labelledby', 'ab-help-modal-title'); | |
| const titleEl = modal.querySelector('.modal-title'); | |
| if (titleEl) titleEl.id = 'ab-help-modal-title'; | |
| } | |
| if (_abPpModalEscHandler) document.removeEventListener('keydown', _abPpModalEscHandler); | |
| _abPpModalEscHandler = (e) => { if (e.key === 'Escape') closeModal(); }; | |
| document.addEventListener('keydown', _abPpModalEscHandler); | |
| } | |
| function abPpCopyHelpSummary() { | |
| const ctx = window._abHelpContext; | |
| if (!ctx) return; | |
| const text = abPpHelpSummaryText(ctx) + '\n\n' + (document.getElementById('ab-help-message')?.value || ''); | |
| navigator.clipboard.writeText(text).then(() => toast('Request summary copied', 'success')) | |
| .catch(() => toast('Could not copy to clipboard', 'error')); | |
| } | |
| async function abSubmitPublishHelp() { | |
| const ctx = window._abHelpContext; | |
| if (!ctx || !ctx.platform_ids.length) { toast('Select at least one platform', 'error'); return; } | |
| const btn = document.getElementById('ab-help-submit-btn'); | |
| const errEl = document.getElementById('ab-help-error'); | |
| if (btn) { btn.disabled = true; btn.textContent = 'Sending…'; } | |
| if (errEl) errEl.style.display = 'none'; | |
| try { | |
| const message = document.getElementById('ab-help-message')?.value?.trim() || ''; | |
| await apiPost(`/admin/${authorSlug}/support/publish-request`, { | |
| book_title: ctx.book_title, | |
| book_id: ctx.book_id || null, | |
| platform_ids: ctx.platform_ids, | |
| message, | |
| source_url: ctx.source_url || null, | |
| }); | |
| abPpMarkHelpSent(ctx); | |
| closeModal(); | |
| toast("Request sent — we'll reply within 24h", 'success'); | |
| } catch (e) { | |
| if (errEl) { errEl.textContent = e.message || 'Failed to send request'; errEl.style.display = 'block'; } | |
| else toast(e.message || 'Failed to send request', 'error'); | |
| } finally { | |
| if (btn) { btn.disabled = false; btn.textContent = 'Submit request'; } | |
| } | |
| } | |
| function abPpRequestAllMissing() { | |
| abShowPublishHelpForm(); | |
| } | |
| function abShowContactForm() { | |
| const body = ` | |
| <p class="modal-sub" style="margin-bottom:16px;">Send a message to our team. We typically respond within one business day.</p> | |
| <div class="form-group"> | |
| <label class="form-label" for="ab-contact-category">Topic</label> | |
| <select class="form-input" id="ab-contact-category"> | |
| <option value="general">General question</option> | |
| <option value="billing">Billing & subscription</option> | |
| <option value="technical">Technical support</option> | |
| <option value="publishing">Publishing & distribution</option> | |
| </select> | |
| </div> | |
| <div class="form-group"> | |
| <label class="form-label" for="ab-contact-message">Message</label> | |
| <textarea class="form-input" id="ab-contact-message" rows="5" maxlength="2000" placeholder="How can we help?"></textarea> | |
| </div> | |
| <div id="ab-contact-error" class="text-xs" style="color:var(--red);display:none;margin-top:8px;"></div>`; | |
| const actions = ` | |
| <button type="button" class="btn btn-secondary" onclick="closeModal()">Cancel</button> | |
| <button type="button" class="btn btn-primary" id="ab-contact-submit-btn" onclick="abSubmitContact()">Send message</button>`; | |
| showModal('Contact support', body, actions); | |
| const modal = document.getElementById('modal-content'); | |
| if (modal) { | |
| modal.setAttribute('role', 'dialog'); | |
| modal.setAttribute('aria-modal', 'true'); | |
| modal.setAttribute('aria-labelledby', 'ab-contact-modal-title'); | |
| const titleEl = modal.querySelector('.modal-title'); | |
| if (titleEl) titleEl.id = 'ab-contact-modal-title'; | |
| } | |
| } | |
| async function abSubmitContact() { | |
| const message = document.getElementById('ab-contact-message')?.value?.trim() || ''; | |
| const category = document.getElementById('ab-contact-category')?.value || 'general'; | |
| const btn = document.getElementById('ab-contact-submit-btn'); | |
| const errEl = document.getElementById('ab-contact-error'); | |
| if (!message) { | |
| if (errEl) { errEl.textContent = 'Please enter a message'; errEl.style.display = 'block'; } | |
| else toast('Please enter a message', 'error'); | |
| return; | |
| } | |
| if (btn) { btn.disabled = true; btn.textContent = 'Sending…'; } | |
| if (errEl) errEl.style.display = 'none'; | |
| try { | |
| await apiPost(`/admin/${authorSlug}/support/contact`, { category, message }); | |
| closeModal(); | |
| toast("Message sent — we'll reply within 24h", 'success'); | |
| } catch (e) { | |
| if (errEl) { errEl.textContent = e.message || 'Failed to send message'; errEl.style.display = 'block'; } | |
| else toast(e.message || 'Failed to send message', 'error'); | |
| } finally { | |
| if (btn) { btn.disabled = false; btn.textContent = 'Send message'; } | |
| } | |
| } | |
| function abSuccessRequestHelp() { | |
| if (_abLastImportGaps) { | |
| abShowPublishHelpForm({ | |
| platforms: _abLastImportGaps, | |
| platform_ids: _abLastImportGaps.map(p => p.platform_id), | |
| book_title: _abLastImportBook?.title, | |
| book_id: _abLastImportBook?.book_id, | |
| }); | |
| } else { | |
| abShowPublishHelpForm(); | |
| } | |
| } | |
| function abPpOpenPublishModal(platform) { | |
| if (!platform) return; | |
| const name = platform.display_name || 'this platform'; | |
| const benefit = platform.benefit || 'Expand your reach to more readers.'; | |
| const checklist = AB_PP_CHECKLISTS[platform.platform_id]; | |
| const checklistHtml = checklist | |
| ? `<div class="ab-pp-checklist"><strong>You'll need:</strong><ul>${checklist.map(i => `<li>${esc(i)}</li>`).join('')}</ul></div>` | |
| : '📖'; | |
| const helpFields = abPpRenderHelpFormFields(abPpBuildHelpContext({ platforms: [platform], platform_ids: [platform.platform_id] })); | |
| const body = ` | |
| <div class="ab-pp-modal-icon" aria-hidden="true">${esc(platform.icon || '📚')}</div> | |
| <div class="modal-sub" style="margin-bottom:0;"> | |
| Your book was <strong style="color:var(--red);">not found</strong> on <strong>${esc(name)}</strong>. | |
| Listing here helps readers discover and buy your work. | |
| </div> | |
| <div class="ab-pp-modal-benefit"><strong>Why it matters:</strong> ${esc(benefit)}</div> | |
| ${checklistHtml} | |
| <div style="margin-top:18px;padding-top:16px;border-top:1px solid var(--border-light);"> | |
| <div style="font-size:13px;font-weight:700;margin-bottom:10px;">Request help publishing</div> | |
| ${helpFields} | |
| <div id="ab-help-error" class="text-xs" style="color:var(--red);display:none;margin-top:8px;"></div> | |
| </div> | |
| <div class="ab-pp-modal-tip">Tip: After you publish, re-fetch your book URL to refresh platform coverage.</div>`; | |
| const actions = ` | |
| <button type="button" class="btn btn-secondary btn-sm" data-pp-copy-id="${platform.platform_id}" onclick="abPpCopyPlatformName(this.dataset.ppCopyId)">Copy name</button> | |
| <button type="button" class="btn btn-secondary btn-sm" onclick="abPpCopyHelpSummary()">Copy summary</button> | |
| <button type="button" class="btn btn-primary" id="ab-help-submit-btn" onclick="abSubmitPublishHelp()">Request help publishing</button> | |
| <button type="button" class="btn btn-secondary" onclick="closeModal()">Dismiss</button>`; | |
| showModal(`Publish on ${esc(name)}`, body, actions); | |
| window._abHelpContext = abPpBuildHelpContext({ platforms: [platform], platform_ids: [platform.platform_id] }); | |
| const modal = document.getElementById('modal-content'); | |
| if (modal) { | |
| modal.setAttribute('role', 'dialog'); | |
| modal.setAttribute('aria-modal', 'true'); | |
| modal.setAttribute('aria-labelledby', 'ab-pp-modal-title'); | |
| const titleEl = modal.querySelector('.modal-title'); | |
| if (titleEl) titleEl.id = 'ab-pp-modal-title'; | |
| } | |
| if (_abPpModalEscHandler) document.removeEventListener('keydown', _abPpModalEscHandler); | |
| _abPpModalEscHandler = (e) => { if (e.key === 'Escape') closeModal(); }; | |
| document.addEventListener('keydown', _abPpModalEscHandler); | |
| } | |
| function abPpCopyPlatformName(platformId) { | |
| const platforms = _abMeta?.platform_presence?.platforms || []; | |
| const recs = _abMeta?.platform_presence?.recommendations || []; | |
| const p = platforms.find(x => x.platform_id === platformId) | |
| || recs.find(x => x.platform_id === platformId); | |
| const name = p?.display_name || platformId; | |
| navigator.clipboard.writeText(name).then(() => { | |
| toast('Copied: ' + name, 'success'); | |
| }).catch(() => toast('Could not copy to clipboard', 'error')); | |
| } | |
| function abPpBindMissingClicks(container) { | |
| if (!container || container.dataset.ppBound) return; | |
| container.dataset.ppBound = '1'; | |
| container.addEventListener('click', (e) => { | |
| const el = e.target.closest('[data-pp-platform-id]'); | |
| if (!el || el.dataset.ppMissing !== '1') return; | |
| const id = el.dataset.ppPlatformId; | |
| const platforms = _abMeta?.platform_presence?.platforms || []; | |
| const recs = _abMeta?.platform_presence?.recommendations || []; | |
| const platform = platforms.find(p => p.platform_id === id) | |
| || recs.find(r => r.platform_id === id); | |
| if (platform) abPpOpenPublishModal(platform); | |
| }); | |
| container.addEventListener('keydown', (e) => { | |
| if (e.key !== 'Enter' && e.key !== ' ') return; | |
| const el = e.target.closest('[data-pp-platform-id][data-pp-missing="1"]'); | |
| if (!el) return; | |
| e.preventDefault(); | |
| el.click(); | |
| }); | |
| } | |
| function abPpListingLink(p) { | |
| if (!p.listing_url || p.status !== 'verified') return ''; | |
| return `<a class="ab-pp-link" href="${esc(p.listing_url)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">View listing ↗</a>`; | |
| } | |
| function abRenderFoundLinks(pp) { | |
| const wrap = document.getElementById('ab-found-links'); | |
| if (!wrap) return; | |
| const found = (pp?.platforms || []).filter(p => p.status === 'verified' && p.listing_url); | |
| if (!found.length) { | |
| wrap.style.display = 'none'; | |
| wrap.innerHTML = ''; | |
| return; | |
| } | |
| const verified = found.filter(p => p.status === 'verified'); | |
| const links = verified.map(p => | |
| `<a class="ab-found-link" href="${esc(p.listing_url)}" target="_blank" rel="noopener noreferrer" title="${esc(p.display_name)}">${p.icon || '🔗'} ${esc(p.display_name)} ↗</a>` | |
| ).join(''); | |
| wrap.style.display = 'flex'; | |
| wrap.innerHTML = `<span class="ab-found-links-label">Also on</span>${links}`; | |
| } | |
| function abRenderPlatformScan(pp) { | |
| const panel = document.getElementById('ab-platform-panel'); | |
| if (!panel) return; | |
| if (!pp || !pp.platforms) { | |
| panel.style.display = 'none'; | |
| return; | |
| } | |
| panel.style.display = 'block'; | |
| const listed = pp.listed_count ?? pp.score ?? 0; | |
| const pct = Math.round((listed / 10) * 100); | |
| const missingCount = (pp.platforms || []).filter(p => abPpIsMissing(p.status)).length; | |
| const numEl = document.getElementById('ab-pp-score-num'); | |
| const ringEl = document.getElementById('ab-pp-ring'); | |
| const listedEl = document.getElementById('ab-pp-listed'); | |
| const missingBadge = document.getElementById('ab-pp-missing-badge'); | |
| const headerMissingBadge = document.getElementById('ab-pp-header-missing-badge'); | |
| if (numEl) numEl.textContent = String(listed); | |
| if (ringEl) ringEl.style.setProperty('--pp-pct', pct); | |
| if (listedEl) { | |
| listedEl.textContent = `${pp.listed_count || 0} of ${pp.platforms.length} platforms listed`; | |
| listedEl.classList.toggle('ab-pp-listed--warn', missingCount > 0 && !pp.distribution_note); | |
| } | |
| const noteEl = document.getElementById('ab-pp-distribution-note'); | |
| if (noteEl) { | |
| if (pp.distribution_note) { | |
| noteEl.style.display = 'block'; | |
| noteEl.textContent = pp.distribution_note; | |
| } else { | |
| noteEl.style.display = 'none'; | |
| noteEl.textContent = ''; | |
| } | |
| } | |
| [missingBadge, headerMissingBadge].forEach(badge => { | |
| if (!badge) return; | |
| if (missingCount > 0) { | |
| badge.style.display = 'inline-flex'; | |
| badge.textContent = `⚠ ${missingCount} missing`; | |
| badge.setAttribute('aria-label', `${missingCount} platforms missing`); | |
| } else { | |
| badge.style.display = 'none'; | |
| } | |
| }); | |
| const bulkBtn = document.getElementById('ab-pp-bulk-help-btn'); | |
| if (bulkBtn) bulkBtn.style.display = missingCount > 0 ? 'inline-flex' : 'none'; | |
| const sticky = document.getElementById('ab-pp-sticky-banner'); | |
| const stickyText = document.getElementById('ab-pp-sticky-text'); | |
| if (sticky && stickyText) { | |
| if (missingCount > 0) { | |
| sticky.style.display = 'flex'; | |
| stickyText.textContent = `${missingCount} platform${missingCount === 1 ? '' : 's'} missing — we can help you publish`; | |
| } else { | |
| sticky.style.display = 'none'; | |
| } | |
| } | |
| const rows = document.getElementById('ab-pp-platforms'); | |
| if (rows) { | |
| rows.innerHTML = pp.platforms.map(p => { | |
| const st = AB_PP_STATUS[p.status] || AB_PP_STATUS.not_found; | |
| const conf = Math.round((p.confidence || 0) * 100); | |
| const missing = abPpIsMissing(p.status); | |
| const rowCls = ['ab-pp-row', missing ? 'ab-pp-row--missing' : '', missing ? 'ab-pp-row--clickable' : ''].filter(Boolean).join(' '); | |
| const benefit = (missing && p.benefit) | |
| ? `<div class="ab-pp-benefit">${esc(p.benefit)}</div>` : ''; | |
| const link = abPpListingLink(p); | |
| const price = (p.status === 'verified' && p.price) | |
| ? `<div class="ab-pp-price">${esc(p.price)}</div>` : ''; | |
| const attrs = missing | |
| ? ` data-pp-platform-id="${esc(p.platform_id)}" data-pp-missing="1" role="button" tabindex="0" aria-label="Not found on ${esc(p.display_name)} — click for publishing help"` | |
| : '📖'; | |
| return `<div class="${rowCls}"${attrs}> | |
| <span class="ab-pp-icon" aria-hidden="true">${missing ? '⚠️' : (p.icon || '🔗')}</span> | |
| <div class="ab-pp-info"> | |
| <div class="ab-pp-name">${esc(p.display_name)}</div> | |
| ${link} | |
| ${price} | |
| ${benefit} | |
| </div> | |
| <div class="ab-pp-bar-wrap"><div class="ab-pp-bar" style="width:${conf}%"></div></div> | |
| <span class="ab-pp-badge ${st.cls}">${st.label}</span> | |
| </div>`; | |
| }).join(''); | |
| abPpBindMissingClicks(rows); | |
| } | |
| abRenderFoundLinks(pp); | |
| const recWrap = document.getElementById('ab-pp-recs'); | |
| const recCards = document.getElementById('ab-pp-rec-cards'); | |
| const recs = (pp.recommendations || []).slice(0, 4); | |
| if (recWrap && recCards) { | |
| if (!recs.length) { | |
| recWrap.style.display = 'none'; | |
| } else { | |
| recWrap.style.display = 'block'; | |
| recCards.innerHTML = recs.map(r => | |
| `<div class="ab-pp-rec-card ab-pp-rec-card--missing" | |
| data-pp-platform-id="${esc(r.platform_id)}" data-pp-missing="1" | |
| role="button" tabindex="0" | |
| aria-label="Publish on ${esc(r.display_name)} — open details"> | |
| <div class="ab-pp-rec-name">${esc(r.icon || '📚')} ${esc(r.display_name)}</div> | |
| <div class="ab-pp-rec-text">${esc(r.benefit)} — consider publishing here.</div> | |
| <div class="ab-pp-rec-cta">Tap to see how →</div> | |
| </div>` | |
| ).join(''); | |
| abPpBindMissingClicks(recCards); | |
| } | |
| } | |
| } | |
| function abRenderPreview(data) { | |
| const plat = AB_PLATFORMS.find(p => p.id === data.platform) || { icon:'🔗', name: data.platform||'Unknown' }; | |
| document.getElementById('ab-p-platform').textContent = `${plat.icon} ${plat.name}`; | |
| const pct = Math.round((data.confidence||0)*100); | |
| document.getElementById('ab-conf-fill').style.width = pct + '%'; | |
| document.getElementById('ab-conf-label').textContent = pct + '% matched'; | |
| const cov = document.getElementById('ab-cover'); | |
| if (data.cover_url) { | |
| cov.innerHTML = `<img src="${data.cover_url}" style="width:100%;height:100%;object-fit:cover" onerror="this.parentElement.textContent='📖'">`; | |
| } else { cov.textContent = '📖'; } | |
| document.getElementById('ab-title-disp').textContent = data.title || '(Title not found)'; | |
| document.getElementById('ab-author-disp').textContent = data.author ? 'by ' + data.author : ''; | |
| document.getElementById('ab-desc-disp').textContent = data.description || ''; | |
| // Chips | |
| const chips = []; | |
| if (data.rating) { | |
| const s = parseFloat(data.rating); const f = Math.round(s); | |
| chips.push(`<span style="display:inline-flex;align-items:center;gap:3px;padding:2px 8px;border-radius:20px;font-size:11px;background:var(--accent-soft);border:1px solid rgba(99,102,241,.2);color:var(--accent);"> | |
| <span style="color:#f59e0b">${'★'.repeat(f)}${'☆'.repeat(5-f)}</span> ${data.rating}${data.rating_count ? ' ('+data.rating_count+')' : ''}</span>`); | |
| } | |
| if (data.genre) chips.push(`<span style="display:inline-flex;padding:2px 8px;border-radius:20px;font-size:11px;background:var(--surface2);border:1px solid var(--border);color:var(--muted);">🏷️ ${data.genre.split(',')[0].trim()}</span>`); | |
| if (data.page_count) chips.push(`<span style="display:inline-flex;padding:2px 8px;border-radius:20px;font-size:11px;background:var(--surface2);border:1px solid var(--border);color:var(--muted);">📄 ${data.page_count}p</span>`); | |
| if (data.publisher) chips.push(`<span style="display:inline-flex;padding:2px 8px;border-radius:20px;font-size:11px;background:var(--surface2);border:1px solid var(--border);color:var(--muted);">🏢 ${data.publisher}</span>`); | |
| if (data.publish_year) chips.push(`<span style="display:inline-flex;padding:2px 8px;border-radius:20px;font-size:11px;background:var(--surface2);border:1px solid var(--border);color:var(--muted);">📅 ${data.publish_year}</span>`); | |
| if (data.isbn13) chips.push(`<span style="display:inline-flex;padding:2px 8px;border-radius:20px;font-size:11px;background:var(--surface2);border:1px solid var(--border);color:var(--muted);">🔢 ISBN-13 ${data.isbn13}</span>`); | |
| else if (data.isbn10) chips.push(`<span style="display:inline-flex;padding:2px 8px;border-radius:20px;font-size:11px;background:var(--surface2);border:1px solid var(--border);color:var(--muted);">🔢 ISBN-10 ${data.isbn10}</span>`); | |
| else if (data.isbn) chips.push(`<span style="display:inline-flex;padding:2px 8px;border-radius:20px;font-size:11px;background:var(--surface2);border:1px solid var(--border);color:var(--muted);">🔢 ${data.isbn}</span>`); | |
| if (data.asin) chips.push(`<span style="display:inline-flex;padding:2px 8px;border-radius:20px;font-size:11px;background:var(--surface2);border:1px solid var(--border);color:var(--muted);">🏷️ ASIN ${data.asin}</span>`); | |
| if (data.format) chips.push(`<span style="display:inline-flex;padding:2px 8px;border-radius:20px;font-size:11px;background:var(--surface2);border:1px solid var(--border);color:var(--muted);">📘 ${data.format}</span>`); | |
| if (data.series) chips.push(`<span style="display:inline-flex;padding:2px 8px;border-radius:20px;font-size:11px;background:var(--surface2);border:1px solid var(--border);color:var(--muted);">📚 ${data.series}</span>`); | |
| if (data.price) chips.push(`<span style="display:inline-flex;padding:2px 8px;border-radius:20px;font-size:11px;background:var(--surface2);border:1px solid var(--border);color:var(--muted);">💲 ${data.price}</span>`); | |
| document.getElementById('ab-chips').innerHTML = chips.join(''); | |
| // Bio panel | |
| if (data.about_author) { | |
| document.getElementById('ab-bio-panel').style.display = 'block'; | |
| document.getElementById('ab-bio-text').textContent = data.about_author; | |
| } else { document.getElementById('ab-bio-panel').style.display = 'none'; } | |
| // Pre-fill edit fields | |
| document.getElementById('ab-e-title').value = data.title || ''; | |
| document.getElementById('ab-e-author').value = data.author || ''; | |
| document.getElementById('ab-e-genre').value = data.genre || ''; | |
| document.getElementById('ab-e-isbn').value = data.isbn13 || data.isbn10 || data.isbn || data.asin || ''; | |
| document.getElementById('ab-e-cover').value = data.cover_url || ''; | |
| document.getElementById('ab-e-desc').value = data.description || ''; | |
| document.getElementById('ab-e-bio').value = data.about_author || ''; | |
| if (data.warnings && data.warnings.length) { | |
| abShowAlert('warning', '⚠️ Some data was enriched from APIs: ' + data.warnings.join('; ')); | |
| } | |
| abRenderPlatformScan(data.platform_presence); | |
| abSetState('preview'); | |
| } | |
| function abToggleEdit() { | |
| const form = document.getElementById('ab-edit-form'); | |
| const chev = document.getElementById('ab-chevron'); | |
| const open = form.style.display === 'block'; | |
| form.style.display = open ? 'none' : 'block'; | |
| if (chev) chev.style.transform = open ? '' : 'rotate(180deg)'; | |
| } | |
| async function abConfirmImport() { | |
| if (!_abMeta) return; | |
| const title = (document.getElementById('ab-e-title')?.value || _abMeta.title || '').trim(); | |
| if (!title) { abShowAlert('error', 'Please enter a title.'); return; } | |
| const btn = document.getElementById('ab-import-btn'); | |
| btn.disabled = true; btn.textContent = 'Saving…'; | |
| try { | |
| // Genre: prefer user-edited value, then scraped value from meta | |
| const genreVal = (document.getElementById('ab-e-genre')?.value?.trim()) | |
| || (_abMeta.genre || ''); | |
| const body = { | |
| url: _abMeta.source_url, | |
| title, | |
| author: (document.getElementById('ab-e-author')?.value?.trim()) || _abMeta.author || '', | |
| description: (document.getElementById('ab-e-desc')?.value?.trim()) || _abMeta.description || '', | |
| cover_url: (document.getElementById('ab-e-cover')?.value?.trim()) || _abMeta.cover_url || '', | |
| isbn: (document.getElementById('ab-e-isbn')?.value?.trim()) || _abMeta.isbn || '', | |
| genre: genreVal, | |
| }; | |
| const res = await auth.request('POST', `/admin/${authorSlug}/books/import-url`, body); | |
| const data = await res.json(); | |
| if (!res.ok) { abShowAlert('error', data.detail || 'Import failed.'); return; } | |
| const gaps = abPpGetMissingPlatforms(_abMeta?.platform_presence); | |
| _abLastImportGaps = gaps.length ? gaps : null; | |
| _abLastImportBook = { book_id: data.book_id, title: data.title || title }; | |
| document.getElementById('ab-success-book').textContent = data.title || title; | |
| document.getElementById('ab-success').querySelector('p') && (document.getElementById('ab-success').querySelector('p').style.display = 'none'); | |
| const gapsEl = document.getElementById('ab-success-gaps'); | |
| if (gapsEl && gaps.length) { | |
| gapsEl.style.display = 'block'; | |
| document.getElementById('ab-success-gaps-title').textContent = | |
| `Your book isn't on ${gaps.slice(0, 3).map(p => p.display_name).join(', ')}${gaps.length > 3 ? '…' : ''} yet`; | |
| document.getElementById('ab-success-gaps-detail').textContent = | |
| `${gaps.length} platform${gaps.length === 1 ? '' : 's'} could expand your reach. Request help and we'll guide you through publishing.`; | |
| } else if (gapsEl) { | |
| gapsEl.style.display = 'none'; | |
| } | |
| abSetState('success'); | |
| // Update hint text to reflect auto-indexing | |
| const hintEl = document.querySelector('#ab-success .ab-hint'); | |
| if (hintEl) hintEl.textContent = '🚀 Indexing started automatically from scraped metadata. The book will be Ready in seconds.'; | |
| if (typeof loadBooks === 'function') loadBooks(); | |
| abLoadSidebar(); | |
| toast('Book added successfully!', 'success'); | |
| } catch(e) { | |
| abShowAlert('error', 'Error: ' + e.message); | |
| } finally { btn.disabled = false; btn.textContent = '✅ Add to My Books'; } | |
| } | |
| function abReset() { | |
| _abMeta = null; | |
| _abLastImportGaps = null; | |
| _abLastImportBook = null; | |
| const urlEl = document.getElementById('ab-url'); | |
| if (urlEl) urlEl.value = ''; | |
| document.getElementById('ab-edit-form').style.display = 'none'; | |
| document.getElementById('ab-chevron').style.transform = ''; | |
| const ppPanel = document.getElementById('ab-platform-panel'); | |
| if (ppPanel) ppPanel.style.display = 'none'; | |
| const sticky = document.getElementById('ab-pp-sticky-banner'); | |
| if (sticky) sticky.style.display = 'none'; | |
| abSetState('input'); | |
| abDetectPlatform(); | |
| } | |
| async function abLoadSidebar() { | |
| const c = document.getElementById('ab-books-sidebar'); | |
| if (!c) return; | |
| c.innerHTML = '<div style="padding:24px;text-align:center;color:var(--light);font-size:13px;">Loading…</div>'; | |
| try { | |
| const data = await auth.get(`/admin/${authorSlug}/books?limit=50`); | |
| const books = data.books || data || []; | |
| if (!books.length) { c.innerHTML = '<div style="padding:32px;text-align:center;color:var(--light);font-size:13px;">No books yet.<br>Import one above!</div>'; return; } | |
| c.innerHTML = books.map(b => { | |
| const thumb = b.cover_url | |
| ? `<img src="${b.cover_url}" style="width:100%;height:100%;object-fit:cover" onerror="this.style.display='none'">` | |
| : '📖'; | |
| const sc = { ready:'color:#10b981', url_imported:'color:var(--accent)', processing:'color:var(--yellow)', error:'color:var(--red)' }[b.status] || 'color:var(--muted)'; | |
| const sl = { ready:'✅ Ready', url_imported:'🔗 Imported', processing:'⚙️ Processing', error:'❌ Error' }[b.status] || b.status; | |
| return `<div style="display:flex;align-items:center;gap:11px;padding:11px 14px;border-bottom:1px solid var(--border-light);transition:background .18s;" onmouseenter="this.style.background='var(--surface2)'" onmouseleave="this.style.background=''"> | |
| <div style="width:34px;height:48px;border-radius:4px;background:var(--surface2);border:1px solid var(--border);display:flex;align-items:center;justify-content:center;font-size:14px;overflow:hidden;flex-shrink:0;">${thumb}</div> | |
| <div style="flex:1;min-width:0;"> | |
| <div style="font-size:12px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${b.title||'Untitled'}</div> | |
| <div style="font-size:11px;color:var(--light);margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${b.book_author||b.author_name||'—'}</div> | |
| <div style="font-size:10px;font-weight:600;margin-top:3px;${sc}">${sl}</div> | |
| </div> | |
| </div>`; | |
| }).join(''); | |
| } catch(e) { | |
| c.innerHTML = `<div style="padding:20px;text-align:center;color:var(--red);font-size:12px;">${e.message}</div>`; | |
| } | |
| } | |
| function abShowAlert(type, msg) { | |
| const el = document.getElementById('ab-alert'); | |
| if (!el) return; | |
| const colors = { error:'var(--red-bg);border:1px solid rgba(239,68,68,.25);color:var(--red)', warning:'var(--yellow-bg);border:1px solid rgba(245,158,11,.25);color:#92400e' }; | |
| el.style.cssText = `display:flex;align-items:flex-start;gap:10px;padding:11px 14px;border-radius:var(--radius-sm);margin-bottom:14px;font-size:12px;background:${colors[type]||colors.error}`; | |
| el.textContent = msg; | |
| } | |
| function abHideAlert() { | |
| const el = document.getElementById('ab-alert'); | |
| if (el) el.style.display = 'none'; | |
| } | |
| // Init URL tab when Upload Book nav is clicked | |
| (function() { | |
| const origNav = window.nav; | |
| window.nav = function(page) { | |
| origNav(page); | |
| if (page === 'upload') { | |
| setTimeout(() => { | |
| abSwitchTab('url'); | |
| abLoadSidebar(); | |
| }, 50); | |
| } | |
| }; | |
| })(); | |
| // Also add @keyframes spin if not already present (used by the loader spinner) | |
| if (!document.querySelector('style[data-ab]')) { | |
| const s = document.createElement('style'); | |
| s.dataset.ab = '1'; | |
| s.textContent = '@keyframes spin { to { transform: rotate(360deg); } }'; | |
| document.head.appendChild(s); | |
| } | |
| </script> | |
| </body> | |
| </html> | |