Files
ta1sph.info/src/components/blog/Sidebar.astro
T
suphicakir c1c57c4f85 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>
2026-10-16 01:15:12 +03:00

152 lines
5.1 KiB
Plaintext

---
import { blog } from '../../lib/data';
import {
categoryCounts,
categoryUrl,
featuredPosts,
postUrl,
tagCounts,
tagUrl,
type Post,
} from '../../lib/posts';
import SearchBox from './SearchBox.astro';
import { Icon } from 'astro-icon/components';
export interface Props {
/** All published posts (not just the current page). */
posts: Post[];
/** Highlights the current category in the list. */
activeCategory?: string;
activeTag?: string;
}
const { posts, activeCategory, activeTag } = Astro.props;
const cats = categoryCounts(posts);
const featured = featuredPosts(posts);
const tags = tagCounts(posts);
const max = Math.max(1, ...tags.map((t) => t.count));
// 4 size steps by relative frequency.
const tagSize = (count: number) =>
['text-sm', 'text-base', 'text-lg', 'text-xl'][Math.min(3, Math.floor((count / max) * 3.999))];
const box = 'border-line bg-surface border p-5';
const title = 'font-display mb-4 flex items-center gap-3 text-sm font-bold tracking-wide uppercase';
---
<aside aria-label={blog.sidebar.label} class="flex flex-col gap-6">
<section class={box} aria-labelledby="sb-search">
<h2 id="sb-search" class={title}>
{blog.sidebar.search}
<span aria-hidden="true" class="bg-accent h-0.5 w-8"></span>
</h2>
<SearchBox />
</section>
{cats.length > 0 && (
<section class={box} aria-labelledby="sb-categories">
<h2 id="sb-categories" class={title}>
{blog.sidebar.categories}
<span aria-hidden="true" class="bg-accent h-0.5 w-8"></span>
</h2>
<ul class="flex flex-col">
{cats.map(({ category, count }) => (
<li>
<a
href={categoryUrl(category.slug)}
aria-current={activeCategory === category.slug ? 'page' : undefined}
class="border-line hover:text-accent-text aria-[current=page]:text-accent-text flex items-center gap-2 border-b py-2 aria-[current=page]:font-bold"
>
<Icon name={category.icon} class="size-5 shrink-0" aria-hidden="true" />
<span class="flex-1">{category.name}</span>
<span class="font-display text-fg-muted text-sm font-bold">{count}</span>
</a>
</li>
))}
</ul>
</section>
)}
{featured.length > 0 && (
<section class={box} aria-labelledby="sb-featured">
<h2 id="sb-featured" class={title}>
{blog.sidebar.featured}
<span aria-hidden="true" class="bg-accent h-0.5 w-8"></span>
</h2>
<ol data-featured data-limit="3" class="flex flex-col gap-4">
{featured.map((post, i) => (
<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"
>
{String(i + 1).padStart(2, '0')}
</span>
<a href={postUrl(post)} class="leading-snug font-semibold hover:underline">
{post.data.title}
</a>
</li>
))}
</ol>
</section>
)}
{tags.length > 0 && (
<section class={box} aria-labelledby="sb-tags">
<h2 id="sb-tags" class={title}>
{blog.sidebar.tags}
<span aria-hidden="true" class="bg-accent h-0.5 w-8"></span>
</h2>
<ul class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
{tags.map((t) => (
<li>
<a
href={tagUrl(t.tag)}
aria-current={activeTag === t.slug ? 'page' : undefined}
class:list={[
'hover:text-accent-text aria-[current=page]:text-accent-text font-semibold hover:underline',
tagSize(t.count),
]}
>
#{t.tag}
<span class="sr-only">({t.count})</span>
</a>
</li>
))}
</ul>
</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>