From e5d91d673aa8f074488331b5eec33a521e643664 Mon Sep 17 00:00:00 2001 From: pigui Date: Tue, 29 Sep 2026 23:02:55 +0200 Subject: [PATCH] Put the blog where people look for it The blog was reachable only from a link in the footer, which is to say hardly at all. Once there is a post: - "Blog" joins the header menu, on desktop and in the mobile menu, on every page. - The landing shows the three latest posts under "Novedades", with a link to the rest. The post card moves into its own component, post-card, shared by the blog (image beside the text) and the landing (image above). Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +- landing/src/app/core/copy.ts | 4 ++ landing/src/app/core/routed-page.ts | 21 ++++++- landing/src/app/pages/(home).page.ts | 29 ++++++++++ landing/src/app/ui/blog-page.scss | 63 --------------------- landing/src/app/ui/blog-page.ts | 57 +++---------------- landing/src/app/ui/post-card.scss | 77 ++++++++++++++++++++++++++ landing/src/app/ui/post-card.ts | 83 ++++++++++++++++++++++++++++ 8 files changed, 224 insertions(+), 116 deletions(-) create mode 100644 landing/src/app/ui/post-card.scss create mode 100644 landing/src/app/ui/post-card.ts diff --git a/README.md b/README.md index e7653e5..ec475ff 100644 --- a/README.md +++ b/README.md @@ -266,8 +266,10 @@ any page. That category has to exist with the slug `blog` in every language - A post shows its date and leads back to the blog; its structured data is a `BlogPosting`. - `/blog/rss.xml?lang=xx` is the feed, one per language, announced in ``. -- The footer does not list posts one by one — it would grow without end — but - links the blog once there is any. +- Once there is a post, the blog is in the header menu (desktop and mobile) + and the landing shows the three latest under "Novedades"; the footer links + it too, but does not list posts one by one — it would grow without end. + A blog reachable only from the footer turned out to be one nobody found. ## Search engines and link previews diff --git a/landing/src/app/core/copy.ts b/landing/src/app/core/copy.ts index dd9f896..8696acb 100644 --- a/landing/src/app/core/copy.ts +++ b/landing/src/app/core/copy.ts @@ -136,6 +136,7 @@ export interface Copy { readMore: string; published: string; feed: string; + seeAll: string; }; service: { eyebrow: string; @@ -326,6 +327,7 @@ const es: Copy = { readMore: 'Leer', published: 'Publicado el', feed: 'Suscríbete por RSS', + seeAll: 'Ver todas las novedades', }, service: { eyebrow: 'Servicio', @@ -529,6 +531,7 @@ const en: Copy = { readMore: 'Read', published: 'Published on', feed: 'Subscribe via RSS', + seeAll: 'See all the news', }, service: { eyebrow: 'Service', @@ -732,6 +735,7 @@ const ca: Copy = { readMore: 'Llegir', published: 'Publicat el', feed: "Subscriu-t'hi per RSS", + seeAll: 'Veure totes les novetats', }, service: { eyebrow: 'Servei', diff --git a/landing/src/app/core/routed-page.ts b/landing/src/app/core/routed-page.ts index 987309f..77e7f40 100644 --- a/landing/src/app/core/routed-page.ts +++ b/landing/src/app/core/routed-page.ts @@ -23,7 +23,8 @@ import type { PublicTechnology, } from './api.types'; import { BLOG_CATEGORY, byPublication, isBlogPost } from './blog'; -import { resolveRoute, type RouteMatch } from './routes'; +import { copyFor } from './copy'; +import { listingPath, resolveRoute, type RouteMatch } from './routes'; /** The record, tagged with its type so the template can switch on it. */ export type RoutedRecord = @@ -132,13 +133,15 @@ async function fetchRecord(route: RouteMatch): Promise { /** * Featured pages go in the header; the rest are one click away in the footer. - * Blog posts are neither: they have the blog. + * Blog posts are neither: they have the blog, which joins the header as soon + * as there is a post in it — a blog reachable only from the footer is one + * nobody finds. */ export function pageNavLinks( pages: PublicPage[], locale: Locale, ): { href: string; label: string }[] { - return pages + const featured = pages .filter( (page) => page.is_featured && @@ -150,4 +153,16 @@ export function pageNavLinks( href: page.path as string, label: page.title as string, })); + + const hasPosts = pages.some((page) => isBlogPost(page, locale)); + + return hasPosts + ? [ + ...featured, + { + href: listingPath('blog', locale), + label: copyFor(locale).blog.eyebrow, + }, + ] + : featured; } diff --git a/landing/src/app/pages/(home).page.ts b/landing/src/app/pages/(home).page.ts index 2caf943..d45c9db 100644 --- a/landing/src/app/pages/(home).page.ts +++ b/landing/src/app/pages/(home).page.ts @@ -17,6 +17,7 @@ import { PiChipComponent, } from '@pi/ui'; +import { byPublication, isBlogPost } from '../core/blog'; import { pageNavLinks } from '../core/routed-page'; import { listingPath } from '../core/routes'; import { copyFor } from '../core/copy'; @@ -30,6 +31,7 @@ import { Hero } from '../ui/hero'; import { IssueRow } from '../ui/issue-row'; import { MaintenanceScreen } from '../ui/maintenance-screen'; import { MemberCard } from '../ui/member-card'; +import { PostCard } from '../ui/post-card'; import { ProjectCard } from '../ui/project-card'; import { Section } from '../ui/section'; import { ServiceCard } from '../ui/service-card'; @@ -66,6 +68,7 @@ export const routeMeta: RouteMeta = { IssueRow, MaintenanceScreen, MemberCard, + PostCard, ProjectCard, Section, ServiceCard, @@ -267,6 +270,25 @@ export const routeMeta: RouteMeta = { } + + @if (latestPosts().length > 0) { + +
+ @for (post of latestPosts(); track post.id) { + + } +
+

+ {{ copy().blog.seeAll }} → +

+
+ } + listingPath('radar', this.data().locale)); + protected readonly blogHref = computed(() => listingPath('blog', this.data().locale)); + + /** The three most recent posts; the rest are on the blog. */ + protected readonly latestPosts = computed(() => + byPublication(this.data().pages.filter((page) => isBlogPost(page, this.data().locale))).slice(0, 3), + ); + protected readonly contributeHref = computed(() => listingPath('contribute', this.data().locale), ); diff --git a/landing/src/app/ui/blog-page.scss b/landing/src/app/ui/blog-page.scss index 547cdd5..4d49aa8 100644 --- a/landing/src/app/ui/blog-page.scss +++ b/landing/src/app/ui/blog-page.scss @@ -30,66 +30,3 @@ margin: 0; } } - -.blog-card { - display: grid; - overflow: hidden; - border-radius: var(--pi-theme-card-radius, 1.5rem); - box-shadow: var(--pi-theme-card-shadow, none); -} - -// The image column only when there is an image: without one the text would -// sit in the image's column with the rest of the card empty. -.blog-card--media { - @media (min-width: 768px) { - grid-template-columns: minmax(0, 20rem) minmax(0, 1fr); - } -} - -.blog-card__media ::ng-deep img { - display: block; - width: 100%; - height: 100%; - aspect-ratio: 16 / 10; - object-fit: cover; -} - -.blog-card__body { - display: flex; - flex-direction: column; - gap: 0.5rem; - padding: 1.5rem 1.75rem; -} - -.blog-card__date { - font-size: 0.8125rem; -} - -.blog-card__title { - margin: 0; - font-size: 1.375rem; - font-weight: 700; - line-height: 1.25; - - a { - color: inherit; - text-decoration: none; - - &:hover { - text-decoration: underline; - } - } -} - -.blog-card__excerpt { - margin: 0; - line-height: 1.6; -} - -.blog-card__more { - margin-top: auto; - padding-top: 0.5rem; - color: var(--pi-color-primary); - font-weight: 600; - text-decoration: none; -} diff --git a/landing/src/app/ui/blog-page.ts b/landing/src/app/ui/blog-page.ts index 1af13c5..a5ca971 100644 --- a/landing/src/app/ui/blog-page.ts +++ b/landing/src/app/ui/blog-page.ts @@ -9,7 +9,7 @@ import { import type { Locale } from '../core/api'; import type { PublicPage } from '../core/api.types'; import type { Copy } from '../core/copy'; -import { ApiImage } from './api-image'; +import { PostCard } from './post-card'; /** * The blog: the pages in the panel's "Blog" category, newest first, each @@ -19,7 +19,7 @@ import { ApiImage } from './api-image'; selector: 'app-blog-page', changeDetection: ChangeDetectionStrategy.OnPush, imports: [ - ApiImage, + PostCard, PiNoteComponent, PiTextColorDirective, PiTextLargeDirective, @@ -46,44 +46,13 @@ import { ApiImage } from './api-image'; @if (posts().length > 0) {
    @for (post of posts(); track post.id) { -
  • - @if (post.featured_image; as image) { - - } -
    - @if (post.published_at) { - - } -

    - {{ post.title }} -

    - @if (post.excerpt; as excerpt) { -

    - {{ excerpt }} -

    - } - {{ copy().blog.readMore }} → -
    +
  • +
  • }
@@ -103,12 +72,4 @@ export class BlogPage { readonly posts = input.required(); readonly copy = input.required(); readonly locale = input.required(); - - protected date(value: string): string { - return new Intl.DateTimeFormat(this.locale(), { - day: 'numeric', - month: 'long', - year: 'numeric', - }).format(new Date(value)); - } } diff --git a/landing/src/app/ui/post-card.scss b/landing/src/app/ui/post-card.scss new file mode 100644 index 0000000..84d0038 --- /dev/null +++ b/landing/src/app/ui/post-card.scss @@ -0,0 +1,77 @@ +:host { + display: block; + height: 100%; +} + +.post-card { + display: grid; + grid-template-rows: auto 1fr; + height: 100%; + overflow: hidden; + border-radius: var(--pi-theme-card-radius, 1.5rem); + box-shadow: var(--pi-theme-card-shadow, none); +} + +// Beside the text rather than above it, and only when there is an image: +// without one the text would sit in the image's column with the rest empty. +:host(.post-card--row) .post-card--media { + @media (min-width: 768px) { + grid-template-rows: none; + grid-template-columns: minmax(0, 20rem) minmax(0, 1fr); + } +} + +// The `` belongs to `app-api-image`, so a scoped rule cannot reach it. +.post-card__media ::ng-deep img { + display: block; + width: 100%; + height: 100%; + aspect-ratio: 16 / 10; + object-fit: cover; +} + +.post-card__body { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 1.5rem 1.75rem; +} + +.post-card__date { + font-size: 0.8125rem; + color: color-mix(in srgb, currentColor 68%, transparent); +} + +.post-card__title { + margin: 0; + font-size: 1.25rem; + font-weight: 700; + line-height: 1.25; + + :host(.post-card--row) & { + font-size: 1.375rem; + } + + a { + color: inherit; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +} + +.post-card__excerpt { + margin: 0; + line-height: 1.6; + color: color-mix(in srgb, currentColor 68%, transparent); +} + +.post-card__more { + margin-top: auto; + padding-top: 0.5rem; + color: var(--pi-color-primary); + font-weight: 600; + text-decoration: none; +} diff --git a/landing/src/app/ui/post-card.ts b/landing/src/app/ui/post-card.ts new file mode 100644 index 0000000..8f33ca7 --- /dev/null +++ b/landing/src/app/ui/post-card.ts @@ -0,0 +1,83 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, +} from '@angular/core'; + +import type { Locale } from '../core/api'; +import type { PublicPage } from '../core/api.types'; +import type { Copy } from '../core/copy'; +import { ApiImage } from './api-image'; + +/** + * One blog post as a card: image, date, title, excerpt, and the way in. + * + * `row` on the blog itself, image beside the text, one post a line; `stack` + * in a grid — the landing's latest posts — image above. + */ +@Component({ + selector: 'app-post-card', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ApiImage], + styleUrl: './post-card.scss', + host: { '[class.post-card--row]': "layout() === 'row'" }, + template: ` + + `, +}) +export class PostCard { + readonly post = input.required(); + readonly copy = input.required(); + readonly locale = input.required(); + readonly layout = input<'row' | 'stack'>('stack'); + + protected readonly date = computed(() => { + const published = this.post().published_at; + + return published + ? new Intl.DateTimeFormat(this.locale(), { + day: 'numeric', + month: 'long', + year: 'numeric', + }).format(new Date(published)) + : ''; + }); +}