Compare commits

..

13 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
pigui
e9082b90ee Restore the numeric field's left inset
The value in "Horas por semana" sat 12px to the left of every other
field -- and, more visibly, 12px left of its own label, which stayed
where the others were.

It is a defect in @pi/ui, measured rather than guessed:

  field           has-icon-prefix   prefix   value starts at
  text-field      false             --       16px
  email-field     false             --       16px
  number-field    true              4px      4px

`pi-number-field` declares `<ng-container matPrefix>` unconditionally,
so Material treats the field as having an icon prefix even with nothing
projected into it. The empty 4px prefix displaces the 16px inset the
other fields get. Only two of the library's twenty field templates
declare a prefix, both numeric, and only `incremental-number-field`
actually fills one -- so it is an oversight, not a pattern.

This is the stopgap, marked as such and scoped to that field. The real
fix is already written in the library (`pireon-ui`,
`number-field.component.html`, guarded by a `contentChild`) but is not
built or published yet; this rule comes out when that version lands.

Verified in both themes: all four fields now start their value and their
label at 16px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 19:36:00 +02:00
pigui
aea8f5675d Make the button-shaped links actual links
"Web oficial" did nothing when clicked, and it was not alone: six places
wrapped `<pi-action-button>` in an `<a>`. That component renders a
`<button>`, so each one was a button inside a link -- invalid HTML, and
the button swallows the click. Besides "Web oficial" it covered Demo,
Repositorio, the contact CTA on every detail page, the 404's way home
and a call card's apply button.

They become one `app-link-button`, which renders a real anchor. The
click was only part of it: a link can also be opened in a new tab,
middle-clicked, copied, tabbed to and followed by a crawler.

Its look does not come from @pi/ui's button classes, and not for want of
trying: those rules are scoped to the element (`html button:not(…)` in
`_pi-button.scss`), so on an anchor they contribute nothing except
`pi-color__primary` -- a *text* colour, which painted the label violet on
a violet fill. The stylesheet reads the same `--pi-theme-button-*`
tokens the library reads instead, so a change of shell or theme still
moves these buttons and dark mode needs nothing of its own.

Verified: the anchor reports href, target and a real navigation to the
external site; no `<button>` inside an `<a>` remains on the page; and
the rendered pill matches the header's button in radius, gradient fill,
white text and weight, in both themes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 19:07:15 +02:00
pigui
e2e90d5512 Fix lists in posts and pages, and style images inside the text
Every list in a post rendered its bullet alone on one line and the
item's text on the next. The panel's editor wraps each item's text in
a <p>, and @pi/ui's reset puts the bullet inside the line: a block
after an inline marker falls below it, paragraph margins and all.
Bullets now sit outside, with the paragraph inside an item unspaced.

Images in the text — attached in the panel or written in by URL through
the API — are never wider than the column and sit apart from the prose,
with a caption under a figure. Inline code and code blocks get a tinted
surface; quotes keep the library's own quotation marks.

The mock's posts carry such a body, so the typography can be reviewed
without writing one in the panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:11:46 +02:00
pigui
e5d91d673a 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>
2026-09-29 23:02:55 +02:00
pigui
5ea9914583 Give every technology a page of its own
A technology's name appeared in nine places -- the chips on project,
call, service and member cards and detail pages, the landing's ring
cards, the radar -- and not one was clickable. Now each leads to
`/tecnologias/:slug`, with the logo, what it is, where the collective
places it on the radar and a link to its own site.

Two constraints from the API shaped this, both verified against it:

Its address is the site's own. Technologies carry no `path` and no
`alternates`, and `/slugs` cannot list them -- its type vocabulary is a
closed enum. `routes.ts` opens by stating the API decides every record's
address; this is the first type where that is not true, so `SECTIONS`
gains the one entry and the comment says why. The sitemap composes these
URLs locally for the same reason, and an unreachable technologies call
there is not fatal the way `/slugs` is: it costs a handful of URLs
rather than misrepresenting the site.

There is no detail endpoint. `GET /technologies/{slug}` answers "route
not found" -- the route does not exist. `pireonApi.technology` filters
the collection instead, passing `in_use: false` because the default
returns only technologies attached to a project, which today is none.
An outage and an unknown slug stay distinguishable so the first is not
reported as a 404.

`website` is the only link the payload has: the project's own home page,
where its documentation lives. There is no separate docs field, so the
label says what the link is.

The chip markup was identical in all nine places, so it becomes one
`app-technology-chip` rather than nine anchors. `project-card` and
`radar-page` gain a `locale` input, which is all they lacked to address
it.

Verified: lint, 54 unit tests, build and 11 e2e. Against the mock, the
three addresses answer 200 and an unknown slug 404; the switcher moves
between them; canonical and hreflang are right; the sitemap lists all
three per technology.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-29 20:13:05 +02:00
46 changed files with 6292 additions and 223 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,9 +274,16 @@ 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>`.
- The footer does not list posts one by one — it would grow without end — but - Once there is a post, the blog is in the header menu (desktop and mobile)
links the blog once there is any. and the landing shows the three latest under "Novedades"; the footer links
it too, but does not list posts one by one — it would grow without end.
A blog reachable only from the footer turned out to be one nobody found.
## Search engines and link previews ## Search engines and link previews

View File

@@ -108,6 +108,15 @@ test.describe('landing', () => {
} }
}); });
test('answers 404 for a technology it does not know', async ({ page }) => {
// Shaped like a technology address, so the middleware lets it through and
// the status comes from the page once the collection had no such slug.
const response = await page.goto('/tecnologias/no-such-technology');
expect(response?.status()).toBe(404);
await expect(page.locator('h1')).toContainText('no existe');
});
test('refuses an incomplete contact submission without calling the API', async ({ test('refuses an incomplete contact submission without calling the API', async ({
page, page,
}) => { }) => {

File diff suppressed because it is too large Load Diff

View File

@@ -238,6 +238,39 @@ export const pireonApi = {
technologies: (lang: Locale, query?: GetOptions['query']) => technologies: (lang: Locale, query?: GetOptions['query']) =>
list<PublicTechnology>('/technologies', { lang, query }), list<PublicTechnology>('/technologies', { lang, query }),
/**
* One technology, by slug.
*
* The public API has no `/technologies/{slug}` — that route does not exist,
* so asking for one answers "route not found" rather than "no such record".
* This filters the collection instead: a couple of dozen records in a single
* request the route's `s-maxage` already caches.
*
* `in_use: false` because the parameter defaults to true, which returns only
* technologies attached to a project — none of them, today.
*
* A failed call and an unknown slug have to stay distinguishable: the first
* is an outage, the second is a 404. So the collection's `error` is passed
* through untouched and only a successful, complete answer can conclude that
* a technology does not exist.
*/
technology: async (
lang: Locale,
slug: string
): Promise<ApiResult<PublicTechnology>> => {
const all = await list<PublicTechnology>('/technologies', {
lang,
query: { in_use: false },
});
return {
...all,
data: all.error
? null
: (all.data?.find((technology) => technology.slug === slug) ?? null),
};
},
goodFirstIssues: (query?: GetOptions['query']) => goodFirstIssues: (query?: GetOptions['query']) =>
list<PublicIssue>('/good-first-issues', { query }), list<PublicIssue>('/good-first-issues', { query }),

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;
@@ -136,6 +151,12 @@ export interface Copy {
readMore: string; readMore: string;
published: string; published: string;
feed: string; feed: string;
seeAll: string;
by: string;
tags: string;
allTags: string;
tagged: string;
noneTagged: string;
}; };
service: { service: {
eyebrow: string; eyebrow: string;
@@ -143,6 +164,14 @@ export interface Copy {
testimonials: string; testimonials: string;
technologies: string; technologies: string;
}; };
technology: {
eyebrow: string;
ringTitle: string;
website: string;
usedIn: string;
membersUsing: string;
back: string;
};
radar: { radar: {
eyebrow: string; eyebrow: string;
lead: string; lead: string;
@@ -283,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',
@@ -318,6 +362,12 @@ 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',
by: 'Por',
tags: 'Temas',
allTags: 'Todo',
tagged: 'Entradas sobre «{tag}»',
noneTagged: 'No hay entradas con esta etiqueta.',
}, },
service: { service: {
eyebrow: 'Servicio', eyebrow: 'Servicio',
@@ -325,6 +375,14 @@ const es: Copy = {
testimonials: 'Qué dicen', testimonials: 'Qué dicen',
technologies: 'Con qué lo hacemos', technologies: 'Con qué lo hacemos',
}, },
technology: {
eyebrow: 'Tecnología',
ringTitle: 'En el radar',
website: 'Web oficial',
usedIn: 'proyectos la usan',
membersUsing: 'personas la usan',
back: 'Ver el radar completo',
},
radar: { radar: {
eyebrow: 'Radar tecnológico', eyebrow: 'Radar tecnológico',
lead: 'Las herramientas con las que trabajamos y lo que opinamos de cada una: qué usamos sin dudar, qué estamos probando y qué preferimos evitar.', lead: 'Las herramientas con las que trabajamos y lo que opinamos de cada una: qué usamos sin dudar, qué estamos probando y qué preferimos evitar.',
@@ -478,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',
@@ -513,6 +586,12 @@ 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',
by: 'By',
tags: 'Topics',
allTags: 'Everything',
tagged: 'Posts about “{tag}”',
noneTagged: 'There are no posts with this tag.',
}, },
service: { service: {
eyebrow: 'Service', eyebrow: 'Service',
@@ -520,6 +599,14 @@ const en: Copy = {
testimonials: 'What people say', testimonials: 'What people say',
technologies: 'What we use', technologies: 'What we use',
}, },
technology: {
eyebrow: 'Technology',
ringTitle: 'On the radar',
website: 'Official site',
usedIn: 'projects use it',
membersUsing: 'people use it',
back: 'See the whole radar',
},
radar: { radar: {
eyebrow: 'Tech radar', eyebrow: 'Tech radar',
lead: 'The tools we work with and what we think of each: what we use without a second thought, what we are trying out and what we would rather avoid.', lead: 'The tools we work with and what we think of each: what we use without a second thought, what we are trying out and what we would rather avoid.',
@@ -673,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',
@@ -708,6 +810,12 @@ 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',
by: 'Per',
tags: 'Temes',
allTags: 'Tot',
tagged: 'Entrades sobre «{tag}»',
noneTagged: 'No hi ha entrades amb aquesta etiqueta.',
}, },
service: { service: {
eyebrow: 'Servei', eyebrow: 'Servei',
@@ -715,6 +823,14 @@ const ca: Copy = {
testimonials: 'Què en diuen', testimonials: 'Què en diuen',
technologies: 'Amb què ho fem', technologies: 'Amb què ho fem',
}, },
technology: {
eyebrow: 'Tecnologia',
ringTitle: 'Al radar',
website: 'Web oficial',
usedIn: 'projectes la fan servir',
membersUsing: 'persones la fan servir',
back: 'Veure el radar complet',
},
radar: { radar: {
eyebrow: 'Radar tecnològic', eyebrow: 'Radar tecnològic',
lead: "Les eines amb què treballem i què en pensem de cadascuna: què fem servir sense dubtar, què estem provant i què preferim evitar.", lead: "Les eines amb què treballem i què en pensem de cadascuna: què fem servir sense dubtar, què estem provant i què preferim evitar.",

View File

@@ -23,15 +23,22 @@ 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';
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 }
| { type: 'technology'; record: PublicTechnology }
| { type: 'radar'; record: PublicTechnology[] } | { type: 'radar'; record: PublicTechnology[] }
| { type: 'blog'; record: PublicPage[] } | { type: 'blog'; record: PublicPage[] }
| { | {
@@ -80,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);
@@ -94,6 +103,10 @@ async function fetchRecord(route: RouteMatch): Promise<RoutedRecord | null> {
const { data } = await pireonApi.service(locale, slug); const { data } = await pireonApi.service(locale, slug);
return data ? { type: 'service', record: data } : null; return data ? { type: 'service', record: data } : null;
} }
case 'technology': {
const { data } = await pireonApi.technology(locale, slug);
return data ? { type: 'technology', record: data } : null;
}
case 'blog': { case 'blog': {
const { data, error } = await pireonApi.pages(locale, { const { data, error } = await pireonApi.pages(locale, {
category: BLOG_CATEGORY[locale], category: BLOG_CATEGORY[locale],
@@ -127,13 +140,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 &&
@@ -145,4 +160,26 @@ 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;
}
/** 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

@@ -7,6 +7,8 @@ import {
listingPath, listingPath,
resolveRoute, resolveRoute,
SECTIONS, SECTIONS,
technologyAlternates,
technologyPath,
} from './routes'; } from './routes';
describe('resolveRoute', () => { describe('resolveRoute', () => {
@@ -121,6 +123,48 @@ describe('listings', () => {
}); });
}); });
describe('technologies', () => {
it('reads one under its section, in each language', () => {
expect(resolveRoute('/tecnologias/angular')).toEqual({
type: 'technology',
locale: 'es',
slug: 'angular',
});
expect(resolveRoute('/en/technologies/angular')).toEqual({
type: 'technology',
locale: 'en',
slug: 'angular',
});
expect(resolveRoute('/ca/tecnologies/angular')).toEqual({
type: 'technology',
locale: 'ca',
slug: 'angular',
});
});
it('builds the address resolveRoute reads back', () => {
for (const locale of SUPPORTED_LOCALES) {
const path = technologyPath('angular', locale);
expect(resolveRoute(path)).toEqual({
type: 'technology',
locale,
slug: 'angular',
});
}
});
// The slug is a proper noun the API does not translate, so the three
// addresses differ only by the prefix.
it('keeps the same slug in every language', () => {
expect(technologyAlternates('angular')).toEqual({
es: '/tecnologias/angular',
en: '/en/technologies/angular',
ca: '/ca/tecnologies/angular',
});
});
});
describe('homeHref', () => { describe('homeHref', () => {
it('answers on ?lang= for every language but the default', () => { it('answers on ?lang= for every language but the default', () => {
expect(homeHref(DEFAULT_LOCALE)).toBe('/'); expect(homeHref(DEFAULT_LOCALE)).toBe('/');

View File

@@ -13,7 +13,13 @@
*/ */
import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type Locale } from './api'; import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type Locale } from './api';
export type RecordType = 'page' | 'project' | 'member' | 'call' | 'service'; export type RecordType =
| 'page'
| 'project'
| 'member'
| 'call'
| 'service'
| 'technology';
/** Pages this site composes itself rather than one record's: no slug. */ /** Pages this site composes itself rather than one record's: no slug. */
export type ListingType = 'radar' | 'contribute' | 'blog'; export type ListingType = 'radar' | 'contribute' | 'blog';
@@ -31,6 +37,11 @@ export const SECTIONS: Record<
member: { es: 'equipo', en: 'team', ca: 'equip' }, member: { es: 'equipo', en: 'team', ca: 'equip' },
call: { es: 'colabora', en: 'contribute', ca: 'col-labora' }, call: { es: 'colabora', en: 'contribute', ca: 'col-labora' },
service: { es: 'servicios', en: 'services', ca: 'serveis' }, service: { es: 'servicios', en: 'services', ca: 'serveis' },
// The one section the API does not know about. It gives technologies no
// `path` and no `alternates`, and `/slugs` cannot carry them — its type
// vocabulary is a closed enum. So this address is the site's own. Should the
// API ever publish one, this is where the two have to be reconciled.
technology: { es: 'tecnologias', en: 'technologies', ca: 'tecnologies' },
}; };
/** /**
@@ -100,6 +111,26 @@ export function listingAlternates(
); );
} }
/**
* A technology's address in one language, as `resolveRoute` reads it back.
*
* Unlike every other record, the slug is the same in all three: a technology's
* name is a proper noun, so the API does not translate it and the addresses
* differ only by the prefix.
*/
export function technologyPath(slug: string, locale: Locale): string {
const prefix = locale === DEFAULT_LOCALE ? '' : `/${locale}`;
return `${prefix}/${SECTIONS.technology[locale]}/${slug}`;
}
/** The same technology in every language, for `hreflang`. */
export function technologyAlternates(slug: string): Record<string, string> {
return Object.fromEntries(
SUPPORTED_LOCALES.map((locale) => [locale, technologyPath(slug, locale)]),
);
}
/** The landing itself in a given language. It answers on `?lang=`, not a prefix. */ /** The landing itself in a given language. It answers on `?lang=`, not a prefix. */
export function homeHref(locale: Locale): string { export function homeHref(locale: Locale): string {
return locale === DEFAULT_LOCALE ? '/' : `/?lang=${locale}`; return locale === DEFAULT_LOCALE ? '/' : `/?lang=${locale}`;

View File

@@ -14,7 +14,16 @@ const xml = buildSitemap('https://pireon.dev', {
}, },
}, },
], ],
}); }, [
{
id: 1,
name: 'Angular',
slug: 'angular',
category: 'framework',
projects_count: 0,
members_count: 0,
},
]);
describe('buildSitemap', () => { describe('buildSitemap', () => {
it('lists every language of a record as its own url', () => { it('lists every language of a record as its own url', () => {
@@ -41,6 +50,21 @@ describe('buildSitemap', () => {
expect(xml).toContain('<loc>https://pireon.dev/ca/col-labora</loc>'); expect(xml).toContain('<loc>https://pireon.dev/ca/col-labora</loc>');
}); });
// The API gives technologies no address and `/slugs` cannot carry them, so
// these are composed here and have to be in the sitemap all the same.
it('includes the technology pages this site composes', () => {
expect(xml).toContain('<loc>https://pireon.dev/tecnologias/angular</loc>');
expect(xml).toContain(
'<loc>https://pireon.dev/en/technologies/angular</loc>',
);
expect(xml).toContain(
'<loc>https://pireon.dev/ca/tecnologies/angular</loc>',
);
expect(xml).toContain(
'hreflang="ca" href="https://pireon.dev/ca/tecnologies/angular"',
);
});
it('is a well-formed document', () => { it('is a well-formed document', () => {
const document = new DOMParser().parseFromString(xml, 'application/xml'); const document = new DOMParser().parseFromString(xml, 'application/xml');

View File

@@ -7,10 +7,12 @@
* is not fetched first. * is not fetched first.
*/ */
import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type SlugEntry } from './api'; import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type SlugEntry } from './api';
import type { PublicTechnology } from './api.types';
import { import {
homeHref, homeHref,
listingAlternates, listingAlternates,
LISTINGS, LISTINGS,
technologyAlternates,
type ListingType, type ListingType,
} from './routes'; } from './routes';
@@ -22,6 +24,7 @@ interface SitemapEntry {
export function buildSitemap( export function buildSitemap(
origin: string, origin: string,
slugs: Record<string, SlugEntry[]>, slugs: Record<string, SlugEntry[]>,
technologies: PublicTechnology[] = [],
): string { ): string {
const entries: SitemapEntry[] = [ const entries: SitemapEntry[] = [
// The landing answers on `?lang=`, not a prefix. // The landing answers on `?lang=`, not a prefix.
@@ -34,6 +37,12 @@ export function buildSitemap(
alternates: listingAlternates(listing), alternates: listingAlternates(listing),
})), })),
...Object.values(slugs).flat(), ...Object.values(slugs).flat(),
// Technologies are absent from `/slugs` — the API gives them no address,
// so this site composes theirs. No `<lastmod>`: the payload has no
// `updated_at` to report.
...technologies.map((technology) => ({
alternates: technologyAlternates(technology.slug),
})),
]; ];
const urls = entries.flatMap((entry) => const urls = entries.flatMap((entry) =>

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

@@ -17,11 +17,13 @@ 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';
import { absoluteUrl, setShareImage, setStructuredData } from '../core/head'; import { absoluteUrl, setShareImage, setStructuredData } from '../core/head';
import { originFromHeaders } from '../core/origin'; import { originFromHeaders } from '../core/origin';
import { TechnologyChip } from '../ui/technology-chip';
import { CallCard } from '../ui/call-card'; import { CallCard } from '../ui/call-card';
import { ClientLogo } from '../ui/client-logo'; import { ClientLogo } from '../ui/client-logo';
import { ContactForm } from '../ui/contact-form'; import { ContactForm } from '../ui/contact-form';
@@ -29,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';
@@ -65,6 +68,7 @@ export const routeMeta: RouteMeta = {
IssueRow, IssueRow,
MaintenanceScreen, MaintenanceScreen,
MemberCard, MemberCard,
PostCard,
ProjectCard, ProjectCard,
Section, Section,
ServiceCard, ServiceCard,
@@ -72,6 +76,7 @@ export const routeMeta: RouteMeta = {
SiteHeader, SiteHeader,
TalkCard, TalkCard,
TestimonialCarousel, TestimonialCarousel,
TechnologyChip,
], ],
styleUrl: './home.page.scss', styleUrl: './home.page.scss',
template: ` template: `
@@ -123,7 +128,11 @@ export const routeMeta: RouteMeta = {
> >
<div class="landing-grid"> <div class="landing-grid">
@for (project of data().projects; track project.id) { @for (project of data().projects; track project.id) {
<app-project-card [project]="project" [copy]="copy()" /> <app-project-card
[project]="project"
[copy]="copy()"
[locale]="data().locale"
/>
} }
</div> </div>
</app-section> </app-section>
@@ -176,10 +185,9 @@ export const routeMeta: RouteMeta = {
<ul class="landing-card__chips"> <ul class="landing-card__chips">
@for (technology of group.technologies; track technology.id) { @for (technology of group.technologies; track technology.id) {
<li> <li>
<pi-chip <app-technology-chip
size="small" [technology]="technology"
appearance="outline" [locale]="data().locale"
[label]="technology.name"
/> />
</li> </li>
} }
@@ -262,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"
@@ -297,6 +324,13 @@ export default class HomePage {
protected readonly radarHref = computed(() => listingPath('radar', this.data().locale)); protected readonly radarHref = computed(() => listingPath('radar', this.data().locale));
protected readonly blogHref = computed(() => listingPath('blog', this.data().locale));
/** The three most recent posts; the rest are on the blog. */
protected readonly latestPosts = computed(() =>
byPublication(this.data().pages.filter((page) => isBlogPost(page, this.data().locale))).slice(0, 3),
);
protected readonly contributeHref = computed(() => protected readonly contributeHref = computed(() =>
listingPath('contribute', this.data().locale), listingPath('contribute', this.data().locale),
); );

View File

@@ -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;
@@ -90,18 +94,28 @@
margin: 0 0 1rem; 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, ul,
ol { ol {
margin: 0 0 1.5rem; margin: 0 0 1.5rem;
padding-left: 1.25rem; padding-left: 1.5rem;
} }
li { li {
margin: 0 0 0.5rem; margin: 0 0 0.5rem;
padding-left: 0.25rem;
list-style-position: outside;
&::marker { &::marker {
color: var(--pi-color-primary); color: var(--pi-color-primary);
} }
> p {
margin: 0;
}
} }
strong { strong {
@@ -112,6 +126,67 @@
color: var(--pi-color-primary); 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 { > :first-child {
margin-top: 0; margin-top: 0;
} }

View File

@@ -31,65 +31,41 @@
} }
} }
.blog-card { .blog-tagged {
display: grid; margin: 1rem 0 0;
overflow: hidden; font-weight: 600;
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 .blog-tags {
// 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; display: flex;
flex-direction: column; flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem;
padding: 1.5rem 1.75rem; margin: 0 0 2rem;
} }
.blog-card__date { .blog-tag {
font-size: 0.8125rem; display: inline-flex;
} align-items: center;
gap: 0.375rem;
.blog-card__title { padding: 0.375rem 0.875rem;
margin: 0; border: 1px solid color-mix(in oklab, currentColor 18%, transparent);
font-size: 1.375rem; border-radius: 999px;
font-weight: 700;
line-height: 1.25;
a {
color: inherit; color: inherit;
font-size: 0.875rem;
text-decoration: none; text-decoration: none;
&:hover { &:hover {
text-decoration: underline; 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-card__excerpt { .blog-tag__count {
margin: 0; opacity: 0.7;
line-height: 1.6; font-size: 0.75rem;
}
.blog-card__more {
margin-top: auto;
padding-top: 0.5rem;
color: var(--pi-color-primary);
font-weight: 600;
text-decoration: none;
} }

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,18 +17,23 @@ 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 { ApiImage } from './api-image'; import { listingPath } from '../core/routes';
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',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
ApiImage, PostCard,
PiNoteComponent, PiNoteComponent,
PiTextColorDirective, PiTextColorDirective,
PiTextLargeDirective, PiTextLargeDirective,
@@ -34,6 +48,11 @@ import { ApiImage } from './api-image';
<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,47 +62,36 @@ import { ApiImage } from './api-image';
</header> </header>
<div class="landing-shell blog-body"> <div class="landing-shell blog-body">
@if (posts().length > 0) { @if (tags().length > 0) {
<ul class="blog-list"> <nav class="blog-tags" [attr.aria-label]="copy().blog.tags">
@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 <a
class="blog-card__media" class="blog-tag"
[href]="post.path" [href]="blogHref()"
tabindex="-1" [attr.aria-current]="activeSlug() === null ? 'page' : null"
aria-hidden="true" >{{ copy().blog.allTags }}</a
> >
<app-api-image @for (tag of tags(); track tag.slug) {
[image]="image" <a
sizes="(min-width: 1024px) 24rem, 100vw" 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 shown(); track post.id) {
<li>
<app-post-card
[post]="post"
[copy]="copy()"
[locale]="locale()"
layout="row"
/> />
</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>
@@ -92,7 +100,7 @@ import { ApiImage } from './api-image';
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]="''"
/> />
} }
@@ -104,11 +112,28 @@ export class BlogPage {
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 { protected readonly activeSlug = toSignal(
return new Intl.DateTimeFormat(this.locale(), { inject(ActivatedRoute).queryParamMap.pipe(
day: 'numeric', map((params) => params.get('tag')),
month: 'long', ),
year: 'numeric', { initialValue: null },
}).format(new Date(value)); );
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

@@ -15,6 +15,8 @@ import {
import type { Copy } from '../core/copy'; import type { Copy } from '../core/copy';
import type { Locale } from '../core/api'; import type { Locale } from '../core/api';
import type { PublicCall } from '../core/api.types'; import type { PublicCall } from '../core/api.types';
import { TechnologyChip } from './technology-chip';
import { LinkButton } from './link-button';
@Component({ @Component({
selector: 'app-call-card', selector: 'app-call-card',
@@ -30,6 +32,8 @@ import type { PublicCall } from '../core/api.types';
PiTextXSmallDirective, PiTextXSmallDirective,
PiTextColorDirective, PiTextColorDirective,
PiTextWeightBoldDirective, PiTextWeightBoldDirective,
TechnologyChip,
LinkButton,
], ],
template: ` template: `
<pi-card <pi-card
@@ -74,7 +78,10 @@ import type { PublicCall } from '../core/api.types';
<ul class="landing-card__chips"> <ul class="landing-card__chips">
@for (technology of call().technologies; track technology.id) { @for (technology of call().technologies; track technology.id) {
<li> <li>
<pi-chip size="small" appearance="outline" [label]="technology.name" /> <app-technology-chip
[technology]="technology"
[locale]="locale()"
/>
</li> </li>
} }
</ul> </ul>
@@ -91,15 +98,13 @@ import type { PublicCall } from '../core/api.types';
<!-- To the call's own page and its form when it has one; to the <!-- To the call's own page and its form when it has one; to the
landing's general form otherwise. --> landing's general form otherwise. -->
@if (call().path; as path) { @if (call().path; as path) {
<a class="landing-card__button-link" [href]="path"> <app-link-button
<pi-action-button class="landing-card__button-link"
size="small" size="small"
color="primary"
appearance="outline" appearance="outline"
[hasIcon]="true" [href]="path"
[text]="copy().labels.apply" [text]="copy().labels.apply"
/> />
</a>
} @else { } @else {
<pi-action-button <pi-action-button
size="small" size="small"

View File

@@ -17,6 +17,7 @@ import type { PublicCall } from '../core/api.types';
import type { Copy } from '../core/copy'; import type { Copy } from '../core/copy';
import { homeHref } from '../core/routes'; import { homeHref } from '../core/routes';
import { ContactForm } from './contact-form'; import { ContactForm } from './contact-form';
import { TechnologyChip } from './technology-chip';
/** /**
* A call for help on its own address, with the form to answer it. * A call for help on its own address, with the form to answer it.
@@ -36,6 +37,7 @@ import { ContactForm } from './contact-form';
PiTextColorDirective, PiTextColorDirective,
PiTextLargeDirective, PiTextLargeDirective,
PiTextWeightBoldDirective, PiTextWeightBoldDirective,
TechnologyChip,
], ],
styleUrl: './call-detail.scss', styleUrl: './call-detail.scss',
template: ` template: `
@@ -82,10 +84,9 @@ import { ContactForm } from './contact-form';
} }
@for (technology of call().technologies ?? []; track technology.id) { @for (technology of call().technologies ?? []; track technology.id) {
<li> <li>
<pi-chip <app-technology-chip
size="small" [technology]="technology"
appearance="outline" [locale]="locale()"
[label]="technology.name"
/> />
</li> </li>
} }

View File

@@ -4,11 +4,12 @@ import {
computed, computed,
input, input,
} from '@angular/core'; } from '@angular/core';
import { PiActionButtonComponent, PiTextColorDirective } from '@pi/ui'; import { PiTextColorDirective } from '@pi/ui';
import type { Locale } from '../core/api'; import type { Locale } from '../core/api';
import type { Copy } from '../core/copy'; import type { Copy } from '../core/copy';
import { homeHref } from '../core/routes'; import { homeHref } from '../core/routes';
import { LinkButton } from './link-button';
/** /**
* "Shall we talk?" at the end of a page's side column, so no page is a dead * "Shall we talk?" at the end of a page's side column, so no page is a dead
@@ -17,20 +18,18 @@ import { homeHref } from '../core/routes';
@Component({ @Component({
selector: 'app-contact-cta', selector: 'app-contact-cta',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiActionButtonComponent, PiTextColorDirective], imports: [PiTextColorDirective, LinkButton],
template: ` template: `
<div class="landing-card contact-cta"> <div class="landing-card contact-cta">
<h2 class="contact-cta__title">{{ copy().page.ctaTitle }}</h2> <h2 class="contact-cta__title">{{ copy().page.ctaTitle }}</h2>
<p pi-color color="grey">{{ copy().page.ctaLead }}</p> <p pi-color color="grey">{{ copy().page.ctaLead }}</p>
<!-- An anchor around the library's button, which renders a <button>: <!-- An anchor around the library's button, which renders a <button>:
a real link, so it works before hydration and opens in a new tab. --> a real link, so it works before hydration and opens in a new tab. -->
<a class="contact-cta__link" [href]="href()"> <app-link-button
<pi-action-button class="contact-cta__link"
color="primary" [href]="href()"
[text]="copy().nav.contact" [text]="copy().nav.contact"
[hasIcon]="true"
/> />
</a>
</div> </div>
`, `,
styles: ` styles: `

View File

@@ -130,3 +130,26 @@
font-size: var(--pi-font-size-x-small, 0.75rem); font-size: var(--pi-font-size-x-small, 0.75rem);
} }
} }
// TEMPORARY. Remove when @pi/ui ships the fix.
//
// `pi-number-field` declares `<ng-container matPrefix>` unconditionally, so
// Material marks the field as having an icon prefix even though nothing is
// projected into it. The empty prefix is 4px wide and takes the place of the
// 16px inset every other field gets: the value ended up 12px to the left of
// its own label, which is what makes it look wrong.
//
// Only two of the library's twenty fields declare a prefix, both numeric, and
// only `incremental-number-field` actually uses one — so this is an oversight
// rather than a pattern. The fix belongs there and is written:
// `pireon-ui`, `number-field.component.html`, guarded by a `contentChild`.
// This restores the inset here until that version is published.
:host ::ng-deep .pi-form-field__number {
.mat-mdc-form-field-icon-prefix {
display: none;
}
.mat-mdc-form-field-infix {
padding-left: 16px;
}
}

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

@@ -0,0 +1,79 @@
:host {
display: inline-flex;
}
// `@pi/ui` scopes its button rules to the element (`html button:not(…)` in
// `_pi-button.scss`), so an anchor cannot inherit them. Every value below is
// still the library's: these are the same `--pi-theme-button-*` custom
// properties its own rules read, so switching shell or theme moves this button
// too, and dark mode needs nothing of its own.
.link-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
min-width: 5.5rem;
border: none;
border-radius: var(--pi-theme-button-radius);
line-height: 100%;
text-decoration: none;
letter-spacing: var(--pi-theme-button-letter-spacing);
text-transform: var(--pi-theme-button-text-transform);
font-weight: var(--pi-theme-button-font-weight);
transition: var(--pi-theme-button-transition);
cursor: pointer;
svg {
flex: none;
width: 0.375rem;
height: 0.5625rem;
}
&:focus-visible {
outline: 2px solid var(--pi-color-primary);
outline-offset: 3px;
}
}
.link-button--medium,
.link-button--large {
min-height: var(--pi-theme-button-height);
padding: var(--pi-spacing-xx-small) var(--pi-spacing-x-large);
}
.link-button--small {
min-width: 4.5rem;
padding: var(--pi-spacing-xxxx-small) var(--pi-spacing-medium);
font-size: 0.8125rem;
}
// Filled. The theme may inject a gradient over the flat colour, exactly as it
// does for a real button.
.link-button--fill {
color: var(--pi-color-white);
box-shadow: var(--pi-theme-button-shadow);
&.link-button--primary {
background-color: var(--pi-color-primary);
background-image: var(--pi-theme-button-fill-primary, none);
}
&.link-button--grey {
background-color: var(--pi-color-grey);
}
}
// Outline. The colour is the border and the text; the surface stays clear.
.link-button--outline {
background: none;
&.link-button--primary {
border: 1px solid var(--pi-color-primary);
color: var(--pi-color-primary);
}
&.link-button--grey {
border: 1px solid var(--pi-color-grey-200);
color: var(--pi-color-grey);
}
}

View File

@@ -0,0 +1,73 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
/**
* A link that looks like the library's action button.
*
* `pi-action-button` renders a `<button>`, so wrapping one in an `<a>` puts a
* button inside a link: invalid HTML, and the button swallows the click, so
* nothing navigates. That is what stopped "Web oficial" working, and the same
* pattern sat in five other places.
*
* Making it an anchor is not only about the click. A real link can be opened
* in a new tab, middle-clicked, copied, reached by keyboard and followed by a
* crawler; a button can do none of that.
*
* The library's own button classes are deliberately NOT reused. Its rules are
* scoped to the element — `html button:not(…)` — so on an anchor they
* contribute nothing except `pi-color__primary`, which is a *text* colour and
* actively fights the white a filled button needs. The stylesheet beside this
* file reads the same `--pi-theme-button-*` tokens the library reads instead,
* so a change of shell or theme still reaches this button.
*/
type Size = 'small' | 'medium' | 'large';
type Tone = 'primary' | 'grey';
@Component({
selector: 'app-link-button',
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './link-button.scss',
template: `
<a
[class]="classes()"
[href]="href()"
[attr.target]="external() ? '_blank' : null"
[attr.rel]="external() ? 'noopener noreferrer' : null"
>
<span>{{ text() }}</span>
<svg
viewBox="0 0 6 9"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<g>
<path
d="M1 1C4.5 4 5 4.38484 5 4.5C5 4.61516 4.5 5 1 8"
stroke="currentColor"
stroke-width="1"
/>
</g>
<g>
<path d="M4.5 4.5H0" stroke="currentColor" stroke-width="1" />
</g>
</svg>
</a>
`,
})
export class LinkButton {
readonly href = input.required<string>();
readonly text = input.required<string>();
readonly size = input<Size>('medium');
readonly color = input<Tone>('primary');
readonly appearance = input<'fill' | 'outline'>('fill');
/** Opens in a new tab, for addresses that are not ours. */
readonly external = input(false);
protected readonly classes = computed(() => [
'link-button',
`link-button--${this.size()}`,
`link-button--${this.color()}`,
`link-button--${this.appearance()}`,
]);
}

View File

@@ -18,6 +18,7 @@ import type { Copy } from '../core/copy';
import { ContactCta } from './contact-cta'; import { ContactCta } from './contact-cta';
import { memberLinks } from './member-card'; import { memberLinks } from './member-card';
import { ProjectCard } from './project-card'; import { ProjectCard } from './project-card';
import { TechnologyChip } from './technology-chip';
/** /**
* Someone in the collective, on their own address: who they are, what they * Someone in the collective, on their own address: who they are, what they
@@ -37,6 +38,7 @@ import { ProjectCard } from './project-card';
PiTextLargeDirective, PiTextLargeDirective,
PiTextWeightBoldDirective, PiTextWeightBoldDirective,
ProjectCard, ProjectCard,
TechnologyChip,
], ],
styleUrl: './member-detail.scss', styleUrl: './member-detail.scss',
template: ` template: `
@@ -100,7 +102,11 @@ import { ProjectCard } from './project-card';
<h2>{{ copy().member.projects }}</h2> <h2>{{ copy().member.projects }}</h2>
<div class="member-projects"> <div class="member-projects">
@for (project of member().projects; track project.id) { @for (project of member().projects; track project.id) {
<app-project-card [project]="project" [copy]="copy()" /> <app-project-card
[project]="project"
[copy]="copy()"
[locale]="locale()"
/>
} }
</div> </div>
</section> </section>
@@ -146,10 +152,9 @@ import { ProjectCard } from './project-card';
<ul class="content-hero__chips member-technologies"> <ul class="content-hero__chips member-technologies">
@for (technology of member().technologies; track technology.id) { @for (technology of member().technologies; track technology.id) {
<li> <li>
<pi-chip <app-technology-chip
size="small" [technology]="technology"
appearance="outline" [locale]="locale()"
[label]="technology.name"
/> />
</li> </li>
} }

View File

@@ -1,6 +1,6 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'; import { ChangeDetectionStrategy, Component } from '@angular/core';
import { LinkButton } from './link-button';
import { import {
PiActionButtonComponent,
PiIconComponent, PiIconComponent,
PiTextColorDirective, PiTextColorDirective,
PiTextMediumDirective, PiTextMediumDirective,
@@ -26,12 +26,12 @@ import {
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
PiIconComponent, PiIconComponent,
PiActionButtonComponent,
PiTextXLargeDirective, PiTextXLargeDirective,
PiTextMediumDirective, PiTextMediumDirective,
PiTextSmallDirective, PiTextSmallDirective,
PiTextColorDirective, PiTextColorDirective,
PiTextWeightBoldDirective, PiTextWeightBoldDirective,
LinkButton,
], ],
styleUrl: './not-found.scss', styleUrl: './not-found.scss',
template: ` template: `
@@ -47,14 +47,12 @@ import {
La dirección puede haber cambiado. Vuelve al inicio y sigue desde ahí. La dirección puede haber cambiado. Vuelve al inicio y sigue desde ahí.
</p> </p>
<a class="not-found__home" href="/"> <app-link-button
<pi-action-button class="not-found__home"
size="large" size="large"
color="primary" href="/"
text="Ir al inicio" text="Ir al inicio"
[hasIcon]="true"
/> />
</a>
<p s pi-color color="grey" class="not-found__note"> <p s pi-color color="grey" class="not-found__note">
Si has llegado desde un enlace nuestro, cuéntanoslo. Si has llegado desde un enlace nuestro, cuéntanoslo.

View File

@@ -0,0 +1,77 @@
:host {
display: block;
height: 100%;
}
.post-card {
display: grid;
grid-template-rows: auto 1fr;
height: 100%;
overflow: hidden;
border-radius: var(--pi-theme-card-radius, 1.5rem);
box-shadow: var(--pi-theme-card-shadow, none);
}
// Beside the text rather than above it, and only when there is an image:
// without one the text would sit in the image's column with the rest empty.
:host(.post-card--row) .post-card--media {
@media (min-width: 768px) {
grid-template-rows: none;
grid-template-columns: minmax(0, 20rem) minmax(0, 1fr);
}
}
// The `<img>` belongs to `app-api-image`, so a scoped rule cannot reach it.
.post-card__media ::ng-deep img {
display: block;
width: 100%;
height: 100%;
aspect-ratio: 16 / 10;
object-fit: cover;
}
.post-card__body {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1.5rem 1.75rem;
}
.post-card__date {
font-size: 0.8125rem;
color: color-mix(in srgb, currentColor 68%, transparent);
}
.post-card__title {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
line-height: 1.25;
:host(.post-card--row) & {
font-size: 1.375rem;
}
a {
color: inherit;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
.post-card__excerpt {
margin: 0;
line-height: 1.6;
color: color-mix(in srgb, currentColor 68%, transparent);
}
.post-card__more {
margin-top: auto;
padding-top: 0.5rem;
color: var(--pi-color-primary);
font-weight: 600;
text-decoration: none;
}

View File

@@ -0,0 +1,83 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import type { Locale } from '../core/api';
import type { PublicPage } from '../core/api.types';
import type { Copy } from '../core/copy';
import { ApiImage } from './api-image';
/**
* One blog post as a card: image, date, title, excerpt, and the way in.
*
* `row` on the blog itself, image beside the text, one post a line; `stack`
* in a grid — the landing's latest posts — image above.
*/
@Component({
selector: 'app-post-card',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ApiImage],
styleUrl: './post-card.scss',
host: { '[class.post-card--row]': "layout() === 'row'" },
template: `
<article
class="landing-card post-card"
[class.post-card--media]="!!post().featured_image"
>
@if (post().featured_image; as image) {
<a
class="post-card__media"
[href]="post().path"
tabindex="-1"
aria-hidden="true"
>
<app-api-image
[image]="image"
[sizes]="
layout() === 'row'
? '(min-width: 1024px) 20rem, 100vw'
: '(min-width: 1024px) 24rem, 100vw'
"
/>
</a>
}
<div class="post-card__body">
@if (post().published_at; as published) {
<time class="post-card__date" [attr.datetime]="published">{{
date()
}}</time>
}
<h3 class="post-card__title">
<a [href]="post().path">{{ post().title }}</a>
</h3>
@if (post().excerpt; as excerpt) {
<p class="post-card__excerpt">{{ excerpt }}</p>
}
<a class="post-card__more" [href]="post().path"
>{{ copy().blog.readMore }} →</a
>
</div>
</article>
`,
})
export class PostCard {
readonly post = input.required<PublicPage>();
readonly copy = input.required<Copy>();
readonly locale = input.required<Locale>();
readonly layout = input<'row' | 'stack'>('stack');
protected readonly date = computed(() => {
const published = this.post().published_at;
return published
? new Intl.DateTimeFormat(this.locale(), {
day: 'numeric',
month: 'long',
year: 'numeric',
}).format(new Date(published))
: '';
});
}

View File

@@ -12,8 +12,10 @@ import {
PiTextWeightBoldDirective, PiTextWeightBoldDirective,
} from '@pi/ui'; } from '@pi/ui';
import type { Locale } from '../core/api';
import type { Copy } from '../core/copy'; import type { Copy } from '../core/copy';
import type { PublicProject } from '../core/api.types'; import type { PublicProject } from '../core/api.types';
import { TechnologyChip } from './technology-chip';
import { ApiImage } from './api-image'; import { ApiImage } from './api-image';
/** The API's five project states onto the status chip's five tones. */ /** The API's five project states onto the status chip's five tones. */
@@ -43,6 +45,7 @@ export const PROJECT_STATUS_TONES: Record<
PiTextSmallDirective, PiTextSmallDirective,
PiTextColorDirective, PiTextColorDirective,
PiTextWeightBoldDirective, PiTextWeightBoldDirective,
TechnologyChip,
], ],
template: ` template: `
<pi-card <pi-card
@@ -96,7 +99,10 @@ export const PROJECT_STATUS_TONES: Record<
<ul class="landing-card__chips"> <ul class="landing-card__chips">
@for (technology of technologies(); track technology.id) { @for (technology of technologies(); track technology.id) {
<li> <li>
<pi-chip size="small" appearance="outline" [label]="technology.name" /> <app-technology-chip
[technology]="technology"
[locale]="locale()"
/>
</li> </li>
} }
</ul> </ul>
@@ -131,6 +137,8 @@ export const PROJECT_STATUS_TONES: Record<
export class ProjectCard { export class ProjectCard {
readonly project = input.required<PublicProject>(); readonly project = input.required<PublicProject>();
readonly copy = input.required<Copy>(); readonly copy = input.required<Copy>();
/** Only to address the technology chips, which link to their own pages. */
readonly locale = input.required<Locale>();
/** Enough to read the stack at a glance; the rest is noise on a card. */ /** Enough to read the stack at a glance; the rest is noise on a card. */
protected readonly technologies = computed(() => protected readonly technologies = computed(() =>

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

@@ -5,7 +5,6 @@ import {
input, input,
} from '@angular/core'; } from '@angular/core';
import { import {
PiActionButtonComponent,
PiAvatarComponent, PiAvatarComponent,
PiChipComponent, PiChipComponent,
PiChipStatusComponent, PiChipStatusComponent,
@@ -17,9 +16,12 @@ 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';
import { TechnologyChip } from './technology-chip';
import { LinkButton } from './link-button';
/** /**
* A project on its own address: what it is, why it was built the way it was, * A project on its own address: what it is, why it was built the way it was,
@@ -34,13 +36,14 @@ import { PROJECT_STATUS_TONES } from './project-card';
imports: [ imports: [
ApiImage, ApiImage,
ContactCta, ContactCta,
PiActionButtonComponent,
PiAvatarComponent, PiAvatarComponent,
PiChipComponent, PiChipComponent,
PiChipStatusComponent, PiChipStatusComponent,
PiTextColorDirective, PiTextColorDirective,
PiTextLargeDirective, PiTextLargeDirective,
PiTextWeightBoldDirective, PiTextWeightBoldDirective,
TechnologyChip,
LinkButton,
], ],
styleUrl: './project-detail.scss', styleUrl: './project-detail.scss',
template: ` template: `
@@ -79,10 +82,9 @@ import { PROJECT_STATUS_TONES } from './project-card';
track technology.id track technology.id
) { ) {
<li> <li>
<pi-chip <app-technology-chip
size="small" [technology]="technology"
appearance="outline" [locale]="locale()"
[label]="technology.name"
/> />
</li> </li>
} }
@@ -91,23 +93,20 @@ import { PROJECT_STATUS_TONES } from './project-card';
@if (project().demo_url || repositoryUrl()) { @if (project().demo_url || repositoryUrl()) {
<div class="content-hero__actions"> <div class="content-hero__actions">
@if (project().demo_url; as demo) { @if (project().demo_url; as demo) {
<a [href]="demo" target="_blank" rel="noopener"> <app-link-button
<pi-action-button [href]="demo"
color="primary"
[text]="copy().labels.demo" [text]="copy().labels.demo"
[hasIcon]="true" [external]="true"
/> />
</a>
} }
@if (repositoryUrl(); as repository) { @if (repositoryUrl(); as repository) {
<a [href]="repository" target="_blank" rel="noopener"> <app-link-button
<pi-action-button
appearance="outline"
color="grey" color="grey"
appearance="outline"
[href]="repository"
[text]="copy().labels.repository" [text]="copy().labels.repository"
[hasIcon]="true" [external]="true"
/> />
</a>
} }
</div> </div>
} }
@@ -145,6 +144,91 @@ import { PROJECT_STATUS_TONES } from './project-card';
</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>
@@ -204,6 +288,36 @@ import { PROJECT_STATUS_TONES } from './project-card';
</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>
@@ -306,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

@@ -144,3 +144,19 @@
color: var(--pi-color-primary); color: var(--pi-color-primary);
} }
} }
// The card's logo and name lead to the technology's own page.
.radar-card__top {
text-decoration: none;
color: inherit;
&:hover .radar-card__name {
text-decoration: underline;
}
&:focus-visible {
outline: 2px solid var(--pi-color-primary);
outline-offset: 4px;
border-radius: 0.5rem;
}
}

View File

@@ -13,7 +13,9 @@ import {
} from '@pi/ui'; } from '@pi/ui';
import type { PublicTechnology } from '../core/api.types'; import type { PublicTechnology } from '../core/api.types';
import type { Locale } from '../core/api';
import type { Copy } from '../core/copy'; import type { Copy } from '../core/copy';
import { technologyPath } from '../core/routes';
import { ApiImage } from './api-image'; import { ApiImage } from './api-image';
type Ring = NonNullable<PublicTechnology['ring']>; type Ring = NonNullable<PublicTechnology['ring']>;
@@ -81,7 +83,7 @@ const RINGS: Ring[] = ['adopt', 'trial', 'assess', 'hold'];
<ul class="radar-grid"> <ul class="radar-grid">
@for (technology of group.technologies; track technology.id) { @for (technology of group.technologies; track technology.id) {
<li class="landing-card radar-card"> <li class="landing-card radar-card">
<div class="radar-card__top"> <a class="radar-card__top" [href]="href(technology)">
<span <span
class="radar-logo" class="radar-logo"
[style.--brand]="technology.color || null" [style.--brand]="technology.color || null"
@@ -104,7 +106,7 @@ const RINGS: Ring[] = ['adopt', 'trial', 'assess', 'hold'];
{{ copy().radar.categories[technology.category] }} {{ copy().radar.categories[technology.category] }}
</p> </p>
</div> </div>
</div> </a>
@if (technology.description; as description) { @if (technology.description; as description) {
<p class="radar-card__text">{{ description }}</p> <p class="radar-card__text">{{ description }}</p>
@@ -148,6 +150,13 @@ const RINGS: Ring[] = ['adopt', 'trial', 'assess', 'hold'];
export class RadarPage { export class RadarPage {
readonly technologies = input.required<PublicTechnology[]>(); readonly technologies = input.required<PublicTechnology[]>();
readonly copy = input.required<Copy>(); readonly copy = input.required<Copy>();
/** Only to address each technology's own page. */
readonly locale = input.required<Locale>();
/** Each card leads to the technology it names. */
protected href(technology: PublicTechnology): string {
return technologyPath(technology.slug, this.locale());
}
/** The rings that have anything in them, in the radar's own order. */ /** The rings that have anything in them, in the radar's own order. */
protected readonly rings = computed(() => protected readonly rings = computed(() =>

View File

@@ -6,12 +6,18 @@ import {
} from '@angular/core'; } from '@angular/core';
import { copyFor, type Copy } from '../core/copy'; import { copyFor, type Copy } from '../core/copy';
import { listingAlternates, listingPath } from '../core/routes'; import {
listingAlternates,
listingPath,
technologyAlternates,
technologyPath,
} from '../core/routes';
import { memberLinks } from './member-card'; import { memberLinks } from './member-card';
import { isBlogPost } from '../core/blog'; import { isBlogPost } from '../core/blog';
import type { Locale } from '../core/api'; import type { Locale } from '../core/api';
import { RadarPage } from './radar-page'; import { RadarPage } from './radar-page';
import { ServiceDetail } from './service-detail'; import { ServiceDetail } from './service-detail';
import { TechnologyDetail } from './technology-detail';
import { BlogPage } from './blog-page'; import { BlogPage } from './blog-page';
import { CallDetail } from './call-detail'; import { CallDetail } from './call-detail';
import type { RoutedPageData, RoutedRecord } from '../core/routed-page'; import type { RoutedPageData, RoutedRecord } from '../core/routed-page';
@@ -38,6 +44,7 @@ import { ProjectDetail } from './project-detail';
ProjectDetail, ProjectDetail,
RadarPage, RadarPage,
ServiceDetail, ServiceDetail,
TechnologyDetail,
], ],
template: ` template: `
<app-page-shell [data]="data()" [seo]="seo()"> <app-page-shell [data]="data()" [seo]="seo()">
@@ -54,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"
/> />
@@ -88,7 +96,18 @@ import { ProjectDetail } from './project-detail';
/> />
} }
@case ('radar') { @case ('radar') {
<app-radar-page [technologies]="found.record" [copy]="copy()" /> <app-radar-page
[technologies]="found.record"
[copy]="copy()"
[locale]="data().locale"
/>
}
@case ('technology') {
<app-technology-detail
[technology]="found.record"
[copy]="copy()"
[locale]="data().locale"
/>
} }
@case ('member') { @case ('member') {
<app-member-detail <app-member-detail
@@ -223,6 +242,26 @@ export function seoFor(
}, },
}; };
} }
case 'technology': {
const technology = found.record;
// The only record whose address this site composes rather than reads
// off the payload: the API gives technologies no `path`.
return {
title: `Pireon — ${technology.name}`,
description: technology.description || copy.radar.lead,
path: technologyPath(technology.slug, locale),
alternates: technologyAlternates(technology.slug),
type: 'article',
jsonLd: {
'@type': 'SoftwareApplication',
name: technology.name,
description: technology.description,
applicationCategory: copy.radar.categories[technology.category],
url: technology.website,
},
};
}
case 'blog': case 'blog':
return { return {
title: `Pireon — ${copy.blog.title}`, title: `Pireon — ${copy.blog.title}`,

View File

@@ -5,7 +5,6 @@ import {
input, input,
} from '@angular/core'; } from '@angular/core';
import { import {
PiChipComponent,
PiIconComponent, PiIconComponent,
PiTextColorDirective, PiTextColorDirective,
PiTextLargeDirective, PiTextLargeDirective,
@@ -19,6 +18,7 @@ import { resolveIcon } from '../core/icons';
import { ContactCta } from './contact-cta'; import { ContactCta } from './contact-cta';
import { ProjectCard } from './project-card'; import { ProjectCard } from './project-card';
import { TestimonialCard } from './testimonial-card'; import { TestimonialCard } from './testimonial-card';
import { TechnologyChip } from './technology-chip';
/** /**
* A service on its own address, and the published work that backs it: the * A service on its own address, and the published work that backs it: the
@@ -32,13 +32,13 @@ import { TestimonialCard } from './testimonial-card';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
ContactCta, ContactCta,
PiChipComponent,
PiIconComponent, PiIconComponent,
PiTextColorDirective, PiTextColorDirective,
PiTextLargeDirective, PiTextLargeDirective,
PiTextWeightBoldDirective, PiTextWeightBoldDirective,
ProjectCard, ProjectCard,
TestimonialCard, TestimonialCard,
TechnologyChip,
], ],
styleUrl: './service-detail.scss', styleUrl: './service-detail.scss',
template: ` template: `
@@ -72,7 +72,11 @@ import { TestimonialCard } from './testimonial-card';
<h2>{{ copy().service.projects }}</h2> <h2>{{ copy().service.projects }}</h2>
<div class="service-grid"> <div class="service-grid">
@for (project of service().projects; track project.id) { @for (project of service().projects; track project.id) {
<app-project-card [project]="project" [copy]="copy()" /> <app-project-card
[project]="project"
[copy]="copy()"
[locale]="locale()"
/>
} }
</div> </div>
</section> </section>
@@ -100,10 +104,9 @@ import { TestimonialCard } from './testimonial-card';
<ul class="content-hero__chips service-technologies"> <ul class="content-hero__chips service-technologies">
@for (technology of service().technologies; track technology.id) { @for (technology of service().technologies; track technology.id) {
<li> <li>
<pi-chip <app-technology-chip
size="small" [technology]="technology"
appearance="outline" [locale]="locale()"
[label]="technology.name"
/> />
</li> </li>
} }

View File

@@ -0,0 +1,22 @@
:host {
display: inline-flex;
}
.technology-chip {
display: inline-flex;
text-decoration: none;
border-radius: var(--pi-theme-chip-radius, 0.25rem);
// The chip carries the visible border, so the focus ring goes on the anchor
// around it rather than being lost inside the component.
&:focus-visible {
outline: 2px solid var(--pi-color-primary);
outline-offset: 2px;
}
&:hover ::ng-deep .pi-chip,
&:hover ::ng-deep span {
border-color: var(--pi-color-primary);
color: var(--pi-color-primary);
}
}

View File

@@ -0,0 +1,33 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import { PiChipComponent } from '@pi/ui';
import type { Locale } from '../core/api';
import type { PublicTechnology } from '../core/api.types';
import { technologyPath } from '../core/routes';
/**
* A technology's name, leading to its page.
*
* The same chip was written out in nine places — every card and every detail
* page that names a technology — so the link lives here rather than nine
* times. `pi-chip` renders a `<span>`, so the anchor goes around it.
*/
@Component({
selector: 'app-technology-chip',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiChipComponent],
styleUrl: './technology-chip.scss',
template: `
<a class="technology-chip" [href]="href()">
<pi-chip size="small" appearance="outline" [label]="technology().name" />
</a>
`,
})
export class TechnologyChip {
readonly technology = input.required<PublicTechnology>();
readonly locale = input.required<Locale>();
protected readonly href = computed(() =>
technologyPath(this.technology().slug, this.locale()),
);
}

View File

@@ -0,0 +1,53 @@
@use './detail';
:host {
display: block;
}
.technology-hero {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 1.5rem;
}
// The same light tile the radar uses, for the same reason: a brand's mark is
// not ours to recolour, and a black one would disappear in dark mode.
.technology-logo {
display: grid;
flex: none;
place-items: center;
width: 4.5rem;
height: 4.5rem;
border-radius: 1.125rem;
background: #fff;
box-shadow:
0 0 0 1px
color-mix(in srgb, var(--brand, var(--pi-color-primary)) 22%, transparent),
0 8px 20px -10px
color-mix(in srgb, var(--brand, var(--pi-color-primary)) 55%, transparent);
}
.technology-logo ::ng-deep img {
display: block;
width: 3rem;
height: 3rem;
object-fit: contain;
}
.technology-logo__initial {
font-weight: 700;
font-size: 1.5rem;
color: var(--brand, var(--pi-color-primary));
}
.technology-text {
margin: 0;
font-size: 1.0625rem;
line-height: 1.7;
}
.technology-note {
margin-top: 0.75rem;
color: var(--pi-color-grey);
}

View File

@@ -0,0 +1,163 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import {
PiTextColorDirective,
PiTextLargeDirective,
PiTextWeightBoldDirective,
} from '@pi/ui';
import type { Locale } from '../core/api';
import type { PublicTechnology } from '../core/api.types';
import type { Copy } from '../core/copy';
import { listingPath } from '../core/routes';
import { LinkButton } from './link-button';
import { ApiImage } from './api-image';
import { ContactCta } from './contact-cta';
/**
* One technology on its own address: what it is, where the collective places
* it on the radar, and where its own people document it.
*
* The logo sits on a light tile in both themes, as it does on the radar. A
* brand's own mark is not ours to recolour, and some — Rust's, black — would
* otherwise vanish against a dark page.
*
* `website` is the only link the API carries. It is the project's own home
* page, which is where its documentation lives; there is no separate field for
* the docs, so the label says what the link actually is.
*/
@Component({
selector: 'app-technology-detail',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ApiImage,
ContactCta,
PiTextColorDirective,
PiTextLargeDirective,
PiTextWeightBoldDirective,
LinkButton,
],
styleUrl: './technology-detail.scss',
template: `
<header class="content-hero">
<div class="landing-shell technology-hero">
<span
class="technology-logo"
[style.--brand]="technology().color || null"
>
@if (technology().logo; as logo) {
<app-api-image
[image]="logo"
sizes="72px"
loading="eager"
imgClass="technology-logo__image"
/>
} @else {
<span class="technology-logo__initial" aria-hidden="true">{{
technology().name[0]
}}</span>
}
</span>
<div>
<p class="landing-eyebrow">{{ category() }}</p>
<h1 bold class="content-hero__title">{{ technology().name }}</h1>
@if (technology().description; as description) {
<p l pi-color color="grey" class="content-hero__lead">
{{ description }}
</p>
}
@if (technology().website; as website) {
<p class="content-hero__actions">
<app-link-button
size="large"
[href]="website"
[text]="copy().technology.website"
[external]="true"
/>
</p>
}
</div>
</div>
</header>
<div class="landing-shell content-page__layout">
<div>
@if (ring(); as ring) {
<section class="content-page__section">
<h2>{{ copy().technology.ringTitle }}: {{ ring.label }}</h2>
<p class="technology-text">{{ ring.lead }}</p>
@if (technology().radar_note; as note) {
<p class="technology-text technology-note">{{ note }}</p>
}
</section>
}
<p>
<a [href]="radarHref()">{{ copy().technology.back }} →</a>
</p>
</div>
<aside class="content-page__aside">
@if (facts().length > 0) {
<div class="landing-card content-page__panel">
<ul class="content-page__facts">
@for (fact of facts(); track fact) {
<li>{{ fact }}</li>
}
</ul>
</div>
}
<app-contact-cta [copy]="copy()" [locale]="locale()" />
</aside>
</div>
`,
})
export class TechnologyDetail {
readonly technology = input.required<PublicTechnology>();
readonly copy = input.required<Copy>();
readonly locale = input.required<Locale>();
protected readonly category = computed(
() => this.copy().radar.categories[this.technology().category],
);
protected readonly radarHref = computed(() =>
listingPath('radar', this.locale()),
);
/** Null for a technology the collective uses but has not placed. */
protected readonly ring = computed(() => {
const ring = this.technology().ring;
return ring
? { label: this.copy().rings[ring], lead: this.copy().radar.ringLead[ring] }
: null;
});
/** Counts the API reports as zero say nothing, so they are not shown. */
protected readonly facts = computed(() => {
const { projects_count: projects, members_count: members } =
this.technology();
const copy = this.copy().technology;
const facts: string[] = [];
if (projects > 0) {
facts.push(`${projects} ${copy.usedIn}`);
}
if (members > 0) {
facts.push(`${members} ${copy.membersUsing}`);
}
return facts;
});
}

View File

@@ -13,13 +13,18 @@ import {
setResponseStatus, setResponseStatus,
} from 'h3'; } from 'h3';
import { pireonApi } from '../../app/core/api'; import { DEFAULT_LOCALE, pireonApi } from '../../app/core/api';
import { originFromHeaders } from '../../app/core/origin'; import { originFromHeaders } from '../../app/core/origin';
import { buildSitemap } from '../../app/core/sitemap'; import { buildSitemap } from '../../app/core/sitemap';
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const origin = originFromHeaders(getRequestHeaders(event)) ?? ''; const origin = originFromHeaders(getRequestHeaders(event)) ?? '';
const { data } = await pireonApi.slugs(); // The technologies are fetched alongside, not instead: their addresses are
// this site's own and `/slugs` cannot carry them.
const [{ data }, technologies] = await Promise.all([
pireonApi.slugs(),
pireonApi.technologies(DEFAULT_LOCALE, { in_use: false }),
]);
if (!data) { if (!data) {
setResponseStatus(event, 503); setResponseStatus(event, 503);
@@ -30,5 +35,9 @@ export default defineEventHandler(async (event) => {
setResponseHeader(event, 'Content-Type', 'application/xml; charset=utf-8'); setResponseHeader(event, 'Content-Type', 'application/xml; charset=utf-8');
return buildSitemap(origin, data); // A failed `/slugs` is a 503 above, because a sitemap missing most of the
// site is worse than none. A failed technologies call is not: they are an
// extra this site adds, and leaving them out costs a handful of URLs rather
// than misrepresenting the whole site.
return buildSitemap(origin, data, technologies.data ?? []);
}); });

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

@@ -17,6 +17,15 @@ import { createServer } from 'node:http';
const tech = (id, name, slug, category, color, ring) => ({ const tech = (id, name, slug, category, color, ring) => ({
id, name, slug, category, color, ring, projects_count: 3, members_count: 2, id, name, slug, category, color, ring, projects_count: 3, members_count: 2,
description: `${name} es una de las herramientas con las que trabajamos a diario. Esta descripción viene de la API, como en producción.`,
website: `https://example.com/${slug}`,
// An inline SVG so the mock needs no file server: the real API serves these
// from its own storage.
logo: {
url: `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="${color}"/><text x="32" y="42" font-family="sans-serif" font-size="30" font-weight="700" fill="#fff" text-anchor="middle">${name[0]}</text></svg>`)}`,
alt: name, width: 64, height: 64, aspect_ratio: 1,
mime: 'image/svg+xml', placeholder: null, srcset: null,
},
}); });
const technologies = [ const technologies = [
@@ -55,9 +64,23 @@ const page = (id, title, slug, featured) => ({
alternates: { es: `/${slug}`, en: `/en/${slug}-en`, ca: `/ca/${slug}-ca` }, path: `/${slug}`, alternates: { es: `/${slug}`, en: `/en/${slug}-en`, ca: `/ca/${slug}-ca` }, path: `/${slug}`,
}); });
const post = (id, title, slug, published_at) => ({ // 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, tags = []) => ({
...page(id, title, slug, false), ...page(id, title, slug, false),
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 },
}); });
@@ -65,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 = {
@@ -100,6 +123,7 @@ const data = {
{ id: 4, quote: 'Trabajar con el repositorio abierto desde el primer día nos quitó el miedo a depender de nadie.', original_locale: 'es', is_original: true, author: { name: 'Pau Ferrer', role: 'Dirección' }, client: { id: 3, name: 'Fundació Oberta', slug: 'oberta', projects_count: 3 } }, { id: 4, quote: 'Trabajar con el repositorio abierto desde el primer día nos quitó el miedo a depender de nadie.', original_locale: 'es', is_original: true, author: { name: 'Pau Ferrer', role: 'Dirección' }, client: { id: 3, name: 'Fundació Oberta', slug: 'oberta', projects_count: 3 } },
], ],
'/public/v1/radar': technologies, '/public/v1/radar': technologies,
'/public/v1/technologies': technologies,
'/public/v1/members': [member(1, 'Ada Lovelace', 'ada', true), member(2, 'Linus T.', 'linus', false), member(3, 'Grace Hopper', 'grace', true), member(4, 'Alan Turing', 'alan', false)], '/public/v1/members': [member(1, 'Ada Lovelace', 'ada', true), member(2, 'Linus T.', 'linus', false), member(3, 'Grace Hopper', 'grace', true), member(4, 'Alan Turing', 'alan', false)],
'/public/v1/calls': [ '/public/v1/calls': [
{ id: 1, locale: 'es', title: 'Frontend Angular', slug: 'frontend-angular', description: 'Ayúdanos con el catálogo público: componentes, accesibilidad y tests.', status: 'open', hours_per_week: 4, duration_weeks: 8, seniority: 'any', closes_at: '2026-10-01T00:00:00Z', project: project(1, 'Catálogo abierto', 'catalogo', 'active', true), technologies: technologies.slice(0, 2), alternates: {} }, { id: 1, locale: 'es', title: 'Frontend Angular', slug: 'frontend-angular', description: 'Ayúdanos con el catálogo público: componentes, accesibilidad y tests.', status: 'open', hours_per_week: 4, duration_weeks: 8, seniority: 'any', closes_at: '2026-10-01T00:00:00Z', project: project(1, 'Catálogo abierto', 'catalogo', 'active', true), technologies: technologies.slice(0, 2), alternates: {} },
@@ -145,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']) {
@@ -201,12 +257,24 @@ createServer((req, res) => {
let payload = data[path]; let payload = data[path];
// The one filter the frontend relies on: pages by category slug. // The one filter the frontend relies on: pages by category slug.
if (path === '/public/v1/technologies') {
const inUse = url.searchParams.get('in_use');
payload =
inUse === 'false' ? payload : payload.filter((t) => t.projects_count > 0);
}
if (path === '/public/v1/pages' && url.searchParams.get('category')) { if (path === '/public/v1/pages' && url.searchParams.get('category')) {
payload = payload.filter((p) => p.category?.slug === url.searchParams.get('category')); payload = payload.filter((p) => p.category?.slug === url.searchParams.get('category'));
} }
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') {