GUTINDEX - INTERACTIVE APP SOURCE CODE
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gutendex Explorer</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600&family=Inter:wght@400;500;600&display=swap"
rel="stylesheet">
<style>
:root {
/* Map to Binaural Light mode defaults */
--bg-black: #ffffff;
--bg-darker: #f5f3f3;
--bg-dark: #f5f3f3;
--bg-card: transparent;
--border-color: rgba(0, 0, 0, 0.18);
--text-white: #000000;
--text-light: #5d5f5f;
--text-muted: #5d5f5f;
--accent-primary: #000000;
--accent-secondary: #5d5f5f;
--accent-success: #00e676;
--accent-warning: #ff9100;
--accent-danger: #ff1744;
--radius-sm: 0px;
--radius-md: 0px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-black: #050505;
--bg-darker: #141515;
--bg-dark: #141515;
--bg-card: transparent;
--border-color: rgba(255, 255, 255, 0.18);
--text-white: #ffffff;
--text-light: #848484;
--text-muted: #848484;
--accent-primary: #ffffff;
--accent-secondary: #848484;
}
}
[data-theme="dark"] {
--bg-black: #050505;
--bg-darker: #141515;
--bg-dark: #141515;
--bg-card: transparent;
--border-color: rgba(255, 255, 255, 0.18);
--text-white: #ffffff;
--text-light: #848484;
--text-muted: #848484;
--accent-primary: #ffffff;
--accent-secondary: #848484;
--modal-overlay: rgba(0, 0, 0, 0.95);
--reader-overlay: rgba(0, 0, 0, 0.98);
--reader-bg: #000000;
}
[data-theme="light"] {
--bg-black: #ffffff;
--bg-darker: #f5f3f3;
--bg-dark: #f5f3f3;
--bg-card: transparent;
--border-color: rgba(0, 0, 0, 0.18);
--text-white: #000000;
--text-light: #5d5f5f;
--text-muted: #5d5f5f;
--accent-primary: #000000;
--accent-secondary: #5d5f5f;
--modal-overlay: rgba(255, 255, 255, 0.95);
--reader-overlay: rgba(255, 255, 255, 0.98);
--reader-bg: #ffffff;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', sans-serif;
background-color: var(--bg-black);
color: var(--text-white);
line-height: 1.4;
font-size: 13px;
overflow-x: hidden;
height: 100vh;
display: flex;
}
.mono {
font-family: 'JetBrains Mono', monospace;
}
/* Main Layout */
.main-layout {
display: flex;
width: 100%;
height: 100vh;
overflow: hidden;
}
/* Left Panel - Filters */
.left-panel {
width: 320px;
background-color: var(--bg-dark);
border-right: 1px solid var(--border-color);
padding: 20px;
overflow-y: auto;
height: 100vh;
}
.panel-section {
margin-bottom: 24px;
}
.panel-title {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 16px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: 8px;
}
.panel-title i {
display: none;
}
/* Filter Controls */
.filter-group {
margin-bottom: 16px;
}
.filter-label {
display: block;
font-size: 11px;
color: var(--text-light);
margin-bottom: 6px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: space-between;
}
.filter-label .help {
color: var(--text-muted);
font-size: 9px;
cursor: help;
}
.filter-input,
.filter-select {
width: 100%;
padding: 8px 10px;
background: var(--bg-dark);
border: 1px solid var(--border-color);
border-radius: 0;
color: var(--text-white);
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
transition: border-color 0.1s;
outline: none;
}
.filter-input:focus,
.filter-select:focus {
border-color: var(--text-white);
}
.filter-checkbox {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
cursor: pointer;
font-size: 12px;
color: var(--text-light);
}
.filter-checkbox input[type="checkbox"] {
accent-color: var(--accent-primary);
width: 14px;
height: 14px;
}
.filter-actions {
display: flex;
gap: 10px;
margin-top: 20px;
}
.btn {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.15em;
text-transform: uppercase;
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-white);
padding: 10px 16px;
cursor: pointer;
transition: all 0.15s ease;
border-radius: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
flex: 1;
text-align: center;
}
.btn:hover {
background: var(--bg-dark);
border-color: var(--text-white);
}
.btn-secondary {
background-color: transparent;
border: 1px solid var(--border-color);
color: var(--text-light);
}
.btn-secondary:hover {
background-color: rgba(255, 255, 255, 0.05);
border-color: var(--accent-primary);
}
/* Main Content Area */
.main-content {
flex: 1;
padding: 20px;
overflow-y: auto;
height: 100vh;
}
/* Search Controls */
.search-controls {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 20px;
}
.search-panel {
background: transparent;
border: 1px solid var(--border-color);
border-radius: 0;
padding: 20px;
}
.search-panel-title {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 16px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: 8px;
}
.search-panel-title i {
display: none;
}
.search-input-group {
display: flex;
gap: 8px;
}
.search-input {
flex: 1;
padding: 10px 12px;
background: var(--bg-dark);
border: 1px solid var(--border-color);
border-radius: 0;
color: var(--text-white);
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
transition: border-color 0.1s;
outline: none;
}
.search-input:focus {
border-color: var(--text-white);
}
.search-examples {
margin-top: 10px;
font-size: 10px;
color: var(--text-muted);
line-height: 1.5;
}
.search-examples span {
color: var(--accent-secondary);
cursor: pointer;
text-decoration: underline;
margin-right: 8px;
}
.search-examples span:hover {
color: var(--text-light);
}
.search-execute {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.search-execute .btn {
width: auto;
min-width: 140px;
}
/* Stats Bar */
.stats-container {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 20px;
}
.stat-card {
background: transparent;
border: 1px solid var(--border-color);
border-radius: 0;
padding: 12px;
text-align: center;
}
.stat-value {
font-size: 18px;
font-weight: 600;
color: var(--accent-primary);
font-family: 'JetBrains Mono', monospace;
margin-bottom: 4px;
}
.stat-label {
font-size: 10px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Books Grid */
.books-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.book-card {
background: transparent;
border: 1px solid var(--border-color);
border-radius: 0;
overflow: hidden;
transition: border-color 0.1s, background-color 0.1s;
cursor: pointer;
position: relative;
}
.book-card:hover {
border-color: var(--text-white);
}
.book-header {
padding: 12px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.book-id {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 700;
color: var(--text-white);
background: var(--bg-dark);
padding: 3px 8px;
border: 1px solid var(--border-color);
border-radius: 0;
min-width: 60px;
text-align: right;
}
.book-title {
font-size: 13px;
font-weight: 600;
line-height: 1.4;
color: var(--text-white);
margin-bottom: 6px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.book-author {
font-size: 11px;
color: var(--accent-secondary);
margin-bottom: 8px;
}
.book-body {
padding: 12px;
}
.book-meta {
display: flex;
justify-content: space-between;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--border-color);
}
.meta-item {
text-align: center;
}
.meta-value {
font-size: 12px;
font-weight: 600;
color: var(--text-white);
font-family: 'JetBrains Mono', monospace;
}
.meta-label {
font-size: 9px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 2px;
}
/* Pagination */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-top: 20px;
}
.page-info {
font-size: 12px;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
}
.btn-icon {
padding: 6px 10px;
background-color: transparent;
border: 1px solid var(--border-color);
color: var(--text-light);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.15s;
}
.btn-icon:hover:not(:disabled) {
background-color: rgba(61, 90, 254, 0.1);
border-color: var(--accent-primary);
}
.btn-icon:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Torus Knot Loader */
.torus-loader {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 80px 40px;
width: 100%;
grid-column: 1 / -1;
min-height: 50vh;
transition: opacity .4s ease;
}
.torus-loader.is-out { opacity: 0; }
.torus-loader canvas { width: 100px; height: 100px; display: block; margin: 0 auto; }
.torus-loader .loading-text {
margin-top: 20px;
color: var(--text-muted);
font-size: 13px;
font-weight: 500;
letter-spacing: 2px;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0% { opacity: 0.5; }
50% { opacity: 1; }
100% { opacity: 0.5; }
}
/* Modal */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--modal-overlay);
z-index: 1000;
padding: 20px;
overflow-y: auto;
}
.modal-content {
background-color: var(--bg-card);
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
max-width: 800px;
margin: 0 auto;
position: relative;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
padding: 16px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
background-color: var(--bg-card);
z-index: 10;
}
.modal-title {
font-size: 16px;
font-weight: 600;
color: var(--text-white);
flex: 1;
}
.close-modal {
background: none;
border: none;
color: var(--text-muted);
font-size: 18px;
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
margin-left: 16px;
}
.close-modal:hover {
color: var(--text-white);
}
.modal-body {
padding: 16px;
font-size: 12px;
}
.modal-section {
margin-bottom: 20px;
}
.modal-section-title {
font-size: 13px;
font-weight: 600;
color: var(--accent-primary);
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.modal-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
margin-bottom: 16px;
}
.modal-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.modal-label {
font-size: 10px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.modal-value {
color: var(--text-light);
font-size: 12px;
}
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
.tag {
background-color: var(--bg-black);
color: var(--text-light);
padding: 4px 8px;
border-radius: 12px;
font-size: 10px;
border: 1px solid var(--border-color);
}
.format-tag {
background-color: rgba(61, 90, 254, 0.1);
color: var(--accent-primary);
border-color: var(--accent-primary);
text-decoration: none;
transition: all 0.15s;
}
.format-tag:hover {
background-color: rgba(61, 90, 254, 0.2);
}
/* DARK MODE READER BUTTONS */
.dark-reader-btn, .text-reader-btn {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.15em;
text-transform: uppercase;
background: transparent;
color: var(--text-white);
border: 1px solid var(--border-color);
padding: 10px 16px;
border-radius: 0;
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
text-decoration: none;
margin-top: 8px;
}
.text-reader-btn:hover {
background: var(--bg-dark);
border-color: var(--text-white);
}
/* Format Buttons Container */
.format-buttons {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 16px;
}
/* Reader Modal */
.reader-modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--reader-overlay);
z-index: 2000;
padding: 0;
overflow: hidden;
}
.reader-content {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background-color: var(--reader-bg);
}
.reader-header {
padding: 16px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
background-color: var(--bg-dark);
}
.reader-title {
font-size: 14px;
font-weight: 600;
color: var(--text-white);
flex: 1;
}
.reader-controls {
display: flex;
gap: 10px;
align-items: center;
}
.reader-body {
flex: 1;
padding: 30px;
overflow-y: auto;
background-color: var(--reader-bg);
color: var(--text-light);
font-family: 'Georgia', serif;
line-height: 1.8;
font-size: 16px;
}
.reader-body h1,
.reader-body h2,
.reader-body h3 {
color: var(--text-white);
margin-bottom: 20px;
font-family: 'Inter', sans-serif;
}
.reader-body p {
margin-bottom: 20px;
text-align: justify;
}
.reader-body pre,
.reader-body code {
background-color: var(--bg-dark);
padding: 10px;
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
overflow-x: auto;
}
/* Active Search Terms */
#active-searches {
margin-bottom: 20px;
display: none;
}
#search-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
/* Responsive */
@media (max-width: 1200px) {
.main-layout {
flex-direction: column;
height: auto;
overflow-y: auto;
}
.left-panel {
width: 100%;
height: auto;
border-right: none;
border-bottom: 1px solid var(--border-color);
}
.main-content {
height: auto;
overflow-y: visible;
}
.search-controls {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.stats-container {
grid-template-columns: repeat(2, 1fr);
}
.books-grid {
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
}
.modal-grid {
grid-template-columns: 1fr;
}
.modal-content {
max-height: 85vh;
}
.reader-body {
padding: 20px;
font-size: 14px;
}
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-dark);
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
</style>
</head>
<body>
<!-- Main Layout -->
<div class="main-layout">
<!-- Left Panel - Filters -->
<aside class="left-panel">
<div class="panel-section">
<div class="panel-title">
<i class="fas fa-filter"></i> QUERY FILTERS
</div>
<div class="filter-group">
<label class="filter-label">
LANGUAGE FILTER
<span class="help" title="Filter by book language">?</span>
</label>
<select id="language-select" class="filter-select">
<option value="">ALL LANGUAGES</option>
<option value="en">ENGLISH</option>
<option value="fr">FRENCH</option>
<option value="de">GERMAN</option>
<option value="es">SPANISH</option>
<option value="it">ITALIAN</option>
<option value="pt">PORTUGUESE</option>
<option value="nl">DUTCH</option>
<option value="fi">FINNISH</option>
<option value="ru">RUSSIAN</option>
</select>
</div>
<div class="filter-group">
<label class="filter-label">
COPYRIGHT STATUS
<span class="help" title="Filter by copyright status">?</span>
</label>
<div>
<label class="filter-checkbox">
<input type="checkbox" id="copyright-false" checked>
<span>PUBLIC DOMAIN</span>
</label>
<label class="filter-checkbox">
<input type="checkbox" id="copyright-true">
<span>COPYRIGHTED</span>
</label>
<label class="filter-checkbox">
<input type="checkbox" id="copyright-null">
<span>UNKNOWN</span>
</label>
</div>
</div>
<div class="filter-group">
<label class="filter-label">
SORT ORDER
<span class="help" title="Order results by">?</span>
</label>
<select id="sort-select" class="filter-select">
<option value="popular">POPULARITY (DOWNLOADS)</option>
<option value="asc">ID ASCENDING</option>
<option value="desc">ID DESCENDING</option>
</select>
</div>
</div>
<div class="filter-actions">
<button id="apply-filters" class="btn">
<i class="fas fa-play"></i> APPLY FILTERS
</button>
<button id="reset-filters" class="btn btn-secondary">
<i class="fas fa-redo"></i> RESET ALL
</button>
</div>
<div class="panel-section" style="margin-top: 24px;">
<div class="panel-title">
<i class="fas fa-info-circle"></i> IN-APP READER
</div>
<div style="font-size: 11px; color: var(--text-muted); line-height: 1.5;">
<p><strong>SEAMLESS READING:</strong> Read books directly in the app</p>
<p style="margin-top: 8px;"><strong>THEME SUPPORT:</strong> Respects your current light/dark theme preference
</p>
<p style="margin-top: 8px; color: var(--accent-secondary);">Click "READ IN APP" for any book
</p>
</div>
</div>
</aside>
<!-- Main Content Area -->
<main class="main-content">
<!-- Search Controls -->
<div class="search-controls">
<div class="search-panel">
<div class="search-panel-title">
<i class="fas fa-search"></i> TITLE / AUTHOR SEARCH
</div>
<div class="search-input-group">
<input type="text" id="title-search" class="search-input"
placeholder="Search titles, authors..." autocomplete="off">
<button id="title-search-btn" class="btn" style="width: auto; min-width: 80px;">
<i class="fas fa-search"></i> SEARCH
</button>
</div>
<div class="search-examples">
Examples:
<span onclick="setSearchExample('title', 'Dickens')">Dickens</span>
<span onclick="setSearchExample('title', 'Frankenstein')">Frankenstein</span>
<span onclick="setSearchExample('title', 'Shakespeare')">Shakespeare</span>
</div>
</div>
<div class="search-panel">
<div class="search-panel-title">
<i class="fas fa-tags"></i> TOPIC / SUBJECT SEARCH
</div>
<div class="search-input-group">
<input type="text" id="topic-search" class="search-input"
placeholder="Search subjects, bookshelves..." autocomplete="off">
<button id="topic-search-btn" class="btn"
style="width: auto; min-width: 80px; background-color: var(--accent-secondary);">
<i class="fas fa-tags"></i> SEARCH
</button>
</div>
<div class="search-examples">
Examples:
<span onclick="setSearchExample('topic', 'Fiction')">Fiction</span>
<span onclick="setSearchExample('topic', 'Science')">Science</span>
<span onclick="setSearchExample('topic', 'Children')">Children</span>
<span onclick="setSearchExample('topic', 'Horror')">Horror</span>
</div>
</div>
</div>
<!-- Combined Search Execution -->
<div class="search-execute">
<button id="combined-search-btn" class="btn" style="background-color: var(--accent-primary);">
<i class="fas fa-rocket"></i> EXECUTE COMBINED SEARCH
</button>
</div>
<!-- Stats -->
<div class="stats-container">
<div class="stat-card">
<div class="stat-value" id="total-books">--</div>
<div class="stat-label">TOTAL RESULTS</div>
</div>
<div class="stat-card">
<div class="stat-value" id="books-loaded">--</div>
<div class="stat-label">BOOKS LOADED</div>
</div>
<div class="stat-card">
<div class="stat-value" id="current-page">--</div>
<div class="stat-label">CURRENT PAGE</div>
</div>
<div class="stat-card">
<div class="stat-value" id="total-pages">--</div>
<div class="stat-label">TOTAL PAGES</div>
</div>
</div>
<!-- Active Search Terms -->
<div id="active-searches" style="margin-bottom: 20px; display: none;">
<div style="font-size: 11px; color: var(--text-muted); margin-bottom: 8px;">ACTIVE SEARCHES:</div>
<div id="search-tags">
<!-- Search tags will be dynamically inserted here -->
</div>
</div>
<!-- Content Grid -->
<div class="books-grid" id="books-grid">
<!-- Book cards will be dynamically inserted here -->
</div>
<!-- Pagination -->
<div class="pagination">
<button id="prev-page" class="btn-icon" disabled>
<i class="fas fa-chevron-left"></i>
</button>
<div class="page-info">
PAGE <span id="page-num" class="mono">1</span> OF <span id="page-total" class="mono">1</span>
</div>
<button id="next-page" class="btn-icon" disabled>
<i class="fas fa-chevron-right"></i>
</button>
</div>
</main>
</div>
<!-- Book Details Modal -->
<div class="modal" id="book-modal">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title" id="modal-title">BOOK DETAILS</div>
<button class="close-modal" id="close-modal">×</button>
</div>
<div class="modal-body" id="modal-body">
<!-- Book details will be dynamically inserted here -->
</div>
</div>
</div>
<!-- Dark Mode Reader Modal -->
<div class="reader-modal" id="reader-modal">
<div class="reader-content">
<div class="reader-header">
<div class="reader-title" id="reader-title">In-App Reader</div>
<div class="reader-controls">
<button id="close-reader" class="btn" style="padding: 6px 12px; font-size: 11px;">
<i class="fas fa-times"></i> CLOSE
</button>
</div>
</div>
<div class="reader-body" id="reader-body">
<!-- Book content will be loaded here -->
</div>
</div>
</div>
<script>
// Torus Loader Factory
window.startTorusLoader = function(containerElement) {
const wrap = document.createElement('div');
wrap.className = 'torus-loader';
wrap.setAttribute('role', 'status');
wrap.setAttribute('aria-label', 'Loading');
const cv = document.createElement('canvas');
wrap.appendChild(cv);
const text = document.createElement('p');
text.className = 'loading-text';
text.textContent = 'LOADING...';
wrap.appendChild(text);
// Clear container and append
containerElement.innerHTML = '';
containerElement.appendChild(wrap);
const ctx = cv.getContext('2d');
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const P = 3, Q = 2, R = 1.0, r = 0.42, N = 900;
const pts = [];
for (let i = 0; i <= N; i++) {
const t = (i / N) * Math.PI * 2;
const cq = Math.cos(Q * t), sq = Math.sin(Q * t);
const rad = R + r * cq;
pts.push([rad * Math.cos(P * t), rad * Math.sin(P * t), r * sq]);
}
let target = 0, shown = 0, rotY = 0.6, rotX = -0.5, raf = null, size = 0, dpr = 1, finished = false;
let autoInterval;
function resize() {
dpr = Math.min(devicePixelRatio || 1, 2);
size = cv.clientWidth;
if (size === 0) size = 100;
cv.width = cv.height = Math.round(size * dpr);
}
function project(p) {
let [x, y, z] = p;
let cx = Math.cos(rotY), sx = Math.sin(rotY);
[x, z] = [x * cx + z * sx, -x * sx + z * cx];
cx = Math.cos(rotX); sx = Math.sin(rotX);
[y, z] = [y * cx - z * sx, y * sx + z * cx];
const d = 4, s = d / (d - z);
const half = size * dpr / 2, scale = half * 0.62;
return [half + x * s * scale, half - y * s * scale, z];
}
function draw(ts) {
const dt = 1 / 60;
if (!reduced) { rotY += 0.35 * dt; rotX = -0.5 + Math.sin(ts * 0.0004) * 0.25; }
shown += (target - shown) * (reduced ? 1 : 0.06);
const proj = pts.map(project);
const w = cv.width;
ctx.clearRect(0, 0, w, w);
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
const css = getComputedStyle(document.documentElement);
const INK = css.getPropertyValue('--accent-primary').trim() || '#4a1a26';
const GHOST = css.getPropertyValue('--border-color').trim() || 'rgba(255, 255, 255, 0.10)';
ctx.strokeStyle = GHOST; ctx.lineWidth = 1.5 * dpr;
ctx.beginPath();
proj.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y));
ctx.stroke();
const count = Math.floor(shown * N);
const segs = [];
for (let i = 0; i < count; i++) segs.push(i);
segs.sort((a, b) => proj[a][2] - proj[b][2]);
for (const i of segs) {
const a = proj[i], b = proj[i + 1];
const depth = (a[2] + r + R) / (2 * (r + R));
ctx.strokeStyle = INK;
ctx.globalAlpha = 0.35 + 0.65 * depth;
ctx.lineWidth = (1.2 + 3.2 * depth) * dpr;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
ctx.globalAlpha = 1;
if (count > 0 && count < N) {
const [x, y, z] = proj[count];
const depth = (z + r + R) / (2 * (r + R));
ctx.fillStyle = INK;
ctx.beginPath(); ctx.arc(x, y, (2.5 + 3 * depth) * dpr, 0, Math.PI * 2); ctx.fill();
}
if (finished && shown > 0.995) { finish(); return; }
raf = requestAnimationFrame(draw);
}
function finish() {
cancelAnimationFrame(raf); raf = null;
wrap.classList.add('is-out');
setTimeout(() => {
if (wrap.parentNode) wrap.parentNode.removeChild(wrap);
}, 400); // Wait for transition
}
resize();
raf = requestAnimationFrame(draw);
autoInterval = setInterval(() => { if (target < 0.9) target += (0.9 - target) * 0.12; }, 200);
return {
done: () => {
clearInterval(autoInterval);
target = 1; finished = true;
}
};
};
function syncTheme() {
try {
if (window.parent && window.parent.document && window.parent.document.documentElement) {
const isDark = window.parent.document.documentElement.classList.contains('dark');
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
}
} catch (e) {}
}
window.addEventListener('load', syncTheme);
window.addEventListener('message', syncTheme);
setInterval(syncTheme, 500);
// Global variables
let currentPage = 1;
let totalPages = 1;
let totalBooks = 0;
let booksLoaded = 0;
let currentBooks = [];
let currentFilters = {
search: '',
languages: '',
copyright: 'false',
sort: 'popular',
topic: ''
};
const booksGrid = document.getElementById('books-grid');
const loadingEl = document.getElementById('loading');
const totalBooksEl = document.getElementById('total-books');
const booksLoadedEl = document.getElementById('books-loaded');
const currentPageEl = document.getElementById('current-page');
const totalPagesEl = document.getElementById('total-pages');
const pageNumEl = document.getElementById('page-num');
const pageTotalEl = document.getElementById('page-total');
const prevPageBtn = document.getElementById('prev-page');
const nextPageBtn = document.getElementById('next-page');
const applyFiltersBtn = document.getElementById('apply-filters');
const resetFiltersBtn = document.getElementById('reset-filters');
const bookModal = document.getElementById('book-modal');
const closeModalBtn = document.getElementById('close-modal');
const readerModal = document.getElementById('reader-modal');
const readerTitle = document.getElementById('reader-title');
const readerBody = document.getElementById('reader-body');
const closeReaderBtn = document.getElementById('close-reader');
const activeSearchesEl = document.getElementById('active-searches');
const searchTagsEl = document.getElementById('search-tags');
const titleSearchEl = document.getElementById('title-search');
const titleSearchBtn = document.getElementById('title-search-btn');
const topicSearchEl = document.getElementById('topic-search');
const topicSearchBtn = document.getElementById('topic-search-btn');
const combinedSearchBtn = document.getElementById('combined-search-btn');
const languageSelect = document.getElementById('language-select');
const copyrightFalse = document.getElementById('copyright-false');
const copyrightTrue = document.getElementById('copyright-true');
const copyrightNull = document.getElementById('copyright-null');
const sortSelect = document.getElementById('sort-select');
document.addEventListener('DOMContentLoaded', () => {
loadBooks(currentPage);
setupEventListeners();
updateActiveSearchTags();
});
function setupEventListeners() {
// Title/Author search
titleSearchBtn.addEventListener('click', executeTitleSearch);
titleSearchEl.addEventListener('keypress', (e) => {
if (e.key === 'Enter') executeTitleSearch();
});
topicSearchBtn.addEventListener('click', executeTopicSearch);
topicSearchEl.addEventListener('keypress', (e) => {
if (e.key === 'Enter') executeTopicSearch();
});
combinedSearchBtn.addEventListener('click', executeCombinedSearch);
applyFiltersBtn.addEventListener('click', applyFilters);
resetFiltersBtn.addEventListener('click', resetAll);
prevPageBtn.addEventListener('click', () => {
if (currentPage > 1) {
currentPage--;
loadBooks(currentPage);
}
});
nextPageBtn.addEventListener('click', () => {
if (currentPage < totalPages) {
currentPage++;
loadBooks(currentPage);
}
});
closeModalBtn.addEventListener('click', () => {
bookModal.style.display = 'none';
});
closeReaderBtn.addEventListener('click', () => {
readerModal.style.display = 'none';
document.body.style.overflow = '';
readerBody.innerHTML = '';
});
window.addEventListener('click', (e) => {
if (e.target === bookModal) {
bookModal.style.display = 'none';
}
if (e.target === readerModal) {
readerModal.style.display = 'none';
document.body.style.overflow = '';
readerBody.innerHTML = '';
}
});
}
function executeTitleSearch() {
currentFilters.search = titleSearchEl.value.trim();
currentFilters.topic = ''; // Clear topic for title-only search
topicSearchEl.value = '';
currentPage = 1;
updateActiveSearchTags();
loadBooks(currentPage);
}
function executeTopicSearch() {
currentFilters.topic = topicSearchEl.value.trim();
currentFilters.search = ''; // Clear title search for topic-only search
titleSearchEl.value = '';
currentPage = 1;
updateActiveSearchTags();
loadBooks(currentPage);
}
function executeCombinedSearch() {
currentFilters.search = titleSearchEl.value.trim();
currentFilters.topic = topicSearchEl.value.trim();
currentPage = 1;
updateActiveSearchTags();
loadBooks(currentPage);
}
function applyFilters() {
currentFilters.languages = languageSelect.value;
let copyrightValues = [];
if (copyrightFalse.checked) copyrightValues.push('false');
if (copyrightTrue.checked) copyrightValues.push('true');
if (copyrightNull.checked) copyrightValues.push('null');
currentFilters.copyright = copyrightValues.join(',');
currentFilters.sort = sortSelect.value;
currentPage = 1;
loadBooks(currentPage);
}
function resetAll() {
// Reset search inputs
titleSearchEl.value = '';
topicSearchEl.value = '';
languageSelect.value = '';
copyrightFalse.checked = true;
copyrightTrue.checked = false;
copyrightNull.checked = false;
sortSelect.value = 'popular';
currentFilters = {
search: '',
languages: '',
copyright: 'false',
sort: 'popular',
topic: ''
};
currentPage = 1;
updateActiveSearchTags();
loadBooks(currentPage);
}
function updateActiveSearchTags() {
searchTagsEl.innerHTML = '';
if (currentFilters.search) {
const tag = document.createElement('div');
tag.className = 'tag';
tag.style.backgroundColor = 'rgba(61, 90, 254, 0.2)';
tag.style.borderColor = 'var(--accent-primary)';
tag.innerHTML = `TITLE/AUTHOR: ${currentFilters.search} <span style="margin-left: 6px; cursor: pointer;" onclick="clearSearch('title')">×</span>`;
searchTagsEl.appendChild(tag);
}
if (currentFilters.topic) {
const tag = document.createElement('div');
tag.className = 'tag';
tag.style.backgroundColor = 'rgba(0, 229, 255, 0.2)';
tag.style.borderColor = 'var(--accent-secondary)';
tag.innerHTML = `TOPIC: ${currentFilters.topic} <span style="margin-left: 6px; cursor: pointer;" onclick="clearSearch('topic')">×</span>`;
searchTagsEl.appendChild(tag);
}
if (currentFilters.languages) {
const tag = document.createElement('div');
tag.className = 'tag';
tag.innerHTML = `LANGUAGE: ${currentFilters.languages.toUpperCase()} <span style="margin-left: 6px; cursor: pointer;" onclick="clearFilter('language')">×</span>`;
searchTagsEl.appendChild(tag);
}
if (currentFilters.copyright && currentFilters.copyright !== 'false') {
const tag = document.createElement('div');
tag.className = 'tag';
tag.innerHTML = `COPYRIGHT: ${currentFilters.copyright} <span style="margin-left: 6px; cursor: pointer;" onclick="clearFilter('copyright')">×</span>`;
searchTagsEl.appendChild(tag);
}
activeSearchesEl.style.display =
(currentFilters.search || currentFilters.topic || currentFilters.languages ||
(currentFilters.copyright && currentFilters.copyright !== 'false')) ? 'block' : 'none';
}
function clearSearch(type) {
if (type === 'title') {
currentFilters.search = '';
titleSearchEl.value = '';
} else if (type === 'topic') {
currentFilters.topic = '';
topicSearchEl.value = '';
}
currentPage = 1;
updateActiveSearchTags();
loadBooks(currentPage);
}
// Clear specific filter
function clearFilter(type) {
if (type === 'language') {
currentFilters.languages = '';
languageSelect.value = '';
} else if (type === 'copyright') {
currentFilters.copyright = 'false';
copyrightFalse.checked = true;
copyrightTrue.checked = false;
copyrightNull.checked = false;
}
currentPage = 1;
updateActiveSearchTags();
loadBooks(currentPage);
}
// Set search example
function setSearchExample(type, term) {
if (type === 'title') {
titleSearchEl.value = term;
currentFilters.search = term;
currentFilters.topic = '';
topicSearchEl.value = '';
} else if (type === 'topic') {
topicSearchEl.value = term;
currentFilters.topic = term;
currentFilters.search = '';
titleSearchEl.value = '';
}
currentPage = 1;
updateActiveSearchTags();
loadBooks(currentPage);
}
// Find text/plain format for reader
function findTextFormat(formats) {
// Check for text/plain formats
const textKeys = Object.keys(formats).filter(key =>
key.includes('text/plain') ||
key.includes('.txt') ||
formats[key].includes('.txt')
);
if (textKeys.length > 0) {
return formats[textKeys[0]];
}
// Check for HTML as fallback
const htmlKeys = Object.keys(formats).filter(key =>
key.includes('text/html')
);
if (htmlKeys.length > 0) {
return formats[htmlKeys[0]];
}
return null;
}
// Load and display book in in-app reader - UPDATED TO WORK WITH CORS
async function loadBookInApp(bookId) {
// Find the book in currentBooks
const book = currentBooks.find(b => b.id === bookId);
if (!book) {
alert('Book not found in current results.');
return;
}
const textUrl = findTextFormat(book.formats);
if (!textUrl) {
alert('No readable format available for this book.');
return;
}
// Set reader title
readerTitle.textContent = `In-App Reader: ${book.title}`;
// Show loading in reader modal
let loader = window.startTorusLoader(readerBody);
// Show reader modal
readerModal.style.display = 'block';
document.body.style.overflow = 'hidden';
try {
// Try multiple approaches to get the content
const content = await getBookContentWithFallbacks(textUrl, book);
// Display in reader
displayTextInApp(content, book.title, textUrl);
} catch (error) {
console.error('Error loading book:', error);
// Final fallback: Show book information with direct links
readerBody.innerHTML = `
<div style="text-align: center; padding: 40px; color: var(--text-light);">
<div style="max-width: 800px; margin: 0 auto;">
<h2 style="color: var(--text-white); margin-bottom: 20px;">${book.title}</h2>
<div style="background-color: var(--bg-card); padding: 25px; border-radius: var(--radius-md); margin: 20px 0; text-align: left;">
<h4 style="color: var(--accent-secondary); margin-bottom: 15px; display: flex; align-items: center; gap: 8px;">
<i class="fas fa-book"></i> BOOK INFORMATION
</h4>
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 15px; margin-bottom: 20px;">
<div>
<div style="font-size: 10px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px;">AUTHOR</div>
<div style="color: var(--text-light); font-size: 14px;">${book.authors[0]?.name || 'Unknown'}</div>
</div>
<div>
<div style="font-size: 10px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px;">PUBLICATION YEAR</div>
<div style="color: var(--text-light); font-size: 14px;">${book.authors[0]?.birth_year ? 'Circa ' + book.authors[0].birth_year : 'Unknown'}</div>
</div>
<div>
<div style="font-size: 10px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px;">LANGUAGES</div>
<div style="color: var(--text-light); font-size: 14px;">${book.languages.map(l => l.toUpperCase()).join(', ')}</div>
</div>
<div>
<div style="font-size: 10px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px;">DOWNLOADS</div>
<div style="color: var(--text-light); font-size: 14px;">${book.download_count.toLocaleString()}</div>
</div>
</div>
${book.summaries && book.summaries.length > 0 ? `
<div style="margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--border-color);">
<h5 style="color: var(--accent-primary); margin-bottom: 10px; display: flex; align-items: center; gap: 8px;">
<i class="fas fa-align-left"></i> SUMMARY
</h5>
<p style="color: var(--text-light); font-size: 14px; line-height: 1.7; text-align: justify;">
${book.summaries[0]}
</p>
</div>
` : ''}
</div>
<div style="background-color: var(--bg-card); padding: 25px; border-radius: var(--radius-md); margin: 20px 0; text-align: left;">
<h4 style="color: var(--accent-secondary); margin-bottom: 15px; display: flex; align-items: center; gap: 8px;">
<i class="fas fa-download"></i> ACCESS THIS BOOK
</h4>
<p style="color: var(--text-light); margin-bottom: 20px; line-height: 1.6;">
Due to CORS restrictions, we cannot load the full book content directly in this reader.
However, you can access it through the following options:
</p>
<div style="display: flex; flex-wrap: wrap; gap: 15px; margin-top: 20px;">
<a href="${textUrl}" target="_blank" class="btn" style="background: linear-gradient(135deg, #6d28d9, #8b5cf6); display: inline-flex; min-width: 200px;">
<i class="fas fa-external-link-alt"></i> OPEN TEXT VERSION
</a>
<a href="https://www.gutenberg.org/ebooks/${book.id}" target="_blank" class="btn" style="background: linear-gradient(135deg, #10b981, #34d399); display: inline-flex; min-width: 200px;">
<i class="fas fa-book"></i> VIEW ON GUTENBERG
</a>
</div>
<div style="margin-top: 25px; padding-top: 20px; border-top: 1px solid var(--border-color);">
<h5 style="color: var(--accent-primary); margin-bottom: 10px; display: flex; align-items: center; gap: 8px;">
<i class="fas fa-lightbulb"></i> DARK MODE TIP
</h5>
<p style="color: var(--text-light); font-size: 13px; line-height: 1.6;">
For dark mode reading, you can:
</p>
<ul style="color: var(--text-light); font-size: 13px; line-height: 1.6; margin-left: 20px; margin-top: 10px;">
<li>Use browser extensions like "Dark Reader"</li>
<li>Enable dark mode in your browser settings</li>
<li>Copy the text and paste into a dark mode text editor</li>
<li>Use the HTML version with browser dark mode</li>
</ul>
</div>
</div>
</div>
</div>
`;
} finally {
if (loader) setTimeout(() => loader.done(), 300);
}
}
// Try multiple methods to get book content
async function getBookContentWithFallbacks(textUrl, book) {
// Method 1: Try direct fetch (may fail due to CORS)
try {
const response = await fetch(textUrl);
if (response.ok) {
return await response.text();
}
} catch (error) {
console.log('Direct fetch failed, trying proxy...');
}
// Method 2: Try with a CORS proxy
try {
// Try our native Netlify function proxy first, then public fallbacks
const proxies = [
`/.netlify/functions/proxy?url=${encodeURIComponent(textUrl)}`,
`https://api.allorigins.win/raw?url=${encodeURIComponent(textUrl)}`,
`https://api.codetabs.com/v1/proxy?quest=${encodeURIComponent(textUrl)}`
];
for (const proxyUrl of proxies) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const response = await fetch(proxyUrl, {
signal: controller.signal
});
clearTimeout(timeoutId);
if (response.ok) {
return await response.text();
}
} catch (error) {
console.log(`Proxy ${proxyUrl} failed`);
continue;
}
}
} catch (error) {
console.log('All proxies failed');
}
// Method 3: Return a sample of the book info
return createBookSample(book);
}
// Create a book sample when we can't fetch the full content
function createBookSample(book) {
let sample = `TITLE: ${book.title}\n\n`;
sample += `AUTHOR: ${book.authors[0]?.name || 'Unknown'}\n`;
sample += `PUBLICATION YEAR: ${book.authors[0]?.birth_year ? 'Circa ' + book.authors[0].birth_year : 'Unknown'}\n`;
sample += `LANGUAGES: ${book.languages.map(l => l.toUpperCase()).join(', ')}\n`;
sample += `DOWNLOAD COUNT: ${book.download_count.toLocaleString()}\n\n`;
if (book.summaries && book.summaries.length > 0) {
sample += `SUMMARY:\n${book.summaries[0]}\n\n`;
}
if (book.subjects.length > 0) {
sample += `SUBJECTS:\n${book.subjects.slice(0, 5).join('\n')}\n\n`;
}
sample += `\n---\nFULL BOOK AVAILABLE AT: https://www.gutenberg.org/ebooks/${book.id}\n`;
sample += `TEXT VERSION: ${findTextFormat(book.formats) || 'Not available'}\n\n`;
sample += `NOTE: Due to CORS restrictions, the full text cannot be loaded directly.\n`;
sample += `Please visit the Project Gutenberg website to read the complete book.\n`;
return sample;
}
// Display text in in-app reader
function displayTextInApp(text, title, textUrl = '') {
// Check if this is a sample (contains our marker)
const isSample = text.includes('FULL BOOK AVAILABLE AT:');
let formattedText = '';
if (isSample) {
// Format the sample differently
formattedText = `
<div style="max-width: 800px; margin: 0 auto;">
<h1 style="color: var(--text-white); font-family: 'Inter', sans-serif; margin-bottom: 20px; text-align: center;">${title}</h1>
<div style="margin-bottom: 40px; color: var(--text-muted); font-size: 12px; font-style: italic; text-align: center;">
Book Preview • Project Gutenberg
</div>
<div style="white-space: pre-wrap; font-family: 'JetBrains Mono', monospace; line-height: 1.8; color: var(--text-light); text-align: left; background-color: var(--bg-card); padding: 25px; border-radius: var(--radius-md);">
${text}
</div>
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid var(--border-color); color: var(--text-muted); font-size: 11px; text-align: center;">
<p>Complete book available at Project Gutenberg.</p>
${textUrl ? `<p><a href="${textUrl}" target="_blank" style="color: var(--accent-primary);">Open full text version</a></p>` : ''}
</div>
</div>
`;
} else {
// Process text for better display (limit for performance)
let rawText = text.replace(/\r\n/g, '\n');
let lines = rawText.split('\n');
let processedLines = [];
let i = 0;
while (i < lines.length) {
let currentPara = lines[i];
while (i < lines.length - 1) {
let l1 = lines[i];
let l2 = lines[i+1];
if (l1.trim().length === 0 || l2.trim().length === 0) {
break; // Paragraph break
}
let trimL1 = l1.trimEnd();
let trimL2 = l2.trimStart();
let endsWithHyphen = trimL1.endsWith('-') && !trimL1.endsWith('--');
let nextStartsLowercase = /^[a-z]/.test(trimL2);
let isLong = trimL1.length > 55;
let isListOrTOC = trimL1.match(/(\.\.\.|[0-9])$/);
let nextIsIndented = /^[ \t]+/.test(l2);
let isSoftWrap = endsWithHyphen || nextStartsLowercase || (isLong && !nextIsIndented && !isListOrTOC);
if (isSoftWrap) {
if (endsWithHyphen) {
currentPara = currentPara.substring(0, currentPara.lastIndexOf('-')) + trimL2;
} else {
currentPara = currentPara + ' ' + trimL2;
}
i++; // Move to next line to consider it part of currentPara
} else {
break; // Stop merging
}
}
processedLines.push(currentPara);
i++;
}
let processedText = processedLines.join('\n')
.replace(/\n{3,}/g, '\n\n') // Limit multiple newlines
.substring(0, 50000); // Limit for performance
formattedText = `
<div style="max-width: 800px; margin: 0 auto;">
<h1 style="color: var(--text-white); font-family: 'Inter', sans-serif; margin-bottom: 20px; text-align: center;">${title}</h1>
<div style="margin-bottom: 40px; color: var(--text-muted); font-size: 12px; font-style: italic; text-align: center;">
In-Browser Reader • Project Gutenberg
</div>
<div style="white-space: pre-wrap; font-family: 'Georgia', serif; line-height: 1.8; color: var(--text-light); text-align: justify;">
${processedText}
</div>
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid var(--border-color); color: var(--text-muted); font-size: 11px; text-align: center;">
<p>Preview of book content. Full book available at Project Gutenberg.</p>
${textUrl ? `<p><a href="${textUrl}" target="_blank" style="color: var(--accent-primary);">Continue reading full version</a></p>` : ''}
</div>
</div>
`;
}
readerBody.innerHTML = formattedText;
}
// Load books from API
async function loadBooks(page) {
// Show loading in grid
let loader = window.startTorusLoader(booksGrid);
// Build query parameters
let params = new URLSearchParams();
params.append('page', page);
if (currentFilters.search) params.append('search', currentFilters.search);
if (currentFilters.languages) params.append('languages', currentFilters.languages);
if (currentFilters.copyright) params.append('copyright', currentFilters.copyright);
if (currentFilters.sort) params.append('sort', currentFilters.sort);
if (currentFilters.topic) params.append('topic', currentFilters.topic);
try {
// Fetch books from Gutendex API
const response = await fetch(`https://gutendex.com/books/?${params.toString()}`);
const data = await response.json();
// Update global variables
currentBooks = data.results || [];
totalBooks = data.count || 0;
booksLoaded = currentBooks.length;
// Calculate total pages (32 books per page)
totalPages = Math.ceil(totalBooks / 32) || 1;
// Update UI
updateStats();
renderBooks();
} catch (error) {
console.error('Error fetching books:', error);
booksGrid.innerHTML = `
<div style="grid-column: 1 / -1; text-align: center; padding: 40px; color: var(--accent-danger);">
<i class="fas fa-exclamation-triangle" style="font-size: 32px; margin-bottom: 12px;"></i>
<h3 style="margin-bottom: 8px;">API CONNECTION ERROR</h3>
<p style="color: var(--text-muted); margin-bottom: 16px;">Failed to fetch data from Gutendex API.</p>
<button class="btn" onclick="loadBooks(currentPage)" style="margin-top: 8px;">
<i class="fas fa-redo"></i> RETRY CONNECTION
</button>
</div>
`;
} finally {
if (loader) setTimeout(() => loader.done(), 300);
}
}
// Update statistics display
function updateStats() {
totalBooksEl.textContent = totalBooks.toLocaleString();
booksLoadedEl.textContent = booksLoaded;
currentPageEl.textContent = currentPage;
totalPagesEl.textContent = totalPages;
pageNumEl.textContent = currentPage;
pageTotalEl.textContent = totalPages;
// Update pagination button states
prevPageBtn.disabled = currentPage <= 1;
nextPageBtn.disabled = currentPage >= totalPages;
}
// Render books to the grid
function renderBooks() {
booksGrid.innerHTML = '';
if (currentBooks.length === 0) {
booksGrid.innerHTML = `
<div style="grid-column: 1 / -1; text-align: center; padding: 40px; color: var(--text-muted);">
<i class="fas fa-search" style="font-size: 32px; margin-bottom: 12px;"></i>
<h3 style="margin-bottom: 8px;">NO BOOKS FOUND</h3>
<p>Try adjusting your search criteria or filters.</p>
<div style="margin-top: 16px; font-size: 11px; color: var(--accent-secondary);">
<p>Current search: ${currentFilters.search ? `"${currentFilters.search}"` : ''} ${currentFilters.topic ? `Topic: "${currentFilters.topic}"` : ''}</p>
</div>
</div>
`;
return;
}
currentBooks.forEach(book => {
const bookCard = document.createElement('div');
bookCard.className = 'book-card';
bookCard.dataset.id = book.id;
// Get first author name
const authorName = book.authors.length > 0 ? book.authors[0].name : 'Unknown Author';
// Get first subject
const firstSubject = book.subjects.length > 0 ?
book.subjects[0].split('--')[0].trim() :
'No subjects listed';
// Format languages
const languages = book.languages.map(lang => lang.toUpperCase()).join(', ');
bookCard.innerHTML = `
<div class="book-header">
<div>
<div class="book-title">${book.title}</div>
<div class="book-author">${authorName}</div>
</div>
<div class="book-id">ID: ${book.id}</div>
</div>
<div class="book-body">
<div style="font-size: 11px; color: var(--text-muted); margin-bottom: 10px; line-height: 1.5;">
${book.summaries && book.summaries.length > 0 ?
book.summaries[0].substring(0, 120) + '...' :
firstSubject}
</div>
<div class="book-meta">
<div class="meta-item">
<div class="meta-value">${book.download_count.toLocaleString()}</div>
<div class="meta-label">DOWNLOADS</div>
</div>
<div class="meta-item">
<div class="meta-value">${languages}</div>
<div class="meta-label">LANGUAGE${book.languages.length > 1 ? 'S' : ''}</div>
</div>
<div class="meta-item">
<div class="meta-value">${book.subjects.length}</div>
<div class="meta-label">SUBJECTS</div>
</div>
</div>
</div>
`;
// Add click event to show book details
bookCard.addEventListener('click', () => showBookDetails(book.id));
booksGrid.appendChild(bookCard);
});
}
// Show book details in modal
function showBookDetails(bookId) {
// Find the book in currentBooks
const book = currentBooks.find(b => b.id === bookId);
if (!book) return;
// Update modal title
document.getElementById('modal-title').textContent = book.title;
// Build modal content
const modalBody = document.getElementById('modal-body');
// Format authors
const authorsHtml = book.authors.map(author => {
const years = author.birth_year || author.death_year ?
`(${author.birth_year || '?'} - ${author.death_year || '?'})` : '';
return `<div>${author.name} ${years}</div>`;
}).join('');
// Format subjects
const subjectsHtml = book.subjects.map(subject => {
return `<span class="tag">${subject}</span>`;
}).join('');
// Format bookshelves
const bookshelvesHtml = book.bookshelves.map(shelf => {
return `<span class="tag">${shelf}</span>`;
}).join('');
// Format formats
const formatsHtml = Object.entries(book.formats).slice(0, 6).map(([format, url]) => {
const formatName = format.split(';')[0].split('/')[1] || format.split('/').pop() || format;
return `<a href="${url}" target="_blank" class="tag format-tag">${formatName.toUpperCase()}</a>`;
}).join('');
// Build modal content
modalBody.innerHTML = `
<div class="modal-grid">
<div class="modal-item">
<div class="modal-label">AUTHORS</div>
<div class="modal-value">${authorsHtml || 'Unknown'}</div>
</div>
<div class="modal-item">
<div class="modal-label">PUBLICATION YEAR</div>
<div class="modal-value">${book.authors[0]?.birth_year ? 'Circa ' + book.authors[0].birth_year : 'Unknown'}</div>
</div>
<div class="modal-item">
<div class="modal-label">LANGUAGES</div>
<div class="modal-value">${book.languages.map(l => l.toUpperCase()).join(', ')}</div>
</div>
<div class="modal-item">
<div class="modal-label">DOWNLOADS</div>
<div class="modal-value">${book.download_count.toLocaleString()}</div>
</div>
</div>
${book.summaries && book.summaries.length > 0 ? `
<div class="modal-section">
<div class="modal-section-title">
<i class="fas fa-align-left"></i> SUMMARY
</div>
<div class="modal-value" style="line-height: 1.6;">${book.summaries[0]}</div>
</div>
` : ''}
${book.subjects.length > 0 ? `
<div class="modal-section">
<div class="modal-section-title">
<i class="fas fa-tags"></i> SUBJECTS
</div>
<div class="tags-container">${subjectsHtml}</div>
</div>
` : ''}
${book.bookshelves.length > 0 ? `
<div class="modal-section">
<div class="modal-section-title">
<i class="fas fa-bookmark"></i> BOOKSHELVES
</div>
<div class="tags-container">${bookshelvesHtml}</div>
</div>
` : ''}
<div class="modal-section">
<div class="modal-section-title">
<i class="fas fa-book-reader"></i> IN-APP READER
</div>
<div class="format-buttons">
<button onclick="loadBookInApp(${book.id})" class="app-reader-btn">
<i class="fas fa-book-open"></i> READ IN APP
</button>
<a href="https://www.gutenberg.org/ebooks/${book.id}" target="_blank" class="text-reader-btn">
<i class="fas fa-external-link-alt"></i> VIEW ON GUTENBERG
</a>
</div>
<div style="margin-top: 16px;">
<div class="modal-section-title">
<i class="fas fa-download"></i> AVAILABLE FORMATS
</div>
<div class="tags-container">${formatsHtml}</div>
</div>
</div>
`;
// Show modal
bookModal.style.display = 'block';
}
// Initial load
loadBooks(currentPage);
</script>
</body>
</html>