LS8's picture
Upload folder using huggingface_hub
a56c681 verified
<!DOCTYPE html>
<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, '&quot;') || '';
}
</script>
</body>
</html>