From 7c455264441f8380fa64dc0c8af71f228a110fa3 Mon Sep 17 00:00:00 2001 From: Adrian Altner Date: Wed, 22 Apr 2026 23:52:17 +0200 Subject: [PATCH] feat: implement pagination for posts list and add category sidebar --- CLAUDE.md | 2 +- src/components/PostsList.astro | 268 ++++++++++++++++++++++++++------- src/i18n/posts.ts | 8 + src/i18n/ui.ts | 6 + src/pages/en/index.astro | 4 +- src/pages/en/page/[page].astro | 18 +++ src/pages/index.astro | 4 +- src/pages/seite/[page].astro | 18 +++ src/styles/global.css | 3 + 9 files changed, 275 insertions(+), 56 deletions(-) create mode 100644 src/pages/en/page/[page].astro create mode 100644 src/pages/seite/[page].astro diff --git a/CLAUDE.md b/CLAUDE.md index 5b6276a..848060d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. - 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. -- 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 (`` / `` with `BaseHead` / `` 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 ``/`` 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' } }`. diff --git a/src/components/PostsList.astro b/src/components/PostsList.astro index 6cb3e9e..308ddbc 100644 --- a/src/components/PostsList.astro +++ b/src/components/PostsList.astro @@ -3,24 +3,64 @@ import { Image } from 'astro:assets'; import FormattedDate from '~/components/FormattedDate.astro'; import BaseLayout from '~/layouts/BaseLayout.astro'; import { type Locale, SITE } from '~/consts'; -import { getPostsByLocale, postSlug } from '~/i18n/posts'; -import { localizePath } from '~/i18n/ui'; +import { + categoryHref, + getCategoriesByLocale, + getPostsByCategory, + getPostsByLocale, + paginationSegment, + POSTS_PER_PAGE, + postSlug, +} from '~/i18n/posts'; +import { localizePath, t } from '~/i18n/ui'; interface Props { locale: Locale; + page?: number; } -const { locale } = Astro.props; -const posts = await getPostsByLocale(locale); +const { locale, page = 1 } = Astro.props; +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}`; --- -
+
+
+ { + totalPages > 1 && ( + + ) + }
+ { + categoriesWithCounts.length > 0 && ( + + ) + } +
diff --git a/src/i18n/posts.ts b/src/i18n/posts.ts index f6c24b8..09f89f2 100644 --- a/src/i18n/posts.ts +++ b/src/i18n/posts.ts @@ -148,6 +148,14 @@ export function tagIndexSegment(locale: Locale): string { 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 { const locale = entryLocale(category); return localizePath(`/${categorySegment(locale)}/${entrySlug(category)}/`, locale); diff --git a/src/i18n/ui.ts b/src/i18n/ui.ts index b71c0b3..be76984 100644 --- a/src/i18n/ui.ts +++ b/src/i18n/ui.ts @@ -33,6 +33,9 @@ export const ui = { 'webmentions.reposts': 'Reposts', 'webmentions.replies': 'Antworten', 'webmentions.mentions': 'Erwähnungen', + 'pagination.newer': 'Neuere Beiträge', + 'pagination.older': 'Ältere Beiträge', + 'pagination.page': 'Seite {current} von {total}', 'lang.de': 'Deutsch', 'lang.en': 'English', }, @@ -68,6 +71,9 @@ export const ui = { 'webmentions.reposts': 'Reposts', 'webmentions.replies': 'Replies', 'webmentions.mentions': 'Mentions', + 'pagination.newer': 'Newer posts', + 'pagination.older': 'Older posts', + 'pagination.page': 'Page {current} of {total}', 'lang.de': 'Deutsch', 'lang.en': 'English', }, diff --git a/src/pages/en/index.astro b/src/pages/en/index.astro index 49c3c42..331734c 100644 --- a/src/pages/en/index.astro +++ b/src/pages/en/index.astro @@ -1,5 +1,5 @@ --- -import HomePage from '~/components/HomePage.astro'; +import PostsList from '~/components/PostsList.astro'; --- - + diff --git a/src/pages/en/page/[page].astro b/src/pages/en/page/[page].astro new file mode 100644 index 0000000..040dfde --- /dev/null +++ b/src/pages/en/page/[page].astro @@ -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; +--- + + diff --git a/src/pages/index.astro b/src/pages/index.astro index 260d93e..85df714 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,5 +1,5 @@ --- -import HomePage from '~/components/HomePage.astro'; +import PostsList from '~/components/PostsList.astro'; --- - + diff --git a/src/pages/seite/[page].astro b/src/pages/seite/[page].astro new file mode 100644 index 0000000..5fe24ce --- /dev/null +++ b/src/pages/seite/[page].astro @@ -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; +--- + + diff --git a/src/styles/global.css b/src/styles/global.css index e3a51b8..36fd8cb 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -47,6 +47,9 @@ main { margin: auto; padding: 3em 1em; } +body.home main { + width: 1040px; +} h1, h2, h3,