Show who signs a post and its tags, and read the blog by tag
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -265,6 +265,11 @@ any page. That category has to exist with the slug `blog` in every language
|
||||
- `/blog` (`/en/blog`, `/ca/blog`) lists them newest first.
|
||||
- A post shows its date and leads back to the blog; its structured data is a
|
||||
`BlogPosting`.
|
||||
- A post shows who signs it (`byline`, linked to their profile) and its tags,
|
||||
each linking to `/blog?tag=…`. The blog offers the tags in use as a row of
|
||||
links and narrows to one with `?tag=` — plain links, so a tag's listing can
|
||||
be shared and works before any script runs. Tags and bylines are set in the
|
||||
panel.
|
||||
- `/blog/rss.xml?lang=xx` is the feed, one per language, announced in `<head>`.
|
||||
- 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
|
||||
|
||||
@@ -165,6 +165,14 @@ export interface PublicPage {
|
||||
unpublish_at?: string;
|
||||
updated_at: string;
|
||||
category: PublicCategory;
|
||||
/**
|
||||
* The ones named in this language, to link to the blog filtered by each.
|
||||
*/
|
||||
tags: ({ name: string; slug: string })[];
|
||||
/**
|
||||
* The member who signs it, when they have a public profile.
|
||||
*/
|
||||
byline?: { name: string; slug: string; headline?: string; avatar?: Image; path: string };
|
||||
alternates: Alternates;
|
||||
path?: string;
|
||||
}
|
||||
@@ -268,6 +276,21 @@ export interface PublicRepository {
|
||||
good_first_issues: PublicIssue[];
|
||||
}
|
||||
|
||||
export interface PublicSearchResult {
|
||||
type: 'page' | 'project' | 'service' | 'call' | 'member' | 'technology';
|
||||
id: number;
|
||||
title: string;
|
||||
/**
|
||||
* Plain text around the first match.
|
||||
*/
|
||||
excerpt: string;
|
||||
path?: string;
|
||||
/**
|
||||
* Higher is a better match. Only comparable within one response.
|
||||
*/
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface PublicService {
|
||||
id: number;
|
||||
locale: string;
|
||||
@@ -382,6 +405,8 @@ export interface PublicApiPayloads {
|
||||
'/public/v1/site': { locales: ({ code: string; name: string; flag: string; is_default: boolean })[]; default_locale: string; content_version: number; submissions_enabled: boolean; stats: { projects: number; active_projects: number; members: number; open_calls: number; good_first_issues: number; stars: number } };
|
||||
'/public/v1/slugs': Record<string, ({ id: number; updated_at: string; alternates: Record<string, string> })[]>;
|
||||
'/public/v1/redirects': ({ from: string; to: string; status: 301 | 302; locale?: string })[];
|
||||
'/public/v1/search': PublicSearchResult[];
|
||||
'/public/v1/tags': ({ name: string; slug: string; count: number })[];
|
||||
'/public/v1/projects': PublicProject[];
|
||||
'/public/v1/projects/{slug}': PublicProject;
|
||||
'/public/v1/members': PublicMember[];
|
||||
|
||||
35
landing/src/app/core/blog.spec.ts
Normal file
35
landing/src/app/core/blog.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { PublicPage } from './api.types';
|
||||
import { tagHref, taggedWith, tagsOf } from './blog';
|
||||
|
||||
const post = (id: number, tags: { name: string; slug: string }[]) =>
|
||||
({ id, tags }) as unknown as PublicPage;
|
||||
|
||||
describe('blog tags', () => {
|
||||
const posts = [
|
||||
post(1, [{ name: 'Angular', slug: 'angular' }]),
|
||||
post(2, [
|
||||
{ name: 'Angular', slug: 'angular' },
|
||||
{ name: 'Accesibilidad', slug: 'accesibilidad' },
|
||||
]),
|
||||
post(3, []),
|
||||
];
|
||||
|
||||
it('lists each tag once, most used first', () => {
|
||||
expect(tagsOf(posts)).toEqual([
|
||||
{ name: 'Angular', slug: 'angular', count: 2 },
|
||||
{ name: 'Accesibilidad', slug: 'accesibilidad', count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('narrows the posts to a tag, or keeps them all', () => {
|
||||
expect(taggedWith(posts, 'accesibilidad').map((p) => p.id)).toEqual([2]);
|
||||
expect(taggedWith(posts, null)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('links to the blog in each language', () => {
|
||||
expect(tagHref('es', 'angular')).toBe('/blog?tag=angular');
|
||||
expect(tagHref('en', 'a b')).toBe('/en/blog?tag=a%20b');
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
import type { Locale } from './api';
|
||||
import type { PublicPage } from './api.types';
|
||||
import { listingPath } from './routes';
|
||||
|
||||
export const BLOG_CATEGORY: Record<Locale, string> = {
|
||||
es: 'blog',
|
||||
@@ -25,3 +26,44 @@ export function byPublication(pages: PublicPage[]): PublicPage[] {
|
||||
(b.published_at ?? '').localeCompare(a.published_at ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
export interface BlogTag {
|
||||
name: string;
|
||||
slug: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** The blog, showing only the posts with this tag. */
|
||||
export function tagHref(locale: Locale, slug: string): string {
|
||||
return `${listingPath('blog', locale)}?tag=${encodeURIComponent(slug)}`;
|
||||
}
|
||||
|
||||
/** Every tag on these posts, most used first, then by name. */
|
||||
export function tagsOf(posts: PublicPage[]): BlogTag[] {
|
||||
const tags = new Map<string, BlogTag>();
|
||||
|
||||
for (const post of posts) {
|
||||
for (const tag of post.tags ?? []) {
|
||||
const known = tags.get(tag.slug);
|
||||
tags.set(tag.slug, {
|
||||
name: tag.name,
|
||||
slug: tag.slug,
|
||||
count: (known?.count ?? 0) + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...tags.values()].sort(
|
||||
(a, b) => b.count - a.count || a.name.localeCompare(b.name),
|
||||
);
|
||||
}
|
||||
|
||||
/** The posts carrying a tag, or all of them when there is none. */
|
||||
export function taggedWith(
|
||||
posts: PublicPage[],
|
||||
tag: string | null,
|
||||
): PublicPage[] {
|
||||
return tag
|
||||
? posts.filter((post) => (post.tags ?? []).some((t) => t.slug === tag))
|
||||
: posts;
|
||||
}
|
||||
|
||||
@@ -137,6 +137,11 @@ export interface Copy {
|
||||
published: string;
|
||||
feed: string;
|
||||
seeAll: string;
|
||||
by: string;
|
||||
tags: string;
|
||||
allTags: string;
|
||||
tagged: string;
|
||||
noneTagged: string;
|
||||
};
|
||||
service: {
|
||||
eyebrow: string;
|
||||
@@ -328,6 +333,11 @@ const es: Copy = {
|
||||
published: 'Publicado el',
|
||||
feed: 'Suscríbete por RSS',
|
||||
seeAll: 'Ver todas las novedades',
|
||||
by: 'Por',
|
||||
tags: 'Temas',
|
||||
allTags: 'Todo',
|
||||
tagged: 'Entradas sobre «{tag}»',
|
||||
noneTagged: 'No hay entradas con esta etiqueta.',
|
||||
},
|
||||
service: {
|
||||
eyebrow: 'Servicio',
|
||||
@@ -532,6 +542,11 @@ const en: Copy = {
|
||||
published: 'Published on',
|
||||
feed: 'Subscribe via RSS',
|
||||
seeAll: 'See all the news',
|
||||
by: 'By',
|
||||
tags: 'Topics',
|
||||
allTags: 'Everything',
|
||||
tagged: 'Posts about “{tag}”',
|
||||
noneTagged: 'There are no posts with this tag.',
|
||||
},
|
||||
service: {
|
||||
eyebrow: 'Service',
|
||||
@@ -736,6 +751,11 @@ const ca: Copy = {
|
||||
published: 'Publicat el',
|
||||
feed: "Subscriu-t'hi per RSS",
|
||||
seeAll: 'Veure totes les novetats',
|
||||
by: 'Per',
|
||||
tags: 'Temes',
|
||||
allTags: 'Tot',
|
||||
tagged: 'Entrades sobre «{tag}»',
|
||||
noneTagged: 'No hi ha entrades amb aquesta etiqueta.',
|
||||
},
|
||||
service: {
|
||||
eyebrow: 'Servei',
|
||||
|
||||
@@ -30,3 +30,42 @@
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.blog-tagged {
|
||||
margin: 1rem 0 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.blog-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin: 0 0 2rem;
|
||||
}
|
||||
|
||||
.blog-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.875rem;
|
||||
border: 1px solid color-mix(in oklab, currentColor 18%, transparent);
|
||||
border-radius: 999px;
|
||||
color: inherit;
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--pi-color-primary);
|
||||
}
|
||||
|
||||
&[aria-current='page'] {
|
||||
border-color: var(--pi-color-primary);
|
||||
background: var(--pi-color-primary);
|
||||
color: var(--pi-color-primary-contrast, #fff);
|
||||
}
|
||||
}
|
||||
|
||||
.blog-tag__count {
|
||||
opacity: 0.7;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { map } from 'rxjs';
|
||||
import {
|
||||
PiNoteComponent,
|
||||
PiTextColorDirective,
|
||||
@@ -8,12 +17,17 @@ import {
|
||||
|
||||
import type { Locale } from '../core/api';
|
||||
import type { PublicPage } from '../core/api.types';
|
||||
import { tagHref, taggedWith, tagsOf } from '../core/blog';
|
||||
import type { Copy } from '../core/copy';
|
||||
import { listingPath } from '../core/routes';
|
||||
import { PostCard } from './post-card';
|
||||
|
||||
/**
|
||||
* The blog: the pages in the panel's "Blog" category, newest first, each
|
||||
* leading to its own address like any page.
|
||||
*
|
||||
* `?tag=` narrows it to one subject. Plain links rather than a client-side
|
||||
* filter, so a tag's page can be shared and works before any script runs.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-blog-page',
|
||||
@@ -34,6 +48,11 @@ import { PostCard } from './post-card';
|
||||
<p l pi-color color="grey" class="content-hero__lead">
|
||||
{{ copy().blog.lead }}
|
||||
</p>
|
||||
@if (activeTag(); as tag) {
|
||||
<p class="blog-tagged">
|
||||
{{ copy().blog.tagged.replace('{tag}', tag.name) }}
|
||||
</p>
|
||||
}
|
||||
<p class="blog-feed">
|
||||
<a [href]="'/blog/rss.xml?lang=' + locale()">{{
|
||||
copy().blog.feed
|
||||
@@ -43,9 +62,29 @@ import { PostCard } from './post-card';
|
||||
</header>
|
||||
|
||||
<div class="landing-shell blog-body">
|
||||
@if (posts().length > 0) {
|
||||
@if (tags().length > 0) {
|
||||
<nav class="blog-tags" [attr.aria-label]="copy().blog.tags">
|
||||
<a
|
||||
class="blog-tag"
|
||||
[href]="blogHref()"
|
||||
[attr.aria-current]="activeSlug() === null ? 'page' : null"
|
||||
>{{ copy().blog.allTags }}</a
|
||||
>
|
||||
@for (tag of tags(); track tag.slug) {
|
||||
<a
|
||||
class="blog-tag"
|
||||
[href]="tagHref(tag.slug)"
|
||||
[attr.aria-current]="activeSlug() === tag.slug ? 'page' : null"
|
||||
>{{ tag.name }}
|
||||
<span class="blog-tag__count">{{ tag.count }}</span></a
|
||||
>
|
||||
}
|
||||
</nav>
|
||||
}
|
||||
|
||||
@if (shown().length > 0) {
|
||||
<ul class="blog-list">
|
||||
@for (post of posts(); track post.id) {
|
||||
@for (post of shown(); track post.id) {
|
||||
<li>
|
||||
<app-post-card
|
||||
[post]="post"
|
||||
@@ -61,7 +100,7 @@ import { PostCard } from './post-card';
|
||||
status="info"
|
||||
appearance="fill"
|
||||
gap="xs"
|
||||
[title]="copy().blog.empty"
|
||||
[title]="activeSlug() ? copy().blog.noneTagged : copy().blog.empty"
|
||||
[text]="''"
|
||||
/>
|
||||
}
|
||||
@@ -72,4 +111,29 @@ export class BlogPage {
|
||||
readonly posts = input.required<PublicPage[]>();
|
||||
readonly copy = input.required<Copy>();
|
||||
readonly locale = input.required<Locale>();
|
||||
|
||||
protected readonly activeSlug = toSignal(
|
||||
inject(ActivatedRoute).queryParamMap.pipe(
|
||||
map((params) => params.get('tag')),
|
||||
),
|
||||
{ initialValue: null },
|
||||
);
|
||||
|
||||
protected readonly tags = computed(() => tagsOf(this.posts()));
|
||||
|
||||
protected readonly activeTag = computed(
|
||||
() => this.tags().find((tag) => tag.slug === this.activeSlug()) ?? null,
|
||||
);
|
||||
|
||||
protected readonly shown = computed(() =>
|
||||
taggedWith(this.posts(), this.activeSlug()),
|
||||
);
|
||||
|
||||
protected readonly blogHref = computed(() =>
|
||||
listingPath('blog', this.locale()),
|
||||
);
|
||||
|
||||
protected tagHref(slug: string): string {
|
||||
return tagHref(this.locale(), slug);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,3 +17,28 @@
|
||||
margin: 1rem 0 0;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.content-page__byline {
|
||||
color: inherit;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.content-page__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 2.5rem;
|
||||
}
|
||||
|
||||
.content-page__tag {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--pi-color-primary) 12%, transparent);
|
||||
color: inherit;
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in oklab, var(--pi-color-primary) 22%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
|
||||
import type { Locale } from '../core/api';
|
||||
import type { PublicPage } from '../core/api.types';
|
||||
import { isBlogPost } from '../core/blog';
|
||||
import { isBlogPost, tagHref } from '../core/blog';
|
||||
import type { Copy } from '../core/copy';
|
||||
import { listingPath } from '../core/routes';
|
||||
import { ApiImage } from './api-image';
|
||||
@@ -55,6 +55,13 @@ import { ContactCta } from './contact-cta';
|
||||
|
||||
@if (isPost() && page().published_at; as published) {
|
||||
<p class="content-page__muted content-page__date">
|
||||
@if (page().byline; as byline) {
|
||||
{{ copy().blog.by }}
|
||||
<a class="content-page__byline" [href]="byline.path">{{
|
||||
byline.name
|
||||
}}</a>
|
||||
·
|
||||
}
|
||||
{{ copy().blog.published }}
|
||||
<time [attr.datetime]="published">{{ publishedOn() }}</time>
|
||||
</p>
|
||||
@@ -82,6 +89,16 @@ import { ContactCta } from './contact-cta';
|
||||
}
|
||||
|
||||
<div class="content-page__body" [innerHTML]="page().body ?? ''"></div>
|
||||
|
||||
@if (isPost() && (page().tags ?? []).length > 0) {
|
||||
<nav class="content-page__tags" [attr.aria-label]="copy().blog.tags">
|
||||
@for (tag of page().tags; track tag.slug) {
|
||||
<a class="content-page__tag" [href]="tagHref(tag.slug)"
|
||||
>#{{ tag.name }}</a
|
||||
>
|
||||
}
|
||||
</nav>
|
||||
}
|
||||
</article>
|
||||
|
||||
<aside class="content-page__aside">
|
||||
@@ -126,6 +143,10 @@ export class ContentPage {
|
||||
listingPath('blog', this.locale()),
|
||||
);
|
||||
|
||||
protected tagHref(slug: string): string {
|
||||
return tagHref(this.locale(), slug);
|
||||
}
|
||||
|
||||
protected readonly publishedOn = computed(() => {
|
||||
const published = this.page().published_at;
|
||||
|
||||
|
||||
@@ -75,10 +75,12 @@ const richBody =
|
||||
'<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) => ({
|
||||
const post = (id, title, slug, published_at, tags = []) => ({
|
||||
...page(id, title, slug, false),
|
||||
body: richBody,
|
||||
published_at,
|
||||
tags,
|
||||
byline: { name: 'Ada Lovelace', slug: 'ada', headline: 'Backend', avatar: null, path: '/equipo/ada' },
|
||||
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 },
|
||||
});
|
||||
@@ -86,8 +88,8 @@ const post = (id, title, slug, published_at) => ({
|
||||
const pages = [
|
||||
page(1, 'Sobre nosotros', 'sobre-nosotros', true),
|
||||
page(2, 'Contacto', 'contacto', false),
|
||||
post(3, 'Por qué volvimos a renderizar en el servidor', 'volvimos-al-servidor', '2026-09-20T09:00:00Z'),
|
||||
post(4, 'Nuestro primer año como colectivo', 'primer-ano', '2026-06-01T09:00:00Z'),
|
||||
post(3, 'Por qué volvimos a renderizar en el servidor', 'volvimos-al-servidor', '2026-09-20T09:00:00Z', [{ name: 'Angular', slug: 'angular' }, { name: 'Rendimiento', slug: 'rendimiento' }]),
|
||||
post(4, 'Nuestro primer año como colectivo', 'primer-ano', '2026-06-01T09:00:00Z', [{ name: 'Colectivo', slug: 'colectivo' }]),
|
||||
];
|
||||
|
||||
const data = {
|
||||
|
||||
Reference in New Issue
Block a user