feat: add anonymous view counter with most-read sidebar

- node:sqlite storage in $DATA_DIR/views.db (no native deps)
- POST /api/views/<slug>/ counts once per visitor per 24h via
  daily-salted hash; IPs are never stored, bots are skipped
- GET /api/views/top/ feeds the sidebar's featured list, falling back
  to frontmatter-featured and latest posts when unavailable

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 01:15:05 +03:00
parent 809f89af08
commit c1c57c4f85
7 changed files with 178 additions and 30 deletions
+36 -2
View File
@@ -71,9 +71,9 @@ const title = 'font-display mb-4 flex items-center gap-3 text-sm font-bold track
{blog.sidebar.featured}
<span aria-hidden="true" class="bg-accent h-0.5 w-8"></span>
</h2>
<ol data-featured class="flex flex-col gap-4">
<ol data-featured data-limit="3" class="flex flex-col gap-4">
{featured.map((post, i) => (
<li class="flex gap-3">
<li class="flex gap-3" data-slug={post.id}>
<span
aria-hidden="true"
class="font-display text-accent-text text-2xl leading-none font-black"
@@ -115,3 +115,37 @@ const title = 'font-display mb-4 flex items-center gap-3 text-sm font-bold track
</section>
)}
</aside>
<script>
// Replace the build-time fallback with the most viewed posts when the API is reachable.
const list = document.querySelector<HTMLOListElement>('ol[data-featured]');
if (list) {
const limit = Number(list.dataset.limit) || 3;
fetch(`/api/views/top/?limit=${limit}`)
.then((r) => (r.ok ? r.json() : []))
.then((top: { slug: string; title: string; url: string }[]) => {
if (!top.length) return;
const template = list.querySelector('li');
if (!template) return;
const fallback = [...list.querySelectorAll<HTMLLIElement>('li')];
const items = top.map((t) => {
const li = template.cloneNode(true) as HTMLLIElement;
li.dataset.slug = t.slug;
const a = li.querySelector('a')!;
a.href = t.url;
a.textContent = t.title;
return li;
});
// Top up with fallback posts not already listed.
for (const li of fallback) {
if (items.length >= limit) break;
if (!items.some((x) => x.dataset.slug === li.dataset.slug)) items.push(li);
}
items.forEach((li, i) => {
li.querySelector('span')!.textContent = String(i + 1).padStart(2, '0');
});
list.replaceChildren(...items);
})
.catch(() => {});
}
</script>