chore: migrate to v3 and adopt Doppler for secrets management #3

Open
kamil wants to merge 574 commits from v3-cleanup into main
Showing only changes of commit af86c6f96f - Show all commits

View File

@@ -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>`