Compare commits

..

8 Commits

Author SHA1 Message Date
pigui
289af6c3a1 Update @pi/ui to 0.0.7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:15:06 +02:00
pigui
1ee4f98a9e Update @pi/ui to 0.0.6
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:18:59 +02:00
pigui
3366f57e14 Update @pi/ui to 0.0.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:33:58 +02:00
pigui
334640d938 Update @pi/ui to 0.0.4
It no longer registers Chart.js on import nor depends on lodash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:55:02 +02:00
pigui
63d4410296 Show proposals and votes from TaskStream on a project's page
The most voted bug reports and ideas, the link to send one while the
board takes them, and how many want each epic on the roadmap. The mock
API serves a showcase for the catalogue so every section can be seen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:21:40 +02:00
pigui
a3783222c8 Show a project's roadmap, releases and first issues from TaskStream
When a project has a board in TaskStream, its page lists the epics with
their dates and progress, the releases with their notes, the sprint under
way and the tickets meant for newcomers, linking to the board. TaskStream
being slow or down only leaves those sections out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:13:58 +02:00
pigui
bca3e4bcc6 Show who signs a post and its tags, and read the blog by tag
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:20:57 +02:00
pigui
918465a8ab Keep posts with code inside a phone's width
The single-column layout grew to its widest line, so a code block pushed the
whole text past the edge of the screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:12:19 +02:00
20 changed files with 5159 additions and 24 deletions

View File

@@ -131,6 +131,15 @@ pass on the server:
| Charlas | `/talks?limit=6` | | Charlas | `/talks?limit=6` |
| Contact form | `POST /contact` | | Contact form | `POST /contact` |
A project page also asks TaskStream, when the project has a board there
(`taskstream.showcase_url` in its payload): roadmap (with how many want each
epic), releases, the sprint under way, tickets labelled `good-first-issue`, and
the most voted bug reports and ideas from the public, with a link to send one
while the board takes them. It is public, answers within four seconds or the
page goes without those sections, and its links open the board (voting needs
signing in there). `npm run mock` serves a showcase for the «Catálogo abierto»
project, so every section can be seen without TaskStream.
`landing/openapi.yaml` is the specification the app was written against, and `landing/openapi.yaml` is the specification the app was written against, and
`landing/src/app/core/api.types.ts` is the API's own generated type file. Both `landing/src/app/core/api.types.ts` is the API's own generated type file. Both
are copies — refresh them from the API when the contract moves: are copies — refresh them from the API when the contract moves:
@@ -265,6 +274,11 @@ any page. That category has to exist with the slug `blog` in every language
- `/blog` (`/en/blog`, `/ca/blog`) lists them newest first. - `/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 - A post shows its date and leads back to the blog; its structured data is a
`BlogPosting`. `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>`. - `/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) - 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 and the landing shows the three latest under "Novedades"; the footer links

File diff suppressed because it is too large Load Diff

View File

@@ -165,6 +165,14 @@ export interface PublicPage {
unpublish_at?: string; unpublish_at?: string;
updated_at: string; updated_at: string;
category: PublicCategory; 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; alternates: Alternates;
path?: string; path?: string;
} }
@@ -233,6 +241,10 @@ export interface PublicProject {
lead: PublicMember; lead: PublicMember;
calls: PublicCall[]; calls: PublicCall[];
repositories: PublicRepository[]; repositories: PublicRepository[];
/**
* Its board in TaskStream, if it has one. `showcase_url` answers without a token: roadmap, releases, the sprint under way and tickets labelled good-first-issue.
*/
taskstream?: { key: string; url: string; web_url: string; showcase_url: string };
alternates: Alternates; alternates: Alternates;
path?: string; path?: string;
} }
@@ -268,6 +280,21 @@ export interface PublicRepository {
good_first_issues: PublicIssue[]; 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 { export interface PublicService {
id: number; id: number;
locale: string; locale: string;
@@ -382,6 +409,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/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/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/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': PublicProject[];
'/public/v1/projects/{slug}': PublicProject; '/public/v1/projects/{slug}': PublicProject;
'/public/v1/members': PublicMember[]; '/public/v1/members': PublicMember[];

View 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');
});
});

View File

@@ -8,6 +8,7 @@
*/ */
import type { Locale } from './api'; import type { Locale } from './api';
import type { PublicPage } from './api.types'; import type { PublicPage } from './api.types';
import { listingPath } from './routes';
export const BLOG_CATEGORY: Record<Locale, string> = { export const BLOG_CATEGORY: Record<Locale, string> = {
es: 'blog', es: 'blog',
@@ -25,3 +26,44 @@ export function byPublication(pages: PublicPage[]): PublicPage[] {
(b.published_at ?? '').localeCompare(a.published_at ?? ''), (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;
}

View File

@@ -107,6 +107,21 @@ export interface Copy {
archived: string; archived: string;
openCalls: string; openCalls: string;
technologies: string; technologies: string;
roadmap: string;
roadmapLead: string;
releases: string;
released: string;
planned: string;
sprint: string;
goodFirst: string;
goodFirstLead: string;
board: string;
done: string;
proposals: string;
proposalsLead: string;
propose: string;
voteOne: string;
voteMany: string;
}; };
member: { member: {
projects: string; projects: string;
@@ -137,6 +152,11 @@ export interface Copy {
published: string; published: string;
feed: string; feed: string;
seeAll: string; seeAll: string;
by: string;
tags: string;
allTags: string;
tagged: string;
noneTagged: string;
}; };
service: { service: {
eyebrow: string; eyebrow: string;
@@ -292,6 +312,21 @@ const es: Copy = {
archived: 'Archivado', archived: 'Archivado',
openCalls: 'Buscamos ayuda', openCalls: 'Buscamos ayuda',
technologies: 'Tecnologías', technologies: 'Tecnologías',
roadmap: 'Hoja de ruta',
roadmapLead: 'Lo que viene, según nuestro tablero.',
releases: 'Versiones',
released: 'Publicada',
planned: 'Prevista',
sprint: 'Ahora mismo',
goodFirst: 'Para empezar a colaborar',
goodFirstLead: 'Tareas pensadas para quien llega nuevo.',
board: 'Ver el tablero',
done: 'Hecho',
proposals: 'Ideas y errores',
proposalsLead: 'Lo que más se pide. Para votar, entra en el tablero.',
propose: 'Proponer una idea o avisar de un error',
voteOne: '1 voto',
voteMany: '{n} votos',
}, },
member: { member: {
projects: 'Proyectos', projects: 'Proyectos',
@@ -328,6 +363,11 @@ const es: Copy = {
published: 'Publicado el', published: 'Publicado el',
feed: 'Suscríbete por RSS', feed: 'Suscríbete por RSS',
seeAll: 'Ver todas las novedades', seeAll: 'Ver todas las novedades',
by: 'Por',
tags: 'Temas',
allTags: 'Todo',
tagged: 'Entradas sobre «{tag}»',
noneTagged: 'No hay entradas con esta etiqueta.',
}, },
service: { service: {
eyebrow: 'Servicio', eyebrow: 'Servicio',
@@ -496,6 +536,21 @@ const en: Copy = {
archived: 'Archived', archived: 'Archived',
openCalls: 'Looking for help', openCalls: 'Looking for help',
technologies: 'Technologies', technologies: 'Technologies',
roadmap: 'Roadmap',
roadmapLead: 'What is coming, from our board.',
releases: 'Releases',
released: 'Released',
planned: 'Planned',
sprint: 'Right now',
goodFirst: 'Good first issues',
goodFirstLead: 'Tasks meant for newcomers.',
board: 'See the board',
done: 'Done',
proposals: 'Ideas and bugs',
proposalsLead: 'What people ask for most. To vote, sign in on the board.',
propose: 'Suggest an idea or report a bug',
voteOne: '1 vote',
voteMany: '{n} votes',
}, },
member: { member: {
projects: 'Projects', projects: 'Projects',
@@ -532,6 +587,11 @@ const en: Copy = {
published: 'Published on', published: 'Published on',
feed: 'Subscribe via RSS', feed: 'Subscribe via RSS',
seeAll: 'See all the news', seeAll: 'See all the news',
by: 'By',
tags: 'Topics',
allTags: 'Everything',
tagged: 'Posts about “{tag}”',
noneTagged: 'There are no posts with this tag.',
}, },
service: { service: {
eyebrow: 'Service', eyebrow: 'Service',
@@ -700,6 +760,21 @@ const ca: Copy = {
archived: 'Arxivat', archived: 'Arxivat',
openCalls: 'Busquem ajuda', openCalls: 'Busquem ajuda',
technologies: 'Tecnologies', technologies: 'Tecnologies',
roadmap: 'Full de ruta',
roadmapLead: 'El que ve, segons el nostre tauler.',
releases: 'Versions',
released: 'Publicada',
planned: 'Prevista',
sprint: 'Ara mateix',
goodFirst: 'Per començar a col·laborar',
goodFirstLead: 'Tasques pensades per a qui arriba de nou.',
board: 'Veure el tauler',
done: 'Fet',
proposals: 'Idees i errors',
proposalsLead: 'El que més es demana. Per votar, entra al tauler.',
propose: "Proposar una idea o avisar d'un error",
voteOne: '1 vot',
voteMany: '{n} vots',
}, },
member: { member: {
projects: 'Projectes', projects: 'Projectes',
@@ -736,6 +811,11 @@ const ca: Copy = {
published: 'Publicat el', published: 'Publicat el',
feed: "Subscriu-t'hi per RSS", feed: "Subscriu-t'hi per RSS",
seeAll: 'Veure totes les novetats', seeAll: 'Veure totes les novetats',
by: 'Per',
tags: 'Temes',
allTags: 'Tot',
tagged: 'Entrades sobre «{tag}»',
noneTagged: 'No hi ha entrades amb aquesta etiqueta.',
}, },
service: { service: {
eyebrow: 'Servei', eyebrow: 'Servei',

View File

@@ -25,11 +25,16 @@ import type {
import { BLOG_CATEGORY, byPublication, isBlogPost } from './blog'; import { BLOG_CATEGORY, byPublication, isBlogPost } from './blog';
import { copyFor } from './copy'; import { copyFor } from './copy';
import { listingPath, resolveRoute, type RouteMatch } from './routes'; import { listingPath, resolveRoute, type RouteMatch } from './routes';
import {
fetchShowcase,
hasContent,
type ProjectBoard,
} from './taskstream';
/** 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 =
| { type: 'page'; record: PublicPage } | { type: 'page'; record: PublicPage }
| { type: 'project'; record: PublicProject } | { type: 'project'; record: PublicProject; board: ProjectBoard | null }
| { type: 'member'; record: PublicMember } | { type: 'member'; record: PublicMember }
| { type: 'call'; record: PublicCall } | { type: 'call'; record: PublicCall }
| { type: 'service'; record: PublicService } | { type: 'service'; record: PublicService }
@@ -82,7 +87,9 @@ async function fetchRecord(route: RouteMatch): Promise<RoutedRecord | null> {
} }
case 'project': { case 'project': {
const { data } = await pireonApi.project(locale, slug); const { data } = await pireonApi.project(locale, slug);
return data ? { type: 'project', record: data } : null; return data
? { type: 'project', record: data, board: await boardOf(data) }
: null;
} }
case 'member': { case 'member': {
const { data } = await pireonApi.member(locale, slug); const { data } = await pireonApi.member(locale, slug);
@@ -166,3 +173,13 @@ export function pageNavLinks(
] ]
: featured; : featured;
} }
/** The project's TaskStream board, when it has one with something to show. */
async function boardOf(project: PublicProject): Promise<ProjectBoard | null> {
const board = project.taskstream;
if (!board?.showcase_url) return null;
const showcase = await fetchShowcase(board.showcase_url);
return showcase && hasContent(showcase)
? { url: board.url, webUrl: board.web_url, showcase }
: null;
}

View File

@@ -0,0 +1,60 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { boardLink, fetchShowcase, hasContent } from './taskstream';
const showcase = {
project: { id: 'p', key: 'WEB', name: 'Web', description: null },
activeSprint: null,
roadmap: [],
releases: [],
goodFirst: [
{ key: 'WEB-9', title: 'Typo', type: 'TASK', path: '/p/WEB/issues/WEB-9' },
],
};
describe('TaskStream showcase', () => {
afterEach(() => vi.unstubAllGlobals());
it('reads a showcase, and goes without it when TaskStream fails', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(JSON.stringify(showcase))),
);
expect(await fetchShowcase('https://api.ts/showcase')).toEqual(showcase);
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('nope', { status: 404 })),
);
expect(await fetchShowcase('https://api.ts/showcase')).toBeNull();
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new Error('down');
}),
);
expect(await fetchShowcase('https://api.ts/showcase')).toBeNull();
});
it('builds links into the board and knows an empty one', () => {
expect(
boardLink('https://taskstream.pireon.dev/', '/p/WEB/issues/WEB-9'),
).toBe('https://taskstream.pireon.dev/p/WEB/issues/WEB-9');
expect(hasContent(showcase)).toBe(true);
expect(hasContent({ ...showcase, goodFirst: [] })).toBe(false);
});
it('counts a board that takes proposals as something to show', () => {
const empty = { ...showcase, goodFirst: [] };
expect(
hasContent({
...empty,
proposals: { open: true, path: '/p/WEB/proponer', top: [] },
}),
).toBe(true);
expect(
hasContent({ ...empty, proposals: { open: false, path: null, top: [] } }),
).toBe(false);
});
});

View File

@@ -0,0 +1,104 @@
/**
* A project's board in TaskStream, the collective's issue tracker: its public
* showcase (roadmap, releases, the sprint under way, tickets for newcomers
* and the proposals people send and vote for). Fetched on the server; a slow or absent TaskStream only means
* the project page goes without those sections.
*/
export interface TaskStreamShowcase {
project: {
id: string;
key: string;
name: string;
description: string | null;
};
activeSprint: {
name: string;
goal: string | null;
startDate: string;
endDate: string;
percent: number;
} | null;
roadmap: {
key: string;
title: string;
startDate: string | null;
dueDate: string | null;
percent: number;
done: boolean;
/** How many want it; older TaskStreams leave it out. */
votes?: number;
path: string;
}[];
releases: {
name: string;
status: 'PLANNED' | 'RELEASED' | 'ARCHIVED';
targetDate: string | null;
releasedAt: string | null;
percent: number;
notes: string | null;
}[];
goodFirst: { key: string; title: string; type: string; path: string }[];
/** Bug reports and ideas from anyone; older TaskStreams leave it out. */
proposals?: {
/** Whether it takes them now. */
open: boolean;
/** The form in TaskStream's web app, while it takes them. */
path: string | null;
/** Open ones, most voted first. */
top: {
key: string;
title: string;
type: string;
path: string;
votes: number;
}[];
};
}
/** What the page renders: the showcase plus where its links go. */
export interface ProjectBoard {
url: string;
webUrl: string;
showcase: TaskStreamShowcase;
}
export async function fetchShowcase(
showcaseUrl: string,
timeout = 4000,
): Promise<TaskStreamShowcase | null> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(showcaseUrl, {
headers: { Accept: 'application/json' },
signal: controller.signal,
});
if (!response.ok) return null;
const body = (await response.json()) as Partial<TaskStreamShowcase>;
return Array.isArray(body.roadmap) && Array.isArray(body.goodFirst)
? (body as TaskStreamShowcase)
: null;
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
/** Something worth showing: a board with nothing on it says nothing. */
export function hasContent(showcase: TaskStreamShowcase): boolean {
return (
showcase.roadmap.length > 0 ||
showcase.releases.length > 0 ||
showcase.goodFirst.length > 0 ||
showcase.activeSprint !== null ||
!!showcase.proposals?.open ||
(showcase.proposals?.top.length ?? 0) > 0
);
}
/** A showcase path as an address in TaskStream's web app. */
export function boardLink(webUrl: string, path: string): string {
return `${webUrl.replace(/\/$/, '')}${path}`;
}

View File

@@ -32,6 +32,10 @@
// ending in a dead end. // ending in a dead end.
.content-page__layout { .content-page__layout {
display: grid; display: grid;
// One column below the desktop layout, and one that may shrink: an
// implicit `auto` track grows to its widest line, and a code block in a
// post pushed the whole text past the edge of a phone.
grid-template-columns: minmax(0, 1fr);
gap: 3rem; gap: 3rem;
padding-bottom: 5rem; padding-bottom: 5rem;

View File

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

View File

@@ -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 { import {
PiNoteComponent, PiNoteComponent,
PiTextColorDirective, PiTextColorDirective,
@@ -8,12 +17,17 @@ 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 { tagHref, taggedWith, tagsOf } from '../core/blog';
import type { Copy } from '../core/copy'; import type { Copy } from '../core/copy';
import { listingPath } from '../core/routes';
import { PostCard } from './post-card'; 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
* leading to its own address like any page. * 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({ @Component({
selector: 'app-blog-page', selector: 'app-blog-page',
@@ -34,6 +48,11 @@ import { PostCard } from './post-card';
<p l pi-color color="grey" class="content-hero__lead"> <p l pi-color color="grey" class="content-hero__lead">
{{ copy().blog.lead }} {{ copy().blog.lead }}
</p> </p>
@if (activeTag(); as tag) {
<p class="blog-tagged">
{{ copy().blog.tagged.replace('{tag}', tag.name) }}
</p>
}
<p class="blog-feed"> <p class="blog-feed">
<a [href]="'/blog/rss.xml?lang=' + locale()">{{ <a [href]="'/blog/rss.xml?lang=' + locale()">{{
copy().blog.feed copy().blog.feed
@@ -43,9 +62,29 @@ import { PostCard } from './post-card';
</header> </header>
<div class="landing-shell blog-body"> <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"> <ul class="blog-list">
@for (post of posts(); track post.id) { @for (post of shown(); track post.id) {
<li> <li>
<app-post-card <app-post-card
[post]="post" [post]="post"
@@ -61,7 +100,7 @@ import { PostCard } from './post-card';
status="info" status="info"
appearance="fill" appearance="fill"
gap="xs" gap="xs"
[title]="copy().blog.empty" [title]="activeSlug() ? copy().blog.noneTagged : copy().blog.empty"
[text]="''" [text]="''"
/> />
} }
@@ -72,4 +111,29 @@ 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 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);
}
} }

View File

@@ -17,3 +17,28 @@
margin: 1rem 0 0; margin: 1rem 0 0;
font-size: 0.9375rem; 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);
}
}

View File

@@ -12,7 +12,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 { isBlogPost } from '../core/blog'; import { isBlogPost, tagHref } from '../core/blog';
import type { Copy } from '../core/copy'; import type { Copy } from '../core/copy';
import { listingPath } from '../core/routes'; import { listingPath } from '../core/routes';
import { ApiImage } from './api-image'; import { ApiImage } from './api-image';
@@ -55,6 +55,13 @@ import { ContactCta } from './contact-cta';
@if (isPost() && page().published_at; as published) { @if (isPost() && page().published_at; as published) {
<p class="content-page__muted content-page__date"> <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 }} {{ copy().blog.published }}
<time [attr.datetime]="published">{{ publishedOn() }}</time> <time [attr.datetime]="published">{{ publishedOn() }}</time>
</p> </p>
@@ -82,6 +89,16 @@ import { ContactCta } from './contact-cta';
} }
<div class="content-page__body" [innerHTML]="page().body ?? ''"></div> <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> </article>
<aside class="content-page__aside"> <aside class="content-page__aside">
@@ -126,6 +143,10 @@ export class ContentPage {
listingPath('blog', this.locale()), listingPath('blog', this.locale()),
); );
protected tagHref(slug: string): string {
return tagHref(this.locale(), slug);
}
protected readonly publishedOn = computed(() => { protected readonly publishedOn = computed(() => {
const published = this.page().published_at; const published = this.page().published_at;

View File

@@ -87,3 +87,69 @@
margin: 0; margin: 0;
} }
} }
.project-roadmap,
.project-releases,
.project-first,
.project-proposals {
display: grid;
gap: 0.75rem;
margin: 0;
padding: 0;
list-style: none;
}
.project-roadmap__item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.25rem 1rem;
align-items: baseline;
&.is-done a {
text-decoration: line-through;
}
}
.project-roadmap__bar {
grid-column: 1 / -1;
height: 0.375rem;
overflow: hidden;
border-radius: 999px;
background: color-mix(in srgb, currentColor 12%, transparent);
span {
display: block;
height: 100%;
background: var(--pi-color-primary, currentColor);
}
}
.project-releases li {
display: grid;
gap: 0.25rem;
}
.project-releases__notes {
margin: 0;
white-space: pre-wrap;
font: inherit;
font-size: 0.9rem;
}
.project-proposals li {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 1rem;
justify-content: space-between;
align-items: baseline;
}
// Clear of the lead above and of the link below.
.project-roadmap,
.project-proposals {
margin-top: 0.75rem;
}
.project-proposals__propose {
margin: 1.25rem 0 0;
}

View File

@@ -16,6 +16,7 @@ import {
import type { Locale } from '../core/api'; import type { Locale } from '../core/api';
import type { PublicProject } from '../core/api.types'; import type { PublicProject } from '../core/api.types';
import type { Copy } from '../core/copy'; import type { Copy } from '../core/copy';
import { boardLink, type ProjectBoard } from '../core/taskstream';
import { ApiImage } from './api-image'; import { ApiImage } from './api-image';
import { ContactCta } from './contact-cta'; import { ContactCta } from './contact-cta';
import { PROJECT_STATUS_TONES } from './project-card'; import { PROJECT_STATUS_TONES } from './project-card';
@@ -143,6 +144,91 @@ import { LinkButton } from './link-button';
</section> </section>
} }
@if (board(); as b) {
@if (b.showcase.roadmap.length > 0) {
<section class="content-page__section">
<h2>{{ copy().project.roadmap }}</h2>
<p class="content-page__muted">{{ copy().project.roadmapLead }}</p>
<ol class="project-roadmap">
@for (epic of b.showcase.roadmap; track epic.key) {
<li class="project-roadmap__item" [class.is-done]="epic.done">
<a [href]="link(epic.path)" target="_blank" rel="noopener">{{
epic.title
}}</a>
<span class="content-page__muted project-roadmap__when"
>{{ dates(epic.startDate, epic.dueDate) }}
@if (epic.votes && !epic.done) {
· {{ votes(epic.votes) }}
}
</span>
<span
class="project-roadmap__bar"
role="img"
[attr.aria-label]="epic.percent + '%'"
><span [style.width.%]="epic.percent"></span
></span>
</li>
}
</ol>
</section>
}
@if (b.showcase.proposals; as proposals) {
@if (proposals.open || proposals.top.length > 0) {
<section class="content-page__section">
<h2>{{ copy().project.proposals }}</h2>
@if (proposals.top.length > 0) {
<p class="content-page__muted">{{ copy().project.proposalsLead }}</p>
<ol class="project-proposals">
@for (proposal of proposals.top; track proposal.key) {
<li>
<a [href]="link(proposal.path)" target="_blank" rel="noopener">{{
proposal.title
}}</a>
<span class="content-page__muted">{{ votes(proposal.votes) }}</span>
</li>
}
</ol>
}
@if (proposals.open && proposals.path) {
<p class="project-proposals__propose">
<a [href]="link(proposals.path)" target="_blank" rel="noopener"
>{{ copy().project.propose }} →</a
>
</p>
}
</section>
}
}
@if (b.showcase.releases.length > 0) {
<section class="content-page__section">
<h2>{{ copy().project.releases }}</h2>
<ul class="project-releases">
@for (release of b.showcase.releases; track release.name) {
<li>
<strong>{{ release.name }}</strong>
<span class="content-page__muted">
{{
release.status === 'RELEASED'
? copy().project.released
: copy().project.planned
}}
@if (release.releasedAt ?? release.targetDate; as day) {
· {{ dates(day, null) }}
}
@if (release.status !== 'RELEASED') {
· {{ release.percent }}%
}
</span>
@if (release.notes) {
<pre class="project-releases__notes">{{ release.notes }}</pre>
}
</li>
}
</ul>
</section>
}
}
@if ((project().gallery ?? []).length > 0) { @if ((project().gallery ?? []).length > 0) {
<section class="content-page__section"> <section class="content-page__section">
<h2>{{ copy().project.gallery }}</h2> <h2>{{ copy().project.gallery }}</h2>
@@ -202,6 +288,36 @@ import { LinkButton } from './link-button';
</dl> </dl>
</div> </div>
@if (board(); as b) {
<div class="landing-card content-page__panel">
@if (b.showcase.activeSprint; as sprint) {
<p class="landing-eyebrow">{{ copy().project.sprint }}</p>
<p>
<strong>{{ sprint.name }}</strong> · {{ sprint.percent }}%
@if (sprint.goal) {
<br /><span class="content-page__muted">{{ sprint.goal }}</span>
}
</p>
}
@if (b.showcase.goodFirst.length > 0) {
<h2>{{ copy().project.goodFirst }}</h2>
<p class="content-page__muted">{{ copy().project.goodFirstLead }}</p>
<ul class="project-first">
@for (issue of b.showcase.goodFirst; track issue.key) {
<li>
<a [href]="link(issue.path)" target="_blank" rel="noopener"
>{{ issue.key }} · {{ issue.title }}</a
>
</li>
}
</ul>
}
<a [href]="b.url" target="_blank" rel="noopener">{{
copy().project.board
}} →</a>
</div>
}
@if ((project().calls ?? []).length > 0) { @if ((project().calls ?? []).length > 0) {
<nav [attr.aria-label]="copy().project.openCalls"> <nav [attr.aria-label]="copy().project.openCalls">
<p class="landing-eyebrow">{{ copy().project.openCalls }}</p> <p class="landing-eyebrow">{{ copy().project.openCalls }}</p>
@@ -304,6 +420,32 @@ export class ProjectDetail {
readonly project = input.required<PublicProject>(); readonly project = input.required<PublicProject>();
readonly copy = input.required<Copy>(); readonly copy = input.required<Copy>();
readonly locale = input.required<Locale>(); readonly locale = input.required<Locale>();
/** Its TaskStream board, when it has one with something to show. */
readonly board = input<ProjectBoard | null>(null);
protected link(path: string): string {
return boardLink(this.board()?.webUrl ?? '', path);
}
/** "1 voto", "3 votos". */
protected votes(count: number): string {
const copy = this.copy().project;
return count === 1 ? copy.voteOne : copy.voteMany.replace('{n}', String(count));
}
/** "3 nov – 20 dic 2026", or one end when only one is known. */
protected dates(start: string | null, end: string | null): string {
const format = (date: string | null) =>
date
? new Intl.DateTimeFormat(this.locale(), {
day: 'numeric',
month: 'short',
year: 'numeric',
timeZone: 'UTC',
}).format(new Date(`${date.slice(0, 10)}T00:00:00Z`))
: null;
return [format(start), format(end)].filter(Boolean).join(' – ');
}
protected readonly statusTone = computed( protected readonly statusTone = computed(
() => PROJECT_STATUS_TONES[this.project().status], () => PROJECT_STATUS_TONES[this.project().status],

View File

@@ -61,6 +61,7 @@ import { ProjectDetail } from './project-detail';
@case ('project') { @case ('project') {
<app-project-detail <app-project-detail
[project]="found.record" [project]="found.record"
[board]="found.board"
[copy]="copy()" [copy]="copy()"
[locale]="data().locale" [locale]="data().locale"
/> />

9
package-lock.json generated
View File

@@ -22,7 +22,7 @@
"@angular/platform-server": "~22.0.4", "@angular/platform-server": "~22.0.4",
"@angular/router": "~22.0.4", "@angular/router": "~22.0.4",
"@ngx-translate/core": "^18.0.0", "@ngx-translate/core": "^18.0.0",
"@pi/ui": "^0.0.2", "@pi/ui": "^0.0.7",
"front-matter": "^4.0.2", "front-matter": "^4.0.2",
"marked": "^15.0.7", "marked": "^15.0.7",
"marked-gfm-heading-id": "^4.1.1", "marked-gfm-heading-id": "^4.1.1",
@@ -9587,12 +9587,11 @@
} }
}, },
"node_modules/@pi/ui": { "node_modules/@pi/ui": {
"version": "0.0.2", "version": "0.0.7",
"resolved": "https://nexus.pireon.dev/repository/npm-releases/@pi/ui/-/ui-0.0.2.tgz", "resolved": "https://nexus.pireon.dev/repository/npm-releases/@pi/ui/-/ui-0.0.7.tgz",
"integrity": "sha1-kCo1VJKozqkA7ag+oOYIRoGESAg=", "integrity": "sha1-PkfcIIYypFMmuypt5C3UbxDNJbc=",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"lodash": "^4.17.0",
"luxon": "^3.0.0", "luxon": "^3.0.0",
"ngx-infinite-scroll": "^22.0.0", "ngx-infinite-scroll": "^22.0.0",
"tslib": "^2.3.0" "tslib": "^2.3.0"

View File

@@ -29,7 +29,7 @@
"@angular/platform-server": "~22.0.4", "@angular/platform-server": "~22.0.4",
"@angular/router": "~22.0.4", "@angular/router": "~22.0.4",
"@ngx-translate/core": "^18.0.0", "@ngx-translate/core": "^18.0.0",
"@pi/ui": "^0.0.2", "@pi/ui": "^0.0.7",
"front-matter": "^4.0.2", "front-matter": "^4.0.2",
"marked": "^15.0.7", "marked": "^15.0.7",
"marked-gfm-heading-id": "^4.1.1", "marked-gfm-heading-id": "^4.1.1",

View File

@@ -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>' + '<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>'; '<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), ...page(id, title, slug, false),
body: richBody, body: richBody,
published_at, 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.', 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 }, category: { id: 9, name: 'Blog', slug: 'blog', position: 3 },
}); });
@@ -86,8 +88,8 @@ const post = (id, title, slug, published_at) => ({
const pages = [ const pages = [
page(1, 'Sobre nosotros', 'sobre-nosotros', true), page(1, 'Sobre nosotros', 'sobre-nosotros', true),
page(2, 'Contacto', 'contacto', false), page(2, 'Contacto', 'contacto', false),
post(3, 'Por qué volvimos a renderizar en el servidor', 'volvimos-al-servidor', '2026-09-20T09: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'), post(4, 'Nuestro primer año como colectivo', 'primer-ano', '2026-06-01T09:00:00Z', [{ name: 'Colectivo', slug: 'colectivo' }]),
]; ];
const data = { const data = {
@@ -167,6 +169,38 @@ for (const listed of data['/public/v1/projects']) {
}; };
} }
// The catalogue has a board in TaskStream: its showcase is served here too,
// so the roadmap, releases, proposals and first issues show without one.
const showcase = {
project: { id: 'ts-1', key: 'CAT', name: 'Catálogo abierto', description: null },
activeSprint: { name: 'Sprint 4', goal: 'Buscar por etiqueta', startDate: '2026-10-05', endDate: '2026-10-19', percent: 40 },
roadmap: [
{ key: 'CAT-1', title: 'Fichas accesibles', startDate: '2026-07-01', dueDate: '2026-09-15', percent: 100, done: true, votes: 2, path: '/p/CAT/issues/CAT-1' },
{ key: 'CAT-7', title: 'Búsqueda por etiquetas', startDate: '2026-09-20', dueDate: '2026-11-30', percent: 45, done: false, votes: 9, path: '/p/CAT/issues/CAT-7' },
{ key: 'CAT-12', title: 'Exportar a PDF', startDate: null, dueDate: '2027-02-01', percent: 0, done: false, votes: 1, path: '/p/CAT/issues/CAT-12' },
],
releases: [
{ name: '1.2', status: 'PLANNED', targetDate: '2026-11-30', releasedAt: null, percent: 60, notes: null },
{ name: '1.1', status: 'RELEASED', targetDate: null, releasedAt: '2026-09-16', percent: 100, notes: '- Fichas accesibles\n- Arreglos en móvil' },
],
goodFirst: [{ key: 'CAT-21', title: 'Traducir los avisos al catalán', type: 't-task', path: '/p/CAT/issues/CAT-21' }],
proposals: {
open: true,
path: '/p/CAT/proponer',
top: [
{ key: 'CAT-30', title: 'Modo oscuro', type: 't-feature', path: '/p/CAT/issues/CAT-30', votes: 14 },
{ key: 'CAT-28', title: 'El buscador no encuentra palabras con tilde', type: 't-bug', path: '/p/CAT/issues/CAT-28', votes: 6 },
{ key: 'CAT-33', title: 'Compartir una ficha por enlace corto', type: 't-feature', path: '/p/CAT/issues/CAT-33', votes: 1 },
],
},
};
data['/public/v1/projects/catalogo'].taskstream = {
key: 'CAT',
url: 'https://taskstream.pireon.dev/p/CAT',
web_url: 'https://taskstream.pireon.dev',
showcase_url: 'http://localhost:4599/taskstream/CAT/showcase',
};
// Calls link to their own page, and one of them is closed so the page can // Calls link to their own page, and one of them is closed so the page can
// show what a visitor sees when a link to a filled call is still out there. // show what a visitor sees when a link to a filled call is still out there.
for (const call of data['/public/v1/calls']) { for (const call of data['/public/v1/calls']) {
@@ -235,6 +269,12 @@ createServer((req, res) => {
res.setHeader('content-type', 'application/json'); res.setHeader('content-type', 'application/json');
// TaskStream answers its showcase bare, not inside `data`.
if (path === '/taskstream/CAT/showcase') {
res.end(JSON.stringify(showcase));
return;
}
// The two write endpoints: accept whatever arrives, as the real API does // The two write endpoints: accept whatever arrives, as the real API does
// for a valid submission. // for a valid submission.
if (req.method === 'POST') { if (req.method === 'POST') {