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 <noreply@anthropic.com>
This commit is contained in:
pigui
2026-09-29 23:02:55 +02:00
parent 5ea9914583
commit e5d91d673a
8 changed files with 224 additions and 116 deletions

View File

@@ -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 - A post shows its date and leads back to the blog; its structured data is a
`BlogPosting`. `BlogPosting`.
- `/blog/rss.xml?lang=xx` is the feed, one per language, announced in `<head>`. - `/blog/rss.xml?lang=xx` is the feed, one per language, announced in `<head>`.
- The footer does not list posts one by one — it would grow without end — but - Once there is a post, the blog is in the header menu (desktop and mobile)
links the blog once there is any. 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 ## Search engines and link previews

View File

@@ -136,6 +136,7 @@ export interface Copy {
readMore: string; readMore: string;
published: string; published: string;
feed: string; feed: string;
seeAll: string;
}; };
service: { service: {
eyebrow: string; eyebrow: string;
@@ -326,6 +327,7 @@ const es: Copy = {
readMore: 'Leer', readMore: 'Leer',
published: 'Publicado el', published: 'Publicado el',
feed: 'Suscríbete por RSS', feed: 'Suscríbete por RSS',
seeAll: 'Ver todas las novedades',
}, },
service: { service: {
eyebrow: 'Servicio', eyebrow: 'Servicio',
@@ -529,6 +531,7 @@ const en: Copy = {
readMore: 'Read', readMore: 'Read',
published: 'Published on', published: 'Published on',
feed: 'Subscribe via RSS', feed: 'Subscribe via RSS',
seeAll: 'See all the news',
}, },
service: { service: {
eyebrow: 'Service', eyebrow: 'Service',
@@ -732,6 +735,7 @@ const ca: Copy = {
readMore: 'Llegir', readMore: 'Llegir',
published: 'Publicat el', published: 'Publicat el',
feed: "Subscriu-t'hi per RSS", feed: "Subscriu-t'hi per RSS",
seeAll: 'Veure totes les novetats',
}, },
service: { service: {
eyebrow: 'Servei', eyebrow: 'Servei',

View File

@@ -23,7 +23,8 @@ import type {
PublicTechnology, PublicTechnology,
} from './api.types'; } from './api.types';
import { BLOG_CATEGORY, byPublication, isBlogPost } from './blog'; 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. */ /** The record, tagged with its type so the template can switch on it. */
export type RoutedRecord = export type RoutedRecord =
@@ -132,13 +133,15 @@ async function fetchRecord(route: RouteMatch): Promise<RoutedRecord | null> {
/** /**
* Featured pages go in the header; the rest are one click away in the footer. * 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( export function pageNavLinks(
pages: PublicPage[], pages: PublicPage[],
locale: Locale, locale: Locale,
): { href: string; label: string }[] { ): { href: string; label: string }[] {
return pages const featured = pages
.filter( .filter(
(page) => (page) =>
page.is_featured && page.is_featured &&
@@ -150,4 +153,16 @@ export function pageNavLinks(
href: page.path as string, href: page.path as string,
label: page.title 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;
} }

View File

@@ -17,6 +17,7 @@ import {
PiChipComponent, PiChipComponent,
} from '@pi/ui'; } from '@pi/ui';
import { byPublication, isBlogPost } from '../core/blog';
import { pageNavLinks } from '../core/routed-page'; import { pageNavLinks } from '../core/routed-page';
import { listingPath } from '../core/routes'; import { listingPath } from '../core/routes';
import { copyFor } from '../core/copy'; import { copyFor } from '../core/copy';
@@ -30,6 +31,7 @@ import { Hero } from '../ui/hero';
import { IssueRow } from '../ui/issue-row'; import { IssueRow } from '../ui/issue-row';
import { MaintenanceScreen } from '../ui/maintenance-screen'; import { MaintenanceScreen } from '../ui/maintenance-screen';
import { MemberCard } from '../ui/member-card'; import { MemberCard } from '../ui/member-card';
import { PostCard } from '../ui/post-card';
import { ProjectCard } from '../ui/project-card'; import { ProjectCard } from '../ui/project-card';
import { Section } from '../ui/section'; import { Section } from '../ui/section';
import { ServiceCard } from '../ui/service-card'; import { ServiceCard } from '../ui/service-card';
@@ -66,6 +68,7 @@ export const routeMeta: RouteMeta = {
IssueRow, IssueRow,
MaintenanceScreen, MaintenanceScreen,
MemberCard, MemberCard,
PostCard,
ProjectCard, ProjectCard,
Section, Section,
ServiceCard, ServiceCard,
@@ -267,6 +270,25 @@ export const routeMeta: RouteMeta = {
</app-section> </app-section>
} }
<!-- The latest posts, so the blog is found from the landing rather than
only from a link in the footer. -->
@if (latestPosts().length > 0) {
<app-section
anchor="novedades"
[title]="copy().blog.title"
[lead]="copy().blog.lead"
>
<div class="landing-grid">
@for (post of latestPosts(); track post.id) {
<app-post-card [post]="post" [copy]="copy()" [locale]="data().locale" />
}
</div>
<p class="landing-section__more">
<a [href]="blogHref()">{{ copy().blog.seeAll }} →</a>
</p>
</app-section>
}
<app-section <app-section
anchor="contacto" anchor="contacto"
[title]="copy().sections.contact.title" [title]="copy().sections.contact.title"
@@ -302,6 +324,13 @@ export default class HomePage {
protected readonly radarHref = computed(() => listingPath('radar', this.data().locale)); protected readonly radarHref = computed(() => 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(() => protected readonly contributeHref = computed(() =>
listingPath('contribute', this.data().locale), listingPath('contribute', this.data().locale),
); );

View File

@@ -30,66 +30,3 @@
margin: 0; 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;
}

View File

@@ -9,7 +9,7 @@ import {
import type { Locale } from '../core/api'; import type { Locale } from '../core/api';
import type { PublicPage } from '../core/api.types'; import type { PublicPage } from '../core/api.types';
import type { Copy } from '../core/copy'; 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 * 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', selector: 'app-blog-page',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
ApiImage, PostCard,
PiNoteComponent, PiNoteComponent,
PiTextColorDirective, PiTextColorDirective,
PiTextLargeDirective, PiTextLargeDirective,
@@ -46,44 +46,13 @@ import { ApiImage } from './api-image';
@if (posts().length > 0) { @if (posts().length > 0) {
<ul class="blog-list"> <ul class="blog-list">
@for (post of posts(); track post.id) { @for (post of posts(); track post.id) {
<li <li>
class="landing-card blog-card" <app-post-card
[class.blog-card--media]="!!post.featured_image" [post]="post"
> [copy]="copy()"
@if (post.featured_image; as image) { [locale]="locale()"
<a layout="row"
class="blog-card__media"
[href]="post.path"
tabindex="-1"
aria-hidden="true"
>
<app-api-image
[image]="image"
sizes="(min-width: 1024px) 24rem, 100vw"
/> />
</a>
}
<div class="blog-card__body">
@if (post.published_at) {
<time
class="content-page__muted blog-card__date"
[attr.datetime]="post.published_at"
>
{{ date(post.published_at) }}
</time>
}
<h2 class="blog-card__title">
<a [href]="post.path">{{ post.title }}</a>
</h2>
@if (post.excerpt; as excerpt) {
<p class="content-page__muted blog-card__excerpt">
{{ excerpt }}
</p>
}
<a class="blog-card__more" [href]="post.path"
>{{ copy().blog.readMore }} →</a
>
</div>
</li> </li>
} }
</ul> </ul>
@@ -103,12 +72,4 @@ export class BlogPage {
readonly posts = input.required<PublicPage[]>(); readonly posts = input.required<PublicPage[]>();
readonly copy = input.required<Copy>(); readonly copy = input.required<Copy>();
readonly locale = input.required<Locale>(); readonly locale = input.required<Locale>();
protected date(value: string): string {
return new Intl.DateTimeFormat(this.locale(), {
day: 'numeric',
month: 'long',
year: 'numeric',
}).format(new Date(value));
}
} }

View File

@@ -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 `<img>` 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;
}

View File

@@ -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: `
<article
class="landing-card post-card"
[class.post-card--media]="!!post().featured_image"
>
@if (post().featured_image; as image) {
<a
class="post-card__media"
[href]="post().path"
tabindex="-1"
aria-hidden="true"
>
<app-api-image
[image]="image"
[sizes]="
layout() === 'row'
? '(min-width: 1024px) 20rem, 100vw'
: '(min-width: 1024px) 24rem, 100vw'
"
/>
</a>
}
<div class="post-card__body">
@if (post().published_at; as published) {
<time class="post-card__date" [attr.datetime]="published">{{
date()
}}</time>
}
<h3 class="post-card__title">
<a [href]="post().path">{{ post().title }}</a>
</h3>
@if (post().excerpt; as excerpt) {
<p class="post-card__excerpt">{{ excerpt }}</p>
}
<a class="post-card__more" [href]="post().path"
>{{ copy().blog.readMore }} →</a
>
</div>
</article>
`,
})
export class PostCard {
readonly post = input.required<PublicPage>();
readonly copy = input.required<Copy>();
readonly locale = input.required<Locale>();
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))
: '';
});
}