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:
281
app/assets/css/journal-base.css
Normal file
281
app/assets/css/journal-base.css
Normal file
@@ -0,0 +1,281 @@
|
||||
/* Neutral Journal styles. Only --journal-* tokens; no site colours. */
|
||||
|
||||
.journal-layout {
|
||||
box-sizing: border-box;
|
||||
max-width: var(--journal-wide-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--journal-space-5) var(--journal-space-4) var(--journal-space-6);
|
||||
color: var(--journal-color-text);
|
||||
background: var(--journal-color-bg);
|
||||
font-family: var(--journal-font-body);
|
||||
font-size: var(--journal-font-size-base);
|
||||
line-height: var(--journal-line-height);
|
||||
}
|
||||
|
||||
.journal-layout a {
|
||||
color: var(--journal-color-link);
|
||||
}
|
||||
|
||||
.journal-layout a:hover {
|
||||
color: var(--journal-color-link-hover);
|
||||
}
|
||||
|
||||
.journal-layout a:focus-visible {
|
||||
outline: 2px solid var(--journal-color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.journal-layout h1,
|
||||
.journal-layout h2,
|
||||
.journal-layout h3 {
|
||||
font-family: var(--journal-font-heading);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
/* Headers */
|
||||
.journal-index-header,
|
||||
.journal-category-header {
|
||||
margin-bottom: var(--journal-space-5);
|
||||
}
|
||||
|
||||
.journal-index-header__title,
|
||||
.journal-category-header__title {
|
||||
margin: 0 0 var(--journal-space-2);
|
||||
}
|
||||
|
||||
.journal-category-header__description {
|
||||
margin: 0 0 var(--journal-space-4);
|
||||
color: var(--journal-color-muted);
|
||||
}
|
||||
|
||||
.journal-category-header__content {
|
||||
max-width: var(--journal-content-width);
|
||||
}
|
||||
|
||||
/* Post page */
|
||||
.journal-post {
|
||||
max-width: var(--journal-content-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.journal-post__title {
|
||||
margin: 0 0 var(--journal-space-3);
|
||||
}
|
||||
|
||||
.journal-post-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--journal-space-2) var(--journal-space-3);
|
||||
margin-bottom: var(--journal-space-4);
|
||||
color: var(--journal-color-muted);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.journal-post-meta__categories {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--journal-space-2);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.journal-featured-image {
|
||||
margin: 0 0 var(--journal-space-4);
|
||||
}
|
||||
|
||||
.journal-featured-image img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: var(--journal-image-ratio);
|
||||
object-fit: cover;
|
||||
border-radius: var(--journal-radius-lg);
|
||||
background: var(--journal-color-surface);
|
||||
}
|
||||
|
||||
.journal-post__siblings {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--journal-space-3);
|
||||
margin: var(--journal-space-5) 0;
|
||||
padding-top: var(--journal-space-4);
|
||||
border-top: 1px solid var(--journal-color-border);
|
||||
}
|
||||
|
||||
.journal-post__siblings span {
|
||||
display: block;
|
||||
color: var(--journal-color-muted);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.journal-post__next {
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Rendered HTML bodies (posts and category content) */
|
||||
.journal-post-body > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.journal-post-body img,
|
||||
.journal-post-body video {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--journal-radius-md);
|
||||
}
|
||||
|
||||
.journal-post-body ul,
|
||||
.journal-post-body ol {
|
||||
padding-left: var(--journal-space-4);
|
||||
}
|
||||
|
||||
.journal-post-body blockquote {
|
||||
margin: var(--journal-space-4) 0;
|
||||
padding: var(--journal-space-3) var(--journal-space-4);
|
||||
border-left: 4px solid var(--journal-color-border);
|
||||
background: var(--journal-color-surface);
|
||||
}
|
||||
|
||||
.journal-post-body code {
|
||||
padding: 0 var(--journal-space-1);
|
||||
border-radius: var(--journal-radius-sm);
|
||||
background: var(--journal-color-surface);
|
||||
font-family: var(--journal-font-mono);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.journal-post-body pre {
|
||||
overflow-x: auto;
|
||||
padding: var(--journal-space-3);
|
||||
border-radius: var(--journal-radius-md);
|
||||
background: var(--journal-color-surface);
|
||||
}
|
||||
|
||||
.journal-post-body pre code {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.journal-post-body table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: var(--journal-space-4) 0;
|
||||
}
|
||||
|
||||
.journal-post-body th,
|
||||
.journal-post-body td {
|
||||
padding: var(--journal-space-2) var(--journal-space-3);
|
||||
border: 1px solid var(--journal-color-border);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.journal-post-body--blocked {
|
||||
padding: var(--journal-space-3);
|
||||
border: 1px dashed var(--journal-color-border);
|
||||
border-radius: var(--journal-radius-md);
|
||||
color: var(--journal-color-muted);
|
||||
}
|
||||
|
||||
/* Listings */
|
||||
.journal-post-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(var(--journal-card-min), 1fr));
|
||||
gap: var(--journal-space-4);
|
||||
}
|
||||
|
||||
.journal-post-list__empty {
|
||||
grid-column: 1 / -1;
|
||||
color: var(--journal-color-muted);
|
||||
}
|
||||
|
||||
.journal-post-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--journal-space-2);
|
||||
padding: var(--journal-space-4);
|
||||
border: 1px solid var(--journal-color-border);
|
||||
border-radius: var(--journal-radius-md);
|
||||
background: var(--journal-color-bg);
|
||||
}
|
||||
|
||||
.journal-post-card .journal-featured-image {
|
||||
margin: calc(-1 * var(--journal-space-4)) calc(-1 * var(--journal-space-4)) var(--journal-space-2);
|
||||
}
|
||||
|
||||
.journal-post-card .journal-featured-image img {
|
||||
border-radius: var(--journal-radius-md) var(--journal-radius-md) 0 0;
|
||||
}
|
||||
|
||||
.journal-post-card__title {
|
||||
margin: 0;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.journal-post-card__title a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.journal-post-card__date {
|
||||
color: var(--journal-color-muted);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.journal-post-card__excerpt {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.journal-post-card__more {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.journal-related {
|
||||
margin-top: var(--journal-space-5);
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.journal-pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--journal-space-3);
|
||||
margin-top: var(--journal-space-5);
|
||||
}
|
||||
|
||||
.journal-pagination__pages {
|
||||
display: flex;
|
||||
gap: var(--journal-space-1);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.journal-pagination__pages a,
|
||||
.journal-pagination__current {
|
||||
display: inline-block;
|
||||
min-width: 2.25em;
|
||||
padding: var(--journal-space-1) var(--journal-space-2);
|
||||
border-radius: var(--journal-radius-sm);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.journal-pagination__current {
|
||||
background: var(--journal-color-accent);
|
||||
color: var(--journal-color-on-accent);
|
||||
}
|
||||
|
||||
/* Author */
|
||||
.journal-author-card {
|
||||
margin-bottom: var(--journal-space-5);
|
||||
}
|
||||
|
||||
.journal-author-card__name {
|
||||
margin: 0 0 var(--journal-space-2);
|
||||
}
|
||||
|
||||
.journal-rss-link {
|
||||
display: inline-block;
|
||||
margin-top: var(--journal-space-4);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
40
app/assets/css/journal-tokens.css
Normal file
40
app/assets/css/journal-tokens.css
Normal file
@@ -0,0 +1,40 @@
|
||||
/* Journal design tokens. Every visual value in the layer comes from these.
|
||||
An app overrides any of them in its own CSS, e.g. on :root:root. */
|
||||
:root {
|
||||
/* Colour */
|
||||
--journal-color-text: #1f2328; /* body text */
|
||||
--journal-color-muted: #59636e; /* dates, meta, captions */
|
||||
--journal-color-bg: #ffffff; /* page background */
|
||||
--journal-color-surface: #f6f8fa; /* cards, quotes, code blocks */
|
||||
--journal-color-border: #d1d9e0; /* card and table borders, rules */
|
||||
--journal-color-link: #0969da; /* links */
|
||||
--journal-color-link-hover: #0550ae; /* hovered links */
|
||||
--journal-color-accent: #0969da; /* current page, focus ring */
|
||||
--journal-color-on-accent: #ffffff; /* text on the accent colour */
|
||||
|
||||
/* Type */
|
||||
--journal-font-body: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; /* body copy */
|
||||
--journal-font-heading: var(--journal-font-body); /* headings */
|
||||
--journal-font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; /* code */
|
||||
--journal-font-size-base: 1.0625rem; /* body size */
|
||||
--journal-line-height: 1.7; /* body leading */
|
||||
|
||||
/* Spacing scale */
|
||||
--journal-space-1: 0.25rem; /* hairline gaps */
|
||||
--journal-space-2: 0.5rem; /* inline gaps */
|
||||
--journal-space-3: 1rem; /* paragraph gap */
|
||||
--journal-space-4: 1.5rem; /* card padding */
|
||||
--journal-space-5: 2.5rem; /* section gap */
|
||||
--journal-space-6: 4rem; /* page padding */
|
||||
|
||||
/* Radii */
|
||||
--journal-radius-sm: 4px; /* code, tags */
|
||||
--journal-radius-md: 8px; /* cards, images */
|
||||
--journal-radius-lg: 16px; /* featured image */
|
||||
|
||||
/* Layout */
|
||||
--journal-content-width: 46rem; /* reading column */
|
||||
--journal-wide-width: 72rem; /* listings */
|
||||
--journal-card-min: 16rem; /* narrowest card in a grid */
|
||||
--journal-image-ratio: 1200 / 630; /* featured image crop */
|
||||
}
|
||||
20
app/components/Journal/CategoryHeader.vue
Normal file
20
app/components/Journal/CategoryHeader.vue
Normal 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>
|
||||
11
app/components/Journal/FeaturedImage.vue
Normal file
11
app/components/Journal/FeaturedImage.vue
Normal 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>
|
||||
12
app/components/Journal/IndexHeader.vue
Normal file
12
app/components/Journal/IndexHeader.vue
Normal 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>
|
||||
33
app/components/Journal/Pagination.vue
Normal file
33
app/components/Journal/Pagination.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
26
app/components/Journal/PostCard.vue
Normal file
26
app/components/Journal/PostCard.vue
Normal 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>
|
||||
13
app/components/Journal/PostList.vue
Normal file
13
app/components/Journal/PostList.vue
Normal 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>
|
||||
39
app/components/Journal/PostMeta.vue
Normal file
39
app/components/Journal/PostMeta.vue
Normal 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>
|
||||
14
app/components/Journal/RelatedPosts.vue
Normal file
14
app/components/Journal/RelatedPosts.vue
Normal 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>
|
||||
23
app/composables/useJournalLinks.ts
Normal file
23
app/composables/useJournalLinks.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// Links to Journal views, built from stable route names through
|
||||
// useLocalePath() (i18n gives every route a per-locale name), never from
|
||||
// concatenated paths. A link to a disabled view (journal.routes.<key>: false)
|
||||
// is null; components then render plain text.
|
||||
|
||||
import { JOURNAL_ROUTE_NAMES } from '../utils/routes.ts'
|
||||
import type { JournalRoutes } from '../utils/types.ts'
|
||||
|
||||
export function useJournalLinks() {
|
||||
const localePath = useLocalePath()
|
||||
const routes = (useRuntimeConfig().public.journal as { routes: JournalRoutes }).routes
|
||||
|
||||
const link = (key: keyof JournalRoutes, params?: Record<string, string>, query?: Record<string, string>) =>
|
||||
routes[key] === false ? null : localePath({ name: JOURNAL_ROUTE_NAMES[key], params, query })
|
||||
|
||||
return {
|
||||
postLink: (slug: string) => link('post', { slug }),
|
||||
categoryLink: (slug: string) => link('category', { category: slug }),
|
||||
tagLink: (slug: string) => link('tag', { tag: slug }),
|
||||
authorLink: (slug: string) => link('author', { author: slug }),
|
||||
indexLink: (page: number = 1) => link('index', undefined, page > 1 ? { page: String(page) } : undefined),
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
// journal-category: posts in one category; an unknown category is a 404.
|
||||
// journal-category: one category's header and rich content above its posts.
|
||||
// The slug resolves against GET /categories?include_empty=1, so an empty
|
||||
// category answers 200 with its content; an unknown slug is a real 404 (this
|
||||
// route may be a root-level catch-all in the app).
|
||||
import { gateJournalHtml } from '../utils/htmlGate.ts'
|
||||
import { pageOutOfRange, parsePageParam } from '../utils/pagination.ts'
|
||||
import { toListPost } from '../utils/view.ts'
|
||||
import type { JournalModuleOptions } from '../utils/types.ts'
|
||||
|
||||
definePageMeta({ key: (route) => route.fullPath })
|
||||
|
||||
const route = useRoute()
|
||||
const api = useJournalApi()
|
||||
const cfg = useRuntimeConfig().public.journal as JournalModuleOptions
|
||||
const slug = String(route.params.category ?? '')
|
||||
const { data } = await useAsyncData(`journal-category-${slug}`, async () => {
|
||||
const category = await api.findCategory(slug)
|
||||
if (!category) return null
|
||||
return { category, page: await api.listPosts({ category: category.slug }) }
|
||||
|
||||
const page = parsePageParam(route.query.page)
|
||||
if (page === null) throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
|
||||
|
||||
const { data, error } = await useAsyncData(`journal-category-${slug}-${page}`, async () => {
|
||||
const found = await api.findCategory(slug)
|
||||
if (!found) return null
|
||||
const posts = await api.listPosts({ category: found.slug, page, perPage: cfg.perPage.category, sort: cfg.sort })
|
||||
// Only this category reaches the payload, with its content already gated.
|
||||
const gated = gateJournalHtml(found.content)
|
||||
const { children: _children, ...category } = found
|
||||
return {
|
||||
category: { ...category, content: gated.html || null },
|
||||
contentBlocked: !gated.safe,
|
||||
posts: { ...posts, data: posts.data.map(toListPost) },
|
||||
}
|
||||
})
|
||||
if (!data.value) throw createError({ statusCode: 404, fatal: true })
|
||||
if (error.value) {
|
||||
throw createError({ statusCode: (error.value as { statusCode?: number }).statusCode ?? 500, fatal: true })
|
||||
}
|
||||
if (!data.value) throw createError({ statusCode: 404, statusMessage: 'Category not found', fatal: true })
|
||||
if (pageOutOfRange(page, data.value.posts.meta.last_page)) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="data" class="journal-category">
|
||||
<h1>{{ data.category.name }}</h1>
|
||||
<ul>
|
||||
<li v-for="post in data.page.data" :key="post.id">{{ post.title }}</li>
|
||||
</ul>
|
||||
<JournalCategoryHeader :category="data.category" :page="page" :blocked="data.contentBlocked" />
|
||||
<JournalPostList :posts="data.posts.data" :empty="$t('journal.category.empty')" />
|
||||
<JournalPagination :current="data.posts.meta.current_page" :last="data.posts.meta.last_page" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,13 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
// journal-index: the latest posts.
|
||||
// journal-index: the post list, paginated with ?page=N. A non-numeric page
|
||||
// or a page past the last one is a real 404.
|
||||
import { pageOutOfRange, parsePageParam } from '../utils/pagination.ts'
|
||||
import { toListPost } from '../utils/view.ts'
|
||||
import type { JournalModuleOptions } from '../utils/types.ts'
|
||||
|
||||
// Re-run setup (and its 404 checks) on every query change.
|
||||
definePageMeta({ key: (route) => route.fullPath })
|
||||
|
||||
const route = useRoute()
|
||||
const api = useJournalApi()
|
||||
const { data: page } = await useAsyncData('journal-index', () => api.listPosts({ perPage: 9 }))
|
||||
const cfg = useRuntimeConfig().public.journal as JournalModuleOptions
|
||||
|
||||
const page = parsePageParam(route.query.page)
|
||||
if (page === null) throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
|
||||
|
||||
const { data, error } = await useAsyncData(
|
||||
`journal-index-${page}`,
|
||||
() => api.listPosts({ page, perPage: cfg.perPage.index, sort: cfg.sort }),
|
||||
{ transform: (res) => ({ ...res, data: res.data.map(toListPost) }) },
|
||||
)
|
||||
if (error.value || !data.value) {
|
||||
throw createError({ statusCode: (error.value as { statusCode?: number } | undefined)?.statusCode ?? 500, fatal: true })
|
||||
}
|
||||
if (pageOutOfRange(page, data.value.meta.last_page)) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="journal-index">
|
||||
<ul v-if="page">
|
||||
<li v-for="post in page.data" :key="post.id">{{ post.title }}</li>
|
||||
</ul>
|
||||
<section v-if="data" class="journal-index">
|
||||
<JournalIndexHeader :page="page" />
|
||||
<JournalPostList :posts="data.data" />
|
||||
<JournalPagination :current="data.meta.current_page" :last="data.meta.last_page" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
// journal-post: one published post by slug. An unknown, draft or future post
|
||||
// is a real 404. The transform gates the body before it reaches the payload.
|
||||
import { toPostView } from '../utils/view.ts'
|
||||
// Related posts fall back to the latest posts when the API has none (it
|
||||
// returns none for a post without a category).
|
||||
import { toListPost, toPostView } from '../utils/view.ts'
|
||||
import type { JournalModuleOptions } from '../utils/types.ts'
|
||||
|
||||
const route = useRoute()
|
||||
const api = useJournalApi()
|
||||
const links = useJournalLinks()
|
||||
const cfg = useRuntimeConfig().public.journal as JournalModuleOptions
|
||||
const slug = computed(() => String(route.params.slug ?? ''))
|
||||
|
||||
const { data: post, error } = await useAsyncData(
|
||||
() => `journal-post-${slug.value}`,
|
||||
() => api.getPost(slug.value),
|
||||
async () => {
|
||||
const found = await api.getPost(slug.value)
|
||||
if (!found) return null
|
||||
const count = Math.max(0, cfg.relatedCount)
|
||||
let related = found.related_posts ?? []
|
||||
if (related.length === 0 && count > 0) {
|
||||
const latest = await api.listPosts({ perPage: count + 1, sort: cfg.sort })
|
||||
related = latest.data.filter((p) => p.slug !== found.slug)
|
||||
}
|
||||
return { ...found, related_posts: related.slice(0, count).map(toListPost) }
|
||||
},
|
||||
{ transform: (p) => (p ? toPostView(p) : null) },
|
||||
)
|
||||
|
||||
@@ -23,7 +38,30 @@ if (!post.value) {
|
||||
|
||||
<template>
|
||||
<article v-if="post" class="journal-post">
|
||||
<h1 class="journal-post__title">{{ post.title }}</h1>
|
||||
<header class="journal-post__header">
|
||||
<h1 class="journal-post__title">{{ post.title }}</h1>
|
||||
<JournalPostMeta :post="post" />
|
||||
</header>
|
||||
<JournalFeaturedImage :src="post.featured_image_url" :alt="post.title" eager />
|
||||
<JournalPostBody :html="post.content_html" :blocked="post.content_blocked" />
|
||||
<nav v-if="post.previous_post || post.next_post" class="journal-post__siblings">
|
||||
<NuxtLink
|
||||
v-if="post.previous_post && links.postLink(post.previous_post.slug)"
|
||||
:to="links.postLink(post.previous_post.slug)!"
|
||||
rel="prev"
|
||||
class="journal-post__previous"
|
||||
>
|
||||
<span>{{ $t('journal.post.previous') }}</span> {{ post.previous_post.title }}
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
v-if="post.next_post && links.postLink(post.next_post.slug)"
|
||||
:to="links.postLink(post.next_post.slug)!"
|
||||
rel="next"
|
||||
class="journal-post__next"
|
||||
>
|
||||
<span>{{ $t('journal.post.next') }}</span> {{ post.next_post.title }}
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
<JournalRelatedPosts :posts="post.related_posts" />
|
||||
</article>
|
||||
</template>
|
||||
|
||||
36
app/utils/pagination.ts
Normal file
36
app/utils/pagination.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// Page-number rules for Journal listings. Pages use ?page=N (never a path
|
||||
// segment). Anything but a positive integer, and any page past the last one,
|
||||
// is a real 404.
|
||||
|
||||
/**
|
||||
* Parses the ?page query value: 1 when absent, the integer for a string of
|
||||
* digits greater than 0, and null (a 404) for anything else: non-numeric,
|
||||
* empty, 0, negative, decimal, repeated (array) or beyond the safe range.
|
||||
*/
|
||||
export function parsePageParam(raw: unknown): number | null {
|
||||
if (raw === undefined || raw === null) return 1
|
||||
if (typeof raw !== 'string' || !/^\d+$/.test(raw)) return null
|
||||
const n = Number(raw)
|
||||
if (!Number.isSafeInteger(n) || n < 1) return null
|
||||
return n
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `page` lies past the last page. Page 1 is always in range, so an
|
||||
* empty listing renders its empty state instead of a 404.
|
||||
*/
|
||||
export function pageOutOfRange(page: number, lastPage: number): boolean {
|
||||
return page > 1 && page > lastPage
|
||||
}
|
||||
|
||||
/**
|
||||
* The page numbers to render around `current`: at most `size` consecutive
|
||||
* pages, clipped to 1..last and kept full-width near either end.
|
||||
*/
|
||||
export function paginationWindow(current: number, last: number, size: number = 5): number[] {
|
||||
if (last < 1 || size < 1) return []
|
||||
const width = Math.min(size, last)
|
||||
let start = Math.max(1, current - Math.floor(width / 2))
|
||||
if (start + width - 1 > last) start = last - width + 1
|
||||
return Array.from({ length: width }, (_, i) => start + i)
|
||||
}
|
||||
@@ -16,6 +16,22 @@ export interface JournalPostView extends JournalPostDetail {
|
||||
content_blocked: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an API timestamp as a long date for `language` (a BCP 47 tag such
|
||||
* as "pl-PL"). UTC on both server and browser, so hydration matches. Returns
|
||||
* "" for a missing or invalid value.
|
||||
*/
|
||||
export function formatJournalDate(iso: string | null | undefined, language: string): string {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
try {
|
||||
return new Intl.DateTimeFormat(language || 'en', { dateStyle: 'long', timeZone: 'UTC' }).format(d)
|
||||
} catch {
|
||||
return new Intl.DateTimeFormat('en', { dateStyle: 'long', timeZone: 'UTC' }).format(d)
|
||||
}
|
||||
}
|
||||
|
||||
export function toPostView(post: JournalPostDetail): JournalPostView {
|
||||
const gated = gateJournalHtml(post.content_html)
|
||||
return {
|
||||
|
||||
39
i18n/locales/en.json
Normal file
39
i18n/locales/en.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"journal": {
|
||||
"index": {
|
||||
"title": "Latest posts",
|
||||
"empty": "There are no posts yet."
|
||||
},
|
||||
"pagination": {
|
||||
"label": "Pagination",
|
||||
"previous": "Previous page",
|
||||
"next": "Next page",
|
||||
"pageTitle": "Page {page}"
|
||||
},
|
||||
"post": {
|
||||
"publishedOn": "Published on {date}",
|
||||
"readingTime": "{minutes} min read",
|
||||
"by": "By",
|
||||
"related": "Related posts",
|
||||
"previous": "Previous post",
|
||||
"next": "Next post",
|
||||
"contentUnavailable": "This content cannot be displayed."
|
||||
},
|
||||
"category": {
|
||||
"empty": "There are no posts in this category yet."
|
||||
},
|
||||
"tag": {
|
||||
"title": "Posts tagged {name}",
|
||||
"empty": "There are no posts with this tag yet."
|
||||
},
|
||||
"author": {
|
||||
"title": "Posts by {name}",
|
||||
"posts": "Posts",
|
||||
"empty": "This author has not published any posts yet."
|
||||
},
|
||||
"readMore": "Read more",
|
||||
"rss": {
|
||||
"title": "RSS feed"
|
||||
}
|
||||
}
|
||||
}
|
||||
39
i18n/locales/pl.json
Normal file
39
i18n/locales/pl.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"journal": {
|
||||
"index": {
|
||||
"title": "Najnowsze wpisy",
|
||||
"empty": "Nie ma jeszcze żadnych wpisów."
|
||||
},
|
||||
"pagination": {
|
||||
"label": "Stronicowanie",
|
||||
"previous": "Poprzednia strona",
|
||||
"next": "Następna strona",
|
||||
"pageTitle": "Strona {page}"
|
||||
},
|
||||
"post": {
|
||||
"publishedOn": "Opublikowano {date}",
|
||||
"readingTime": "{minutes} min czytania",
|
||||
"by": "Autor:",
|
||||
"related": "Powiązane wpisy",
|
||||
"previous": "Poprzedni wpis",
|
||||
"next": "Następny wpis",
|
||||
"contentUnavailable": "Nie można wyświetlić tej treści."
|
||||
},
|
||||
"category": {
|
||||
"empty": "W tej kategorii nie ma jeszcze wpisów."
|
||||
},
|
||||
"tag": {
|
||||
"title": "Wpisy z tagiem {name}",
|
||||
"empty": "Nie ma jeszcze wpisów z tym tagiem."
|
||||
},
|
||||
"author": {
|
||||
"title": "Wpisy autora {name}",
|
||||
"posts": "Wpisy",
|
||||
"empty": "Ten autor nie opublikował jeszcze żadnych wpisów."
|
||||
},
|
||||
"readMore": "Czytaj dalej",
|
||||
"rss": {
|
||||
"title": "Kanał RSS"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,18 @@ const PAGE_FILES: Record<keyof JournalRoutes, string> = {
|
||||
}
|
||||
|
||||
const pagesDir = fileURLToPath(new URL('../app/journal-pages/', import.meta.url))
|
||||
const langDir = fileURLToPath(new URL('../i18n/locales/', import.meta.url))
|
||||
|
||||
/** UI string locales the layer ships, each in i18n/locales/<code>.json. */
|
||||
const LAYER_LOCALES = ['pl', 'en'] as const
|
||||
|
||||
/** Locale codes the project enables in its i18n config (strings or objects). */
|
||||
function enabledLocaleCodes(locales: unknown): string[] {
|
||||
if (!Array.isArray(locales)) return []
|
||||
return locales
|
||||
.map((l) => (typeof l === 'string' ? l : l && typeof l === 'object' ? (l as { code?: unknown }).code : undefined))
|
||||
.filter((c): c is string => typeof c === 'string')
|
||||
}
|
||||
|
||||
export default defineNuxtModule<JournalModuleOptions>({
|
||||
meta: {
|
||||
@@ -43,6 +55,20 @@ export default defineNuxtModule<JournalModuleOptions>({
|
||||
}
|
||||
})
|
||||
|
||||
// Register the layer's strings only for locales the app enables. The
|
||||
// layer config declares no locales: @nuxtjs/i18n merges every locale a
|
||||
// layer declares into the app, which would add /en routes to an app that
|
||||
// enables only pl.
|
||||
const hook = nuxt.hook as unknown as (
|
||||
name: 'i18n:registerModule',
|
||||
fn: (register: (config: { langDir: string; locales: Array<{ code: string; file: string }> }) => void) => void,
|
||||
) => void
|
||||
hook('i18n:registerModule', (register) => {
|
||||
const enabled = enabledLocaleCodes((nuxt.options as { i18n?: { locales?: unknown } }).i18n?.locales)
|
||||
const locales = LAYER_LOCALES.filter((code) => enabled.includes(code)).map((code) => ({ code, file: `${code}.json` }))
|
||||
if (locales.length > 0) register({ langDir, locales })
|
||||
})
|
||||
|
||||
// The public runtime config carries the resolved options next to apiBase,
|
||||
// so pages, links and the sitemap source read one source of truth.
|
||||
const pub = nuxt.options.runtimeConfig.public as Record<string, any>
|
||||
|
||||
@@ -20,6 +20,10 @@ export default defineNuxtConfig({
|
||||
// @nuxtjs/i18n split locale state).
|
||||
modules: ['@nuxt/fonts', '@nuxtjs/i18n', '@nuxtjs/seo', `${currentDir}lib/journal-module.ts`],
|
||||
|
||||
// Tokens first, then the neutral styles that use them. The app's own css
|
||||
// array loads after these, so its token overrides win.
|
||||
css: [`${currentDir}app/assets/css/journal-tokens.css`, `${currentDir}app/assets/css/journal-base.css`],
|
||||
|
||||
runtimeConfig: {
|
||||
journal: {
|
||||
// Server-only base for SSR fetches (NUXT_JOURNAL_API_BASE). It never
|
||||
|
||||
@@ -7,3 +7,19 @@
|
||||
404 /blog/missing-post
|
||||
200 /blog/unsafe-sample contains=journal-post-body--blocked notcontains=alert(
|
||||
404 /blog/draft-note
|
||||
|
||||
# Post index and pagination (9 per page, 13 published posts: 2 pages)
|
||||
200 /blog contains=Hello Acme
|
||||
200 /blog?page=2
|
||||
404 /blog?page=3
|
||||
404 /blog?page=abc
|
||||
404 /blog?page=0
|
||||
200 /pl/blog contains=Najnowsze wpisy
|
||||
|
||||
# Categories (include_empty=1; content renders above the list)
|
||||
200 /blog/category/news contains=Acme news marker
|
||||
200 /blog/category/quiet contains=There are no posts in this category yet.
|
||||
404 /blog/category/nope
|
||||
|
||||
# Related posts fall back to the latest posts for a post without a category
|
||||
200 /blog/uncategorised-note contains=Hello Acme
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
// end of the line, so it may contain spaces. Requests are plain GETs that do
|
||||
// not follow redirects and carry `X-Forwarded-For: 203.0.113.7`. After the
|
||||
// lines, at least one fixture API request must have carried that address
|
||||
// (the SSR fetches forward the visitor's x-forwarded-for).
|
||||
// (the SSR fetches forward the visitor's x-forwarded-for), and every
|
||||
// categories request must have carried include_empty=1.
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
@@ -114,6 +115,13 @@ async function main() {
|
||||
if (!api.requests.some((r) => (r.xff ?? '').includes(VISITOR_IP))) {
|
||||
failures.push('x-forwarded-for: x-forwarded-for not forwarded')
|
||||
}
|
||||
// Categories are always requested with include_empty=1, so empty ones resolve.
|
||||
for (const r of api.requests) {
|
||||
const u = new URL(r.url, 'http://fixture.local')
|
||||
if (u.pathname.endsWith('/categories') && u.searchParams.get('include_empty') !== '1') {
|
||||
failures.push(`include_empty: ${r.url} requested without include_empty=1`)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (nuxt) await nuxt.close()
|
||||
await api.close()
|
||||
|
||||
Reference in New Issue
Block a user