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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
77
landing/src/app/ui/post-card.scss
Normal file
77
landing/src/app/ui/post-card.scss
Normal 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;
|
||||||
|
}
|
||||||
83
landing/src/app/ui/post-card.ts
Normal file
83
landing/src/app/ui/post-card.ts
Normal 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))
|
||||||
|
: '';
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user