feat: homepage hero card, progress bars, unread badges, stats, sort, genre chips, recently added
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled

This commit is contained in:
Admin
2026-03-02 11:31:31 +05:00
parent d9c69a089c
commit 1caa1f7ab5
2 changed files with 389 additions and 122 deletions

View File

@@ -179,34 +179,79 @@ func (s *Server) respond(w http.ResponseWriter, r *http.Request, title, fragment
// ─── GET / — book catalogue ───────────────────────────────────────────────────
const homeTmpl = `
<div class="max-w-4xl mx-auto px-4 py-10 overflow-hidden" hx-history="false">
<div class="flex items-center justify-between mb-2">
<style>
.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>
<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="/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>
<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="/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>
<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">
<h2 class="text-lg font-semibold text-zinc-200 mb-3">Continue reading</h2>
<div id="continue-reading-grid" class="grid gap-4 sm:grid-cols-2"></div>
<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-3 sm:grid-cols-2"></div>
</div>
<!-- Filter bar -->
<div class="mb-6">
<input id="filter-input" type="search" placeholder="Filter by title, author, genre…"
autocomplete="off" spellcheck="false"
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>
<!-- ── Recently added (populated by JS) ── -->
<div id="recently-added-section" class="hidden mb-6">
<h2 class="text-sm font-semibold text-zinc-500 uppercase tracking-wider mb-3">Recently added</h2>
<div id="recently-added-grid" class="grid gap-3 sm:grid-cols-2"></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">AZ</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">
<h2 id="available-heading" class="text-lg font-semibold text-zinc-200 mb-3{{if eq (len .Books) 0}} hidden{{end}}">Available</h2>
<div id="books-grid" class="grid gap-4 sm:grid-cols-2">
<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-3 sm:grid-cols-2">
{{range .Books}}
<a href="/books/{{.Slug}}"
hx-get="/books/{{.Slug}}"
@@ -214,19 +259,33 @@ const homeTmpl = `
hx-push-url="true"
hx-swap="innerHTML"
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}}"
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">
<div class="flex gap-4">
data-genres="{{range .Genres}}{{.}}|{{end}}"
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}}
<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}}
<div class="min-w-0">
<h2 class="font-semibold text-zinc-100 group-hover:text-amber-400 truncate">{{.Title}}</h2>
{{if .Author}}<p class="text-sm text-zinc-400 mt-0.5">{{.Author}}</p>{{end}}
<div class="flex gap-2 mt-2 flex-wrap">
{{if .Status}}<span class="text-xs px-2 py-0.5 rounded-full bg-zinc-800 text-zinc-300">{{.Status}}</span>{{end}}
{{if .TotalChapters}}<span class="text-xs px-2 py-0.5 rounded-full bg-zinc-800 text-zinc-300">{{.TotalChapters}} ch</span>{{end}}
{{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="min-w-0 flex-1">
<h2 class="font-semibold text-zinc-100 group-hover:text-amber-400 truncate text-sm leading-snug">{{.Title}}</h2>
{{if .Author}}<p class="text-xs text-zinc-500 mt-0.5 truncate">{{.Author}}</p>{{end}}
<!-- progress bar injected by JS -->
<div class="book-progress-wrap hidden mt-2">
<div class="h-0.5 rounded-full bg-zinc-700 overflow-hidden">
<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>
@@ -240,118 +299,241 @@ const homeTmpl = `
<script>
(function () {
/* ── continue-reading ──────────────────────────────────────────────────── */
/* ── state ─────────────────────────────────────────────────────────────── */
var progress = {};
try { progress = JSON.parse(localStorage.getItem('reading_progress') || '{}'); } catch(_) {}
// progress = { slug: chapterNum, ... }
var inProgress = Object.keys(progress);
var continueSection = document.getElementById('continue-reading-section');
var continueGrid = document.getElementById('continue-reading-grid');
var progressTs = {};
try { progressTs = JSON.parse(localStorage.getItem('reading_progress_ts') || '{}'); } catch(_) {}
// progressTs = { slug: timestampMs, ... }
// Always clear before re-populating so back-navigation never duplicates cards.
if (continueGrid) continueGrid.innerHTML = '';
var booksGrid = document.getElementById('books-grid');
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 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 allCards = booksGrid ? Array.from(booksGrid.querySelectorAll('[data-slug]')) : [];
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,
// so repeated back-navigations cannot accumulate injected nodes.
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', 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);
/* ── progress bars + unread badges ─────────────────────────────────────── */
allCards.forEach(function(card) {
var slug = card.dataset.slug;
var total = parseInt(card.dataset.total, 10) || 0;
var downloaded = parseInt(card.dataset.downloaded, 10) || 0;
var chapterNum = progress[slug] ? parseInt(progress[slug], 10) : 0;
var base = downloaded || total;
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);
}
}
if (chapterNum > 0 && base > 0) {
var pct = Math.min(100, Math.round(chapterNum / base * 100));
var wrap = card.querySelector('.book-progress-wrap');
var bar = card.querySelector('.book-progress-bar');
var lbl = card.querySelector('.book-progress-label');
if (wrap && bar && lbl) {
bar.style.width = pct + '%';
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).
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));
}
// 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);
// Unread badge: downloaded > last-read chapter
if (downloaded > 0 && chapterNum > 0 && downloaded > chapterNum) {
var unread = downloaded - chapterNum;
var badge = card.querySelector('.book-unread-badge');
if (badge) {
badge.textContent = '+' + unread + ' new';
badge.classList.remove('hidden');
}
}
});
continueGrid.appendChild(a);
// Keep the original card visible in #books-grid — do not hide it.
// Books should always appear in Available regardless of reading progress.
moved++;
});
if (moved > 0) {
continueSection.classList.remove('hidden');
/* ── stats strip ─────────────────────────────────────────────────────────── */
var totalChaptersRead = 0;
var booksCompleted = 0;
Object.keys(progress).forEach(function(slug) {
var card = booksGrid ? booksGrid.querySelector('[data-slug="' + slug + '"]') : null;
var ch = parseInt(progress[slug], 10) || 0;
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 ────────────────────────────────────────────────────────────── */
var input = document.getElementById('filter-input');
var countEl = document.getElementById('filter-count');
var booksGrid = document.getElementById('books-grid');
/* ── continue reading (excluding hero) ──────────────────────────────────── */
if (continueGrid) continueGrid.innerHTML = '';
var inProgress = Object.keys(progress).sort(function(a,b) {
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() {
var q = input.value.trim().toLowerCase();
var cards = booksGrid ? booksGrid.querySelectorAll('[data-filter]') : [];
/* ── recently added (top 4, excluding books already in continue reading) ── */
if (recentlyAddedGrid) recentlyAddedGrid.innerHTML = '';
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;
cards.forEach(function(card) {
var match = !q || card.dataset.filter.toLowerCase().indexOf(q) !== -1;
sorted.forEach(function(card) {
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';
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.classList.remove('hidden');
} else if (countEl) {
@@ -359,15 +541,85 @@ const homeTmpl = `
}
}
if (input) input.addEventListener('input', filterCards);
/* ── helpers ──────────────────────────────────────────────────────────── */
function escHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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>`
// 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 {
scraper.BookMeta
Downloaded int
AddedAt int64
}
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{
BookMeta: b,
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') || '{}');
p[SLUG] = CHAPTER_N;
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(_) {}
})();

View File

@@ -85,6 +85,17 @@ func (w *Writer) ReadMetadata(slug string) (scraper.BookMeta, bool, error) {
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 ─────────────────────────────────────────────────────────────────
// ChapterExists returns true if the markdown file for ref already exists on disk.