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:
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>
|
||||
Reference in New Issue
Block a user