feat: Journal list, category pages, pagination, tokens and pl/en strings

- index and category pages paginate with ?page=N and 404 out of range
- category content renders above the list through the html gate
- related posts fall back to the latest posts
- --journal-* tokens and neutral base styles
- pl/en strings registered only for locales the app enables
This commit is contained in:
2026-10-09 18:02:48 +02:00
parent 6f6792f51c
commit c408eeb2ca
23 changed files with 807 additions and 21 deletions

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
// Heading of a category page: name, description and the category's rich
// content, which passes the Journal HTML gate before v-html.
import type { JournalCategory } from '../../utils/types.ts'
import { gateJournalHtml } from '../../utils/htmlGate.ts'
const props = defineProps<{ category: JournalCategory; page?: number; blocked?: boolean }>()
const content = computed(() => (props.blocked ? { safe: false, html: '' } : gateJournalHtml(props.category.content)))
</script>
<template>
<header class="journal-category-header">
<h1 class="journal-category-header__title">
{{ category.name }}<template v-if="page && page > 1"> · {{ $t('journal.pagination.pageTitle', { page }) }}</template>
</h1>
<p v-if="category.description" class="journal-category-header__description">{{ category.description }}</p>
<div v-if="content.safe && content.html" class="journal-category-header__content journal-post-body" v-html="content.html" />
<p v-else-if="!content.safe" class="journal-post-body--blocked">{{ $t('journal.post.contentUnavailable') }}</p>
</header>
</template>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
// A post's featured image. The intrinsic width/height reserve space before
// the image loads; the rendered size and crop come from --journal-image-ratio.
defineProps<{ src: string | null | undefined; alt: string; eager?: boolean }>()
</script>
<template>
<figure v-if="src" class="journal-featured-image">
<img :src="src" :alt="alt" width="1200" height="630" :loading="eager ? 'eager' : 'lazy'" decoding="async" />
</figure>
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
// Heading of the post index; pages after the first say which page they are.
defineProps<{ page: number }>()
</script>
<template>
<header class="journal-index-header">
<h1 class="journal-index-header__title">
{{ $t('journal.index.title') }}<template v-if="page > 1"> · {{ $t('journal.pagination.pageTitle', { page }) }}</template>
</h1>
</header>
</template>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
// Previous/next and a window of page links for the current listing. Links
// keep the current path and use ?page=N; page 1 drops the query.
import { paginationWindow } from '../../utils/pagination.ts'
const props = defineProps<{ current: number; last: number }>()
const route = useRoute()
const to = (page: number) => {
const query = { ...route.query }
if (page > 1) query.page = String(page)
else delete query.page
return { path: route.path, query }
}
const pages = computed(() => paginationWindow(props.current, props.last))
</script>
<template>
<nav v-if="last > 1" class="journal-pagination" :aria-label="$t('journal.pagination.label')">
<NuxtLink v-if="current > 1" :to="to(current - 1)" rel="prev" class="journal-pagination__prev">
{{ $t('journal.pagination.previous') }}
</NuxtLink>
<ol class="journal-pagination__pages">
<li v-for="page in pages" :key="page">
<span v-if="page === current" class="journal-pagination__current" aria-current="page">{{ page }}</span>
<NuxtLink v-else :to="to(page)">{{ page }}</NuxtLink>
</li>
</ol>
<NuxtLink v-if="current < last" :to="to(current + 1)" rel="next" class="journal-pagination__next">
{{ $t('journal.pagination.next') }}
</NuxtLink>
</nav>
</template>

View File

@@ -9,5 +9,5 @@ const gated = computed(() => (props.blocked ? { safe: false, html: '' } : gateJo
<template>
<div v-if="gated.safe" class="journal-post-body" v-html="gated.html" />
<p v-else class="journal-post-body journal-post-body--blocked">This content cannot be displayed.</p>
<p v-else class="journal-post-body journal-post-body--blocked">{{ $t('journal.post.contentUnavailable') }}</p>
</template>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
// One post in a listing: image, title link, date and excerpt.
import type { JournalPost } from '../../utils/types.ts'
import { formatJournalDate } from '../../utils/view.ts'
const props = defineProps<{ post: JournalPost }>()
const { localeProperties } = useI18n()
const links = useJournalLinks()
const href = computed(() => links.postLink(props.post.slug))
const date = computed(() => formatJournalDate(props.post.published_at, localeProperties.value.language ?? 'en'))
</script>
<template>
<article class="journal-post-card">
<NuxtLink v-if="href && post.featured_image_url" :to="href" class="journal-post-card__image" tabindex="-1" aria-hidden="true">
<JournalFeaturedImage :src="post.featured_image_url" :alt="post.title" />
</NuxtLink>
<h2 class="journal-post-card__title">
<NuxtLink v-if="href" :to="href">{{ post.title }}</NuxtLink>
<template v-else>{{ post.title }}</template>
</h2>
<time v-if="date" class="journal-post-card__date" :datetime="post.published_at ?? undefined">{{ date }}</time>
<p v-if="post.excerpt" class="journal-post-card__excerpt">{{ post.excerpt }}</p>
<NuxtLink v-if="href" :to="href" class="journal-post-card__more">{{ $t('journal.readMore') }}</NuxtLink>
</article>
</template>

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
// A grid of post cards, or the empty-state text when there are none.
import type { JournalPost } from '../../utils/types.ts'
defineProps<{ posts: JournalPost[]; empty?: string }>()
</script>
<template>
<div class="journal-post-list">
<JournalPostCard v-for="post in posts" :key="post.id" :post="post" />
<p v-if="posts.length === 0" class="journal-post-list__empty">{{ empty ?? $t('journal.index.empty') }}</p>
</div>
</template>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
// Date, reading time, categories and author of a post. Links go through
// route names, so a disabled view renders its label as plain text.
import type { JournalPost } from '../../utils/types.ts'
import { formatJournalDate } from '../../utils/view.ts'
const props = defineProps<{ post: JournalPost }>()
const { localeProperties } = useI18n()
const links = useJournalLinks()
const date = computed(() => formatJournalDate(props.post.published_at, localeProperties.value.language ?? 'en'))
const authorName = computed(() => {
const a = props.post.author
return a ? `${a.first_name} ${a.last_name}`.trim() || a.login : ''
})
const authorHref = computed(() => (props.post.author ? links.authorLink(props.post.author.login) : null))
</script>
<template>
<div class="journal-post-meta">
<time v-if="date" class="journal-post-meta__date" :datetime="post.published_at ?? undefined">
{{ $t('journal.post.publishedOn', { date }) }}
</time>
<span v-if="post.reading_time > 0" class="journal-post-meta__reading">
{{ $t('journal.post.readingTime', { minutes: post.reading_time }) }}
</span>
<span v-if="authorName" class="journal-post-meta__author">
{{ $t('journal.post.by') }}
<NuxtLink v-if="authorHref" :to="authorHref">{{ authorName }}</NuxtLink>
<template v-else>{{ authorName }}</template>
</span>
<ul v-if="post.categories.length" class="journal-post-meta__categories">
<li v-for="cat in post.categories" :key="cat.id">
<NuxtLink v-if="links.categoryLink(cat.slug)" :to="links.categoryLink(cat.slug)!">{{ cat.name }}</NuxtLink>
<span v-else>{{ cat.name }}</span>
</li>
</ul>
</div>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
// Related posts under a post. The page passes the API's related_posts, or
// the latest posts when the post has no category.
import type { JournalPost } from '../../utils/types.ts'
defineProps<{ posts: JournalPost[] }>()
</script>
<template>
<section v-if="posts.length" class="journal-related">
<h2 class="journal-related__title">{{ $t('journal.post.related') }}</h2>
<JournalPostList :posts="posts" />
</section>
</template>