Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Natural Hygiene Library</title> | |
| <link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&family=Inter:wght@300;400;500;600&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet"> | |
| <style> | |
| /* ═══════════════════════════════════════════ | |
| COLOR PALETTE — Sky Blue & Dusty Gold Pastels | |
| ═══════════════════════════════════════════ */ | |
| :root { | |
| --sky-50: #f0f7ff; | |
| --sky-100: #dbeafe; | |
| --sky-200: #b8d4f0; | |
| --sky-300: #8bb8e0; | |
| --sky-400: #6ba3d6; | |
| --sky-500: #4a8ec4; | |
| --gold-50: #fdf6ed; | |
| --gold-100: #f9e8d0; | |
| --gold-200: #f0d4a8; | |
| --gold-300: #e4bd7a; | |
| --gold-400: #d4a455; | |
| --gold-500: #c08b3e; | |
| --cream: #fefcf8; | |
| --sand: #f5f0e8; | |
| --ink: #2c3e50; | |
| --ink-light: #5a6c7d; | |
| --ink-faint: #94a3b8; | |
| --white: #ffffff; | |
| --success: #7ec8a0; | |
| --shadow-sm: 0 1px 3px rgba(44,62,80,0.06); | |
| --shadow-md: 0 4px 16px rgba(44,62,80,0.08); | |
| --shadow-lg: 0 8px 32px rgba(44,62,80,0.12); | |
| --shadow-glow: 0 0 40px rgba(74,142,196,0.15); | |
| --radius: 16px; | |
| --radius-sm: 10px; | |
| --radius-xs: 6px; | |
| } | |
| /* ═══════════════════════════════════════════ | |
| BASE RESET & TYPOGRAPHY | |
| ═══════════════════════════════════════════ */ | |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { | |
| font-family: 'Inter', -apple-system, sans-serif; | |
| background: var(--cream); | |
| color: var(--ink); | |
| line-height: 1.6; | |
| overflow-x: hidden; | |
| -webkit-font-smoothing: antialiased; | |
| } | |
| h1, h2, h3, h4 { font-family: 'Playfair Display', Georgia, serif; } | |
| /* ═══════════════════════════════════════════ | |
| ANIMATED BACKGROUND PARTICLES | |
| ═══════════════════════════════════════════ */ | |
| .bg-particles { | |
| position: fixed; | |
| top: 0; left: 0; width: 100%; height: 100%; | |
| pointer-events: none; | |
| z-index: 0; | |
| overflow: hidden; | |
| } | |
| .particle { | |
| position: absolute; | |
| border-radius: 50%; | |
| opacity: 0.15; | |
| animation: float linear infinite; | |
| } | |
| @keyframes float { | |
| 0% { transform: translateY(100vh) rotate(0deg); opacity: 0; } | |
| 10% { opacity: 0.15; } | |
| 90% { opacity: 0.15; } | |
| 100% { transform: translateY(-10vh) rotate(360deg); opacity: 0; } | |
| } | |
| /* ═══════════════════════════════════════════ | |
| LAYOUT | |
| ═══════════════════════════════════════════ */ | |
| .app-container { | |
| position: relative; | |
| z-index: 1; | |
| max-width: 1200px; | |
| margin: 0 auto; | |
| padding: 0 24px; | |
| } | |
| /* ═══════════════════════════════════════════ | |
| NAVIGATION | |
| ═══════════════════════════════════════════ */ | |
| nav { | |
| position: sticky; | |
| top: 0; | |
| z-index: 100; | |
| background: rgba(254,252,248,0.85); | |
| backdrop-filter: blur(20px); | |
| -webkit-backdrop-filter: blur(20px); | |
| border-bottom: 1px solid var(--sky-100); | |
| padding: 0 24px; | |
| } | |
| .nav-inner { | |
| max-width: 1200px; | |
| margin: 0 auto; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| height: 64px; | |
| } | |
| .nav-brand { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| font-family: 'Playfair Display', serif; | |
| font-weight: 700; | |
| font-size: 1.15rem; | |
| color: var(--ink); | |
| text-decoration: none; | |
| } | |
| .nav-brand .leaf { font-size: 1.4rem; } | |
| .nav-tabs { | |
| display: flex; | |
| gap: 4px; | |
| } | |
| .nav-tab { | |
| padding: 8px 18px; | |
| border-radius: 99px; | |
| border: none; | |
| background: transparent; | |
| font-family: 'Inter', sans-serif; | |
| font-size: 0.88rem; | |
| font-weight: 500; | |
| color: var(--ink-light); | |
| cursor: pointer; | |
| transition: all 0.25s ease; | |
| } | |
| .nav-tab:hover { background: var(--sky-50); color: var(--ink); } | |
| .nav-tab.active { | |
| background: var(--sky-200); | |
| color: var(--ink); | |
| } | |
| .nav-stats { | |
| display: flex; | |
| gap: 16px; | |
| font-size: 0.78rem; | |
| color: var(--ink-faint); | |
| } | |
| .nav-stat b { | |
| color: var(--gold-400); | |
| font-weight: 600; | |
| } | |
| /* ═══════════════════════════════════════════ | |
| HERO SECTION | |
| ═══════════════════════════════════════════ */ | |
| .hero { | |
| text-align: center; | |
| padding: 72px 0 48px; | |
| position: relative; | |
| } | |
| .hero h1 { | |
| font-size: clamp(2.2rem, 5vw, 3.4rem); | |
| font-weight: 700; | |
| line-height: 1.15; | |
| margin-bottom: 12px; | |
| animation: fadeUp 0.8s ease forwards; | |
| } | |
| .hero h1 .hl { | |
| background: linear-gradient(135deg, var(--sky-400), var(--gold-400)); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| background-clip: text; | |
| } | |
| .hero p { | |
| font-size: 1.1rem; | |
| color: var(--ink-light); | |
| max-width: 560px; | |
| margin: 0 auto 36px; | |
| animation: fadeUp 0.8s ease 0.1s both; | |
| } | |
| @keyframes fadeUp { | |
| from { opacity: 0; transform: translateY(20px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| /* ═══════════════════════════════════════════ | |
| SEARCH BAR | |
| ═══════════════════════════════════════════ */ | |
| .search-container { | |
| max-width: 680px; | |
| margin: 0 auto 20px; | |
| animation: fadeUp 0.8s ease 0.2s both; | |
| } | |
| .search-box { | |
| display: flex; | |
| align-items: center; | |
| background: var(--white); | |
| border: 2px solid var(--sky-200); | |
| border-radius: 99px; | |
| padding: 6px 8px 6px 24px; | |
| box-shadow: var(--shadow-md); | |
| transition: all 0.3s ease; | |
| } | |
| .search-box:focus-within { | |
| border-color: var(--sky-400); | |
| box-shadow: var(--shadow-glow); | |
| transform: translateY(-1px); | |
| } | |
| .search-box input { | |
| flex: 1; | |
| border: none; | |
| outline: none; | |
| font-size: 1.05rem; | |
| font-family: 'Inter', sans-serif; | |
| color: var(--ink); | |
| background: transparent; | |
| padding: 12px 0; | |
| } | |
| .search-box input::placeholder { color: var(--ink-faint); } | |
| .search-mode-toggle { | |
| display: flex; | |
| background: var(--sky-50); | |
| border-radius: 99px; | |
| padding: 3px; | |
| margin-right: 8px; | |
| } | |
| .mode-btn { | |
| padding: 6px 14px; | |
| border: none; | |
| border-radius: 99px; | |
| font-size: 0.78rem; | |
| font-weight: 500; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| background: transparent; | |
| color: var(--ink-light); | |
| font-family: 'Inter', sans-serif; | |
| } | |
| .mode-btn.active { | |
| background: var(--white); | |
| color: var(--sky-500); | |
| box-shadow: var(--shadow-sm); | |
| } | |
| .search-btn { | |
| width: 44px; | |
| height: 44px; | |
| border-radius: 50%; | |
| border: none; | |
| background: linear-gradient(135deg, var(--sky-400), var(--sky-500)); | |
| color: white; | |
| font-size: 1.2rem; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| transition: all 0.25s; | |
| flex-shrink: 0; | |
| } | |
| .search-btn:hover { | |
| transform: scale(1.08); | |
| box-shadow: 0 4px 16px rgba(74,142,196,0.3); | |
| } | |
| .search-btn:active { transform: scale(0.95); } | |
| /* ═══════════════════════════════════════════ | |
| FILTER CHIPS | |
| ═══════════════════════════════════════════ */ | |
| .filter-bar { | |
| display: flex; | |
| justify-content: center; | |
| flex-wrap: wrap; | |
| gap: 8px; | |
| margin-bottom: 16px; | |
| animation: fadeUp 0.8s ease 0.3s both; | |
| position: relative; | |
| z-index: 500; | |
| } | |
| .filter-chip { | |
| padding: 6px 16px; | |
| border-radius: 99px; | |
| border: 1.5px solid var(--sky-100); | |
| background: var(--white); | |
| font-size: 0.82rem; | |
| font-weight: 500; | |
| color: var(--ink-light); | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| font-family: 'Inter', sans-serif; | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| } | |
| .filter-chip:hover { border-color: var(--sky-300); color: var(--ink); } | |
| .filter-chip.active { | |
| border-color: var(--gold-300); | |
| background: var(--gold-50); | |
| color: var(--gold-500); | |
| } | |
| .filter-chip .icon { font-size: 0.95rem; } | |
| /* Dropdowns inside filters */ | |
| .filter-dropdown { | |
| position: relative; | |
| } | |
| .filter-dropdown-menu { | |
| display: none; | |
| position: absolute; | |
| top: calc(100% + 6px); | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background: var(--white); | |
| border: 1px solid var(--sky-100); | |
| border-radius: var(--radius-sm); | |
| box-shadow: var(--shadow-lg); | |
| padding: 6px; | |
| min-width: 200px; | |
| max-height: 280px; | |
| overflow-y: auto; | |
| z-index: 9999; | |
| } | |
| .filter-dropdown-menu.show { display: block; animation: dropIn 0.2s ease; } | |
| @keyframes dropIn { | |
| from { opacity: 0; transform: translateX(-50%) translateY(-6px); } | |
| to { opacity: 1; transform: translateX(-50%) translateY(0); } | |
| } | |
| .dropdown-item { | |
| padding: 8px 14px; | |
| border-radius: var(--radius-xs); | |
| font-size: 0.85rem; | |
| cursor: pointer; | |
| transition: background 0.15s; | |
| color: var(--ink); | |
| } | |
| .dropdown-item:hover { background: var(--sky-50); } | |
| .dropdown-item.active { background: var(--gold-50); color: var(--gold-500); font-weight: 500; } | |
| /* ═══════════════════════════════════════════ | |
| PAGES / SECTIONS | |
| ═══════════════════════════════════════════ */ | |
| .page { display: none; } | |
| .page.active { display: block; animation: fadeUp 0.4s ease; } | |
| /* ═══════════════════════════════════════════ | |
| STATS CARDS | |
| ═══════════════════════════════════════════ */ | |
| .stats-row { | |
| display: grid; | |
| grid-template-columns: repeat(5, 1fr); | |
| gap: 16px; | |
| margin-bottom: 40px; | |
| /* NOTE: no animation/position/z-index — prevents stacking context that hides filter dropdowns */ | |
| } | |
| .stat-card { | |
| background: var(--white); | |
| border-radius: var(--radius); | |
| padding: 24px; | |
| text-align: center; | |
| box-shadow: var(--shadow-sm); | |
| border: 1px solid var(--sky-50); | |
| transition: all 0.3s ease; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .stat-card::before { | |
| content: ''; | |
| position: absolute; | |
| top: 0; left: 0; right: 0; | |
| height: 3px; | |
| background: linear-gradient(90deg, var(--sky-300), var(--gold-300)); | |
| transform: scaleX(0); | |
| transition: transform 0.4s ease; | |
| } | |
| .stat-card:hover::before { transform: scaleX(1); } | |
| .stat-card:hover { transform: translateY(-3px); box-shadow: var(--shadow-md); } | |
| .stat-icon { font-size: 1.8rem; margin-bottom: 8px; } | |
| .stat-value { | |
| font-family: 'Playfair Display', serif; | |
| font-size: 2rem; | |
| font-weight: 700; | |
| color: var(--ink); | |
| line-height: 1.1; | |
| } | |
| .stat-value .counter { display: inline-block; } | |
| .stat-label { | |
| font-size: 0.82rem; | |
| color: var(--ink-faint); | |
| margin-top: 4px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| } | |
| /* ═══════════════════════════════════════════ | |
| RESULTS AREA | |
| ═══════════════════════════════════════════ */ | |
| .results-area { | |
| min-height: 200px; | |
| margin-bottom: 48px; | |
| } | |
| .results-header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| margin-bottom: 20px; | |
| } | |
| .results-header h3 { | |
| font-size: 1.2rem; | |
| font-weight: 600; | |
| color: var(--ink); | |
| } | |
| .results-count { | |
| font-size: 0.85rem; | |
| color: var(--ink-faint); | |
| } | |
| /* AI Answer Card */ | |
| .ai-answer-card { | |
| background: linear-gradient(135deg, var(--sky-50), var(--gold-50)); | |
| border: 1.5px solid var(--sky-200); | |
| border-radius: var(--radius); | |
| padding: 28px; | |
| margin-bottom: 24px; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .ai-answer-card::before { | |
| content: ''; | |
| position: absolute; | |
| top: 0; left: 0; | |
| width: 4px; | |
| height: 100%; | |
| background: linear-gradient(180deg, var(--sky-400), var(--gold-400)); | |
| border-radius: 0 4px 4px 0; | |
| } | |
| .ai-badge { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| background: var(--white); | |
| padding: 4px 12px; | |
| border-radius: 99px; | |
| font-size: 0.75rem; | |
| font-weight: 600; | |
| color: var(--sky-500); | |
| margin-bottom: 14px; | |
| border: 1px solid var(--sky-200); | |
| } | |
| .ai-badge .pulse { | |
| width: 6px; height: 6px; | |
| background: var(--sky-400); | |
| border-radius: 50%; | |
| animation: pulse 2s ease infinite; | |
| } | |
| @keyframes pulse { | |
| 0%, 100% { opacity: 1; transform: scale(1); } | |
| 50% { opacity: 0.5; transform: scale(1.5); } | |
| } | |
| .ai-answer-text { | |
| font-size: 1rem; | |
| line-height: 1.85; | |
| color: var(--ink); | |
| } | |
| .ai-answer-text p { | |
| margin-bottom: 1.1em; | |
| } | |
| .ai-answer-text p:last-child { | |
| margin-bottom: 0; | |
| } | |
| /* Inline source citations styled as subtle tags */ | |
| .ai-answer-text strong { | |
| color: var(--ink); | |
| font-weight: 600; | |
| } | |
| /* Quoted passages from sources */ | |
| .ai-answer-text em { | |
| color: var(--ink-light); | |
| } | |
| /* Visual separator between topics within the answer */ | |
| .ai-answer-text hr { | |
| border: none; | |
| border-top: 1px solid var(--gold-100); | |
| margin: 20px 0; | |
| } | |
| .ai-model-tag { | |
| margin-top: 16px; | |
| padding-top: 12px; | |
| border-top: 1px solid var(--sky-100); | |
| font-size: 0.75rem; | |
| color: var(--ink-faint); | |
| font-family: 'JetBrains Mono', monospace; | |
| } | |
| /* Source Cards */ | |
| .source-card { | |
| background: var(--white); | |
| border: 1px solid var(--sky-100); | |
| border-radius: var(--radius); | |
| padding: 20px 24px; | |
| margin-bottom: 12px; | |
| transition: all 0.25s ease; | |
| cursor: pointer; | |
| position: relative; | |
| } | |
| .source-card:hover { | |
| border-color: var(--sky-300); | |
| box-shadow: var(--shadow-md); | |
| transform: translateX(4px); | |
| } | |
| .source-card-header { | |
| display: flex; | |
| align-items: flex-start; | |
| justify-content: space-between; | |
| margin-bottom: 10px; | |
| } | |
| .source-title { | |
| font-family: 'Playfair Display', serif; | |
| font-size: 1.05rem; | |
| font-weight: 600; | |
| color: var(--ink); | |
| } | |
| .source-meta { | |
| display: flex; | |
| gap: 10px; | |
| flex-wrap: wrap; | |
| align-items: center; | |
| margin-bottom: 14px; | |
| } | |
| .source-tag { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 4px; | |
| font-size: 0.78rem; | |
| color: var(--ink-light); | |
| } | |
| .source-tag .tag-icon { font-size: 0.85rem; } | |
| /* Chapter/section title — visually prominent gold pill */ | |
| .source-chapter { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| background: var(--gold-50); | |
| border: 1px solid var(--gold-200); | |
| color: var(--gold-500); | |
| font-family: 'Playfair Display', serif; | |
| font-size: 0.82rem; | |
| font-weight: 600; | |
| padding: 4px 12px; | |
| border-radius: 99px; | |
| letter-spacing: 0.01em; | |
| } | |
| /* Page number badge */ | |
| .source-page { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 4px; | |
| background: var(--sky-50); | |
| border: 1px solid var(--sky-200); | |
| color: var(--sky-500); | |
| font-size: 0.75rem; | |
| font-weight: 500; | |
| padding: 3px 10px; | |
| border-radius: 99px; | |
| } | |
| /* Divider between meta row and quote */ | |
| .source-meta-divider { | |
| border: none; | |
| border-top: 1px solid var(--sky-100); | |
| margin: 10px 0 14px; | |
| } | |
| .relevance-badge { | |
| padding: 3px 10px; | |
| border-radius: 99px; | |
| font-size: 0.72rem; | |
| font-weight: 600; | |
| white-space: nowrap; | |
| } | |
| .relevance-high { | |
| background: #e8f5e9; | |
| color: #2e7d32; | |
| } | |
| .relevance-medium { | |
| background: var(--gold-50); | |
| color: var(--gold-500); | |
| } | |
| .relevance-low { | |
| background: var(--sky-50); | |
| color: var(--sky-500); | |
| } | |
| .source-quote { | |
| font-size: 0.92rem; | |
| line-height: 1.85; | |
| color: var(--ink-light); | |
| border-left: 3px solid var(--gold-200); | |
| padding-left: 16px; | |
| font-style: italic; | |
| } | |
| /* Headings inside quote text (rendered from ### markdown) */ | |
| .quote-h1, .quote-h2, .quote-h3 { | |
| display: block; | |
| font-family: 'Playfair Display', serif; | |
| font-style: normal; | |
| font-weight: 700; | |
| color: var(--ink); | |
| margin: 14px 0 6px; | |
| letter-spacing: 0.01em; | |
| } | |
| .quote-h1 { font-size: 1.05rem; color: var(--sky-500); } | |
| .quote-h2 { font-size: 0.97rem; color: var(--ink); } | |
| .quote-h3 { | |
| font-size: 0.88rem; | |
| color: var(--gold-500); | |
| text-transform: uppercase; | |
| letter-spacing: 0.06em; | |
| border-bottom: 1px solid var(--gold-100); | |
| padding-bottom: 3px; | |
| } | |
| .source-link { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 4px; | |
| margin-top: 10px; | |
| font-size: 0.8rem; | |
| color: var(--sky-500); | |
| text-decoration: none; | |
| font-weight: 500; | |
| transition: color 0.2s; | |
| } | |
| .source-link:hover { color: var(--sky-400); } | |
| /* ═══════════════════════════════════════════ | |
| LIBRARY — BOOK GRID | |
| ═══════════════════════════════════════════ */ | |
| .library-controls { | |
| display: flex; | |
| gap: 12px; | |
| margin-bottom: 24px; | |
| flex-wrap: wrap; | |
| align-items: center; | |
| } | |
| .library-search { | |
| flex: 1; | |
| min-width: 200px; | |
| padding: 10px 18px; | |
| border: 1.5px solid var(--sky-100); | |
| border-radius: 99px; | |
| font-size: 0.9rem; | |
| font-family: 'Inter', sans-serif; | |
| outline: none; | |
| transition: border-color 0.2s; | |
| } | |
| .library-search:focus { border-color: var(--sky-400); } | |
| .library-sort { | |
| padding: 10px 18px; | |
| border: 1.5px solid var(--sky-100); | |
| border-radius: 99px; | |
| font-size: 0.85rem; | |
| font-family: 'Inter', sans-serif; | |
| background: var(--white); | |
| cursor: pointer; | |
| color: var(--ink); | |
| } | |
| .book-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); | |
| gap: 16px; | |
| margin-bottom: 48px; | |
| } | |
| .book-card { | |
| background: var(--white); | |
| border: 1px solid var(--sky-50); | |
| border-radius: var(--radius); | |
| padding: 22px; | |
| transition: all 0.3s ease; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .book-card:hover { | |
| transform: translateY(-4px); | |
| box-shadow: var(--shadow-lg); | |
| border-color: var(--sky-200); | |
| } | |
| .book-card-spine { | |
| position: absolute; | |
| top: 0; left: 0; | |
| width: 4px; | |
| height: 100%; | |
| border-radius: 4px 0 0 4px; | |
| } | |
| .book-title { | |
| font-family: 'Playfair Display', serif; | |
| font-size: 1rem; | |
| font-weight: 600; | |
| margin-bottom: 6px; | |
| padding-left: 12px; | |
| } | |
| .book-author { | |
| font-size: 0.85rem; | |
| color: var(--ink-light); | |
| padding-left: 12px; | |
| margin-bottom: 12px; | |
| } | |
| .book-details { | |
| display: flex; | |
| gap: 12px; | |
| padding-left: 12px; | |
| flex-wrap: wrap; | |
| } | |
| .book-detail { | |
| font-size: 0.75rem; | |
| color: var(--ink-faint); | |
| display: flex; | |
| align-items: center; | |
| gap: 4px; | |
| } | |
| .book-status { | |
| display: inline-block; | |
| padding: 2px 8px; | |
| border-radius: 99px; | |
| font-size: 0.7rem; | |
| font-weight: 500; | |
| } | |
| .status-completed { background: #e8f5e9; color: #2e7d32; } | |
| .status-failed { background: #fce4ec; color: #c62828; } | |
| .status-pending { background: var(--gold-50); color: var(--gold-500); } | |
| /* ═══════════════════════════════════════════ | |
| AUTHORS PAGE | |
| ═══════════════════════════════════════════ */ | |
| .author-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); | |
| gap: 16px; | |
| margin-bottom: 48px; | |
| } | |
| .author-card { | |
| background: var(--white); | |
| border: 1px solid var(--sky-50); | |
| border-radius: var(--radius); | |
| padding: 22px; | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| text-align: center; | |
| } | |
| .author-card:hover { | |
| transform: translateY(-3px); | |
| box-shadow: var(--shadow-md); | |
| border-color: var(--gold-200); | |
| } | |
| .author-avatar { | |
| width: 56px; | |
| height: 56px; | |
| border-radius: 50%; | |
| background: linear-gradient(135deg, var(--sky-200), var(--gold-200)); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| margin: 0 auto 12px; | |
| font-size: 1.3rem; | |
| font-family: 'Playfair Display', serif; | |
| font-weight: 700; | |
| color: var(--white); | |
| } | |
| .author-name { | |
| font-family: 'Playfair Display', serif; | |
| font-size: 1rem; | |
| font-weight: 600; | |
| margin-bottom: 4px; | |
| } | |
| .author-book-count { | |
| font-size: 0.8rem; | |
| color: var(--ink-faint); | |
| } | |
| /* ═══════════════════════════════════════════ | |
| LOADING STATE | |
| ═══════════════════════════════════════════ */ | |
| .loading { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 60px 0; | |
| } | |
| .spinner { | |
| width: 36px; | |
| height: 36px; | |
| border: 3px solid var(--sky-100); | |
| border-top-color: var(--sky-400); | |
| border-radius: 50%; | |
| animation: spin 0.8s linear infinite; | |
| } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| .loading-text { | |
| margin-top: 14px; | |
| font-size: 0.9rem; | |
| color: var(--ink-faint); | |
| } | |
| /* Typing dots for AI */ | |
| .typing-dots { display: inline-flex; gap: 4px; margin-left: 6px; } | |
| .typing-dots span { | |
| width: 6px; height: 6px; | |
| background: var(--sky-300); | |
| border-radius: 50%; | |
| animation: bounce 1.4s ease-in-out infinite; | |
| } | |
| .typing-dots span:nth-child(2) { animation-delay: 0.15s; } | |
| .typing-dots span:nth-child(3) { animation-delay: 0.3s; } | |
| @keyframes bounce { | |
| 0%, 80%, 100% { transform: translateY(0); } | |
| 40% { transform: translateY(-8px); } | |
| } | |
| /* ═══════════════════════════════════════════ | |
| EMPTY STATE | |
| ═══════════════════════════════════════════ */ | |
| .empty-state { | |
| text-align: center; | |
| padding: 60px 20px; | |
| color: var(--ink-faint); | |
| } | |
| .empty-icon { font-size: 3rem; margin-bottom: 12px; opacity: 0.4; } | |
| .empty-text { font-size: 1rem; } | |
| /* ═══════════════════════════════════════════ | |
| SUGGESTED QUESTIONS | |
| ═══════════════════════════════════════════ */ | |
| .suggestions { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); | |
| gap: 12px; | |
| margin-top: 24px; | |
| animation: fadeUp 0.8s ease 0.4s both; | |
| } | |
| .suggestion-card { | |
| background: var(--white); | |
| border: 1.5px solid var(--sky-100); | |
| border-radius: var(--radius-sm); | |
| padding: 16px 20px; | |
| cursor: pointer; | |
| transition: all 0.25s ease; | |
| text-align: left; | |
| } | |
| .suggestion-card:hover { | |
| border-color: var(--gold-300); | |
| background: var(--gold-50); | |
| transform: translateY(-2px); | |
| box-shadow: var(--shadow-sm); | |
| } | |
| .suggestion-icon { font-size: 1.2rem; margin-bottom: 6px; } | |
| .suggestion-text { | |
| font-size: 0.88rem; | |
| color: var(--ink); | |
| font-weight: 500; | |
| } | |
| .suggestion-sub { | |
| font-size: 0.78rem; | |
| color: var(--ink-faint); | |
| margin-top: 3px; | |
| } | |
| /* ═══════════════════════════════════════════ | |
| SCROLL TO TOP | |
| ═══════════════════════════════════════════ */ | |
| .scroll-top { | |
| position: fixed; | |
| bottom: 24px; | |
| right: 24px; | |
| width: 44px; | |
| height: 44px; | |
| border-radius: 50%; | |
| border: none; | |
| background: var(--white); | |
| box-shadow: var(--shadow-md); | |
| cursor: pointer; | |
| font-size: 1.1rem; | |
| transition: all 0.25s; | |
| opacity: 0; | |
| pointer-events: none; | |
| z-index: 50; | |
| } | |
| .scroll-top.show { opacity: 1; pointer-events: auto; } | |
| .scroll-top:hover { transform: translateY(-3px); box-shadow: var(--shadow-lg); } | |
| /* ═══════════════════════════════════════════ | |
| RESPONSIVE | |
| ═══════════════════════════════════════════ */ | |
| @media (max-width: 768px) { | |
| .stats-row { grid-template-columns: repeat(2, 1fr); } | |
| .nav-stats { display: none; } | |
| .nav-tabs { gap: 2px; } | |
| .nav-tab { padding: 6px 12px; font-size: 0.8rem; } | |
| .search-mode-toggle { display: none; } | |
| .hero { padding: 48px 0 32px; } | |
| } | |
| @media (max-width: 480px) { | |
| .stats-row { grid-template-columns: 1fr 1fr; gap: 10px; } | |
| .stat-card { padding: 16px; } | |
| .stat-value { font-size: 1.5rem; } | |
| .suggestions { grid-template-columns: 1fr; } | |
| } | |
| /* ═══════════════════════════════════════════ | |
| TOOLTIP | |
| ═══════════════════════════════════════════ */ | |
| .tooltip { | |
| position: relative; | |
| } | |
| .tooltip::after { | |
| content: attr(data-tip); | |
| position: absolute; | |
| bottom: calc(100% + 8px); | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background: var(--ink); | |
| color: white; | |
| padding: 6px 12px; | |
| border-radius: var(--radius-xs); | |
| font-size: 0.75rem; | |
| white-space: nowrap; | |
| opacity: 0; | |
| pointer-events: none; | |
| transition: opacity 0.2s; | |
| } | |
| .tooltip:hover::after { opacity: 1; } | |
| /* ═══════════════════════════════════════════ | |
| BOOK DETAIL MODAL | |
| ═══════════════════════════════════════════ */ | |
| .modal-overlay { | |
| display: none; | |
| position: fixed; | |
| inset: 0; | |
| background: rgba(44,62,80,0.4); | |
| backdrop-filter: blur(4px); | |
| z-index: 200; | |
| justify-content: center; | |
| align-items: center; | |
| } | |
| .modal-overlay.show { display: flex; animation: fadeIn 0.2s ease; } | |
| @keyframes fadeIn { | |
| from { opacity: 0; } | |
| to { opacity: 1; } | |
| } | |
| .modal { | |
| background: var(--white); | |
| border-radius: var(--radius); | |
| padding: 32px; | |
| max-width: 560px; | |
| width: 90%; | |
| max-height: 80vh; | |
| overflow-y: auto; | |
| box-shadow: var(--shadow-lg); | |
| animation: slideUp 0.3s ease; | |
| } | |
| @keyframes slideUp { | |
| from { transform: translateY(20px); opacity: 0; } | |
| to { transform: translateY(0); opacity: 1; } | |
| } | |
| .modal-close { | |
| float: right; | |
| background: none; | |
| border: none; | |
| font-size: 1.3rem; | |
| cursor: pointer; | |
| color: var(--ink-faint); | |
| padding: 4px; | |
| transition: color 0.2s; | |
| } | |
| .modal-close:hover { color: var(--ink); } | |
| .modal h2 { | |
| font-size: 1.4rem; | |
| margin-bottom: 16px; | |
| padding-right: 32px; | |
| } | |
| .modal-info { | |
| display: grid; | |
| gap: 10px; | |
| } | |
| .modal-row { | |
| display: flex; | |
| justify-content: space-between; | |
| padding: 8px 0; | |
| border-bottom: 1px solid var(--sky-50); | |
| font-size: 0.9rem; | |
| } | |
| .modal-row-label { color: var(--ink-faint); } | |
| .modal-row-value { font-weight: 500; text-align: right; } | |
| .modal-file-link { | |
| display: block; | |
| margin-top: 16px; | |
| padding: 10px 18px; | |
| background: var(--sky-50); | |
| border-radius: var(--radius-sm); | |
| font-size: 0.82rem; | |
| color: var(--sky-500); | |
| text-decoration: none; | |
| font-family: 'JetBrains Mono', monospace; | |
| word-break: break-all; | |
| cursor: pointer; | |
| transition: background 0.2s, color 0.2s; | |
| } | |
| .modal-file-link:hover { | |
| background: var(--sky-100); | |
| color: var(--sky-400); | |
| } | |
| /* Reader View */ | |
| .reader-overlay { | |
| display: none; | |
| position: fixed; | |
| inset: 0; | |
| background: var(--cream); | |
| z-index: 300; | |
| overflow-y: auto; | |
| } | |
| .reader-overlay.show { display: block; animation: fadeIn 0.3s ease; } | |
| .reader-toolbar { | |
| position: sticky; | |
| top: 0; | |
| background: rgba(254,252,248,0.92); | |
| backdrop-filter: blur(20px); | |
| -webkit-backdrop-filter: blur(20px); | |
| border-bottom: 1px solid var(--sky-100); | |
| padding: 12px 24px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| z-index: 301; | |
| } | |
| .reader-toolbar-left { | |
| display: flex; | |
| align-items: center; | |
| gap: 16px; | |
| } | |
| .reader-back { | |
| background: none; | |
| border: 1.5px solid var(--sky-200); | |
| border-radius: 99px; | |
| padding: 6px 16px; | |
| font-size: 0.85rem; | |
| font-family: 'Inter', sans-serif; | |
| cursor: pointer; | |
| color: var(--ink-light); | |
| transition: all 0.2s; | |
| } | |
| .reader-back:hover { border-color: var(--sky-400); color: var(--ink); } | |
| .reader-title-bar { | |
| font-family: 'Playfair Display', serif; | |
| font-size: 1rem; | |
| font-weight: 600; | |
| color: var(--ink); | |
| } | |
| .reader-title-bar .reader-author { | |
| font-family: 'Inter', sans-serif; | |
| font-size: 0.82rem; | |
| font-weight: 400; | |
| color: var(--ink-faint); | |
| margin-left: 8px; | |
| } | |
| .reader-toolbar-right { | |
| display: flex; | |
| gap: 8px; | |
| } | |
| .reader-action-btn { | |
| padding: 6px 14px; | |
| border-radius: 99px; | |
| border: 1.5px solid var(--gold-200); | |
| background: var(--gold-50); | |
| font-size: 0.82rem; | |
| font-family: 'Inter', sans-serif; | |
| font-weight: 500; | |
| cursor: pointer; | |
| color: var(--gold-500); | |
| transition: all 0.2s; | |
| text-decoration: none; | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 4px; | |
| } | |
| .reader-action-btn:hover { background: var(--gold-100); } | |
| .reader-content { | |
| max-width: 720px; | |
| margin: 0 auto; | |
| padding: 40px 24px 80px; | |
| } | |
| .reader-book-header { | |
| text-align: center; | |
| margin-bottom: 48px; | |
| padding-bottom: 32px; | |
| border-bottom: 2px solid var(--gold-200); | |
| } | |
| .reader-book-header h1 { | |
| font-size: 2rem; | |
| font-weight: 700; | |
| margin-bottom: 8px; | |
| color: var(--ink); | |
| } | |
| .reader-book-header .reader-book-author { | |
| font-size: 1.1rem; | |
| color: var(--ink-light); | |
| font-style: italic; | |
| } | |
| .reader-book-header .reader-book-year { | |
| font-size: 0.9rem; | |
| color: var(--ink-faint); | |
| margin-top: 4px; | |
| } | |
| .reader-toc { | |
| background: var(--sky-50); | |
| border: 1px solid var(--sky-100); | |
| border-radius: var(--radius); | |
| padding: 20px 24px; | |
| margin-bottom: 40px; | |
| } | |
| .reader-toc h3 { | |
| font-size: 0.95rem; | |
| margin-bottom: 12px; | |
| color: var(--ink); | |
| } | |
| .reader-toc-item { | |
| display: block; | |
| padding: 6px 0; | |
| font-size: 0.9rem; | |
| color: var(--sky-500); | |
| text-decoration: none; | |
| cursor: pointer; | |
| transition: color 0.2s; | |
| border-bottom: 1px solid var(--sky-50); | |
| } | |
| .reader-toc-item:hover { color: var(--gold-500); } | |
| .reader-chapter { | |
| margin-bottom: 40px; | |
| } | |
| .reader-chapter-title { | |
| font-family: 'Playfair Display', serif; | |
| font-size: 1.3rem; | |
| font-weight: 700; | |
| color: var(--ink); | |
| margin-bottom: 20px; | |
| padding-bottom: 8px; | |
| border-bottom: 1px solid var(--gold-200); | |
| scroll-margin-top: 70px; | |
| } | |
| .reader-passage { | |
| font-size: 1rem; | |
| line-height: 1.9; | |
| color: var(--ink); | |
| margin-bottom: 8px; | |
| text-align: justify; | |
| } | |
| .reader-page-marker { | |
| display: inline-block; | |
| font-size: 0.7rem; | |
| color: var(--ink-faint); | |
| background: var(--sky-50); | |
| padding: 1px 6px; | |
| border-radius: 3px; | |
| margin-left: 4px; | |
| font-family: 'JetBrains Mono', monospace; | |
| vertical-align: super; | |
| } | |
| /* Author modal book list */ | |
| .author-book-list { | |
| margin-top: 16px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| } | |
| .author-book-item { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 12px 16px; | |
| background: var(--sky-50); | |
| border-radius: var(--radius-sm); | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| border: 1px solid transparent; | |
| } | |
| .author-book-item:hover { | |
| background: var(--gold-50); | |
| border-color: var(--gold-200); | |
| transform: translateX(4px); | |
| } | |
| .author-book-item-title { | |
| font-family: 'Playfair Display', serif; | |
| font-size: 0.92rem; | |
| font-weight: 600; | |
| color: var(--ink); | |
| } | |
| .author-book-item-meta { | |
| display: flex; | |
| gap: 12px; | |
| align-items: center; | |
| flex-shrink: 0; | |
| } | |
| .author-book-item-meta span { | |
| font-size: 0.78rem; | |
| color: var(--ink-faint); | |
| } | |
| .author-book-item-arrow { | |
| color: var(--gold-400); | |
| font-size: 0.9rem; | |
| transition: transform 0.2s; | |
| } | |
| .author-book-item:hover .author-book-item-arrow { | |
| transform: translateX(3px); | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- Floating particles background --> | |
| <div class="bg-particles" id="particles"></div> | |
| <!-- Navigation --> | |
| <nav> | |
| <div class="nav-inner"> | |
| <a href="#" class="nav-brand" onclick="showPage('search')"> | |
| <span class="leaf">🌿</span> Natural Hygiene Library | |
| </a> | |
| <div class="nav-tabs"> | |
| <button class="nav-tab active" data-page="search" onclick="showPage('search')">Search</button> | |
| <button class="nav-tab" data-page="library" onclick="showPage('library')">Library</button> | |
| <button class="nav-tab" data-page="authors" onclick="showPage('authors')">Authors</button> | |
| </div> | |
| <div class="nav-stats"> | |
| <span class="nav-stat"><b id="nav-books">—</b> titles</span> | |
| <span class="nav-stat"><b id="nav-chunks">—</b> passages</span> | |
| </div> | |
| </div> | |
| </nav> | |
| <div class="app-container"> | |
| <!-- ═══════════ SEARCH PAGE ═══════════ --> | |
| <div class="page active" id="page-search"> | |
| <div class="hero"> | |
| <h1>Explore the Wisdom of<br><span class="hl">Natural Hygiene</span></h1> | |
| <p>Search across 86 books spanning two centuries of health science — from Graham and Trall to Shelton and beyond.</p> | |
| </div> | |
| <!-- Search Bar --> | |
| <div class="search-container"> | |
| <div class="search-box"> | |
| <input type="text" id="search-input" placeholder="Ask a question or search the literature..." | |
| onkeypress="if(event.key==='Enter'){event.preventDefault();doSearch()}" | |
| onkeydown="if(event.key==='Enter'){event.preventDefault();doSearch()}" | |
| > | |
| <div class="search-mode-toggle"> | |
| <button class="mode-btn active" data-mode="ask" onclick="setMode('ask')">AI Answer</button> | |
| <button class="mode-btn" data-mode="search" onclick="setMode('search')">Search</button> | |
| </div> | |
| <button class="search-btn" onclick="doSearch()">🔍</button> | |
| </div> | |
| </div> | |
| <!-- Filters --> | |
| <div class="filter-bar"> | |
| <div class="filter-dropdown"> | |
| <button class="filter-chip" onclick="toggleDropdown('author-dropdown')"> | |
| <span class="icon">✍️</span> <span id="author-filter-label">Any Author</span> ▾ | |
| </button> | |
| <div class="filter-dropdown-menu" id="author-dropdown"></div> | |
| </div> | |
| <div class="filter-dropdown"> | |
| <button class="filter-chip" onclick="toggleDropdown('era-dropdown')"> | |
| <span class="icon">🕰️</span> <span id="era-filter-label">Any Era</span> ▾ | |
| </button> | |
| <div class="filter-dropdown-menu" id="era-dropdown"> | |
| <div class="dropdown-item" data-era="" onclick="setEra('')">All Eras</div> | |
| <div class="dropdown-item" data-era="pre_1900" onclick="setEra('pre_1900')">Pre-1900 (Founding Era)</div> | |
| <div class="dropdown-item" data-era="1900_1930" onclick="setEra('1900_1930')">1900–1930</div> | |
| <div class="dropdown-item" data-era="1930_1950" onclick="setEra('1930_1950')">1930–1950 (Shelton Era)</div> | |
| <div class="dropdown-item" data-era="post_1950" onclick="setEra('post_1950')">Post-1950 (Modern)</div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Stats --> | |
| <div class="stats-row"> | |
| <div class="stat-card"> | |
| <div class="stat-icon">📚</div> | |
| <div class="stat-value"><span class="counter" data-target="0" id="stat-books">0</span></div> | |
| <div class="stat-label">Books</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-icon">📰</div> | |
| <div class="stat-value"><span class="counter" data-target="0" id="stat-periodicals">0</span></div> | |
| <div class="stat-label">Journal Issues</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-icon">📝</div> | |
| <div class="stat-value"><span class="counter" data-target="0" id="stat-chunks">0</span></div> | |
| <div class="stat-label">Passages</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-icon">✍️</div> | |
| <div class="stat-value"><span class="counter" data-target="0" id="stat-authors">0</span></div> | |
| <div class="stat-label">Authors</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-icon">🔍</div> | |
| <div class="stat-value"><span class="counter" data-target="0" id="stat-queries">0</span></div> | |
| <div class="stat-label">Queries Made</div> | |
| </div> | |
| </div> | |
| <!-- Suggested Questions --> | |
| <div id="suggestions-area"> | |
| <div class="suggestions"> | |
| <div class="suggestion-card" onclick="quickSearch('What did Shelton teach about fasting?')"> | |
| <div class="suggestion-icon">🍃</div> | |
| <div class="suggestion-text">What did Shelton teach about fasting?</div> | |
| <div class="suggestion-sub">Herbert M. Shelton's fasting philosophy</div> | |
| </div> | |
| <div class="suggestion-card" onclick="quickSearch('How does Natural Hygiene view the cause of disease?')"> | |
| <div class="suggestion-icon">🔬</div> | |
| <div class="suggestion-text">The cause of disease</div> | |
| <div class="suggestion-sub">NH perspective on illness and toxemia</div> | |
| </div> | |
| <div class="suggestion-card" onclick="quickSearch('What is the natural diet for humans according to Sylvester Graham?')"> | |
| <div class="suggestion-icon">🥗</div> | |
| <div class="suggestion-text">The natural human diet</div> | |
| <div class="suggestion-sub">Graham's dietary principles</div> | |
| </div> | |
| <div class="suggestion-card" onclick="quickSearch('How did Russell Trall view drugs and medications?')"> | |
| <div class="suggestion-icon">💊</div> | |
| <div class="suggestion-text">NH view on drugs & medicine</div> | |
| <div class="suggestion-sub">Trall's critique of the medical system</div> | |
| </div> | |
| <div class="suggestion-card" onclick="quickSearch('What role does sunlight play in health according to Natural Hygiene?')"> | |
| <div class="suggestion-icon">☀️</div> | |
| <div class="suggestion-text">Sunlight and health</div> | |
| <div class="suggestion-sub">The hygiene of light and air</div> | |
| </div> | |
| <div class="suggestion-card" onclick="quickSearch('What did the early hygienists teach about sleep and rest?')"> | |
| <div class="suggestion-icon">🌙</div> | |
| <div class="suggestion-text">Sleep and rest</div> | |
| <div class="suggestion-sub">The role of rest in healing</div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Results --> | |
| <div id="results-area" class="results-area" style="display:none;"></div> | |
| </div> | |
| <!-- ═══════════ LIBRARY PAGE ═══════════ --> | |
| <div class="page" id="page-library"> | |
| <div style="padding-top: 36px;"> | |
| <h2 style="margin-bottom: 24px;">📚 Full Library</h2> | |
| <div class="library-controls"> | |
| <input type="text" class="library-search" id="library-search" | |
| placeholder="Filter books by title or author..." oninput="filterLibrary()"> | |
| <select class="library-sort" id="library-sort" onchange="sortLibrary()"> | |
| <option value="title">Sort: Title A→Z</option> | |
| <option value="author">Sort: Author A→Z</option> | |
| <option value="year">Sort: Year (oldest)</option> | |
| <option value="chunks">Sort: Most passages</option> | |
| </select> | |
| </div> | |
| <div id="book-grid" class="book-grid"></div> | |
| </div> | |
| </div> | |
| <!-- ═══════════ AUTHORS PAGE ═══════════ --> | |
| <div class="page" id="page-authors"> | |
| <div style="padding-top: 36px;"> | |
| <h2 style="margin-bottom: 24px;">✍️ Authors</h2> | |
| <div id="author-grid" class="author-grid"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Book Detail Modal --> | |
| <div class="modal-overlay" id="book-modal" onclick="if(event.target===this)closeModal()"> | |
| <div class="modal"> | |
| <button class="modal-close" onclick="closeModal()">✕</button> | |
| <h2 id="modal-title"></h2> | |
| <div class="modal-info" id="modal-info"></div> | |
| </div> | |
| </div> | |
| <!-- Reader Overlay --> | |
| <div class="reader-overlay" id="reader-overlay"> | |
| <div class="reader-toolbar"> | |
| <div class="reader-toolbar-left"> | |
| <button class="reader-back" onclick="closeReader()">← Back</button> | |
| <span class="reader-title-bar" id="reader-toolbar-title"></span> | |
| </div> | |
| <div class="reader-toolbar-right"> | |
| <button class="reader-action-btn" id="reader-download-btn" onclick="downloadBookText()">📥 Download Text</button> | |
| </div> | |
| </div> | |
| <div class="reader-content" id="reader-content"></div> | |
| </div> | |
| <!-- Scroll to Top --> | |
| <button class="scroll-top" id="scroll-top" onclick="window.scrollTo({top:0,behavior:'smooth'})">↑</button> | |
| <script> | |
| // ═══════════════════════════════════════════ | |
| // STATE | |
| // ═══════════════════════════════════════════ | |
| const API = ''; | |
| let books = []; | |
| let searchMode = 'ask'; | |
| let selectedAuthor = ''; | |
| let selectedEra = ''; | |
| let stats = {}; | |
| // ═══════════════════════════════════════════ | |
| // INIT | |
| // ═══════════════════════════════════════════ | |
| document.addEventListener('DOMContentLoaded', async () => { | |
| createParticles(); | |
| setupScrollTop(); | |
| await Promise.all([loadStats(), loadBooks()]); | |
| populateAuthorFilter(); | |
| // Ensure Enter key triggers search (belt-and-suspenders) | |
| const searchInput = document.getElementById('search-input'); | |
| searchInput.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter') { e.preventDefault(); doSearch(); } | |
| }); | |
| }); | |
| // ═══════════════════════════════════════════ | |
| // PARTICLES BACKGROUND | |
| // ═══════════════════════════════════════════ | |
| function createParticles() { | |
| const container = document.getElementById('particles'); | |
| const colors = ['var(--sky-200)', 'var(--gold-200)', 'var(--sky-300)', 'var(--gold-300)']; | |
| for (let i = 0; i < 20; i++) { | |
| const p = document.createElement('div'); | |
| p.className = 'particle'; | |
| const size = Math.random() * 20 + 6; | |
| p.style.cssText = ` | |
| width: ${size}px; height: ${size}px; | |
| left: ${Math.random() * 100}%; | |
| background: ${colors[i % colors.length]}; | |
| animation-duration: ${Math.random() * 20 + 15}s; | |
| animation-delay: ${Math.random() * 10}s; | |
| `; | |
| container.appendChild(p); | |
| } | |
| } | |
| // ═══════════════════════════════════════════ | |
| // SCROLL TO TOP | |
| // ═══════════════════════════════════════════ | |
| function setupScrollTop() { | |
| window.addEventListener('scroll', () => { | |
| document.getElementById('scroll-top') | |
| .classList.toggle('show', window.scrollY > 300); | |
| }); | |
| } | |
| // ═══════════════════════════════════════════ | |
| // DATA LOADING | |
| // ═══════════════════════════════════════════ | |
| async function loadStats() { | |
| try { | |
| const res = await fetch(`${API}/api/v1/admin/stats`); | |
| stats = await res.json(); | |
| animateCounter('stat-chunks', stats.total_chunks); | |
| animateCounter('stat-queries', stats.total_queries); | |
| document.getElementById('nav-books').textContent = stats.total_books; | |
| document.getElementById('nav-chunks').textContent = stats.total_chunks.toLocaleString(); | |
| } catch (e) { | |
| console.error('Failed to load stats:', e); | |
| } | |
| } | |
| async function loadBooks() { | |
| try { | |
| const res = await fetch(`${API}/api/v1/admin/books`); | |
| books = await res.json(); | |
| const authors = new Set(books.map(b => b.author)); | |
| // Count periodicals vs books based on notes field | |
| const periodicals = books.filter(b => (b.notes || '').includes('PERIODICAL')); | |
| const bookCount = books.length - periodicals.length; | |
| animateCounter('stat-books', bookCount); | |
| animateCounter('stat-periodicals', periodicals.length); | |
| animateCounter('stat-authors', authors.size); | |
| renderLibrary(); | |
| renderAuthors(); | |
| } catch (e) { | |
| console.error('Failed to load books:', e); | |
| } | |
| } | |
| // ═══════════════════════════════════════════ | |
| // ANIMATED COUNTER | |
| // ═══════════════════════════════════════════ | |
| function animateCounter(id, target) { | |
| const el = document.getElementById(id); | |
| if (!el) return; | |
| const duration = 1200; | |
| const start = performance.now(); | |
| const from = 0; | |
| function step(now) { | |
| const progress = Math.min((now - start) / duration, 1); | |
| const eased = 1 - Math.pow(1 - progress, 3); // ease-out cubic | |
| el.textContent = Math.floor(from + (target - from) * eased).toLocaleString(); | |
| if (progress < 1) requestAnimationFrame(step); | |
| } | |
| requestAnimationFrame(step); | |
| } | |
| // ═══════════════════════════════════════════ | |
| // NAVIGATION | |
| // ═══════════════════════════════════════════ | |
| function showPage(name) { | |
| document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); | |
| document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active')); | |
| document.getElementById(`page-${name}`).classList.add('active'); | |
| document.querySelector(`.nav-tab[data-page="${name}"]`).classList.add('active'); | |
| window.scrollTo({ top: 0, behavior: 'smooth' }); | |
| } | |
| // ═══════════════════════════════════════════ | |
| // SEARCH | |
| // ═══════════════════════════════════════════ | |
| function setMode(mode) { | |
| searchMode = mode; | |
| document.querySelectorAll('.mode-btn').forEach(b => { | |
| b.classList.toggle('active', b.dataset.mode === mode); | |
| }); | |
| } | |
| function quickSearch(q) { | |
| document.getElementById('search-input').value = q; | |
| setMode('ask'); | |
| doSearch(); | |
| } | |
| async function doSearch() { | |
| const query = document.getElementById('search-input').value.trim(); | |
| if (!query) return; | |
| const resultsArea = document.getElementById('results-area'); | |
| const suggestionsArea = document.getElementById('suggestions-area'); | |
| suggestionsArea.style.display = 'none'; | |
| resultsArea.style.display = 'block'; | |
| if (searchMode === 'ask') { | |
| resultsArea.innerHTML = ` | |
| <div class="ai-answer-card"> | |
| <div class="ai-badge"><span class="pulse"></span> AI is thinking</div> | |
| <div class="ai-answer-text"> | |
| Searching through the Natural Hygiene literature | |
| <span class="typing-dots"><span></span><span></span><span></span></span> | |
| </div> | |
| </div> | |
| `; | |
| try { | |
| const body = { question: query, max_sources: 8 }; | |
| if (selectedAuthor) body.author = selectedAuthor; | |
| if (selectedEra) body.era = selectedEra; | |
| const res = await fetch(`${API}/api/v1/query/ask`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(body), | |
| }); | |
| const data = await res.json(); | |
| if (!res.ok) { | |
| resultsArea.innerHTML = ` | |
| <div class="ai-answer-card" style="border-color: #f0a0a0;"> | |
| <div class="ai-badge" style="color: #c62828;">⚠️ Error</div> | |
| <div class="ai-answer-text">${data.detail || 'Something went wrong. Is the Gemini API key configured?'}</div> | |
| </div> | |
| `; | |
| return; | |
| } | |
| let html = ` | |
| <div class="ai-answer-card"> | |
| <div class="ai-badge"><span class="pulse"></span> AI Answer</div> | |
| <div class="ai-answer-text">${formatAnswer(data.answer)}</div> | |
| <div class="ai-model-tag">Model: ${data.model_used}</div> | |
| </div> | |
| <div class="results-header"> | |
| <h3>📖 Sources</h3> | |
| <span class="results-count">${data.sources.length} passages cited</span> | |
| </div> | |
| `; | |
| data.sources.forEach(s => { | |
| html += renderSourceCard(s); | |
| }); | |
| resultsArea.innerHTML = html; | |
| } catch (e) { | |
| resultsArea.innerHTML = ` | |
| <div class="ai-answer-card" style="border-color: #f0a0a0;"> | |
| <div class="ai-badge" style="color: #c62828;">⚠️ Connection Error</div> | |
| <div class="ai-answer-text">Search is temporarily unavailable. The server may be starting up — please try again in a moment. (${e.message || e})</div> | |
| </div> | |
| `; | |
| } | |
| } else { | |
| // Simple search mode | |
| resultsArea.innerHTML = `<div class="loading"><div class="spinner"></div><div class="loading-text">Searching...</div></div>`; | |
| try { | |
| const body = { query, n_results: 15 }; | |
| if (selectedAuthor) body.author = selectedAuthor; | |
| if (selectedEra) body.era = selectedEra; | |
| const res = await fetch(`${API}/api/v1/query/search`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(body), | |
| }); | |
| const data = await res.json(); | |
| let html = ` | |
| <div class="results-header"> | |
| <h3>📖 Search Results</h3> | |
| <span class="results-count">${data.length} passages found</span> | |
| </div> | |
| `; | |
| if (data.length === 0) { | |
| html += `<div class="empty-state"><div class="empty-icon">🔍</div><div class="empty-text">No results found. Try different keywords.</div></div>`; | |
| } else { | |
| data.forEach(s => { html += renderSourceCard(s); }); | |
| } | |
| resultsArea.innerHTML = html; | |
| } catch (e) { | |
| resultsArea.innerHTML = `<div class="empty-state"><div class="empty-icon">⚠️</div><div class="empty-text">Cannot reach the API server.</div></div>`; | |
| } | |
| } | |
| // Update query count | |
| loadStats(); | |
| } | |
| function renderSourceCard(s) { | |
| const score = (s.relevance_score * 100).toFixed(0); | |
| const relClass = score >= 70 ? 'relevance-high' : score >= 40 ? 'relevance-medium' : 'relevance-low'; | |
| const relLabel = score >= 70 ? 'High match' : score >= 40 ? 'Good match' : 'Partial match'; | |
| // Find the book to get file path | |
| const book = books.find(b => b.title === s.book_title || b.author === s.author); | |
| const filePath = book?.source_file || ''; | |
| // Classification badge | |
| const cls = s.classification || 'UNCLASSIFIED'; | |
| let clsBadge = ''; | |
| if (cls === 'CORE_NH') { | |
| clsBadge = '<span style="background:#e8f5e9;color:#2e7d32;border:1px solid #a5d6a7;border-radius:4px;padding:1px 8px;font-size:0.75em;font-weight:600;letter-spacing:0.5px;">CORE NH</span>'; | |
| } else if (cls.startsWith('NH_ADJACENT')) { | |
| const sub = cls.replace('NH_ADJACENT (','').replace(')','').replace(/_/g,' '); | |
| clsBadge = `<span style="background:#fff3e0;color:#e65100;border:1px solid #ffcc80;border-radius:4px;padding:1px 8px;font-size:0.75em;font-weight:600;letter-spacing:0.5px;">ADJACENT: ${sub}</span>`; | |
| } else if (cls === 'PERIODICAL') { | |
| clsBadge = '<span style="background:#e3f2fd;color:#1565c0;border:1px solid #90caf9;border-radius:4px;padding:1px 8px;font-size:0.75em;font-weight:600;letter-spacing:0.5px;">PERIODICAL</span>'; | |
| } | |
| return ` | |
| <div class="source-card" onclick="this.querySelector('.source-quote').style.maxHeight = this.querySelector('.source-quote').style.maxHeight ? '' : 'none'"> | |
| <div class="source-card-header"> | |
| <div class="source-title">${escapeHtml(s.book_title)}</div> | |
| <span class="relevance-badge ${relClass}">${relLabel} ${score}%</span> | |
| </div> | |
| <div style="margin-bottom:6px;">${clsBadge}</div> | |
| <div class="source-meta"> | |
| <span class="source-tag"><span class="tag-icon">✍️</span> ${escapeHtml(s.author)}</span> | |
| ${s.publication_year ? `<span class="source-tag"><span class="tag-icon">📅</span> ${s.publication_year}</span>` : ''} | |
| ${s.page ? `<span class="source-page">p.${s.page}</span>` : ''} | |
| </div> | |
| ${s.chapter ? `<div style="margin-bottom:12px;"><span class="source-chapter">📑 ${escapeHtml(s.chapter)}</span></div>` : ''} | |
| <hr class="source-meta-divider"> | |
| <div class="source-quote">${formatQuote(s.quote.substring(0, 600))}${s.quote.length > 600 ? '<em style="color:var(--ink-faint)"> …</em>' : ''}</div> | |
| ${filePath ? `<a class="source-link" href="#" onclick="event.stopPropagation(); showBookByFile('${escapeAttr(filePath)}')">📄 View book details →</a>` : ''} | |
| </div> | |
| `; | |
| } | |
| function formatAnswer(text) { | |
| // Full markdown formatting for LLM answers | |
| // First: markdown headings (before HTML wrapping) | |
| let result = text | |
| .replace(/^### (.+)$/gm, '%%%H3%%%$1%%%/H3%%%') | |
| .replace(/^## (.+)$/gm, '%%%H2%%%$1%%%/H2%%%') | |
| .replace(/^# (.+)$/gm, '%%%H1%%%$1%%%/H1%%%'); | |
| // Inline formatting | |
| result = result | |
| .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') | |
| .replace(/\*(.*?)\*/g, '<em>$1</em>'); | |
| // Highlight inline citations like [Author, "Book Title"] | |
| result = result.replace(/\[([^\]]+?),\s*"([^"]+?)"(?:,\s*p\.?\s*(\d+))?\]/g, | |
| (match, author, book, page) => { | |
| let cite = `<span style="background:var(--gold-50);border:1px solid var(--gold-200);border-radius:4px;padding:1px 6px;font-size:0.88em;font-style:normal;white-space:nowrap;">📖 ${author}, <em>"${book}"</em>${page ? ', p.' + page : ''}</span>`; | |
| return cite; | |
| }); | |
| // Split into paragraphs on double newlines | |
| const paragraphs = result.split(/\n\n+/); | |
| let html = paragraphs.map(p => { | |
| p = p.trim(); | |
| if (!p) return ''; | |
| // Convert remaining single newlines to <br> | |
| p = p.replace(/\n/g, '<br>'); | |
| // Replace heading placeholders with styled HTML | |
| p = p | |
| .replace(/%%%H3%%%(.*?)%%%\/H3%%%/g, '<h3 style="font-family:\'Playfair Display\',serif;font-size:0.95rem;color:var(--gold-500);margin:20px 0 8px;text-transform:uppercase;letter-spacing:0.06em;border-bottom:1px solid var(--gold-100);padding-bottom:5px;">$1</h3>') | |
| .replace(/%%%H2%%%(.*?)%%%\/H2%%%/g, '<h2 style="font-family:\'Playfair Display\',serif;font-size:1.08rem;color:var(--ink);margin:22px 0 8px;">$1</h2>') | |
| .replace(/%%%H1%%%(.*?)%%%\/H1%%%/g, '<h1 style="font-family:\'Playfair Display\',serif;font-size:1.2rem;color:var(--sky-500);margin:24px 0 10px;">$1</h1>'); | |
| // If it's a heading, don't wrap in <p> | |
| if (p.startsWith('<h1') || p.startsWith('<h2') || p.startsWith('<h3')) return p; | |
| return `<p>${p}</p>`; | |
| }).join(''); | |
| return html; | |
| } | |
| function formatQuote(text) { | |
| // Render markdown headings and formatting within source quote excerpts | |
| const safe = escapeHtml(text); | |
| return safe | |
| .replace(/^### (.+)$/gm, '<span class="quote-h3">$1</span>') | |
| .replace(/^## (.+)$/gm, '<span class="quote-h2">$1</span>') | |
| .replace(/^# (.+)$/gm, '<span class="quote-h1">$1</span>') | |
| .replace(/\*\*(.*?)\*\*/g, '<strong style="font-style:normal;color:var(--ink);font-weight:600;">$1</strong>') | |
| .replace(/\*(.*?)\*/g, '<em>$1</em>') | |
| .replace(/\n/g, '<br>'); | |
| } | |
| // ═══════════════════════════════════════════ | |
| // FILTERS | |
| // ═══════════════════════════════════════════ | |
| function populateAuthorFilter() { | |
| const dropdown = document.getElementById('author-dropdown'); | |
| const authors = [...new Set(books.map(b => b.author))].sort(); | |
| dropdown.innerHTML = '<div class="dropdown-item active" data-author="" onclick="setAuthor(\'\')">All Authors</div>'; | |
| authors.forEach(a => { | |
| dropdown.innerHTML += `<div class="dropdown-item" data-author="${escapeAttr(a)}" onclick="setAuthor('${escapeAttr(a)}')">${escapeHtml(a)}</div>`; | |
| }); | |
| } | |
| function toggleDropdown(id) { | |
| const menu = document.getElementById(id); | |
| const wasOpen = menu.classList.contains('show'); | |
| // Close all | |
| document.querySelectorAll('.filter-dropdown-menu').forEach(m => m.classList.remove('show')); | |
| if (!wasOpen) menu.classList.add('show'); | |
| } | |
| function setAuthor(author) { | |
| selectedAuthor = author; | |
| document.getElementById('author-filter-label').textContent = author || 'Any Author'; | |
| document.querySelectorAll('#author-dropdown .dropdown-item').forEach(item => { | |
| item.classList.toggle('active', item.dataset.author === author); | |
| }); | |
| document.getElementById('author-dropdown').classList.remove('show'); | |
| } | |
| function setEra(era) { | |
| selectedEra = era; | |
| const labels = { '': 'Any Era', 'pre_1900': 'Pre-1900', '1900_1930': '1900–1930', '1930_1950': '1930–1950', 'post_1950': 'Post-1950' }; | |
| document.getElementById('era-filter-label').textContent = labels[era] || 'Any Era'; | |
| document.querySelectorAll('#era-dropdown .dropdown-item').forEach(item => { | |
| item.classList.toggle('active', item.dataset.era === era); | |
| }); | |
| document.getElementById('era-dropdown').classList.remove('show'); | |
| } | |
| // Close dropdowns on outside click | |
| document.addEventListener('click', (e) => { | |
| if (!e.target.closest('.filter-dropdown')) { | |
| document.querySelectorAll('.filter-dropdown-menu').forEach(m => m.classList.remove('show')); | |
| } | |
| }); | |
| // ═══════════════════════════════════════════ | |
| // LIBRARY | |
| // ═══════════════════════════════════════════ | |
| const spineColors = [ | |
| 'linear-gradient(180deg, #8bb8e0, #6ba3d6)', | |
| 'linear-gradient(180deg, #e4bd7a, #d4a455)', | |
| 'linear-gradient(180deg, #7ec8a0, #5ab87e)', | |
| 'linear-gradient(180deg, #c0a0d0, #a080b8)', | |
| 'linear-gradient(180deg, #f0a0a0, #d88080)', | |
| 'linear-gradient(180deg, #a0c8e0, #80b0d0)', | |
| 'linear-gradient(180deg, #d4c090, #c0a860)', | |
| ]; | |
| function renderLibrary() { | |
| const grid = document.getElementById('book-grid'); | |
| const sorted = getSortedBooks(); | |
| grid.innerHTML = sorted.map((b, i) => ` | |
| <div class="book-card" onclick="showBookDetail('${b.id}')"> | |
| <div class="book-card-spine" style="background: ${spineColors[i % spineColors.length]}"></div> | |
| <div class="book-title">${escapeHtml(b.title)}</div> | |
| <div class="book-author">${escapeHtml(b.author)}</div> | |
| <div class="book-details"> | |
| ${b.publication_year ? `<span class="book-detail">📅 ${b.publication_year}</span>` : ''} | |
| <span class="book-detail">📝 ${b.total_chunks} passages</span> | |
| <span class="book-status status-${b.ingestion_status}">${b.ingestion_status}</span> | |
| </div> | |
| </div> | |
| `).join(''); | |
| } | |
| function filterLibrary() { | |
| const q = document.getElementById('library-search').value.toLowerCase(); | |
| const grid = document.getElementById('book-grid'); | |
| const filtered = getSortedBooks().filter(b => | |
| b.title.toLowerCase().includes(q) || b.author.toLowerCase().includes(q) | |
| ); | |
| grid.innerHTML = filtered.map((b, i) => ` | |
| <div class="book-card" onclick="showBookDetail('${b.id}')"> | |
| <div class="book-card-spine" style="background: ${spineColors[i % spineColors.length]}"></div> | |
| <div class="book-title">${escapeHtml(b.title)}</div> | |
| <div class="book-author">${escapeHtml(b.author)}</div> | |
| <div class="book-details"> | |
| ${b.publication_year ? `<span class="book-detail">📅 ${b.publication_year}</span>` : ''} | |
| <span class="book-detail">📝 ${b.total_chunks} passages</span> | |
| <span class="book-status status-${b.ingestion_status}">${b.ingestion_status}</span> | |
| </div> | |
| </div> | |
| `).join(''); | |
| } | |
| function getSortedBooks() { | |
| const sort = document.getElementById('library-sort')?.value || 'title'; | |
| return [...books].sort((a, b) => { | |
| if (sort === 'title') return a.title.localeCompare(b.title); | |
| if (sort === 'author') return a.author.localeCompare(b.author); | |
| if (sort === 'year') return (a.publication_year || 9999) - (b.publication_year || 9999); | |
| if (sort === 'chunks') return b.total_chunks - a.total_chunks; | |
| return 0; | |
| }); | |
| } | |
| function sortLibrary() { filterLibrary(); } | |
| // ═══════════════════════════════════════════ | |
| // AUTHORS | |
| // ═══════════════════════════════════════════ | |
| function renderAuthors() { | |
| const grid = document.getElementById('author-grid'); | |
| const authorMap = {}; | |
| books.forEach(b => { | |
| if (!authorMap[b.author]) authorMap[b.author] = []; | |
| authorMap[b.author].push(b); | |
| }); | |
| const sorted = Object.entries(authorMap).sort((a, b) => b[1].length - a[1].length); | |
| grid.innerHTML = sorted.map(([name, authorBooks]) => { | |
| const initials = name.split(' ').map(w => w[0]).join('').substring(0, 2).toUpperCase(); | |
| const totalChunks = authorBooks.reduce((sum, b) => sum + b.total_chunks, 0); | |
| return ` | |
| <div class="author-card" onclick="showAuthorDetail('${escapeAttr(name)}')"> | |
| <div class="author-avatar">${initials}</div> | |
| <div class="author-name">${escapeHtml(name)}</div> | |
| <div class="author-book-count">${authorBooks.length} book${authorBooks.length !== 1 ? 's' : ''} · ${totalChunks.toLocaleString()} passages</div> | |
| </div> | |
| `; | |
| }).join(''); | |
| } | |
| function showAuthorDetail(author) { | |
| const authorBooks = books.filter(b => b.author === author); | |
| if (!authorBooks.length) return; | |
| const totalChunks = authorBooks.reduce((sum, b) => sum + b.total_chunks, 0); | |
| const years = authorBooks.filter(b => b.publication_year).map(b => b.publication_year).sort(); | |
| const yearRange = years.length ? `${years[0]}–${years[years.length - 1]}` : 'Unknown'; | |
| document.getElementById('modal-title').textContent = author; | |
| document.getElementById('modal-info').innerHTML = ` | |
| <div class="modal-row"><span class="modal-row-label">Books in collection</span><span class="modal-row-value">${authorBooks.length}</span></div> | |
| <div class="modal-row"><span class="modal-row-label">Total passages</span><span class="modal-row-value">${totalChunks.toLocaleString()}</span></div> | |
| <div class="modal-row"><span class="modal-row-label">Publication years</span><span class="modal-row-value">${yearRange}</span></div> | |
| <div class="author-book-list"> | |
| ${authorBooks.sort((a, b) => a.title.localeCompare(b.title)).map(b => ` | |
| <div class="author-book-item" onclick="event.stopPropagation(); closeModal(); showPage('library'); setTimeout(() => showBookDetail('${b.id}'), 200);"> | |
| <div> | |
| <div class="author-book-item-title">${escapeHtml(b.title)}</div> | |
| </div> | |
| <div class="author-book-item-meta"> | |
| ${b.publication_year ? `<span>📅 ${b.publication_year}</span>` : ''} | |
| <span>📝 ${b.total_chunks}</span> | |
| <span class="author-book-item-arrow">→</span> | |
| </div> | |
| </div> | |
| `).join('')} | |
| </div> | |
| <a class="modal-file-link" href="https://www.google.com/search?q=${encodeURIComponent(author + ' natural hygiene books')}" target="_blank" rel="noopener"> | |
| 🔍 Search Google for more by ${escapeHtml(author)} | |
| </a> | |
| `; | |
| document.getElementById('book-modal').classList.add('show'); | |
| } | |
| function searchByAuthor(author) { | |
| showPage('search'); | |
| setAuthor(author); | |
| document.getElementById('search-input').value = ''; | |
| document.getElementById('search-input').focus(); | |
| } | |
| // ═══════════════════════════════════════════ | |
| // MODAL | |
| // ═══════════════════════════════════════════ | |
| function showBookDetail(id) { | |
| const book = books.find(b => b.id === id); | |
| if (!book) return; | |
| const searchQuery = encodeURIComponent(`${book.title} ${book.author} book`); | |
| const googleUrl = `https://www.google.com/search?q=${searchQuery}`; | |
| document.getElementById('modal-title').textContent = book.title; | |
| document.getElementById('modal-info').innerHTML = ` | |
| <div class="modal-row"><span class="modal-row-label">Author</span><span class="modal-row-value">${escapeHtml(book.author)}</span></div> | |
| ${book.publication_year ? `<div class="modal-row"><span class="modal-row-label">Year</span><span class="modal-row-value">${book.publication_year}</span></div>` : ''} | |
| ${book.edition ? `<div class="modal-row"><span class="modal-row-label">Edition</span><span class="modal-row-value">${escapeHtml(book.edition)}</span></div>` : ''} | |
| <div class="modal-row"><span class="modal-row-label">Status</span><span class="modal-row-value"><span class="book-status status-${book.ingestion_status}">${book.ingestion_status}</span></span></div> | |
| <div class="modal-row"><span class="modal-row-label">Passages</span><span class="modal-row-value">${book.total_chunks.toLocaleString()}</span></div> | |
| <div style="display:flex;gap:10px;margin-top:18px;flex-wrap:wrap;"> | |
| <button class="reader-action-btn" onclick="closeModal(); openReader('${book.id}')" style="flex:1;justify-content:center;padding:12px 18px;font-size:0.9rem;"> | |
| 📖 Read Full Text | |
| </button> | |
| <button class="reader-action-btn" onclick="downloadBookById('${book.id}')" style="flex:1;justify-content:center;padding:12px 18px;font-size:0.9rem;border-color:var(--sky-200);background:var(--sky-50);color:var(--sky-500);"> | |
| 📥 Download as Text | |
| </button> | |
| </div> | |
| <p style="font-size:0.75rem;color:var(--ink-faint);margin-top:10px;text-align:center;"> | |
| This text is in the public domain and freely available for reading and download. | |
| </p> | |
| `; | |
| document.getElementById('book-modal').classList.add('show'); | |
| } | |
| function showBookByFile(file) { | |
| const book = books.find(b => b.source_file === file); | |
| if (book) showBookDetail(book.id); | |
| } | |
| function closeModal() { | |
| document.getElementById('book-modal').classList.remove('show'); | |
| } | |
| document.addEventListener('keydown', (e) => { | |
| if (e.key === 'Escape') closeModal(); | |
| }); | |
| // ═══════════════════════════════════════════ | |
| // HELPERS | |
| // ═══════════════════════════════════════════ | |
| function escapeHtml(str) { | |
| if (!str) return ''; | |
| const div = document.createElement('div'); | |
| div.textContent = str; | |
| return div.innerHTML; | |
| } | |
| // ═══════════════════════════════════════════ | |
| // READER VIEW | |
| // ═══════════════════════════════════════════ | |
| let currentReaderData = null; | |
| async function openReader(bookId) { | |
| const overlay = document.getElementById('reader-overlay'); | |
| const content = document.getElementById('reader-content'); | |
| // Show loading | |
| overlay.classList.add('show'); | |
| document.body.style.overflow = 'hidden'; | |
| content.innerHTML = `<div class="loading"><div class="spinner"></div><div class="loading-text">Loading full text...</div></div>`; | |
| try { | |
| const res = await fetch(`${API}/api/v1/admin/books/${bookId}/fulltext`); | |
| const data = await res.json(); | |
| currentReaderData = data; | |
| // Update toolbar | |
| document.getElementById('reader-toolbar-title').innerHTML = ` | |
| ${escapeHtml(data.title)} | |
| <span class="reader-author">by ${escapeHtml(data.author)}</span> | |
| `; | |
| // Build reader HTML | |
| let html = ` | |
| <div class="reader-book-header"> | |
| <h1>${escapeHtml(data.title)}</h1> | |
| <div class="reader-book-author">by ${escapeHtml(data.author)}</div> | |
| ${data.publication_year ? `<div class="reader-book-year">${data.publication_year}</div>` : ''} | |
| <p style="font-size:0.82rem;color:var(--ink-faint);margin-top:12px;"> | |
| Public domain · ${data.total_chunks.toLocaleString()} passages · Free to read and download | |
| </p> | |
| </div> | |
| `; | |
| // Table of contents (if more than one chapter) | |
| if (data.chapters.length > 1) { | |
| html += `<div class="reader-toc"><h3>📑 Table of Contents</h3>`; | |
| data.chapters.forEach((ch, i) => { | |
| html += `<a class="reader-toc-item" onclick="document.getElementById('ch-${i}').scrollIntoView({behavior:'smooth'})"> | |
| ${escapeHtml(ch.title)} | |
| </a>`; | |
| }); | |
| html += `</div>`; | |
| } | |
| // Chapters | |
| data.chapters.forEach((ch, i) => { | |
| html += `<div class="reader-chapter">`; | |
| html += `<h2 class="reader-chapter-title" id="ch-${i}">${escapeHtml(ch.title)}</h2>`; | |
| ch.passages.forEach(p => { | |
| html += `<p class="reader-passage">${escapeHtml(p.text)}`; | |
| if (p.page) html += `<span class="reader-page-marker">p.${p.page}</span>`; | |
| html += `</p>`; | |
| }); | |
| html += `</div>`; | |
| }); | |
| content.innerHTML = html; | |
| } catch (e) { | |
| content.innerHTML = `<div class="empty-state"><div class="empty-icon">⚠️</div><div class="empty-text">Failed to load book text. ${e.message}</div></div>`; | |
| } | |
| } | |
| function closeReader() { | |
| document.getElementById('reader-overlay').classList.remove('show'); | |
| document.body.style.overflow = ''; | |
| currentReaderData = null; | |
| } | |
| function downloadBookText() { | |
| if (!currentReaderData) return; | |
| downloadTextData(currentReaderData); | |
| } | |
| async function downloadBookById(bookId) { | |
| try { | |
| const res = await fetch(`${API}/api/v1/admin/books/${bookId}/fulltext`); | |
| const data = await res.json(); | |
| downloadTextData(data); | |
| } catch (e) { | |
| alert('Failed to download book text.'); | |
| } | |
| } | |
| function downloadTextData(data) { | |
| let text = `${'='.repeat(60)}\n`; | |
| text += `${data.title}\n`; | |
| text += `by ${data.author}\n`; | |
| if (data.publication_year) text += `(${data.publication_year})\n`; | |
| text += `${'='.repeat(60)}\n\n`; | |
| text += `This text is in the public domain.\n`; | |
| text += `Reconstructed from the Natural Hygiene Library database.\n\n`; | |
| data.chapters.forEach(ch => { | |
| text += `${'─'.repeat(40)}\n`; | |
| text += `${ch.title}\n`; | |
| text += `${'─'.repeat(40)}\n\n`; | |
| ch.passages.forEach(p => { | |
| text += p.text + '\n\n'; | |
| }); | |
| }); | |
| const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `${data.title.replace(/[^a-zA-Z0-9]/g, '_')}.txt`; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| } | |
| // Close reader on Escape | |
| document.addEventListener('keydown', (e) => { | |
| if (e.key === 'Escape' && document.getElementById('reader-overlay').classList.contains('show')) { | |
| closeReader(); | |
| e.stopPropagation(); | |
| } | |
| }); | |
| function escapeAttr(str) { | |
| return str?.replace(/'/g, "\\'").replace(/"/g, '"') || ''; | |
| } | |
| </script> | |
| </body> | |
| </html> | |