feat: implement pagination for posts list and add category sidebar
All checks were successful
Deploy / deploy (push) Successful in 46s

This commit is contained in:
Adrian Altner 2026-04-22 23:52:17 +02:00
parent 6c09629c45
commit 7c45526444
9 changed files with 275 additions and 56 deletions

View file

@ -29,7 +29,7 @@ Astro 6 static site, based on the `blog` starter template. No tests, no linter c
- Because posts sit two levels deep, hero images and component imports inside MD/MDX use `../../../assets/…` / `../../../components/…` relative paths. - Because posts sit two levels deep, hero images and component imports inside MD/MDX use `../../../assets/…` / `../../../components/…` relative paths.
- UI strings and path helpers live in [src/i18n/ui.ts](src/i18n/ui.ts). `t(locale, key)` for translations; `localizePath(path, locale)` prefixes `/en` when needed; `switchLocalePath(pathname, target)` rewrites the current URL to the other locale (used by the header language switcher and hreflang alternates in [BaseHead.astro](src/components/BaseHead.astro)). - UI strings and path helpers live in [src/i18n/ui.ts](src/i18n/ui.ts). `t(locale, key)` for translations; `localizePath(path, locale)` prefixes `/en` when needed; `switchLocalePath(pathname, target)` rewrites the current URL to the other locale (used by the header language switcher and hreflang alternates in [BaseHead.astro](src/components/BaseHead.astro)).
- Site titles/descriptions per locale live in [src/consts.ts](src/consts.ts) (`SITE.de`, `SITE.en`). The `SITE[locale]` map is the single source of truth — update when rebranding. - Site titles/descriptions per locale live in [src/consts.ts](src/consts.ts) (`SITE.de`, `SITE.en`). The `SITE[locale]` map is the single source of truth — update when rebranding.
- Pages: German under `src/pages/` (`index.astro`, `about.astro`, `[...slug].astro`, `rss.xml.js`), English mirrors under `src/pages/en/`. The shared home UI lives in [src/components/HomePage.astro](src/components/HomePage.astro); both `index.astro` files are thin wrappers that pass `locale="de"` / `locale="en"`. - Pages: German under `src/pages/` (`index.astro`, `about.astro`, `[...slug].astro`, `rss.xml.js`), English mirrors under `src/pages/en/`. The shared home UI lives in [src/components/PostsList.astro](src/components/PostsList.astro); both `index.astro` files are thin wrappers that pass `locale="de"` / `locale="en"`.
- Layouts: [BaseLayout.astro](src/layouts/BaseLayout.astro) is the common skeleton (`<html>` / `<head>` with `BaseHead` / `<body>` with `Header` + `main` + `Footer`). Accepts `title`, `description`, `locale`, optional `image`, optional `entry` (for the language-switch translation lookup), optional `bodyClass`, and a `head` named slot for per-page `<link>`/`<meta>` extras. All page templates compose via this layout — don't re-assemble head/header/footer by hand. [Post.astro](src/layouts/Post.astro) wraps `BaseLayout` to add hero image + title block + category line for single posts. `Header`, `BaseHead`, and `FormattedDate` also accept `locale` directly and fall back to `getLocaleFromUrl(Astro.url)`. - Layouts: [BaseLayout.astro](src/layouts/BaseLayout.astro) is the common skeleton (`<html>` / `<head>` with `BaseHead` / `<body>` with `Header` + `main` + `Footer`). Accepts `title`, `description`, `locale`, optional `image`, optional `entry` (for the language-switch translation lookup), optional `bodyClass`, and a `head` named slot for per-page `<link>`/`<meta>` extras. All page templates compose via this layout — don't re-assemble head/header/footer by hand. [Post.astro](src/layouts/Post.astro) wraps `BaseLayout` to add hero image + title block + category line for single posts. `Header`, `BaseHead`, and `FormattedDate` also accept `locale` directly and fall back to `getLocaleFromUrl(Astro.url)`.
- Separate RSS feeds per locale: `/rss.xml` (de) and `/en/rss.xml`. The sitemap integration is configured with `i18n: { defaultLocale: 'de', locales: { de: 'de-DE', en: 'en-US' } }`. - Separate RSS feeds per locale: `/rss.xml` (de) and `/en/rss.xml`. The sitemap integration is configured with `i18n: { defaultLocale: 'de', locales: { de: 'de-DE', en: 'en-US' } }`.

View file

@ -3,24 +3,64 @@ import { Image } from 'astro:assets';
import FormattedDate from '~/components/FormattedDate.astro'; import FormattedDate from '~/components/FormattedDate.astro';
import BaseLayout from '~/layouts/BaseLayout.astro'; import BaseLayout from '~/layouts/BaseLayout.astro';
import { type Locale, SITE } from '~/consts'; import { type Locale, SITE } from '~/consts';
import { getPostsByLocale, postSlug } from '~/i18n/posts'; import {
import { localizePath } from '~/i18n/ui'; categoryHref,
getCategoriesByLocale,
getPostsByCategory,
getPostsByLocale,
paginationSegment,
POSTS_PER_PAGE,
postSlug,
} from '~/i18n/posts';
import { localizePath, t } from '~/i18n/ui';
interface Props { interface Props {
locale: Locale; locale: Locale;
page?: number;
} }
const { locale } = Astro.props; const { locale, page = 1 } = Astro.props;
const posts = await getPostsByLocale(locale); const all = await getPostsByLocale(locale);
const categories = await getCategoriesByLocale(locale);
const categoriesWithCounts = (
await Promise.all(
categories.map(async (c) => ({
category: c,
count: (await getPostsByCategory(c)).length,
})),
)
).filter((c) => c.count > 0);
const totalPages = Math.max(1, Math.ceil(all.length / POSTS_PER_PAGE));
const currentPage = Math.min(Math.max(1, page), totalPages);
const posts = all.slice((currentPage - 1) * POSTS_PER_PAGE, currentPage * POSTS_PER_PAGE);
const segment = paginationSegment(locale);
const homeHref = localizePath('/', locale);
const newerHref =
currentPage === 2
? homeHref
: currentPage > 2
? localizePath(`/${segment}/${currentPage - 1}/`, locale)
: undefined;
const olderHref =
currentPage < totalPages
? localizePath(`/${segment}/${currentPage + 1}/`, locale)
: undefined;
const pageLabel = t(locale, 'pagination.page')
.replace('{current}', String(currentPage))
.replace('{total}', String(totalPages));
const pageTitle =
currentPage === 1 ? SITE[locale].title : `${SITE[locale].title} ${pageLabel}`;
--- ---
<BaseLayout <BaseLayout
title={SITE[locale].title} title={pageTitle}
description={SITE[locale].description} description={SITE[locale].description}
locale={locale} locale={locale}
bodyClass="home" bodyClass="home"
> >
<section> <div class="home-grid">
<section class="posts">
<ul> <ul>
{ {
posts.map((post) => ( posts.map((post) => (
@ -28,91 +68,217 @@ const posts = await getPostsByLocale(locale);
<a href={localizePath(`/${postSlug(post)}/`, locale)}> <a href={localizePath(`/${postSlug(post)}/`, locale)}>
{post.data.heroImage && ( {post.data.heroImage && (
<Image <Image
width={720} width={240}
height={360} height={120}
src={post.data.heroImage} src={post.data.heroImage}
alt="" alt=""
transition:name={`hero-${post.id}`} transition:name={`hero-${post.id}`}
/> />
)} )}
<h4 class="title">{post.data.title}</h4> <div class="meta">
<p class="date"> <h4 class="title">{post.data.title}</h4>
<FormattedDate date={post.data.pubDate} locale={locale} /> <p class="date">
</p> <FormattedDate date={post.data.pubDate} locale={locale} />
</p>
</div>
</a> </a>
</li> </li>
)) ))
} }
</ul> </ul>
{
totalPages > 1 && (
<nav class="pagination" aria-label="pagination">
<div class="pagination-nav">
{newerHref ? (
<a class="pagination-link prev" href={newerHref} rel="prev">
← {t(locale, 'pagination.newer')}
</a>
) : (
<span />
)}
<span class="pagination-info">{pageLabel}</span>
{olderHref ? (
<a class="pagination-link next" href={olderHref} rel="next">
{t(locale, 'pagination.older')} →
</a>
) : (
<span />
)}
</div>
</nav>
)
}
</section> </section>
{
categoriesWithCounts.length > 0 && (
<aside class="sidebar" aria-label={t(locale, 'nav.categories')}>
<h2 class="sidebar-title">{t(locale, 'nav.categories')}</h2>
<ul class="sidebar-list">
{categoriesWithCounts.map(({ category, count }) => (
<li>
<a href={categoryHref(category)}>
<span class="name">{category.data.name}</span>
<span class="count">{count}</span>
</a>
</li>
))}
</ul>
</aside>
)
}
</div>
</BaseLayout> </BaseLayout>
<style> <style>
body.home main { .home-grid {
width: 960px; display: grid;
grid-template-columns: minmax(0, 1fr) 240px;
gap: 3rem;
align-items: start;
} }
ul { .posts ul {
display: flex;
flex-wrap: wrap;
gap: 2rem;
list-style-type: none; list-style-type: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
ul li { .posts ul li {
width: calc(50% - 1rem); margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid rgb(var(--gray-light));
} }
ul li * { .posts ul li:last-child {
border-bottom: none;
}
.posts ul li a {
display: flex;
gap: 1.25rem;
align-items: center;
text-decoration: none; text-decoration: none;
transition: 0.2s ease; transition: 0.2s ease;
} }
ul li:first-child { .posts ul li img {
width: 100%; width: 160px;
margin-bottom: 1rem; height: 80px;
text-align: center; object-fit: cover;
border-radius: 8px;
flex-shrink: 0;
} }
ul li:first-child img { .meta {
width: 100%; flex: 1;
} min-width: 0;
ul li:first-child .title {
font-size: 2.369rem;
}
ul li img {
margin-bottom: 0.5rem;
border-radius: 12px;
}
ul li a {
display: block;
} }
.title { .title {
margin: 0; margin: 0 0 0.25rem;
color: rgb(var(--black)); color: rgb(var(--black));
line-height: 1; line-height: 1.2;
font-size: 1.25rem;
} }
.date { .date {
margin: 0; margin: 0;
color: rgb(var(--gray)); color: rgb(var(--gray));
font-size: 0.9rem;
} }
ul li a:hover h4, .posts ul li a:hover .title,
ul li a:hover .date { .posts ul li a:hover .date {
color: rgb(var(--accent)); color: rgb(var(--accent));
} }
ul a:hover img { .posts ul li a:hover img {
box-shadow: var(--box-shadow); box-shadow: var(--box-shadow);
} }
@media (max-width: 720px) { .sidebar {
ul { position: sticky;
gap: 0.5em; top: 2rem;
}
.sidebar-title {
margin: 0 0 1rem;
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: rgb(var(--gray));
}
.sidebar-list {
list-style: none;
margin: 0;
padding: 0;
border-top: 1px solid rgb(var(--gray-light));
}
.sidebar-list li {
border-bottom: 1px solid rgb(var(--gray-light));
}
.sidebar-list a {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.5rem;
padding: 0.6rem 0;
text-decoration: none;
color: rgb(var(--black));
transition: 0.2s ease;
}
.sidebar-list a:hover {
color: rgb(var(--accent));
}
.sidebar-list .count {
color: rgb(var(--gray));
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
}
.pagination {
margin-top: 2rem;
}
.pagination-nav {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 1rem;
}
.pagination-link {
color: rgb(var(--black));
text-decoration: none;
font-weight: 600;
transition: 0.2s ease;
}
.pagination-link:hover {
color: rgb(var(--accent));
}
.pagination-link.prev {
justify-self: start;
}
.pagination-link.next {
justify-self: end;
}
.pagination-info {
color: rgb(var(--gray));
font-size: 0.9rem;
text-align: center;
}
@media (max-width: 900px) {
.home-grid {
grid-template-columns: 1fr;
gap: 2.5rem;
} }
ul li { .sidebar {
position: static;
}
}
@media (max-width: 600px) {
.posts ul li a {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.posts ul li img {
width: 100%; width: 100%;
text-align: center; height: auto;
aspect-ratio: 2 / 1;
} }
ul li:first-child { .pagination-nav {
margin-bottom: 0; grid-template-columns: 1fr 1fr;
} }
ul li:first-child .title { .pagination-info {
font-size: 1.563em; grid-column: 1 / -1;
order: -1;
} }
} }
</style> </style>

View file

@ -148,6 +148,14 @@ export function tagIndexSegment(locale: Locale): string {
return locale === 'de' ? 'schlagwoerter' : 'tags'; return locale === 'de' ? 'schlagwoerter' : 'tags';
} }
/** URL segment used for paginated home routes per locale. */
export function paginationSegment(locale: Locale): string {
return locale === 'de' ? 'seite' : 'page';
}
/** Number of posts shown per page on the home index. */
export const POSTS_PER_PAGE = 10;
export function categoryHref(category: CollectionEntry<'categories'>): string { export function categoryHref(category: CollectionEntry<'categories'>): string {
const locale = entryLocale(category); const locale = entryLocale(category);
return localizePath(`/${categorySegment(locale)}/${entrySlug(category)}/`, locale); return localizePath(`/${categorySegment(locale)}/${entrySlug(category)}/`, locale);

View file

@ -33,6 +33,9 @@ export const ui = {
'webmentions.reposts': 'Reposts', 'webmentions.reposts': 'Reposts',
'webmentions.replies': 'Antworten', 'webmentions.replies': 'Antworten',
'webmentions.mentions': 'Erwähnungen', 'webmentions.mentions': 'Erwähnungen',
'pagination.newer': 'Neuere Beiträge',
'pagination.older': 'Ältere Beiträge',
'pagination.page': 'Seite {current} von {total}',
'lang.de': 'Deutsch', 'lang.de': 'Deutsch',
'lang.en': 'English', 'lang.en': 'English',
}, },
@ -68,6 +71,9 @@ export const ui = {
'webmentions.reposts': 'Reposts', 'webmentions.reposts': 'Reposts',
'webmentions.replies': 'Replies', 'webmentions.replies': 'Replies',
'webmentions.mentions': 'Mentions', 'webmentions.mentions': 'Mentions',
'pagination.newer': 'Newer posts',
'pagination.older': 'Older posts',
'pagination.page': 'Page {current} of {total}',
'lang.de': 'Deutsch', 'lang.de': 'Deutsch',
'lang.en': 'English', 'lang.en': 'English',
}, },

View file

@ -1,5 +1,5 @@
--- ---
import HomePage from '~/components/HomePage.astro'; import PostsList from '~/components/PostsList.astro';
--- ---
<HomePage locale="en" /> <PostsList locale="en" />

View file

@ -0,0 +1,18 @@
---
import PostsList from '~/components/PostsList.astro';
import { getPostsByLocale, POSTS_PER_PAGE } from '~/i18n/posts';
export async function getStaticPaths() {
const posts = await getPostsByLocale('en');
const totalPages = Math.max(1, Math.ceil(posts.length / POSTS_PER_PAGE));
const paths = [];
for (let page = 2; page <= totalPages; page++) {
paths.push({ params: { page: String(page) }, props: { page } });
}
return paths;
}
const { page } = Astro.props;
---
<PostsList locale="en" page={page} />

View file

@ -1,5 +1,5 @@
--- ---
import HomePage from '~/components/HomePage.astro'; import PostsList from '~/components/PostsList.astro';
--- ---
<HomePage locale="de" /> <PostsList locale="de" />

View file

@ -0,0 +1,18 @@
---
import PostsList from '~/components/PostsList.astro';
import { getPostsByLocale, POSTS_PER_PAGE } from '~/i18n/posts';
export async function getStaticPaths() {
const posts = await getPostsByLocale('de');
const totalPages = Math.max(1, Math.ceil(posts.length / POSTS_PER_PAGE));
const paths = [];
for (let page = 2; page <= totalPages; page++) {
paths.push({ params: { page: String(page) }, props: { page } });
}
return paths;
}
const { page } = Astro.props;
---
<PostsList locale="de" page={page} />

View file

@ -47,6 +47,9 @@ main {
margin: auto; margin: auto;
padding: 3em 1em; padding: 3em 1em;
} }
body.home main {
width: 1040px;
}
h1, h1,
h2, h2,
h3, h3,