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
+16
View File
@@ -0,0 +1,16 @@
import type { APIRoute } from 'astro';
import { getPosts } from '../../../lib/posts';
import { clientIp, isBot, recordView } from '../../../lib/views';
export const prerender = false;
// POST /api/views/<slug>/ — counts one view (sent with navigator.sendBeacon).
export const POST: APIRoute = async ({ params, request, clientAddress }) => {
const slug = params.slug ?? '';
const posts = await getPosts();
if (!posts.some((p) => p.id === slug)) return new Response(null, { status: 404 });
if (!isBot(request.headers.get('user-agent'))) {
recordView(slug, clientIp(request, clientAddress));
}
return new Response(null, { status: 204, headers: { 'Cache-Control': 'no-store' } });
};
+19
View File
@@ -0,0 +1,19 @@
import type { APIRoute } from 'astro';
import { getPosts, postUrl } from '../../../lib/posts';
import { topViews } from '../../../lib/views';
export const prerender = false;
// GET /api/views/top/?limit=3 — most viewed published posts.
export const GET: APIRoute = async ({ url }) => {
const limit = Math.min(10, Math.max(1, Number(url.searchParams.get('limit')) || 3));
const posts = new Map((await getPosts()).map((p) => [p.id, p]));
const items = topViews(limit * 2) // headroom for deleted/draft slugs
.filter((v) => posts.has(v.slug))
.slice(0, limit)
.map((v) => {
const post = posts.get(v.slug)!;
return { slug: v.slug, title: post.data.title, url: postUrl(post), count: v.count };
});
return Response.json(items, { headers: { 'Cache-Control': 'public, max-age=300' } });
};