From c408eeb2ca49dc0a4dbec6ecbfd25ca33b7ec2f6 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 9 Oct 2026 18:02:48 +0200 Subject: [PATCH] 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 --- app/assets/css/journal-base.css | 281 ++++++++++++++++++++++ app/assets/css/journal-tokens.css | 40 +++ app/components/Journal/CategoryHeader.vue | 20 ++ app/components/Journal/FeaturedImage.vue | 11 + app/components/Journal/IndexHeader.vue | 12 + app/components/Journal/Pagination.vue | 33 +++ app/components/Journal/PostBody.vue | 2 +- app/components/Journal/PostCard.vue | 26 ++ app/components/Journal/PostList.vue | 13 + app/components/Journal/PostMeta.vue | 39 +++ app/components/Journal/RelatedPosts.vue | 14 ++ app/composables/useJournalLinks.ts | 23 ++ app/journal-pages/CategoryPage.vue | 48 +++- app/journal-pages/PostIndexPage.vue | 36 ++- app/journal-pages/PostPage.vue | 44 +++- app/utils/pagination.ts | 36 +++ app/utils/view.ts | 16 ++ i18n/locales/en.json | 39 +++ i18n/locales/pl.json | 39 +++ lib/journal-module.ts | 26 ++ nuxt.config.ts | 4 + testing/expect-acme.txt | 16 ++ testing/smoke-ssr.mjs | 10 +- 23 files changed, 807 insertions(+), 21 deletions(-) create mode 100644 app/assets/css/journal-base.css create mode 100644 app/assets/css/journal-tokens.css create mode 100644 app/components/Journal/CategoryHeader.vue create mode 100644 app/components/Journal/FeaturedImage.vue create mode 100644 app/components/Journal/IndexHeader.vue create mode 100644 app/components/Journal/Pagination.vue create mode 100644 app/components/Journal/PostCard.vue create mode 100644 app/components/Journal/PostList.vue create mode 100644 app/components/Journal/PostMeta.vue create mode 100644 app/components/Journal/RelatedPosts.vue create mode 100644 app/composables/useJournalLinks.ts create mode 100644 app/utils/pagination.ts create mode 100644 i18n/locales/en.json create mode 100644 i18n/locales/pl.json diff --git a/app/assets/css/journal-base.css b/app/assets/css/journal-base.css new file mode 100644 index 0000000..7a6700d --- /dev/null +++ b/app/assets/css/journal-base.css @@ -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; +} diff --git a/app/assets/css/journal-tokens.css b/app/assets/css/journal-tokens.css new file mode 100644 index 0000000..f5b6342 --- /dev/null +++ b/app/assets/css/journal-tokens.css @@ -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 */ +} diff --git a/app/components/Journal/CategoryHeader.vue b/app/components/Journal/CategoryHeader.vue new file mode 100644 index 0000000..c0e866c --- /dev/null +++ b/app/components/Journal/CategoryHeader.vue @@ -0,0 +1,20 @@ + + + diff --git a/app/components/Journal/FeaturedImage.vue b/app/components/Journal/FeaturedImage.vue new file mode 100644 index 0000000..1c93524 --- /dev/null +++ b/app/components/Journal/FeaturedImage.vue @@ -0,0 +1,11 @@ + + + diff --git a/app/components/Journal/IndexHeader.vue b/app/components/Journal/IndexHeader.vue new file mode 100644 index 0000000..df7961e --- /dev/null +++ b/app/components/Journal/IndexHeader.vue @@ -0,0 +1,12 @@ + + + diff --git a/app/components/Journal/Pagination.vue b/app/components/Journal/Pagination.vue new file mode 100644 index 0000000..04bdc57 --- /dev/null +++ b/app/components/Journal/Pagination.vue @@ -0,0 +1,33 @@ + + + diff --git a/app/components/Journal/PostBody.vue b/app/components/Journal/PostBody.vue index 28f5ba1..26e30b5 100644 --- a/app/components/Journal/PostBody.vue +++ b/app/components/Journal/PostBody.vue @@ -9,5 +9,5 @@ const gated = computed(() => (props.blocked ? { safe: false, html: '' } : gateJo diff --git a/app/components/Journal/PostCard.vue b/app/components/Journal/PostCard.vue new file mode 100644 index 0000000..1c4e396 --- /dev/null +++ b/app/components/Journal/PostCard.vue @@ -0,0 +1,26 @@ + + + diff --git a/app/components/Journal/PostList.vue b/app/components/Journal/PostList.vue new file mode 100644 index 0000000..59eeba3 --- /dev/null +++ b/app/components/Journal/PostList.vue @@ -0,0 +1,13 @@ + + + diff --git a/app/components/Journal/PostMeta.vue b/app/components/Journal/PostMeta.vue new file mode 100644 index 0000000..1cf7f3c --- /dev/null +++ b/app/components/Journal/PostMeta.vue @@ -0,0 +1,39 @@ + + + diff --git a/app/components/Journal/RelatedPosts.vue b/app/components/Journal/RelatedPosts.vue new file mode 100644 index 0000000..1b4c59e --- /dev/null +++ b/app/components/Journal/RelatedPosts.vue @@ -0,0 +1,14 @@ + + + diff --git a/app/composables/useJournalLinks.ts b/app/composables/useJournalLinks.ts new file mode 100644 index 0000000..b12508b --- /dev/null +++ b/app/composables/useJournalLinks.ts @@ -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.: 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, query?: Record) => + 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), + } +} diff --git a/app/journal-pages/CategoryPage.vue b/app/journal-pages/CategoryPage.vue index fbfd5a7..7c65042 100644 --- a/app/journal-pages/CategoryPage.vue +++ b/app/journal-pages/CategoryPage.vue @@ -1,21 +1,49 @@ diff --git a/app/journal-pages/PostIndexPage.vue b/app/journal-pages/PostIndexPage.vue index c746cc1..4697be9 100644 --- a/app/journal-pages/PostIndexPage.vue +++ b/app/journal-pages/PostIndexPage.vue @@ -1,13 +1,37 @@ diff --git a/app/journal-pages/PostPage.vue b/app/journal-pages/PostPage.vue index abf48a0..ffbbeba 100644 --- a/app/journal-pages/PostPage.vue +++ b/app/journal-pages/PostPage.vue @@ -1,15 +1,30 @@