c1c57c4f85
- 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>
152 lines
5.1 KiB
Plaintext
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>
|