feat: homepage hero card, progress bars, unread badges, stats, sort, genre chips, recently added
This commit is contained in:
@@ -179,34 +179,79 @@ func (s *Server) respond(w http.ResponseWriter, r *http.Request, title, fragment
|
|||||||
// ─── GET / — book catalogue ───────────────────────────────────────────────────
|
// ─── GET / — book catalogue ───────────────────────────────────────────────────
|
||||||
|
|
||||||
const homeTmpl = `
|
const homeTmpl = `
|
||||||
<div class="max-w-4xl mx-auto px-4 py-10 overflow-hidden" hx-history="false">
|
<style>
|
||||||
<div class="flex items-center justify-between mb-2">
|
.home-progress-bar { transition: width 0.4s ease; }
|
||||||
|
.sort-btn { transition: background 0.15s, color 0.15s; }
|
||||||
|
.sort-btn.active { background: #78350f; color: #fbbf24; }
|
||||||
|
.genre-chip { transition: background 0.15s, color 0.15s, border-color 0.15s; }
|
||||||
|
.genre-chip.active { background: #78350f; color: #fbbf24; border-color: #92400e; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="max-w-4xl mx-auto px-4 py-8 overflow-hidden" hx-history="false">
|
||||||
|
|
||||||
|
<!-- ── Header ── -->
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
<h1 class="text-3xl font-bold text-zinc-100">libnovel</h1>
|
<h1 class="text-3xl font-bold text-zinc-100">libnovel</h1>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<a href="/scrape" hx-get="/scrape" hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML" class="text-sm px-3 py-1.5 rounded-lg text-zinc-400 hover:text-zinc-200 transition-colors">+ Add</a>
|
<a href="/scrape" hx-get="/scrape" hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML"
|
||||||
<a href="/ranking" hx-get="/ranking" hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML" class="text-sm px-3 py-1.5 rounded-lg text-amber-400 hover:text-amber-300 transition-colors">Browse Rankings</a>
|
class="text-sm px-3 py-1.5 rounded-lg text-zinc-400 hover:text-zinc-200 transition-colors">+ Add</a>
|
||||||
|
<a href="/ranking" hx-get="/ranking" hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML"
|
||||||
|
class="text-sm px-3 py-1.5 rounded-lg text-amber-400 hover:text-amber-300 transition-colors">Browse Rankings</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-zinc-400 mb-2">{{len .Books}} book{{if ne (len .Books) 1}}s{{end}} on disk</p>
|
|
||||||
|
|
||||||
<!-- Continue reading section (populated by JS) -->
|
<!-- ── Stats strip ── -->
|
||||||
|
<div id="stats-strip" class="hidden flex items-center gap-4 text-xs text-zinc-500 mb-5 flex-wrap">
|
||||||
|
<span id="stat-books"></span>
|
||||||
|
<span class="text-zinc-700">·</span>
|
||||||
|
<span id="stat-chapters"></span>
|
||||||
|
<span class="text-zinc-700">·</span>
|
||||||
|
<span id="stat-time"></span>
|
||||||
|
<span id="stat-completed-wrap" class="hidden">
|
||||||
|
<span class="text-zinc-700">·</span>
|
||||||
|
<span id="stat-completed"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p id="stats-fallback" class="text-zinc-400 text-sm mb-5">{{len .Books}} book{{if ne (len .Books) 1}}s{{end}} on disk</p>
|
||||||
|
|
||||||
|
<!-- ── Hero: last-read book (populated by JS) ── -->
|
||||||
|
<div id="hero-section" class="hidden mb-6"></div>
|
||||||
|
|
||||||
|
<!-- ── Continue reading (populated by JS) ── -->
|
||||||
<div id="continue-reading-section" class="hidden mb-6">
|
<div id="continue-reading-section" class="hidden mb-6">
|
||||||
<h2 class="text-lg font-semibold text-zinc-200 mb-3">Continue reading</h2>
|
<h2 class="text-sm font-semibold text-zinc-500 uppercase tracking-wider mb-3">Continue reading</h2>
|
||||||
<div id="continue-reading-grid" class="grid gap-4 sm:grid-cols-2"></div>
|
<div id="continue-reading-grid" class="grid gap-3 sm:grid-cols-2"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filter bar -->
|
<!-- ── Recently added (populated by JS) ── -->
|
||||||
<div class="mb-6">
|
<div id="recently-added-section" class="hidden mb-6">
|
||||||
<input id="filter-input" type="search" placeholder="Filter by title, author, genre…"
|
<h2 class="text-sm font-semibold text-zinc-500 uppercase tracking-wider mb-3">Recently added</h2>
|
||||||
autocomplete="off" spellcheck="false"
|
<div id="recently-added-grid" class="grid gap-3 sm:grid-cols-2"></div>
|
||||||
class="w-full rounded-lg bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-amber-500 transition-colors" />
|
|
||||||
<p id="filter-count" class="text-xs text-zinc-500 mt-1 hidden"></p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- All books grid -->
|
<!-- ── Filter + Sort + Genre chips ── -->
|
||||||
|
<div class="mb-4 space-y-3">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input id="filter-input" type="search" placeholder="Filter by title, author, genre…"
|
||||||
|
autocomplete="off" spellcheck="false"
|
||||||
|
class="flex-1 min-w-0 rounded-lg bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-amber-500 transition-colors" />
|
||||||
|
</div>
|
||||||
|
<!-- Sort bar -->
|
||||||
|
<div class="flex gap-1.5 flex-wrap" id="sort-bar">
|
||||||
|
<button class="sort-btn active text-xs px-2.5 py-1 rounded-full border border-zinc-700 bg-zinc-800 text-zinc-300" data-sort="alpha">A–Z</button>
|
||||||
|
<button class="sort-btn text-xs px-2.5 py-1 rounded-full border border-zinc-700 bg-zinc-800 text-zinc-300" data-sort="recent">Recently read</button>
|
||||||
|
<button class="sort-btn text-xs px-2.5 py-1 rounded-full border border-zinc-700 bg-zinc-800 text-zinc-300" data-sort="added">Recently added</button>
|
||||||
|
<button class="sort-btn text-xs px-2.5 py-1 rounded-full border border-zinc-700 bg-zinc-800 text-zinc-300" data-sort="downloaded">Most downloaded</button>
|
||||||
|
</div>
|
||||||
|
<!-- Genre chips (populated by JS) -->
|
||||||
|
<div id="genre-chips" class="flex gap-1.5 flex-wrap hidden"></div>
|
||||||
|
<p id="filter-count" class="text-xs text-zinc-500 hidden"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── All books grid ── -->
|
||||||
<div id="available-section">
|
<div id="available-section">
|
||||||
<h2 id="available-heading" class="text-lg font-semibold text-zinc-200 mb-3{{if eq (len .Books) 0}} hidden{{end}}">Available</h2>
|
<h2 id="available-heading" class="text-sm font-semibold text-zinc-500 uppercase tracking-wider mb-3{{if eq (len .Books) 0}} hidden{{end}}">All books</h2>
|
||||||
<div id="books-grid" class="grid gap-4 sm:grid-cols-2">
|
<div id="books-grid" class="grid gap-3 sm:grid-cols-2">
|
||||||
{{range .Books}}
|
{{range .Books}}
|
||||||
<a href="/books/{{.Slug}}"
|
<a href="/books/{{.Slug}}"
|
||||||
hx-get="/books/{{.Slug}}"
|
hx-get="/books/{{.Slug}}"
|
||||||
@@ -214,19 +259,33 @@ const homeTmpl = `
|
|||||||
hx-push-url="true"
|
hx-push-url="true"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
data-slug="{{.Slug}}"
|
data-slug="{{.Slug}}"
|
||||||
|
data-title="{{.Title}}"
|
||||||
|
data-downloaded="{{.Downloaded}}"
|
||||||
|
data-added="{{.AddedAt}}"
|
||||||
|
data-total="{{.TotalChapters}}"
|
||||||
|
data-cover="{{.Cover}}"
|
||||||
data-filter="{{.Title}} {{.Author}} {{.Status}} {{range .Genres}}{{.}} {{end}}"
|
data-filter="{{.Title}} {{.Author}} {{.Status}} {{range .Genres}}{{.}} {{end}}"
|
||||||
class="book-card group block rounded-xl border border-zinc-800 bg-zinc-900 p-5 hover:border-amber-500 transition-colors cursor-pointer min-w-0 overflow-hidden">
|
data-genres="{{range .Genres}}{{.}}|{{end}}"
|
||||||
<div class="flex gap-4">
|
class="book-card group block rounded-xl border border-zinc-800 bg-zinc-900 p-4 hover:border-amber-500 transition-colors cursor-pointer min-w-0 overflow-hidden">
|
||||||
|
<div class="flex gap-3">
|
||||||
{{if .Cover}}
|
{{if .Cover}}
|
||||||
<img src="{{.Cover}}" alt="cover" class="w-14 h-20 object-cover rounded flex-shrink-0">
|
<img src="{{.Cover}}" alt="cover" loading="lazy" class="w-12 h-[4.5rem] object-cover rounded flex-shrink-0">
|
||||||
{{end}}
|
{{end}}
|
||||||
<div class="min-w-0">
|
<div class="min-w-0 flex-1">
|
||||||
<h2 class="font-semibold text-zinc-100 group-hover:text-amber-400 truncate">{{.Title}}</h2>
|
<h2 class="font-semibold text-zinc-100 group-hover:text-amber-400 truncate text-sm leading-snug">{{.Title}}</h2>
|
||||||
{{if .Author}}<p class="text-sm text-zinc-400 mt-0.5">{{.Author}}</p>{{end}}
|
{{if .Author}}<p class="text-xs text-zinc-500 mt-0.5 truncate">{{.Author}}</p>{{end}}
|
||||||
<div class="flex gap-2 mt-2 flex-wrap">
|
<!-- progress bar injected by JS -->
|
||||||
{{if .Status}}<span class="text-xs px-2 py-0.5 rounded-full bg-zinc-800 text-zinc-300">{{.Status}}</span>{{end}}
|
<div class="book-progress-wrap hidden mt-2">
|
||||||
{{if .TotalChapters}}<span class="text-xs px-2 py-0.5 rounded-full bg-zinc-800 text-zinc-300">{{.TotalChapters}} ch</span>{{end}}
|
<div class="h-0.5 rounded-full bg-zinc-700 overflow-hidden">
|
||||||
{{if .Downloaded}}<span class="text-xs px-2 py-0.5 rounded-full bg-amber-900 text-amber-300">{{.Downloaded}} downloaded</span>{{end}}
|
<div class="book-progress-bar home-progress-bar h-full bg-amber-500 rounded-full" style="width:0%"></div>
|
||||||
|
</div>
|
||||||
|
<p class="book-progress-label text-[0.65rem] text-zinc-500 mt-0.5"></p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1.5 mt-2 flex-wrap items-center">
|
||||||
|
{{if .Status}}<span class="text-[0.65rem] px-1.5 py-0.5 rounded-full bg-zinc-800 text-zinc-400">{{.Status}}</span>{{end}}
|
||||||
|
{{if .TotalChapters}}<span class="text-[0.65rem] px-1.5 py-0.5 rounded-full bg-zinc-800 text-zinc-400">{{.TotalChapters}} ch</span>{{end}}
|
||||||
|
{{if .Downloaded}}<span class="text-[0.65rem] px-1.5 py-0.5 rounded-full bg-amber-900/60 text-amber-400">{{.Downloaded}} dl</span>{{end}}
|
||||||
|
<span class="book-unread-badge hidden text-[0.65rem] px-1.5 py-0.5 rounded-full bg-teal-900 text-teal-300 font-medium"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -240,118 +299,241 @@ const homeTmpl = `
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
/* ── continue-reading ──────────────────────────────────────────────────── */
|
/* ── state ─────────────────────────────────────────────────────────────── */
|
||||||
var progress = {};
|
var progress = {};
|
||||||
try { progress = JSON.parse(localStorage.getItem('reading_progress') || '{}'); } catch(_) {}
|
try { progress = JSON.parse(localStorage.getItem('reading_progress') || '{}'); } catch(_) {}
|
||||||
|
// progress = { slug: chapterNum, ... }
|
||||||
|
|
||||||
var inProgress = Object.keys(progress);
|
var progressTs = {};
|
||||||
var continueSection = document.getElementById('continue-reading-section');
|
try { progressTs = JSON.parse(localStorage.getItem('reading_progress_ts') || '{}'); } catch(_) {}
|
||||||
var continueGrid = document.getElementById('continue-reading-grid');
|
// progressTs = { slug: timestampMs, ... }
|
||||||
|
|
||||||
// Always clear before re-populating so back-navigation never duplicates cards.
|
var booksGrid = document.getElementById('books-grid');
|
||||||
if (continueGrid) continueGrid.innerHTML = '';
|
var continueSection = document.getElementById('continue-reading-section');
|
||||||
|
var continueGrid = document.getElementById('continue-reading-grid');
|
||||||
|
var recentlyAddedSec = document.getElementById('recently-added-section');
|
||||||
|
var recentlyAddedGrid = document.getElementById('recently-added-grid');
|
||||||
|
var heroSection = document.getElementById('hero-section');
|
||||||
|
var statsStrip = document.getElementById('stats-strip');
|
||||||
|
var statsFallback = document.getElementById('stats-fallback');
|
||||||
|
var genreChipsEl = document.getElementById('genre-chips');
|
||||||
|
|
||||||
if (inProgress.length > 0 && continueGrid) {
|
var allCards = booksGrid ? Array.from(booksGrid.querySelectorAll('[data-slug]')) : [];
|
||||||
var moved = 0;
|
|
||||||
inProgress.forEach(function(slug) {
|
|
||||||
// Find the source card in #books-grid by data-slug.
|
|
||||||
var card = document.querySelector('#books-grid [data-slug="' + slug + '"]');
|
|
||||||
if (!card) return;
|
|
||||||
|
|
||||||
var chapterNum = progress[slug];
|
/* ── reading_progress_ts: record timestamps for "recently read" sort ───── */
|
||||||
|
// On first load after upgrade, seed timestamps for existing progress entries.
|
||||||
|
var tsUpdated = false;
|
||||||
|
Object.keys(progress).forEach(function(slug) {
|
||||||
|
if (!progressTs[slug]) { progressTs[slug] = Date.now(); tsUpdated = true; }
|
||||||
|
});
|
||||||
|
if (tsUpdated) {
|
||||||
|
try { localStorage.setItem('reading_progress_ts', JSON.stringify(progressTs)); } catch(_) {}
|
||||||
|
}
|
||||||
|
|
||||||
// Build a fresh card element rather than cloning + mutating,
|
/* ── progress bars + unread badges ─────────────────────────────────────── */
|
||||||
// so repeated back-navigations cannot accumulate injected nodes.
|
allCards.forEach(function(card) {
|
||||||
var a = document.createElement('a');
|
var slug = card.dataset.slug;
|
||||||
a.href = card.getAttribute('href');
|
var total = parseInt(card.dataset.total, 10) || 0;
|
||||||
a.setAttribute('hx-get', card.getAttribute('hx-get'));
|
var downloaded = parseInt(card.dataset.downloaded, 10) || 0;
|
||||||
a.setAttribute('hx-target', card.getAttribute('hx-target'));
|
var chapterNum = progress[slug] ? parseInt(progress[slug], 10) : 0;
|
||||||
a.setAttribute('hx-push-url', card.getAttribute('hx-push-url'));
|
var base = downloaded || total;
|
||||||
a.setAttribute('hx-swap', card.getAttribute('hx-swap'));
|
|
||||||
a.setAttribute('data-slug', slug);
|
|
||||||
a.className = card.className;
|
|
||||||
// Copy inner HTML then inject the chapter-progress line.
|
|
||||||
a.innerHTML = card.innerHTML;
|
|
||||||
if (chapterNum) {
|
|
||||||
var meta = a.querySelector('.min-w-0');
|
|
||||||
if (meta) {
|
|
||||||
// Remove any previously injected progress line (defensive).
|
|
||||||
var old = meta.querySelector('.chapter-progress-line');
|
|
||||||
if (old) old.parentNode.removeChild(old);
|
|
||||||
|
|
||||||
var chLine = document.createElement('p');
|
if (chapterNum > 0 && base > 0) {
|
||||||
chLine.className = 'chapter-progress-line text-xs text-amber-400 mt-1';
|
var pct = Math.min(100, Math.round(chapterNum / base * 100));
|
||||||
chLine.textContent = 'Chapter ' + chapterNum;
|
var wrap = card.querySelector('.book-progress-wrap');
|
||||||
var author = meta.querySelector('p');
|
var bar = card.querySelector('.book-progress-bar');
|
||||||
if (author) author.insertAdjacentElement('afterend', chLine);
|
var lbl = card.querySelector('.book-progress-label');
|
||||||
else {
|
if (wrap && bar && lbl) {
|
||||||
var title = meta.querySelector('h2');
|
bar.style.width = pct + '%';
|
||||||
if (title) title.insertAdjacentElement('afterend', chLine);
|
lbl.textContent = 'Ch.\u00a0' + chapterNum + (base ? '\u00a0/\u00a0' + base : '') + '\u00a0\u2014\u00a0' + pct + '%';
|
||||||
}
|
wrap.classList.remove('hidden');
|
||||||
}
|
|
||||||
}
|
}
|
||||||
htmx.process(a);
|
}
|
||||||
|
|
||||||
// Add dismiss button (swipe-left to remove from continue reading).
|
// Unread badge: downloaded > last-read chapter
|
||||||
var dismissBtn = document.createElement('button');
|
if (downloaded > 0 && chapterNum > 0 && downloaded > chapterNum) {
|
||||||
dismissBtn.type = 'button';
|
var unread = downloaded - chapterNum;
|
||||||
dismissBtn.title = 'Remove from continue reading';
|
var badge = card.querySelector('.book-unread-badge');
|
||||||
dismissBtn.className = 'dismiss-btn absolute top-2 right-2 z-10 flex items-center justify-center w-6 h-6 rounded-full bg-zinc-700 hover:bg-zinc-600 text-zinc-400 hover:text-zinc-100 transition-colors opacity-0 group-hover:opacity-100';
|
if (badge) {
|
||||||
dismissBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>';
|
badge.textContent = '+' + unread + ' new';
|
||||||
dismissBtn.addEventListener('click', function(e) {
|
badge.classList.remove('hidden');
|
||||||
e.preventDefault();
|
}
|
||||||
e.stopPropagation();
|
}
|
||||||
var cardEl = this.closest('[data-slug]');
|
});
|
||||||
var cardSlug = cardEl ? cardEl.dataset.slug : null;
|
|
||||||
if (cardSlug) {
|
|
||||||
var p = {};
|
|
||||||
try { p = JSON.parse(localStorage.getItem('reading_progress') || '{}'); } catch(_) {}
|
|
||||||
delete p[cardSlug];
|
|
||||||
localStorage.setItem('reading_progress', JSON.stringify(p));
|
|
||||||
}
|
|
||||||
// Slide left and fade out, then remove.
|
|
||||||
var target = cardEl || this.parentElement;
|
|
||||||
target.style.transition = 'transform 0.25s ease, opacity 0.25s ease';
|
|
||||||
target.style.transform = 'translateX(-60px)';
|
|
||||||
target.style.opacity = '0';
|
|
||||||
setTimeout(function() {
|
|
||||||
target.remove();
|
|
||||||
// Hide section if no cards remain.
|
|
||||||
var remaining = continueGrid.querySelectorAll('[data-slug]');
|
|
||||||
if (remaining.length === 0) {
|
|
||||||
continueSection.classList.add('hidden');
|
|
||||||
}
|
|
||||||
}, 260);
|
|
||||||
});
|
|
||||||
// Make card position:relative so the absolute button positions correctly.
|
|
||||||
a.style.position = 'relative';
|
|
||||||
a.appendChild(dismissBtn);
|
|
||||||
|
|
||||||
continueGrid.appendChild(a);
|
/* ── stats strip ─────────────────────────────────────────────────────────── */
|
||||||
// Keep the original card visible in #books-grid — do not hide it.
|
var totalChaptersRead = 0;
|
||||||
// Books should always appear in Available regardless of reading progress.
|
var booksCompleted = 0;
|
||||||
moved++;
|
Object.keys(progress).forEach(function(slug) {
|
||||||
});
|
var card = booksGrid ? booksGrid.querySelector('[data-slug="' + slug + '"]') : null;
|
||||||
if (moved > 0) {
|
var ch = parseInt(progress[slug], 10) || 0;
|
||||||
continueSection.classList.remove('hidden');
|
totalChaptersRead += ch;
|
||||||
|
if (card) {
|
||||||
|
var total = parseInt(card.dataset.total, 10) || 0;
|
||||||
|
var dl = parseInt(card.dataset.downloaded, 10) || 0;
|
||||||
|
var base = dl || total;
|
||||||
|
if (base > 0 && ch >= base) booksCompleted++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (totalChaptersRead > 0) {
|
||||||
|
var estMins = Math.round(totalChaptersRead * 12); // ~12 min/chapter avg
|
||||||
|
var timeLabel = estMins >= 60
|
||||||
|
? Math.floor(estMins / 60) + 'h ' + (estMins % 60) + 'm'
|
||||||
|
: estMins + 'm';
|
||||||
|
document.getElementById('stat-books').textContent = allCards.length + ' book' + (allCards.length !== 1 ? 's' : '');
|
||||||
|
document.getElementById('stat-chapters').textContent = totalChaptersRead + ' ch read';
|
||||||
|
document.getElementById('stat-time').textContent = '~' + timeLabel + ' read';
|
||||||
|
if (booksCompleted > 0) {
|
||||||
|
document.getElementById('stat-completed').textContent = booksCompleted + ' completed';
|
||||||
|
document.getElementById('stat-completed-wrap').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
statsStrip.classList.remove('hidden');
|
||||||
|
statsStrip.style.display = 'flex';
|
||||||
|
if (statsFallback) statsFallback.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── hero card: most recently read ─────────────────────────────────────── */
|
||||||
|
var heroSlug = null, heroTs = 0;
|
||||||
|
Object.keys(progressTs).forEach(function(slug) {
|
||||||
|
if (progressTs[slug] > heroTs && progress[slug]) {
|
||||||
|
heroTs = progressTs[slug]; heroSlug = slug;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (heroSlug && heroSection) {
|
||||||
|
var heroCard = booksGrid ? booksGrid.querySelector('[data-slug="' + heroSlug + '"]') : null;
|
||||||
|
if (heroCard) {
|
||||||
|
var heroChapter = parseInt(progress[heroSlug], 10) || 0;
|
||||||
|
var heroTotal = parseInt(heroCard.dataset.total, 10) || parseInt(heroCard.dataset.downloaded, 10) || 0;
|
||||||
|
var heroPct = heroTotal > 0 ? Math.min(100, Math.round(heroChapter / heroTotal * 100)) : 0;
|
||||||
|
var heroCover = heroCard.dataset.cover || '';
|
||||||
|
var heroTitle = heroCard.dataset.title || '';
|
||||||
|
var heroHref = heroCard.getAttribute('href');
|
||||||
|
var heroGet = heroCard.getAttribute('hx-get');
|
||||||
|
|
||||||
|
heroSection.innerHTML =
|
||||||
|
'<a href="' + heroHref + '"' +
|
||||||
|
' hx-get="' + heroGet + '" hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML"' +
|
||||||
|
' class="group relative block rounded-2xl overflow-hidden border border-zinc-700 hover:border-amber-500 transition-colors cursor-pointer"' +
|
||||||
|
' style="' + (heroCover ? 'background-image:linear-gradient(to right,#09090b 40%,rgba(9,9,11,0.4)),url(' + heroCover + ');background-size:cover;background-position:right center;' : 'background:#18181b;') + '">' +
|
||||||
|
'<div class="relative z-10 p-5 sm:p-6">' +
|
||||||
|
'<p class="text-[0.65rem] font-semibold uppercase tracking-widest text-amber-500 mb-1">Continue reading</p>' +
|
||||||
|
'<h2 class="text-xl sm:text-2xl font-bold text-zinc-100 group-hover:text-amber-400 transition-colors leading-snug mb-2 max-w-sm">' + escHtml(heroTitle) + '</h2>' +
|
||||||
|
'<p class="text-sm text-zinc-400 mb-4">Chapter\u00a0' + heroChapter + (heroTotal ? '\u00a0/\u00a0' + heroTotal : '') + (heroPct ? '\u00a0\u2014\u00a0' + heroPct + '%' : '') + '</p>' +
|
||||||
|
'<div class="w-48 h-1 rounded-full bg-zinc-700 overflow-hidden mb-4">' +
|
||||||
|
'<div class="h-full bg-amber-500 rounded-full" style="width:' + heroPct + '%"></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<span class="inline-block text-sm font-semibold px-4 py-2 rounded-xl bg-amber-500 text-zinc-900 group-hover:bg-amber-400 transition-colors">Resume</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'</a>';
|
||||||
|
heroSection.classList.remove('hidden');
|
||||||
|
htmx.process(heroSection);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── filter ────────────────────────────────────────────────────────────── */
|
/* ── continue reading (excluding hero) ──────────────────────────────────── */
|
||||||
var input = document.getElementById('filter-input');
|
if (continueGrid) continueGrid.innerHTML = '';
|
||||||
var countEl = document.getElementById('filter-count');
|
var inProgress = Object.keys(progress).sort(function(a,b) {
|
||||||
var booksGrid = document.getElementById('books-grid');
|
return (progressTs[b]||0) - (progressTs[a]||0);
|
||||||
|
});
|
||||||
|
var moved = 0;
|
||||||
|
inProgress.forEach(function(slug) {
|
||||||
|
if (slug === heroSlug) return; // hero already shown
|
||||||
|
var card = booksGrid ? booksGrid.querySelector('[data-slug="' + slug + '"]') : null;
|
||||||
|
if (!card) return;
|
||||||
|
var a = buildContinueCard(card, progress[slug]);
|
||||||
|
continueGrid.appendChild(a);
|
||||||
|
moved++;
|
||||||
|
});
|
||||||
|
if (moved > 0) continueSection.classList.remove('hidden');
|
||||||
|
|
||||||
function filterCards() {
|
/* ── recently added (top 4, excluding books already in continue reading) ── */
|
||||||
var q = input.value.trim().toLowerCase();
|
if (recentlyAddedGrid) recentlyAddedGrid.innerHTML = '';
|
||||||
var cards = booksGrid ? booksGrid.querySelectorAll('[data-filter]') : [];
|
var progressSlugs = new Set(Object.keys(progress));
|
||||||
|
var byAdded = allCards.slice().sort(function(a, b) {
|
||||||
|
return (parseInt(b.dataset.added,10)||0) - (parseInt(a.dataset.added,10)||0);
|
||||||
|
});
|
||||||
|
var addedShown = 0;
|
||||||
|
byAdded.forEach(function(card) {
|
||||||
|
if (addedShown >= 4) return;
|
||||||
|
if (progressSlugs.has(card.dataset.slug)) return; // already in continue reading
|
||||||
|
recentlyAddedGrid.appendChild(buildSimpleCard(card));
|
||||||
|
addedShown++;
|
||||||
|
});
|
||||||
|
if (addedShown > 0) recentlyAddedSec.classList.remove('hidden');
|
||||||
|
|
||||||
|
/* ── genre chips ──────────────────────────────────────────────────────── */
|
||||||
|
var allGenres = {};
|
||||||
|
allCards.forEach(function(card) {
|
||||||
|
(card.dataset.genres || '').split('|').forEach(function(g) {
|
||||||
|
g = g.trim();
|
||||||
|
if (g) allGenres[g] = (allGenres[g] || 0) + 1;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
var sortedGenres = Object.keys(allGenres).sort(function(a,b) { return allGenres[b]-allGenres[a]; }).slice(0, 12);
|
||||||
|
var activeGenres = new Set();
|
||||||
|
if (sortedGenres.length > 1 && genreChipsEl) {
|
||||||
|
sortedGenres.forEach(function(g) {
|
||||||
|
var btn = document.createElement('button');
|
||||||
|
btn.className = 'genre-chip text-xs px-2.5 py-1 rounded-full border border-zinc-700 bg-zinc-800 text-zinc-400';
|
||||||
|
btn.textContent = g;
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
if (activeGenres.has(g)) { activeGenres.delete(g); btn.classList.remove('active'); }
|
||||||
|
else { activeGenres.add(g); btn.classList.add('active'); }
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
genreChipsEl.appendChild(btn);
|
||||||
|
});
|
||||||
|
genreChipsEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── sort ─────────────────────────────────────────────────────────────── */
|
||||||
|
var currentSort = 'alpha';
|
||||||
|
document.querySelectorAll('.sort-btn').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
document.querySelectorAll('.sort-btn').forEach(function(b) { b.classList.remove('active'); });
|
||||||
|
btn.classList.add('active');
|
||||||
|
currentSort = btn.dataset.sort;
|
||||||
|
applySortAndFilter();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── filter ───────────────────────────────────────────────────────────── */
|
||||||
|
var input = document.getElementById('filter-input');
|
||||||
|
var countEl = document.getElementById('filter-count');
|
||||||
|
if (input) input.addEventListener('input', applyFilters);
|
||||||
|
|
||||||
|
function applyFilters() { applySortAndFilter(); }
|
||||||
|
|
||||||
|
function applySortAndFilter() {
|
||||||
|
var q = input ? input.value.trim().toLowerCase() : '';
|
||||||
|
// Sort cards
|
||||||
|
var sorted = allCards.slice().sort(function(a, b) {
|
||||||
|
if (currentSort === 'alpha') return (a.dataset.title||'').localeCompare(b.dataset.title||'');
|
||||||
|
if (currentSort === 'downloaded') return (parseInt(b.dataset.downloaded,10)||0) - (parseInt(a.dataset.downloaded,10)||0);
|
||||||
|
if (currentSort === 'added') return (parseInt(b.dataset.added,10)||0) - (parseInt(a.dataset.added,10)||0);
|
||||||
|
if (currentSort === 'recent') {
|
||||||
|
var ta = progressTs[a.dataset.slug] || 0;
|
||||||
|
var tb = progressTs[b.dataset.slug] || 0;
|
||||||
|
if (tb !== ta) return tb - ta;
|
||||||
|
return (a.dataset.title||'').localeCompare(b.dataset.title||'');
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
// Re-append in sorted order, apply visibility
|
||||||
var shown = 0;
|
var shown = 0;
|
||||||
cards.forEach(function(card) {
|
sorted.forEach(function(card) {
|
||||||
var match = !q || card.dataset.filter.toLowerCase().indexOf(q) !== -1;
|
var matchText = !q || card.dataset.filter.toLowerCase().indexOf(q) !== -1;
|
||||||
|
var matchGenre = activeGenres.size === 0 || (function() {
|
||||||
|
var cardGenres = (card.dataset.genres||'').split('|').map(function(g){return g.trim();});
|
||||||
|
return Array.from(activeGenres).every(function(g) { return cardGenres.indexOf(g) !== -1; });
|
||||||
|
})();
|
||||||
|
var match = matchText && matchGenre;
|
||||||
card.style.display = match ? '' : 'none';
|
card.style.display = match ? '' : 'none';
|
||||||
if (match) shown++;
|
if (match) shown++;
|
||||||
|
booksGrid.appendChild(card); // re-order in DOM
|
||||||
});
|
});
|
||||||
if (q && countEl) {
|
if ((q || activeGenres.size > 0) && countEl) {
|
||||||
countEl.textContent = shown + ' result' + (shown !== 1 ? 's' : '');
|
countEl.textContent = shown + ' result' + (shown !== 1 ? 's' : '');
|
||||||
countEl.classList.remove('hidden');
|
countEl.classList.remove('hidden');
|
||||||
} else if (countEl) {
|
} else if (countEl) {
|
||||||
@@ -359,15 +541,85 @@ const homeTmpl = `
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input) input.addEventListener('input', filterCards);
|
/* ── helpers ──────────────────────────────────────────────────────────── */
|
||||||
|
function escHtml(s) {
|
||||||
|
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSimpleCard(card) {
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = card.getAttribute('href');
|
||||||
|
a.setAttribute('hx-get', card.getAttribute('hx-get'));
|
||||||
|
a.setAttribute('hx-target', card.getAttribute('hx-target'));
|
||||||
|
a.setAttribute('hx-push-url', card.getAttribute('hx-push-url'));
|
||||||
|
a.setAttribute('hx-swap', card.getAttribute('hx-swap'));
|
||||||
|
a.setAttribute('data-slug', card.dataset.slug);
|
||||||
|
a.className = card.className;
|
||||||
|
a.innerHTML = card.innerHTML;
|
||||||
|
htmx.process(a);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildContinueCard(card, chapterNum) {
|
||||||
|
var a = buildSimpleCard(card);
|
||||||
|
if (chapterNum) {
|
||||||
|
var meta = a.querySelector('.min-w-0');
|
||||||
|
if (meta) {
|
||||||
|
var old = meta.querySelector('.chapter-progress-line');
|
||||||
|
if (old) old.parentNode.removeChild(old);
|
||||||
|
var chLine = document.createElement('p');
|
||||||
|
chLine.className = 'chapter-progress-line text-xs text-amber-400 mt-1';
|
||||||
|
chLine.textContent = 'Chapter ' + chapterNum;
|
||||||
|
var author = meta.querySelector('p');
|
||||||
|
if (author) author.insertAdjacentElement('afterend', chLine);
|
||||||
|
else {
|
||||||
|
var title = meta.querySelector('h2');
|
||||||
|
if (title) title.insertAdjacentElement('afterend', chLine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// dismiss button
|
||||||
|
var dismissBtn = document.createElement('button');
|
||||||
|
dismissBtn.type = 'button';
|
||||||
|
dismissBtn.title = 'Remove from continue reading';
|
||||||
|
dismissBtn.className = 'dismiss-btn absolute top-2 right-2 z-10 flex items-center justify-center w-6 h-6 rounded-full bg-zinc-700 hover:bg-zinc-600 text-zinc-400 hover:text-zinc-100 transition-colors opacity-0 group-hover:opacity-100';
|
||||||
|
dismissBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>';
|
||||||
|
dismissBtn.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
var cardEl = this.closest('[data-slug]');
|
||||||
|
var cardSlug = cardEl ? cardEl.dataset.slug : null;
|
||||||
|
if (cardSlug) {
|
||||||
|
var p = {}; try { p = JSON.parse(localStorage.getItem('reading_progress') || '{}'); } catch(_) {}
|
||||||
|
delete p[cardSlug];
|
||||||
|
localStorage.setItem('reading_progress', JSON.stringify(p));
|
||||||
|
var ts = {}; try { ts = JSON.parse(localStorage.getItem('reading_progress_ts') || '{}'); } catch(_) {}
|
||||||
|
delete ts[cardSlug];
|
||||||
|
localStorage.setItem('reading_progress_ts', JSON.stringify(ts));
|
||||||
|
}
|
||||||
|
var target = cardEl || this.parentElement;
|
||||||
|
target.style.transition = 'transform 0.25s ease, opacity 0.25s ease';
|
||||||
|
target.style.transform = 'translateX(-60px)';
|
||||||
|
target.style.opacity = '0';
|
||||||
|
setTimeout(function() {
|
||||||
|
target.remove();
|
||||||
|
var remaining = continueGrid.querySelectorAll('[data-slug]');
|
||||||
|
if (remaining.length === 0) continueSection.classList.add('hidden');
|
||||||
|
}, 260);
|
||||||
|
});
|
||||||
|
a.style.position = 'relative';
|
||||||
|
a.appendChild(dismissBtn);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
}());
|
}());
|
||||||
</script>`
|
</script>`
|
||||||
|
|
||||||
// homeBookItem wraps BookMeta with the count of chapters already on disk.
|
// homeBookItem wraps BookMeta with the count of chapters already on disk
|
||||||
|
// and the Unix timestamp of when the book was added (metadata.yaml mtime).
|
||||||
type homeBookItem struct {
|
type homeBookItem struct {
|
||||||
scraper.BookMeta
|
scraper.BookMeta
|
||||||
Downloaded int
|
Downloaded int
|
||||||
|
AddedAt int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -387,6 +639,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
|||||||
items[i] = homeBookItem{
|
items[i] = homeBookItem{
|
||||||
BookMeta: b,
|
BookMeta: b,
|
||||||
Downloaded: s.writer.CountChapters(b.Slug),
|
Downloaded: s.writer.CountChapters(b.Slug),
|
||||||
|
AddedAt: s.writer.MetadataMtime(b.Slug),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2195,6 +2448,9 @@ const chapterTmpl = `
|
|||||||
var p = JSON.parse(localStorage.getItem('reading_progress') || '{}');
|
var p = JSON.parse(localStorage.getItem('reading_progress') || '{}');
|
||||||
p[SLUG] = CHAPTER_N;
|
p[SLUG] = CHAPTER_N;
|
||||||
localStorage.setItem('reading_progress', JSON.stringify(p));
|
localStorage.setItem('reading_progress', JSON.stringify(p));
|
||||||
|
var ts = JSON.parse(localStorage.getItem('reading_progress_ts') || '{}');
|
||||||
|
ts[SLUG] = Date.now();
|
||||||
|
localStorage.setItem('reading_progress_ts', JSON.stringify(ts));
|
||||||
} catch(_) {}
|
} catch(_) {}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,17 @@ func (w *Writer) ReadMetadata(slug string) (scraper.BookMeta, bool, error) {
|
|||||||
return meta, true, nil
|
return meta, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MetadataMtime returns the modification time (Unix seconds) of the
|
||||||
|
// metadata.yaml file for slug, or 0 if the file cannot be stat'd.
|
||||||
|
func (w *Writer) MetadataMtime(slug string) int64 {
|
||||||
|
path := filepath.Join(w.bookDir(slug), "metadata.yaml")
|
||||||
|
fi, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return fi.ModTime().Unix()
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Chapters ─────────────────────────────────────────────────────────────────
|
// ─── Chapters ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// ChapterExists returns true if the markdown file for ref already exists on disk.
|
// ChapterExists returns true if the markdown file for ref already exists on disk.
|
||||||
|
|||||||
Reference in New Issue
Block a user