Compare commits
2 Commits
5ea9914583
...
e2e90d5512
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2e90d5512 | ||
|
|
e5d91d673a |
@@ -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 `<head>`.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<RoutedRecord | null> {
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
</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
|
||||
anchor="contacto"
|
||||
[title]="copy().sections.contact.title"
|
||||
@@ -302,6 +324,13 @@ export default class HomePage {
|
||||
|
||||
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(() =>
|
||||
listingPath('contribute', this.data().locale),
|
||||
);
|
||||
|
||||
@@ -90,18 +90,28 @@
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
// The bullet outside the text, where a list's bullet goes. `@pi/ui`'s
|
||||
// reset puts it inside, and the editor wraps each item's text in a `<p>`:
|
||||
// together the bullet sat alone on one line and the text, a block, fell to
|
||||
// the next — with a paragraph's margins on top.
|
||||
ul,
|
||||
ol {
|
||||
margin: 0 0 1.5rem;
|
||||
padding-left: 1.25rem;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0 0 0.5rem;
|
||||
padding-left: 0.25rem;
|
||||
list-style-position: outside;
|
||||
|
||||
&::marker {
|
||||
color: var(--pi-color-primary);
|
||||
}
|
||||
|
||||
> p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
strong {
|
||||
@@ -112,6 +122,67 @@
|
||||
color: var(--pi-color-primary);
|
||||
}
|
||||
|
||||
code {
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 0.375rem;
|
||||
background: color-mix(in srgb, var(--pi-color-primary) 10%, transparent);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0 0 1.5rem;
|
||||
padding: 1rem 1.25rem;
|
||||
overflow-x: auto;
|
||||
border-radius: var(--pi-border-radius-medium, 0.75rem);
|
||||
background: color-mix(in srgb, var(--pi-color-primary) 7%, transparent);
|
||||
line-height: 1.55;
|
||||
|
||||
code {
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
// `@pi/ui` already frames a quote with its own quotation marks; only the
|
||||
// spacing is this page's business.
|
||||
blockquote {
|
||||
margin: 0 0 1.5rem;
|
||||
|
||||
p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 2.5rem 0;
|
||||
border: 0;
|
||||
border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent);
|
||||
}
|
||||
|
||||
// Images in the text — attached in the panel's editor, or by URL through
|
||||
// the API. Never wider than the column, and set apart from the prose.
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin: 2rem 0;
|
||||
border-radius: var(--pi-border-radius-medium, 0.75rem);
|
||||
}
|
||||
|
||||
figure {
|
||||
margin: 2rem 0;
|
||||
|
||||
img {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
figcaption {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: color-mix(in srgb, currentColor 68%, transparent);
|
||||
}
|
||||
|
||||
> :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
<ul class="blog-list">
|
||||
@for (post of posts(); track post.id) {
|
||||
<li
|
||||
class="landing-card blog-card"
|
||||
[class.blog-card--media]="!!post.featured_image"
|
||||
>
|
||||
@if (post.featured_image; as image) {
|
||||
<a
|
||||
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>
|
||||
<app-post-card
|
||||
[post]="post"
|
||||
[copy]="copy()"
|
||||
[locale]="locale()"
|
||||
layout="row"
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@@ -103,12 +72,4 @@ export class BlogPage {
|
||||
readonly posts = input.required<PublicPage[]>();
|
||||
readonly copy = input.required<Copy>();
|
||||
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))
|
||||
: '';
|
||||
});
|
||||
}
|
||||
@@ -64,8 +64,20 @@ const page = (id, title, slug, featured) => ({
|
||||
alternates: { es: `/${slug}`, en: `/en/${slug}-en`, ca: `/ca/${slug}-ca` }, path: `/${slug}`,
|
||||
});
|
||||
|
||||
// A body with everything the editor can produce, images included, so the
|
||||
// post's typography can be reviewed without writing one in the panel.
|
||||
const richBody =
|
||||
'<p>Volvimos a renderizar en el servidor por tres motivos.</p>' +
|
||||
'<ul><li><p><strong>Rendimiento</strong> en móviles modestos.</p></li><li><p><strong>SEO</strong> sin trucos.</p></li></ul>' +
|
||||
'<img src="/logo.png" alt="El logo de Pireon">' +
|
||||
'<h2>Cómo lo hicimos</h2><p>Con <code>injectLoad</code> y un loader por página:</p>' +
|
||||
'<pre><code>export const load = async () => fetchPage();</code></pre>' +
|
||||
'<figure><img src="/logo.png" alt="Otra vez el logo"><figcaption>Una imagen con pie de foto.</figcaption></figure>' +
|
||||
'<blockquote><p>Lo que no se mide en un móvil de gama baja, no está medido.</p></blockquote>';
|
||||
|
||||
const post = (id, title, slug, published_at) => ({
|
||||
...page(id, title, slug, false),
|
||||
body: richBody,
|
||||
published_at,
|
||||
excerpt: 'Lo que hemos aprendido este mes, contado sin prisa y con ejemplos de código.',
|
||||
category: { id: 9, name: 'Blog', slug: 'blog', position: 3 },
|
||||
|
||||
Reference in New Issue
Block a user