Step 10: migrate home page reading-progress JS from localStorage to /api/progress
This commit is contained in:
@@ -300,14 +300,9 @@ const homeTmpl = `
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
/* ── state ─────────────────────────────────────────────────────────────── */
|
||||
var progress = {};
|
||||
try { progress = JSON.parse(localStorage.getItem('reading_progress') || '{}'); } catch(_) {}
|
||||
// progress = { slug: chapterNum, ... }
|
||||
|
||||
var progressTs = {};
|
||||
try { progressTs = JSON.parse(localStorage.getItem('reading_progress_ts') || '{}'); } catch(_) {}
|
||||
// progressTs = { slug: timestampMs, ... }
|
||||
/* ── state (populated asynchronously from /api/progress) ───────────────── */
|
||||
var progress = {}; // { slug: chapterNum }
|
||||
var progressTs = {}; // { slug: timestampMs }
|
||||
|
||||
var booksGrid = document.getElementById('books-grid');
|
||||
var continueSection = document.getElementById('continue-reading-section');
|
||||
@@ -321,149 +316,7 @@ const homeTmpl = `
|
||||
|
||||
var allCards = booksGrid ? Array.from(booksGrid.querySelectorAll('[data-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(_) {}
|
||||
}
|
||||
|
||||
/* ── 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;
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* ── 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);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 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');
|
||||
|
||||
/* ── 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 ──────────────────────────────────────────────────────── */
|
||||
/* ── genre chips (no progress dependency) ────────────────────────────── */
|
||||
var allGenres = {};
|
||||
allCards.forEach(function(card) {
|
||||
(card.dataset.genres || '').split('|').forEach(function(g) {
|
||||
@@ -508,7 +361,6 @@ const homeTmpl = `
|
||||
|
||||
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);
|
||||
@@ -521,7 +373,6 @@ const homeTmpl = `
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
// Re-append in sorted order, apply visibility
|
||||
var shown = 0;
|
||||
sorted.forEach(function(card) {
|
||||
var matchText = !q || card.dataset.filter.toLowerCase().indexOf(q) !== -1;
|
||||
@@ -532,7 +383,7 @@ const homeTmpl = `
|
||||
var match = matchText && matchGenre;
|
||||
card.style.display = match ? '' : 'none';
|
||||
if (match) shown++;
|
||||
booksGrid.appendChild(card); // re-order in DOM
|
||||
booksGrid.appendChild(card);
|
||||
});
|
||||
if ((q || activeGenres.size > 0) && countEl) {
|
||||
countEl.textContent = shown + ' result' + (shown !== 1 ? 's' : '');
|
||||
@@ -579,7 +430,7 @@ const homeTmpl = `
|
||||
}
|
||||
}
|
||||
}
|
||||
// dismiss button
|
||||
// dismiss button — calls DELETE /api/progress/{slug}
|
||||
var dismissBtn = document.createElement('button');
|
||||
dismissBtn.type = 'button';
|
||||
dismissBtn.title = 'Remove from continue reading';
|
||||
@@ -590,12 +441,9 @@ const homeTmpl = `
|
||||
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));
|
||||
delete progress[cardSlug];
|
||||
delete progressTs[cardSlug];
|
||||
fetch('/api/progress/' + encodeURIComponent(cardSlug), { method: 'DELETE' });
|
||||
}
|
||||
var target = cardEl || this.parentElement;
|
||||
target.style.transition = 'transform 0.25s ease, opacity 0.25s ease';
|
||||
@@ -612,6 +460,158 @@ const homeTmpl = `
|
||||
return a;
|
||||
}
|
||||
|
||||
/* ── render: called once progress is loaded ──────────────────────────── */
|
||||
function renderWithProgress() {
|
||||
/* ── 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;
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* ── 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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 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;
|
||||
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');
|
||||
|
||||
/* ── recently added (top 4, excluding books 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;
|
||||
recentlyAddedGrid.appendChild(buildSimpleCard(card));
|
||||
addedShown++;
|
||||
});
|
||||
if (addedShown > 0) recentlyAddedSec.classList.remove('hidden');
|
||||
|
||||
/* ── re-apply sort in case "recently read" is active ─────────────────── */
|
||||
applySortAndFilter();
|
||||
}
|
||||
|
||||
/* ── load progress from server, then render ──────────────────────────── */
|
||||
fetch('/api/progress')
|
||||
.then(function(r) { return r.ok ? r.json() : {}; })
|
||||
.catch(function() { return {}; })
|
||||
.then(function(data) {
|
||||
Object.keys(data).forEach(function(key) {
|
||||
if (key.slice(-3) === '_ts') {
|
||||
progressTs[key.slice(0, -3)] = data[key];
|
||||
} else {
|
||||
progress[key] = data[key];
|
||||
}
|
||||
});
|
||||
renderWithProgress();
|
||||
});
|
||||
|
||||
}());
|
||||
</script>`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user