Give projects and members their own pages

The API has always served a project's and a member's detail, with
`path` and `alternates` in three languages, and the site had nowhere to
show it. A project now has its page — tagline, status, technologies,
description, technical decisions, what was learned, gallery, client,
dates, lead, team, repositories and open calls — and so does a member:
headline, bio, links, technologies and projects. Cards on the landing
link to both.

Addresses follow the API's rule read backwards (core/routes.ts):
pages at the root, records under a section named in their language,
the default language unprefixed. One catch-all route serves them all.
Not a route per shape: Analog registers each loader as an internal
Nitro route, and Nitro's router cannot hold `/:prefix/:slug` beside
`/:prefix/:section/:slug` — the first became unreachable.

The frame around a record (header, footer, lights, 404/503, title,
description, canonical, hreflang) is one component now, PageShell, and
each type only brings its body, all sharing one layout partial.

Also fixes chips that stepped down one row after the first, on the
landing's cards too: @pi/ui's reset gives every `li + li` a top margin,
right for prose and wrong for a row spaced by `gap`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pigui
2026-09-29 14:57:13 +02:00
parent 9af6b5aff9
commit 439f951f2e
33 changed files with 2001 additions and 745 deletions

View File

@@ -150,11 +150,13 @@ landing/src/
core/copy.ts the chrome's own words, in es / en / ca core/copy.ts the chrome's own words, in es / en / ca
core/icons.ts the icon set, inlined and registered as literals core/icons.ts the icon set, inlined and registered as literals
core/pi-strings.ts the strings `@pi/ui` translates on its own core/pi-strings.ts the strings `@pi/ui` translates on its own
core/content-page.ts the load function both content-page routes share core/routes.ts which record an address belongs to (resolveRoute)
core/routed-page.ts the load function for every address but the landing
pages/(home).server.ts one load function, eleven parallel calls pages/(home).server.ts one load function, eleven parallel calls
pages/(home).page.ts the landing itself pages/(home).page.ts the landing itself
pages/[slug].page.ts a page the panel publishes, default language pages/[...path].page.ts everything else: pages, projects, members, 404
pages/[locale]/[slug].page.ts the same, in any other language ui/page-shell.ts header, footer, lights, 404/503 and SEO around a record
ui/routed-page.ts picks the body for the record's type
ui/ presentational components, one per payload type ui/ presentational components, one per payload type
(thin wrappers over `@pi/ui`), each with its `.scss` (thin wrappers over `@pi/ui`), each with its `.scss`
server/ server/
@@ -186,9 +188,13 @@ page component through `injectResponse()` (`@analogjs/router/tokens`), which is
the *render* request's response; the one a page `load` sees belongs to the the *render* request's response; the one a page `load` sees belongs to the
internal request Analog resolves loaders through and never reaches the browser. internal request Analog resolves loaders through and never reaches the browser.
That is also why the 404 status comes from middleware instead — except for That is also why the 404 status comes from middleware instead — except for
addresses shaped like a content page, where only the API knows whether the page addresses shaped like a record, where only the API knows whether it exists: the
exists: the middleware lets those through and `ui/content-page.ts` sets the 404 middleware lets those through and `ui/page-shell.ts` sets the 404 itself from
itself from `ngOnInit`, once its input has arrived. `ngOnInit`, once its input has arrived.
**`@pi/ui`'s reset spaces every `li + li`** with a top margin — right for a
list of prose, wrong for a row of chips laid out with `gap`, where it dropped
every chip but the first. Lists spaced by `gap` reset it on their own `> li`.
**Empty sections disappear**, along with their entry in the nav — a young site **Empty sections disappear**, along with their entry in the nav — a young site
shows a hero and a contact form rather than eight headings over nothing. shows a hero and a contact form rather than eight headings over nothing.
@@ -202,14 +208,29 @@ page cached with `s-maxage` that varied on `Accept-Language` would hand the
second visitor the wrong language anyway. The switcher in the header is three second visitor the wrong language anyway. The switcher in the header is three
plain links, and `<html lang>` follows. plain links, and `<html lang>` follows.
**Content pages are the exception**, because the API decides their address: a **Records are the exception**, because the API decides their address: a
page's `path` and `alternates` put the default language unprefixed and every record's `path` and `alternates` put the default language unprefixed and every
other one under its code — `/sobre-nosotros`, `/en/about-us`, `/ca/qui-som` — other one under its code, pages at the root and everything else under a section
so those are the routes that answer. `core/api.ts#contentRoute` is the one rule named in that language:
for that shape; `/es/…` is not one of them, so a page has one address rather
than two. The switcher on a page follows `alternates`, and a language the page ```
has no translation for leads to the landing in that language instead of a 404. /sobre-nosotros /en/about-us /ca/qui-som
The same `alternates` become `hreflang` links, with a `canonical`. /proyectos/catalogo /en/projects/catalogo /ca/projectes/catalogo
/equipo/ada /en/team/ada /ca/equip/ada
```
`core/routes.ts#resolveRoute` is that rule read backwards, and its `SECTIONS`
mirror the API's `config/site.php` (`paths`) — a change there has to be made
here too. `/es/…` is not a shape the API produces, so a record has one address
rather than two. The switcher on a record follows `alternates`, and a language
it has no translation for leads to the landing in that language instead of a
404. The same `alternates` become `hreflang` links, with a `canonical`.
**One catch-all route** (`pages/[...path]`) serves every address but the
landing, and `resolveRoute` sorts the shapes out. Not one route per shape:
Analog registers each loader as an internal Nitro route, and Nitro's router
cannot hold two parameters of different names at the same depth —
`/:prefix/:slug` beside `/:prefix/:section/:slug` made the first unreachable.
Featured pages go in the header after the section anchors; every page is in the Featured pages go in the header after the section anchors; every page is in the
footer. footer.

View File

@@ -53,6 +53,23 @@ test.describe('landing', () => {
await expect(page.locator('meta[name="robots"]')).toHaveAttribute('content', 'noindex'); await expect(page.locator('meta[name="robots"]')).toHaveAttribute('content', 'noindex');
}); });
test('answers 404 for a record the API does not have, in any section', async ({ page }) => {
for (const path of ['/proyectos/no-existe', '/en/team/nobody', '/ca/projectes/res']) {
const response = await page.goto(path);
expect(response?.status(), path).toBe(404);
await expect(page.locator('h1')).toContainText('no existe');
}
});
test('answers 404 for a section spelled in another language', async ({ page }) => {
// `/projects/x` is the English section without the English prefix: not an
// address the API ever hands out.
const response = await page.goto('/projects/anything');
expect(response?.status()).toBe(404);
});
test('refuses an incomplete contact submission without calling the API', async ({ test('refuses an incomplete contact submission without calling the API', async ({
page, page,
}) => { }) => {

View File

@@ -1,12 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import { DEFAULT_LOCALE, localeFromUrl, resolveLocale } from './api';
contentRoute,
DEFAULT_LOCALE,
homeHref,
localeFromUrl,
resolveLocale,
} from './api';
describe('resolveLocale', () => { describe('resolveLocale', () => {
it('accepts the locales the API declares', () => { it('accepts the locales the API declares', () => {
@@ -35,35 +29,3 @@ describe('localeFromUrl', () => {
}); });
}); });
describe('contentRoute', () => {
it('reads an unprefixed page as the default language', () => {
expect(contentRoute('/sobre-nosotros')).toEqual({
locale: DEFAULT_LOCALE,
slug: 'sobre-nosotros',
});
});
it('reads a prefixed page in the language it names', () => {
expect(contentRoute('/en/about-us')).toEqual({ locale: 'en', slug: 'about-us' });
expect(contentRoute('/ca/qui-som/')).toEqual({ locale: 'ca', slug: 'qui-som' });
});
// The API never spells the default language with a prefix, so one page has
// one address rather than two.
it('refuses the default language as a prefix', () => {
expect(contentRoute('/es/sobre-nosotros')).toBeNull();
});
it('refuses what is not shaped like a page', () => {
expect(contentRoute('/')).toBeNull();
expect(contentRoute('/fr/a-propos')).toBeNull();
expect(contentRoute('/en/projects/catalogo')).toBeNull();
});
});
describe('homeHref', () => {
it('answers on ?lang= for every language but the default', () => {
expect(homeHref(DEFAULT_LOCALE)).toBe('/');
expect(homeHref('ca')).toBe('/?lang=ca');
});
});

View File

@@ -74,42 +74,6 @@ export function localeFromUrl(url: string | undefined): Locale {
return resolveLocale(requested); return resolveLocale(requested);
} }
/**
* Where a content page lives, as the API spells it in `path` and `alternates`:
* the default language unprefixed (`/sobre-nosotros`), every other one under
* its code (`/en/about-us`). Only the API knows which slug belongs to which
* language, so this only recognises the shape; whether the page exists is the
* API's answer.
*
* A prefix naming the default language (`/es/sobre-nosotros`) is not a shape
* the API ever produces, so it is not one this answers on either — one page,
* one address.
*/
export function contentRoute(
pathname: string
): { locale: Locale; slug: string } | null {
const segments = pathname.replace(/\/+$/, '').split('/').filter(Boolean);
if (segments.length === 1) {
return { locale: DEFAULT_LOCALE, slug: segments[0] };
}
if (
segments.length === 2 &&
segments[0] !== DEFAULT_LOCALE &&
SUPPORTED_LOCALES.includes(segments[0] as Locale)
) {
return { locale: segments[0] as Locale, slug: segments[1] };
}
return null;
}
/** The landing itself in a given language. It answers on `?lang=`, not a prefix. */
export function homeHref(locale: Locale): string {
return locale === DEFAULT_LOCALE ? '/' : `/?lang=${locale}`;
}
function apiBase(): string { function apiBase(): string {
const configured = const configured =
typeof process !== 'undefined' typeof process !== 'undefined'
@@ -262,6 +226,12 @@ export const pireonApi = {
/** By slug, which only resolves in the language it belongs to. */ /** By slug, which only resolves in the language it belongs to. */
page: (lang: Locale, slug: string) => page: (lang: Locale, slug: string) =>
get<PublicPage>(`/pages/${encodeURIComponent(slug)}`, { lang }), get<PublicPage>(`/pages/${encodeURIComponent(slug)}`, { lang }),
project: (lang: Locale, slug: string) =>
get<PublicProject>(`/projects/${encodeURIComponent(slug)}`, { lang }),
member: (lang: Locale, slug: string) =>
get<PublicMember>(`/members/${encodeURIComponent(slug)}`, { lang }),
} as const; } as const;
/** POST bodies the public API accepts. Only two endpoints write. */ /** POST bodies the public API accepts. Only two endpoints write. */

View File

@@ -1,56 +0,0 @@
/**
* What a content page renders — one of the pages the panel publishes, such as
* "Sobre nosotros" — fetched on the server in one pass.
*
* Shared by the two routes a page answers on, `/:slug` and `/:locale/:slug`,
* whose `.server.ts` files only have to exist because Analog needs one next to
* each `.page.ts`.
*/
import {
pireonApi,
type Locale,
type MaintenanceNotice,
type SitePayload,
} from './api';
import type { PublicPage } from './api.types';
export interface ContentPageData {
locale: Locale;
site: SitePayload | null;
maintenance: MaintenanceNotice | null;
/** Null when the API has no such page in this language: the route answers 404. */
page: PublicPage | null;
/** Every published page, for the header and the footer. */
pages: PublicPage[];
}
export async function loadContentPage(
locale: Locale,
slug: string | undefined,
): Promise<ContentPageData> {
const [site, page, pages] = await Promise.all([
pireonApi.site(locale),
slug ? pireonApi.page(locale, slug) : Promise.resolve(null),
pireonApi.pages(locale, { per_page: 100 }),
]);
return {
locale,
site: site.data,
maintenance: site.maintenance,
page: page?.data ?? null,
pages: pages.data ?? [],
};
}
/** Featured pages go in the header; the rest are one click away in the footer. */
export function pageNavLinks(
pages: PublicPage[],
): { href: string; label: string }[] {
return pages
.filter((page) => page.is_featured && page.path && page.title)
.map((page) => ({
href: page.path as string,
label: page.title as string,
}));
}

View File

@@ -91,6 +91,29 @@ export interface Copy {
}; };
footer: { rights: string; pages: string }; footer: { rights: string; pages: string };
page: { skip: string; related: string; ctaTitle: string; ctaLead: string }; page: { skip: string; related: string; ctaTitle: string; ctaLead: string };
project: {
decisions: string;
postmortem: string;
gallery: string;
details: string;
status: string;
client: string;
period: string;
lead: string;
team: string;
repositories: string;
forks: string;
dormant: string;
archived: string;
openCalls: string;
technologies: string;
};
member: {
projects: string;
technologies: string;
links: string;
since: string;
};
maintenance: { title: string; back: string; until: string }; maintenance: { title: string; back: string; until: string };
} }
@@ -190,6 +213,29 @@ const es: Copy = {
ctaTitle: '¿Hablamos?', ctaTitle: '¿Hablamos?',
ctaLead: 'Si algo de esto encaja con lo que buscas, escríbenos y te respondemos.', ctaLead: 'Si algo de esto encaja con lo que buscas, escríbenos y te respondemos.',
}, },
project: {
decisions: 'Decisiones técnicas',
postmortem: 'Qué aprendimos',
gallery: 'Galería',
details: 'El proyecto',
status: 'Estado',
client: 'Para',
period: 'Fechas',
lead: 'Responsable',
team: 'Equipo',
repositories: 'Código',
forks: 'forks',
dormant: 'Sin actividad reciente',
archived: 'Archivado',
openCalls: 'Buscamos ayuda',
technologies: 'Tecnologías',
},
member: {
projects: 'Proyectos',
technologies: 'Tecnologías',
links: 'Enlaces',
since: 'En el colectivo desde',
},
maintenance: { maintenance: {
title: 'Estamos de mantenimiento', title: 'Estamos de mantenimiento',
back: 'Volvemos en un rato.', back: 'Volvemos en un rato.',
@@ -293,6 +339,29 @@ const en: Copy = {
ctaTitle: 'Shall we talk?', ctaTitle: 'Shall we talk?',
ctaLead: 'If any of this fits what you are looking for, write to us and we will get back to you.', ctaLead: 'If any of this fits what you are looking for, write to us and we will get back to you.',
}, },
project: {
decisions: 'Technical decisions',
postmortem: 'What we learned',
gallery: 'Gallery',
details: 'The project',
status: 'Status',
client: 'For',
period: 'Dates',
lead: 'Lead',
team: 'Team',
repositories: 'Code',
forks: 'forks',
dormant: 'No recent activity',
archived: 'Archived',
openCalls: 'Looking for help',
technologies: 'Technologies',
},
member: {
projects: 'Projects',
technologies: 'Technologies',
links: 'Links',
since: 'In the collective since',
},
maintenance: { maintenance: {
title: 'We are under maintenance', title: 'We are under maintenance',
back: 'Back in a while.', back: 'Back in a while.',
@@ -396,6 +465,29 @@ const ca: Copy = {
ctaTitle: 'En parlem?', ctaTitle: 'En parlem?',
ctaLead: "Si alguna cosa d'aquestes encaixa amb el que busques, escriu-nos i et respondrem.", ctaLead: "Si alguna cosa d'aquestes encaixa amb el que busques, escriu-nos i et respondrem.",
}, },
project: {
decisions: 'Decisions tècniques',
postmortem: 'Què vam aprendre',
gallery: 'Galeria',
details: 'El projecte',
status: 'Estat',
client: 'Per a',
period: 'Dates',
lead: 'Responsable',
team: 'Equip',
repositories: 'Codi',
forks: 'forks',
dormant: 'Sense activitat recent',
archived: 'Arxivat',
openCalls: 'Busquem ajuda',
technologies: 'Tecnologies',
},
member: {
projects: 'Projectes',
technologies: 'Tecnologies',
links: 'Enllaços',
since: 'Al col·lectiu des de',
},
maintenance: { maintenance: {
title: 'Estem de manteniment', title: 'Estem de manteniment',
back: 'Tornem d’aquí una estona.', back: 'Tornem d’aquí una estona.',

View File

@@ -0,0 +1,89 @@
/**
* What a page reached by its address renders — a content page, a project, a
* member — fetched on the server in one pass.
*
* Shared by the routes that differ only in how many segments the address has
* (`/sobre-nosotros`, `/proyectos/x`, `/en/projects/x`), whose `.server.ts`
* files exist because Analog needs one next to each `.page.ts`.
*/
import {
pireonApi,
DEFAULT_LOCALE,
type Locale,
type MaintenanceNotice,
type SitePayload,
} from './api';
import type { PublicMember, PublicPage, PublicProject } from './api.types';
import { resolveRoute, type RouteMatch } from './routes';
/** The record, tagged with its type so the template can switch on it. */
export type RoutedRecord =
| { type: 'page'; record: PublicPage }
| { type: 'project'; record: PublicProject }
| { type: 'member'; record: PublicMember };
export interface RoutedPageData {
locale: Locale;
site: SitePayload | null;
maintenance: MaintenanceNotice | null;
/** Null when there is nothing at this address: the route answers 404. */
found: RoutedRecord | null;
/** Every published page, for the header and the footer. */
pages: PublicPage[];
}
export async function loadRoutedPage(
pathname: string,
): Promise<RoutedPageData> {
const route = resolveRoute(pathname);
const locale = route?.locale ?? DEFAULT_LOCALE;
const [site, found, pages] = await Promise.all([
pireonApi.site(locale),
route ? fetchRecord(route) : Promise.resolve(null),
pireonApi.pages(locale, { per_page: 100 }),
]);
return {
locale,
site: site.data,
maintenance: site.maintenance,
found,
pages: pages.data ?? [],
};
}
async function fetchRecord(route: RouteMatch): Promise<RoutedRecord | null> {
const { locale, slug } = route;
switch (route.type) {
case 'page': {
const { data } = await pireonApi.page(locale, slug);
return data ? { type: 'page', record: data } : null;
}
case 'project': {
const { data } = await pireonApi.project(locale, slug);
return data ? { type: 'project', record: data } : null;
}
case 'member': {
const { data } = await pireonApi.member(locale, slug);
return data ? { type: 'member', record: data } : null;
}
default:
// A type the site has no page for yet: an address the API hands out
// but nothing here renders, which is a 404 until it does.
return null;
}
}
/** Featured pages go in the header; the rest are one click away in the footer. */
export function pageNavLinks(
pages: PublicPage[],
): { href: string; label: string }[] {
return pages
.filter((page) => page.is_featured && page.path && page.title)
.map((page) => ({
href: page.path as string,
label: page.title as string,
}));
}

View File

@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import { DEFAULT_LOCALE, SUPPORTED_LOCALES } from './api';
import { homeHref, resolveRoute, SECTIONS } from './routes';
describe('resolveRoute', () => {
it('reads a page at the root as the default language', () => {
expect(resolveRoute('/sobre-nosotros')).toEqual({
type: 'page',
locale: DEFAULT_LOCALE,
slug: 'sobre-nosotros',
});
});
it('reads a prefixed page in the language it names', () => {
expect(resolveRoute('/en/about-us')).toEqual({
type: 'page',
locale: 'en',
slug: 'about-us',
});
expect(resolveRoute('/ca/qui-som/')).toEqual({
type: 'page',
locale: 'ca',
slug: 'qui-som',
});
});
it('reads a record under its section, in each language', () => {
expect(resolveRoute('/proyectos/catalogo')).toEqual({
type: 'project',
locale: 'es',
slug: 'catalogo',
});
expect(resolveRoute('/en/projects/catalogo')).toEqual({
type: 'project',
locale: 'en',
slug: 'catalogo',
});
expect(resolveRoute('/ca/equip/ada')).toEqual({
type: 'member',
locale: 'ca',
slug: 'ada',
});
expect(resolveRoute('/ca/col-labora/frontend')).toEqual({
type: 'call',
locale: 'ca',
slug: 'frontend',
});
});
// A section only means something in its own language: `/projects/x` is not
// an address the API hands out for Spanish.
it('refuses a section spelled in another language', () => {
expect(resolveRoute('/projects/catalogo')).toBeNull();
expect(resolveRoute('/en/proyectos/catalogo')).toBeNull();
});
// The API never spells the default language with a prefix, so one record
// has one address rather than two.
it('refuses the default language as a prefix', () => {
expect(resolveRoute('/es/sobre-nosotros')).toBeNull();
expect(resolveRoute('/es/proyectos/catalogo')).toBeNull();
});
it('refuses what is not shaped like a record', () => {
expect(resolveRoute('/')).toBeNull();
expect(resolveRoute('/fr/a-propos')).toBeNull();
expect(resolveRoute('/proyectos/catalogo/extra')).toBeNull();
});
it('knows every section in every language', () => {
for (const sections of Object.values(SECTIONS)) {
expect(Object.keys(sections).sort()).toEqual(
[...SUPPORTED_LOCALES].sort(),
);
}
});
});
describe('homeHref', () => {
it('answers on ?lang= for every language but the default', () => {
expect(homeHref(DEFAULT_LOCALE)).toBe('/');
expect(homeHref('ca')).toBe('/?lang=ca');
});
});

View File

@@ -0,0 +1,71 @@
/**
* Which record an address on this site belongs to.
*
* The API decides every record's address — it sends `path` and `alternates`
* with each one — and this is the same rule read backwards. The default
* language has no prefix, the others their code; pages sit at the root, every
* other type under a section named in the visitor's language:
*
* /sobre-nosotros /en/about-us /ca/qui-som
* /proyectos/catalogo /en/projects/catalogo /ca/projectes/catalogo
*
* Only the shape is known here. Whether the record exists is the API's answer.
*/
import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type Locale } from './api';
export type RecordType = 'page' | 'project' | 'member' | 'call' | 'service';
/**
* The section each type lives under, per language. The API's own
* `config/site.php` (`paths`) is the source; a change there has to be made here
* too, or the API will hand out addresses this site answers 404 on.
*/
export const SECTIONS: Record<
Exclude<RecordType, 'page'>,
Record<Locale, string>
> = {
project: { es: 'proyectos', en: 'projects', ca: 'projectes' },
member: { es: 'equipo', en: 'team', ca: 'equip' },
call: { es: 'colabora', en: 'contribute', ca: 'col-labora' },
service: { es: 'servicios', en: 'services', ca: 'serveis' },
};
export interface RouteMatch {
type: RecordType;
locale: Locale;
slug: string;
}
/**
* A prefix naming the default language (`/es/…`) is not a shape the API ever
* produces, so it is not one this answers on either — one record, one address.
*/
export function resolveRoute(pathname: string): RouteMatch | null {
const segments = pathname.replace(/\/+$/, '').split('/').filter(Boolean);
const [first] = segments;
const prefixed =
first !== DEFAULT_LOCALE && SUPPORTED_LOCALES.includes(first as Locale);
const locale = prefixed ? (first as Locale) : DEFAULT_LOCALE;
const rest = prefixed ? segments.slice(1) : segments;
if (rest.length === 1) {
return { type: 'page', locale, slug: rest[0] };
}
if (rest.length === 2) {
const type = (Object.keys(SECTIONS) as (keyof typeof SECTIONS)[]).find(
(candidate) => SECTIONS[candidate][locale] === rest[0],
);
return type ? { type, locale, slug: rest[1] } : null;
}
return null;
}
/** The landing itself in a given language. It answers on `?lang=`, not a prefix. */
export function homeHref(locale: Locale): string {
return locale === DEFAULT_LOCALE ? '/' : `/?lang=${locale}`;
}

View File

@@ -17,7 +17,7 @@ import {
PiChipComponent, PiChipComponent,
} from '@pi/ui'; } from '@pi/ui';
import { pageNavLinks } from '../core/content-page'; import { pageNavLinks } from '../core/routed-page';
import { copyFor } from '../core/copy'; import { copyFor } from '../core/copy';
import { CallCard } from '../ui/call-card'; import { CallCard } from '../ui/call-card';
import { ClientLogo } from '../ui/client-logo'; import { ClientLogo } from '../ui/client-logo';

View File

@@ -2,16 +2,16 @@ import { ChangeDetectionStrategy, Component } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop'; import { toSignal } from '@angular/core/rxjs-interop';
import { injectLoad } from '@analogjs/router'; import { injectLoad } from '@analogjs/router';
import { ContentPage } from '../ui/content-page'; import { RoutedPage } from '../ui/routed-page';
import { load } from './[slug].server'; import { load } from './[...path].server';
@Component({ @Component({
selector: 'app-content-page-default', selector: 'app-route-path',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ContentPage], imports: [RoutedPage],
template: `<app-content-page [data]="data()" />`, template: `<app-routed-page [data]="data()" />`,
}) })
export default class DefaultLocaleContentPage { export default class PathRoute {
protected readonly data = toSignal(injectLoad<typeof load>(), { protected readonly data = toSignal(injectLoad<typeof load>(), {
requireSync: true, requireSync: true,
}); });

View File

@@ -0,0 +1,18 @@
/**
* Every address but the landing's: a page (`/sobre-nosotros`), a record
* (`/proyectos/x`, `/en/team/x`), or nothing at all.
*
* One catch-all rather than a route per shape. Analog registers each loader as
* an internal route, and Nitro's router cannot hold two parameters of
* different names at the same depth — `/:prefix/:slug` beside
* `/:prefix/:section/:slug` left the first unreachable. `resolveRoute` sorts
* the shapes out instead, and an address it does not recognise loads as a 404.
*/
import type { PageServerLoad } from '@analogjs/router';
import { loadRoutedPage, type RoutedPageData } from '../core/routed-page';
export const load = async ({
params,
}: PageServerLoad): Promise<RoutedPageData> =>
loadRoutedPage(`/${params?.['path'] ?? ''}`);

View File

@@ -1,18 +0,0 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { injectLoad } from '@analogjs/router';
import { ContentPage } from '../../ui/content-page';
import { load } from './[slug].server';
@Component({
selector: 'app-content-page-localised',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ContentPage],
template: `<app-content-page [data]="data()" />`,
})
export default class LocalisedContentPage {
protected readonly data = toSignal(injectLoad<typeof load>(), {
requireSync: true,
});
}

View File

@@ -1,20 +0,0 @@
/**
* A content page in any other language: `/en/about-us`, `/ca/qui-som`.
*
* The prefix is checked with `contentRoute`, the same rule the 404 middleware
* uses, so `/es/sobre-nosotros` or `/fr/…` never reach the API as a slug.
*/
import type { PageServerLoad } from '@analogjs/router';
import { contentRoute, DEFAULT_LOCALE } from '../../core/api';
import { loadContentPage, type ContentPageData } from '../../core/content-page';
export const load = async ({
params,
}: PageServerLoad): Promise<ContentPageData> => {
const route = contentRoute(`/${params?.['locale']}/${params?.['slug']}`);
return route
? loadContentPage(route.locale, route.slug)
: loadContentPage(DEFAULT_LOCALE, undefined);
};

View File

@@ -1,12 +0,0 @@
/**
* A content page in the default language: `/sobre-nosotros`.
*/
import type { PageServerLoad } from '@analogjs/router';
import { DEFAULT_LOCALE } from '../core/api';
import { loadContentPage, type ContentPageData } from '../core/content-page';
export const load = async ({
params,
}: PageServerLoad): Promise<ContentPageData> =>
loadContentPage(DEFAULT_LOCALE, params?.['slug']);

View File

@@ -0,0 +1,290 @@
// The layout every page reached by its address shares — a content page, a
// project, a member — so moving between them reads as one site.
//
// A partial each body `@use`s rather than a global sheet: component styles are
// scoped, and each body gets its own scoped copy of these rules.
// The top of the page is the landing's hero, minus the stats and buttons: the
// same left edge as the logo, the same heading weight.
.content-hero {
padding-block: 5rem 2.5rem;
@media (min-width: 768px) {
padding-block: 7rem 3.5rem;
}
}
.content-hero__title {
margin: 0;
max-width: 22ch;
font-size: clamp(2.25rem, 5.5vw, 3.5rem);
line-height: 1.08;
letter-spacing: -0.02em;
}
.content-hero__lead {
max-width: 46ch;
margin: 1.5rem 0 0;
}
// Reading column and a side column, on the landing's own grid width. The
// column keeps prose to a readable measure; the side keeps the page from
// ending in a dead end.
.content-page__layout {
display: grid;
gap: 3rem;
padding-bottom: 5rem;
@media (min-width: 1024px) {
grid-template-columns: minmax(0, 46rem) minmax(16rem, 20rem);
justify-content: space-between;
gap: 4rem;
padding-bottom: 7rem;
}
}
.content-page__media {
margin: 0 0 2.5rem;
overflow: hidden;
border-radius: var(--pi-theme-card-radius, 1.5rem);
}
// The `<img>` belongs to `app-api-image`, so a scoped rule cannot reach it.
.content-page__media ::ng-deep img {
display: block;
width: 100%;
height: auto;
}
.content-page__body {
font-size: 1.0625rem;
// The body is HTML from the panel's editor, not this component's template,
// so it carries none of the scoping attributes: only `::ng-deep` reaches it.
// Confined to this container, it styles nothing else on the page.
::ng-deep {
// Bold, like every heading on the landing — a light heading here was the
// one thing that made the page read as another site.
h2 {
margin: 3rem 0 1rem;
font-size: clamp(1.5rem, 3vw, 1.875rem);
font-weight: 700;
line-height: 1.2;
letter-spacing: -0.015em;
}
h3 {
margin: 2rem 0 0.75rem;
font-size: 1.25rem;
font-weight: 700;
}
// One rhythm for both: left alone, the library's sheet sets paragraphs
// tighter than list items and the body reads in two different spacings.
p,
li {
line-height: 1.7;
}
p {
margin: 0 0 1rem;
}
ul,
ol {
margin: 0 0 1.5rem;
padding-left: 1.25rem;
}
li {
margin: 0 0 0.5rem;
&::marker {
color: var(--pi-color-primary);
}
}
strong {
font-weight: 700;
}
a {
color: var(--pi-color-primary);
}
> :first-child {
margin-top: 0;
}
}
}
.content-page__aside {
display: flex;
flex-direction: column;
gap: 2rem;
// Stays in view beside a long page, under the sticky header.
@media (min-width: 1024px) {
position: sticky;
top: 6rem;
align-self: start;
}
}
.content-page__related {
display: grid;
gap: 1rem;
margin: 0;
padding: 0;
list-style: none;
}
// `landing-card` is the landing's card surface and hover, drawn on a link
// rather than on `pi-card`, which brings no padding or radius of its own here.
.content-page__card,
.content-page__cta {
display: block;
padding: 1.25rem 1.5rem;
border-radius: var(--pi-theme-card-radius, 1.5rem);
box-shadow: var(--pi-theme-card-shadow, none);
}
.content-page__card {
color: inherit;
text-decoration: none;
}
.content-page__card-title {
display: block;
font-size: 1.0625rem;
font-weight: 700;
}
.content-page__card-text {
display: -webkit-box;
margin-top: 0.375rem;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
font-size: 0.875rem;
line-height: 1.5;
// Relative to the card's own text colour, so it holds up in dark mode too,
// where the grey token is nearly the background.
color: color-mix(in srgb, currentColor 68%, transparent);
}
.content-page__cta {
padding: 1.5rem;
// The call to action is not a link as a whole, so it does not lift.
&:hover {
transform: none;
}
p {
margin: 0.5rem 0 1.25rem;
line-height: 1.6;
}
}
.content-page__cta-title {
margin: 0;
font-size: 1.375rem;
font-weight: 700;
}
// Hero actions — a demo, a repository — and the chips under them.
.content-hero__actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 2rem;
a {
text-decoration: none;
}
}
.content-hero__chips {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin: 1.5rem 0 0;
padding: 0;
list-style: none;
}
// A section inside the reading column, under its own bold heading.
.content-page__section {
margin-top: 3.5rem;
> h2 {
margin: 0 0 1.25rem;
font-size: clamp(1.5rem, 3vw, 1.875rem);
font-weight: 700;
line-height: 1.2;
letter-spacing: -0.015em;
}
}
.content-page__main > :first-child {
margin-top: 0;
}
// A card in the side column that holds facts rather than a link.
.content-page__panel {
display: block;
padding: 1.25rem 1.5rem;
border-radius: var(--pi-theme-card-radius, 1.5rem);
box-shadow: var(--pi-theme-card-shadow, none);
&:hover {
transform: none;
}
h2 {
margin: 0 0 1rem;
font-size: 1.0625rem;
font-weight: 700;
}
}
// Label and value, one pair a row.
.content-page__facts {
display: grid;
gap: 0.875rem;
margin: 0;
dt {
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: color-mix(in srgb, currentColor 60%, transparent);
}
dd {
margin: 0.125rem 0 0;
}
a {
color: var(--pi-color-primary);
}
}
.content-page__muted {
color: color-mix(in srgb, currentColor 68%, transparent);
}
// Lists this layout spaces with `gap`. `@pi/ui`'s reset adds a top margin to
// every `li + li`, which suits a list of prose and staggers everything else.
.content-hero__chips,
.content-page__related {
> li {
margin: 0;
}
}
.content-hero__chips {
align-items: center;
}

View File

@@ -0,0 +1,72 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { PiActionButtonComponent, PiTextColorDirective } from '@pi/ui';
import type { Locale } from '../core/api';
import type { Copy } from '../core/copy';
import { homeHref } from '../core/routes';
/**
* "Shall we talk?" at the end of a page's side column, so no page is a dead
* end. The form lives on the landing; this leads to it.
*/
@Component({
selector: 'app-contact-cta',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiActionButtonComponent, PiTextColorDirective],
template: `
<div class="landing-card contact-cta">
<h2 class="contact-cta__title">{{ copy().page.ctaTitle }}</h2>
<p pi-color color="grey">{{ copy().page.ctaLead }}</p>
<!-- 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 class="contact-cta__link" [href]="href()">
<pi-action-button
color="primary"
[text]="copy().nav.contact"
[hasIcon]="true"
/>
</a>
</div>
`,
styles: `
.contact-cta {
display: block;
padding: 1.5rem;
border-radius: var(--pi-theme-card-radius, 1.5rem);
box-shadow: var(--pi-theme-card-shadow, none);
&:hover {
transform: none;
}
p {
margin: 0.5rem 0 1.25rem;
line-height: 1.6;
}
}
.contact-cta__title {
margin: 0;
font-size: 1.375rem;
font-weight: 700;
}
.contact-cta__link {
display: inline-block;
text-decoration: none;
}
`,
})
export class ContactCta {
readonly copy = input.required<Copy>();
readonly locale = input.required<Locale>();
protected readonly href = computed(
() => `${homeHref(this.locale())}#contacto`,
);
}

View File

@@ -1,215 +1,5 @@
@use './lights'; @use './detail';
:host { :host {
display: block; display: block;
} }
.landing-skip {
position: absolute;
left: -9999px;
&:focus {
left: 1rem;
top: 1rem;
z-index: 100;
padding: 0.5rem 1rem;
background: var(--pi-color-white, #fff);
border-radius: 0.5rem;
}
}
// The lights sit on the whole page rather than on its top: on the hero alone
// they end in a hard edge where it does, and here the text starts right there.
// A page is tall enough for them to fade out on their own.
.content-page {
@include lights.glow($overflow: clip);
}
// The top of the page is the landing's hero, minus the stats and buttons: the
// same left edge as the logo, the same heading weight.
.content-hero {
padding-block: 5rem 2.5rem;
@media (min-width: 768px) {
padding-block: 7rem 3.5rem;
}
}
.content-hero__title {
margin: 0;
max-width: 22ch;
font-size: clamp(2.25rem, 5.5vw, 3.5rem);
line-height: 1.08;
letter-spacing: -0.02em;
}
.content-hero__lead {
max-width: 46ch;
margin: 1.5rem 0 0;
}
// Reading column and a side column, on the landing's own grid width. The
// column keeps prose to a readable measure; the side keeps the page from
// ending in a dead end.
.content-page__layout {
display: grid;
gap: 3rem;
padding-bottom: 5rem;
@media (min-width: 1024px) {
grid-template-columns: minmax(0, 46rem) minmax(16rem, 20rem);
justify-content: space-between;
gap: 4rem;
padding-bottom: 7rem;
}
}
.content-page__media {
margin: 0 0 2.5rem;
overflow: hidden;
border-radius: var(--pi-theme-card-radius, 1.5rem);
}
// The `<img>` belongs to `app-api-image`, so a scoped rule cannot reach it.
.content-page__media ::ng-deep img {
display: block;
width: 100%;
height: auto;
}
.content-page__body {
font-size: 1.0625rem;
// The body is HTML from the panel's editor, not this component's template,
// so it carries none of the scoping attributes: only `::ng-deep` reaches it.
// Confined to this container, it styles nothing else on the page.
::ng-deep {
// Bold, like every heading on the landing — a light heading here was the
// one thing that made the page read as another site.
h2 {
margin: 3rem 0 1rem;
font-size: clamp(1.5rem, 3vw, 1.875rem);
font-weight: 700;
line-height: 1.2;
letter-spacing: -0.015em;
}
h3 {
margin: 2rem 0 0.75rem;
font-size: 1.25rem;
font-weight: 700;
}
// One rhythm for both: left alone, the library's sheet sets paragraphs
// tighter than list items and the body reads in two different spacings.
p,
li {
line-height: 1.7;
}
p {
margin: 0 0 1rem;
}
ul,
ol {
margin: 0 0 1.5rem;
padding-left: 1.25rem;
}
li {
margin-bottom: 0.5rem;
&::marker {
color: var(--pi-color-primary);
}
}
strong {
font-weight: 700;
}
a {
color: var(--pi-color-primary);
}
> :first-child {
margin-top: 0;
}
}
}
.content-page__aside {
display: flex;
flex-direction: column;
gap: 2rem;
// Stays in view beside a long page, under the sticky header.
@media (min-width: 1024px) {
position: sticky;
top: 6rem;
align-self: start;
}
}
.content-page__related {
display: grid;
gap: 1rem;
margin: 0;
padding: 0;
list-style: none;
}
// `landing-card` is the landing's card surface and hover, drawn on a link
// rather than on `pi-card`, which brings no padding or radius of its own here.
.content-page__card,
.content-page__cta {
display: block;
padding: 1.25rem 1.5rem;
border-radius: var(--pi-theme-card-radius, 1.5rem);
box-shadow: var(--pi-theme-card-shadow, none);
}
.content-page__card {
color: inherit;
text-decoration: none;
}
.content-page__card-title {
display: block;
font-size: 1.0625rem;
font-weight: 700;
}
.content-page__card-text {
display: -webkit-box;
margin-top: 0.375rem;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
font-size: 0.875rem;
line-height: 1.5;
// Relative to the card's own text colour, so it holds up in dark mode too,
// where the grey token is nearly the background.
color: color-mix(in srgb, currentColor 68%, transparent);
}
.content-page__cta {
padding: 1.5rem;
// The call to action is not a link as a whole, so it does not lift.
&:hover {
transform: none;
}
p {
margin: 0.5rem 0 1.25rem;
line-height: 1.6;
}
}
.content-page__cta-title {
margin: 0;
font-size: 1.375rem;
font-weight: 700;
}

View File

@@ -1,34 +1,23 @@
import { DOCUMENT } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
computed, computed,
effect,
inject,
input, input,
type OnInit,
} from '@angular/core'; } from '@angular/core';
import { Meta, Title } from '@angular/platform-browser';
import { injectRequest, injectResponse } from '@analogjs/router/tokens';
import { import {
PiActionButtonComponent,
PiTextColorDirective, PiTextColorDirective,
PiTextLargeDirective, PiTextLargeDirective,
PiTextWeightBoldDirective, PiTextWeightBoldDirective,
} from '@pi/ui'; } from '@pi/ui';
import { homeHref } from '../core/api'; import type { Locale } from '../core/api';
import { pageNavLinks, type ContentPageData } from '../core/content-page'; import type { PublicPage } from '../core/api.types';
import { copyFor } from '../core/copy'; import type { Copy } from '../core/copy';
import NotFoundPage from '../pages/[...not-found].page';
import { ApiImage } from './api-image'; import { ApiImage } from './api-image';
import { MaintenanceScreen } from './maintenance-screen'; import { ContactCta } from './contact-cta';
import { SiteFooter } from './site-footer';
import { SiteHeader } from './site-header';
/** /**
* One of the pages the panel publishes — "Sobre nosotros", "Contacto" — * One of the pages the panel publishes — "Sobre nosotros", "Contacto".
* rendered on its own address.
* *
* The body arrives as HTML from the panel's rich editor, written by the people * The body arrives as HTML from the panel's rich editor, written by the people
* who run the site, and goes through Angular's sanitiser on the way in: the * who run the site, and goes through Angular's sanitiser on the way in: the
@@ -40,48 +29,24 @@ import { SiteHeader } from './site-header';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
ApiImage, ApiImage,
MaintenanceScreen, ContactCta,
NotFoundPage,
PiActionButtonComponent,
PiTextColorDirective, PiTextColorDirective,
PiTextLargeDirective, PiTextLargeDirective,
PiTextWeightBoldDirective, PiTextWeightBoldDirective,
SiteFooter,
SiteHeader,
], ],
styleUrl: './content-page.scss', styleUrl: './content-page.scss',
template: ` template: `
@if (data().maintenance; as notice) {
<app-maintenance-screen
[notice]="notice"
[copy]="copy()"
[locale]="data().locale"
/>
} @else if (data().page; as page) {
<a class="landing-skip" href="#contenido">{{ copy().page.skip }}</a>
<app-site-header
[copy]="copy()"
[locale]="data().locale"
[links]="navLinks()"
[locales]="locales()"
[brandHref]="home()"
[alternates]="page.alternates"
(navigate)="goHome($event)"
/>
<main id="contenido" class="content-page">
<header class="content-hero"> <header class="content-hero">
<div class="landing-shell"> <div class="landing-shell">
@if (page.category?.name; as category) { @if (page().category?.name; as category) {
<p class="landing-eyebrow">{{ category }}</p> <p class="landing-eyebrow">{{ category }}</p>
} }
<h1 bold class="content-hero__title">{{ page.title }}</h1> <h1 bold class="content-hero__title">{{ page().title }}</h1>
@if (page.excerpt) { @if (page().excerpt; as excerpt) {
<p l pi-color color="grey" class="content-hero__lead"> <p l pi-color color="grey" class="content-hero__lead">
{{ page.excerpt }} {{ excerpt }}
</p> </p>
} }
</div> </div>
@@ -89,7 +54,7 @@ import { SiteHeader } from './site-header';
<div class="landing-shell content-page__layout"> <div class="landing-shell content-page__layout">
<article class="content-page__main"> <article class="content-page__main">
@if (page.featured_image; as image) { @if (page().featured_image; as image) {
<figure class="content-page__media"> <figure class="content-page__media">
<app-api-image <app-api-image
[image]="image" [image]="image"
@@ -100,7 +65,7 @@ import { SiteHeader } from './site-header';
</figure> </figure>
} }
<div class="content-page__body" [innerHTML]="page.body ?? ''"></div> <div class="content-page__body" [innerHTML]="page().body ?? ''"></div>
</article> </article>
<aside class="content-page__aside"> <aside class="content-page__aside">
@@ -110,10 +75,7 @@ import { SiteHeader } from './site-header';
<ul class="content-page__related"> <ul class="content-page__related">
@for (item of related(); track item.id) { @for (item of related(); track item.id) {
<li> <li>
<a <a class="landing-card content-page__card" [href]="item.path">
class="landing-card content-page__card"
[href]="item.path"
>
<span class="content-page__card-title">{{ <span class="content-page__card-title">{{
item.title item.title
}}</span> }}</span>
@@ -129,205 +91,32 @@ import { SiteHeader } from './site-header';
</nav> </nav>
} }
<div class="landing-card content-page__cta"> <app-contact-cta [copy]="copy()" [locale]="locale()" />
<h2 class="content-page__cta-title">
{{ copy().page.ctaTitle }}
</h2>
<p pi-color color="grey">{{ copy().page.ctaLead }}</p>
<pi-action-button
color="primary"
[text]="copy().nav.contact"
[hasIcon]="true"
(execute)="goHome('#contacto')"
/>
</div>
</aside> </aside>
</div> </div>
</main>
<app-site-footer [copy]="copy()" [pages]="data().pages" />
} @else {
<app-not-found />
}
`, `,
}) })
export class ContentPage implements OnInit { export class ContentPage {
readonly data = input.required<ContentPageData>(); readonly page = input.required<PublicPage>();
readonly pages = input.required<PublicPage[]>();
private readonly document = inject(DOCUMENT); readonly copy = input.required<Copy>();
private readonly title = inject(Title); readonly locale = input.required<Locale>();
private readonly meta = inject(Meta);
/** Absolute, because `hreflang` and `canonical` want one. Null in the browser, where it is not needed. */
private readonly origin = originOf(injectRequest());
/** Taken here, in the injection context; used once the input has arrived. */
private readonly response = injectResponse();
protected readonly copy = computed(() => copyFor(this.data().locale));
protected readonly home = computed(() => homeHref(this.data().locale));
protected readonly navLinks = computed(() => pageNavLinks(this.data().pages));
/** /**
* Somewhere to go next, so a page is not a dead end: the rest of its * Somewhere to go next, so a page is not a dead end: the rest of its
* category first, then anything else, three at most. * category first, then anything else, three at most.
*/ */
protected readonly related = computed(() => { protected readonly related = computed(() => {
const { page, pages } = this.data(); const page = this.page();
const others = pages.filter( const others = this.pages().filter(
(other) => other.id !== page?.id && other.path && other.title, (other) => other.id !== page.id && other.path && other.title,
); );
const sameCategory = (other: (typeof pages)[number]) => const sameCategory = (other: PublicPage) =>
other.category?.id === page?.category?.id; other.category?.id === page.category?.id;
return [ return [
...others.filter(sameCategory), ...others.filter(sameCategory),
...others.filter((other) => !sameCategory(other)), ...others.filter((other) => !sameCategory(other)),
].slice(0, 3); ].slice(0, 3);
}); });
protected readonly locales = computed(
() =>
this.data().site?.locales ?? [
{ code: 'es', name: 'Español', flag: '', is_default: true },
],
);
ngOnInit(): void {
this.answerWithStatus();
}
constructor() {
effect(() => {
const { page, locale, maintenance } = this.data();
this.document.documentElement.lang = locale;
if (maintenance) {
return;
}
// What the catch-all route says through its `routeMeta`, which only
// applies when the router picked that route — not when this one had to
// fall back to it.
if (!page) {
this.title.setTitle('Pireon — Página no encontrada');
this.meta.updateTag({ name: 'robots', content: 'noindex' });
return;
}
this.title.setTitle(page.meta_title || `Pireon — ${page.title}`);
this.meta.updateTag({
name: 'description',
content: page.meta_description || page.excerpt || '',
});
this.meta.updateTag({
property: 'og:title',
content: page.meta_title || page.title || '',
});
this.meta.updateTag({ property: 'og:type', content: 'article' });
this.linkAlternates(page.path, page.alternates ?? {});
});
}
/**
* The header's contact button and section anchors point at the landing, not
* at this page — there is no `#contacto` here to scroll to.
*/
protected goHome(anchor: string): void {
this.document.defaultView?.location.assign(`${this.home()}${anchor}`);
}
/**
* 404 for a slug the API does not know in this language, 503 during
* maintenance. Set on the render request's response, for the reason the
* landing sets its 503 the same way: the one a `load` sees never reaches the
* browser. The middleware leaves page-shaped addresses alone precisely so
* this can decide.
*
* From `ngOnInit`, not the constructor: an input has no value until then,
* and still nothing has been flushed.
*/
private answerWithStatus(): void {
const { maintenance, page } = this.data();
const response = this.response;
if (!response || response.headersSent) {
return;
}
if (maintenance) {
response.statusCode = 503;
if (maintenance.retry_after) {
response.setHeader('Retry-After', String(maintenance.retry_after));
}
response.setHeader('Cache-Control', 'no-store');
} else if (!page) {
response.statusCode = 404;
}
}
/**
* Only this side renders the page, but only the API knows that
* `/sobre-nosotros` and `/en/about-us` are the same one — hence `alternates`.
* Languages without a translation are absent from it rather than pointing at
* a 404.
*/
private linkAlternates(
path: string | undefined,
alternates: Record<string, string>,
): void {
const head = this.document.head;
head
.querySelectorAll(
'link[rel="alternate"][hreflang], link[rel="canonical"]',
)
.forEach((link) => link.remove());
const absolute = (href: string) =>
this.origin ? `${this.origin}${href}` : href;
const add = (attributes: Record<string, string>) => {
const link = this.document.createElement('link');
for (const [name, value] of Object.entries(attributes)) {
link.setAttribute(name, value);
}
head.appendChild(link);
};
if (path) {
add({ rel: 'canonical', href: absolute(path) });
}
for (const [code, href] of Object.entries(alternates)) {
add({ rel: 'alternate', hreflang: code, href: absolute(href) });
}
}
}
/** The origin a visitor used, as the reverse proxy in front of this app reports it. */
function originOf(request: ReturnType<typeof injectRequest>): string | null {
if (!request) {
return null;
}
const first = (value: string | string[] | undefined) =>
(Array.isArray(value) ? value[0] : value)?.split(',')[0]?.trim();
const host =
first(request.headers['x-forwarded-host']) ?? first(request.headers.host);
// Behind the proxy the header is always there; without one this is a
// developer's own machine, which does not speak TLS.
const protocol =
first(request.headers['x-forwarded-proto']) ??
(/^(localhost|127\.)/.test(host ?? '') ? 'http' : 'https');
return host ? `${protocol}://${host}` : null;
} }

View File

@@ -54,7 +54,13 @@ import type { PublicMember } from '../core/api.types';
</pi-card-top-content> </pi-card-top-content>
<pi-card-center-content> <pi-card-center-content>
<h3 bold class="landing-card__title">{{ member().name }}</h3> <h3 bold class="landing-card__title">
@if (member().path; as path) {
<a class="landing-card__title-link" [href]="path">{{ member().name }}</a>
} @else {
{{ member().name }}
}
</h3>
@if (member().role || member().headline; as subtitle) { @if (member().role || member().headline; as subtitle) {
<p s pi-color color="grey">{{ subtitle }}</p> <p s pi-color color="grey">{{ subtitle }}</p>
} }
@@ -93,8 +99,11 @@ export class MemberCard {
readonly member = input.required<PublicMember>(); readonly member = input.required<PublicMember>();
readonly copy = input.required<Copy>(); readonly copy = input.required<Copy>();
protected readonly links = computed(() => { protected readonly links = computed(() => memberLinks(this.member()).slice(0, 3));
const member = this.member(); }
/** A member's links, GitHub first when there is a username for it. */
export function memberLinks(member: PublicMember): { label: string; url: string }[] {
const entries = Object.entries(member.links ?? {}).map(([label, url]) => ({ const entries = Object.entries(member.links ?? {}).map(([label, url]) => ({
label, label,
url, url,
@@ -109,6 +118,5 @@ export class MemberCard {
}); });
} }
return entries.slice(0, 3); return entries;
});
} }

View File

@@ -0,0 +1,54 @@
@use './detail';
:host {
display: block;
}
.member-hero {
display: flex;
flex-direction: column;
gap: 1.5rem;
@media (min-width: 640px) {
flex-direction: row;
align-items: center;
gap: 2rem;
}
}
// A textarea's line breaks are the writer's paragraphs.
.member-bio {
margin: 0;
font-size: 1.0625rem;
line-height: 1.7;
white-space: pre-line;
}
.member-projects {
display: grid;
gap: 1.25rem;
@media (min-width: 640px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.member-links {
display: grid;
gap: 0.25rem;
margin: 0;
padding: 0;
list-style: none;
text-transform: capitalize;
}
.member-technologies {
margin-top: 0;
}
// `@pi/ui`'s reset spaces every `li + li`; these lists are spaced by `gap`.
.member-links {
> li {
margin: 0;
}
}

View File

@@ -0,0 +1,182 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import {
PiAvatarComponent,
PiChipComponent,
PiTextColorDirective,
PiTextLargeDirective,
PiTextWeightBoldDirective,
} from '@pi/ui';
import type { Locale } from '../core/api';
import type { PublicMember } from '../core/api.types';
import type { Copy } from '../core/copy';
import { ContactCta } from './contact-cta';
import { memberLinks } from './member-card';
import { ProjectCard } from './project-card';
/**
* Someone in the collective, on their own address: who they are, what they
* work with, and what they have worked on.
*
* The bio is plain text from a textarea, not HTML, so it is printed as text
* with its line breaks kept rather than handed to `innerHTML`.
*/
@Component({
selector: 'app-member-detail',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ContactCta,
PiAvatarComponent,
PiChipComponent,
PiTextColorDirective,
PiTextLargeDirective,
PiTextWeightBoldDirective,
ProjectCard,
],
styleUrl: './member-detail.scss',
template: `
<header class="content-hero">
<div class="landing-shell member-hero">
<pi-avatar
class="member-hero__avatar"
size="x-large"
shape="circle"
color="primary"
icon="user"
[image]="member().avatar?.url"
[alt]="member().avatar?.alt"
[name]="member().name"
/>
<div>
<p class="landing-eyebrow">{{ copy().nav.team }}</p>
<h1 bold class="content-hero__title">{{ member().name }}</h1>
@if (member().headline; as headline) {
<p l pi-color color="grey" class="content-hero__lead">
{{ headline }}
</p>
}
@if (member().is_founder || !member().is_current) {
<ul class="content-hero__chips">
@if (member().is_founder) {
<li>
<pi-chip
size="small"
color="primary"
[label]="copy().labels.founder"
/>
</li>
}
@if (!member().is_current) {
<li>
<pi-chip
size="small"
appearance="outline"
[label]="copy().labels.alumni"
/>
</li>
}
</ul>
}
</div>
</div>
</header>
<div class="landing-shell content-page__layout">
<article class="content-page__main">
@if (member().bio; as bio) {
<p class="member-bio">{{ bio }}</p>
}
@if ((member().projects ?? []).length > 0) {
<section class="content-page__section">
<h2>{{ copy().member.projects }}</h2>
<div class="member-projects">
@for (project of member().projects; track project.id) {
<app-project-card [project]="project" [copy]="copy()" />
}
</div>
</section>
}
</article>
<aside class="content-page__aside">
@if (since() || links().length > 0) {
<div class="landing-card content-page__panel">
<dl class="content-page__facts">
@if (since(); as since) {
<div>
<dt>{{ copy().member.since }}</dt>
<dd>{{ since }}</dd>
</div>
}
@if (links().length > 0) {
<div>
<dt>{{ copy().member.links }}</dt>
<dd>
<ul class="member-links">
@for (link of links(); track link.url) {
<li>
<a
[href]="link.url"
target="_blank"
rel="noopener me"
>{{ link.label }}</a
>
</li>
}
</ul>
</dd>
</div>
}
</dl>
</div>
}
@if ((member().technologies ?? []).length > 0) {
<div class="landing-card content-page__panel">
<h2>{{ copy().member.technologies }}</h2>
<ul class="content-hero__chips member-technologies">
@for (technology of member().technologies; track technology.id) {
<li>
<pi-chip
size="small"
appearance="outline"
[label]="technology.name"
/>
</li>
}
</ul>
</div>
}
<app-contact-cta [copy]="copy()" [locale]="locale()" />
</aside>
</div>
`,
})
export class MemberDetail {
readonly member = input.required<PublicMember>();
readonly copy = input.required<Copy>();
readonly locale = input.required<Locale>();
protected readonly links = computed(() => memberLinks(this.member()));
protected readonly since = computed(() => {
const joined = this.member().joined_at;
return joined
? new Intl.DateTimeFormat(this.locale(), {
month: 'long',
year: 'numeric',
}).format(new Date(joined))
: null;
});
}

View File

@@ -1,5 +1,4 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'; import { ChangeDetectionStrategy, Component } from '@angular/core';
import type { RouteMeta } from '@analogjs/router';
import { import {
PiActionButtonComponent, PiActionButtonComponent,
PiIconComponent, PiIconComponent,
@@ -10,15 +9,10 @@ import {
PiTextXLargeDirective, PiTextXLargeDirective,
} from '@pi/ui'; } from '@pi/ui';
export const routeMeta: RouteMeta = {
title: 'Pireon — Página no encontrada',
meta: [{ name: 'robots', content: 'noindex' }],
};
/** /**
* The status itself comes from `src/server/middleware/not-found-status.ts`: * What an address with nothing at it shows. Rendered by `PageShell`, which
* a page `load` cannot set it, because Analog resolves loaders through an * also sets the 404 status, the title and `noindex` — the middleware answers
* internal request whose response never reaches the browser. * 404 up front for addresses no record could ever live at.
* *
* Composed from the library's parts rather than from `pi-empty-state`, which * Composed from the library's parts rather than from `pi-empty-state`, which
* would have been the obvious fit: it renders its title as an `<h4>`, and a * would have been the obvious fit: it renders its title as an `<h4>`, and a
@@ -39,7 +33,7 @@ export const routeMeta: RouteMeta = {
PiTextColorDirective, PiTextColorDirective,
PiTextWeightBoldDirective, PiTextWeightBoldDirective,
], ],
styleUrl: './not-found.page.scss', styleUrl: './not-found.scss',
template: ` template: `
<main class="not-found"> <main class="not-found">
<div class="landing-shell not-found__inner"> <div class="landing-shell not-found__inner">
@@ -69,4 +63,4 @@ export const routeMeta: RouteMeta = {
</main> </main>
`, `,
}) })
export default class NotFoundPage {} export class NotFound {}

View File

@@ -0,0 +1,26 @@
@use './lights';
:host {
display: block;
}
.landing-skip {
position: absolute;
left: -9999px;
&:focus {
left: 1rem;
top: 1rem;
z-index: 100;
padding: 0.5rem 1rem;
background: var(--pi-color-white, #fff);
border-radius: 0.5rem;
}
}
// The lights sit on the whole page rather than on its top: on the hero alone
// they end in a hard edge where it does, and here the text starts right there.
// A page is tall enough for them to fade out on their own.
.content-page {
@include lights.glow($overflow: clip);
}

View File

@@ -0,0 +1,238 @@
import { DOCUMENT } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
input,
type OnInit,
} from '@angular/core';
import { Meta, Title } from '@angular/platform-browser';
import { injectRequest, injectResponse } from '@analogjs/router/tokens';
import type { Alternates } from '../core/api.types';
import { copyFor } from '../core/copy';
import { pageNavLinks, type RoutedPageData } from '../core/routed-page';
import { homeHref } from '../core/routes';
import { MaintenanceScreen } from './maintenance-screen';
import { NotFound } from './not-found';
import { SiteFooter } from './site-footer';
import { SiteHeader } from './site-header';
/** What the document head says about the record on the page. */
export interface PageSeo {
title: string;
description: string;
path?: string;
alternates: Alternates;
type: 'article' | 'profile' | 'website';
}
/**
* Everything around a page reached by its address: header, footer, the
* landing's lights, and the answers that are not a page at all — maintenance
* and 404.
*
* The record itself is projected in by `RoutedPage`, which knows its type.
* `seo` doubles as "was anything found": null is a 404.
*/
@Component({
selector: 'app-page-shell',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MaintenanceScreen, NotFound, SiteFooter, SiteHeader],
styleUrl: './page-shell.scss',
template: `
@if (data().maintenance; as notice) {
<app-maintenance-screen
[notice]="notice"
[copy]="copy()"
[locale]="data().locale"
/>
} @else if (seo(); as seo) {
<a class="landing-skip" href="#contenido">{{ copy().page.skip }}</a>
<app-site-header
[copy]="copy()"
[locale]="data().locale"
[links]="navLinks()"
[locales]="locales()"
[brandHref]="home()"
[alternates]="seo.alternates"
(navigate)="goHome($event)"
/>
<main id="contenido" class="content-page">
<ng-content />
</main>
<app-site-footer [copy]="copy()" [pages]="data().pages" />
} @else {
<app-not-found />
}
`,
})
export class PageShell implements OnInit {
readonly data = input.required<RoutedPageData>();
readonly seo = input.required<PageSeo | null>();
private readonly document = inject(DOCUMENT);
private readonly title = inject(Title);
private readonly meta = inject(Meta);
/** Absolute, because `hreflang` and `canonical` want one. Null in the browser, where it is not needed. */
private readonly origin = originOf(injectRequest());
/** Taken here, in the injection context; used once the inputs have arrived. */
private readonly response = injectResponse();
protected readonly copy = computed(() => copyFor(this.data().locale));
protected readonly home = computed(() => homeHref(this.data().locale));
protected readonly navLinks = computed(() => pageNavLinks(this.data().pages));
protected readonly locales = computed(
() =>
this.data().site?.locales ?? [
{ code: 'es', name: 'Español', flag: '', is_default: true },
],
);
ngOnInit(): void {
this.answerWithStatus();
}
constructor() {
effect(() => {
const { locale, maintenance } = this.data();
const seo = this.seo();
this.document.documentElement.lang = locale;
if (maintenance) {
return;
}
// What the catch-all route says through its `routeMeta`, which only
// applies when the router picked that route — not when this one had to
// fall back to it.
if (!seo) {
this.title.setTitle('Pireon — Página no encontrada');
this.meta.updateTag({ name: 'robots', content: 'noindex' });
return;
}
this.title.setTitle(seo.title);
this.meta.updateTag({ name: 'description', content: seo.description });
this.meta.updateTag({ property: 'og:title', content: seo.title });
this.meta.updateTag({
property: 'og:description',
content: seo.description,
});
this.meta.updateTag({ property: 'og:type', content: seo.type });
this.linkAlternates(seo.path, seo.alternates ?? {});
});
}
/**
* The header's contact button and section anchors point at the landing, not
* at this page — there is no `#contacto` here to scroll to.
*/
protected goHome(anchor: string): void {
this.document.defaultView?.location.assign(`${this.home()}${anchor}`);
}
/**
* 404 when the API has nothing at this address in this language, 503 during
* maintenance. Set on the render request's response, for the reason the
* landing sets its 503 the same way: the one a `load` sees never reaches the
* browser. The middleware leaves record-shaped addresses alone precisely so
* this can decide.
*
* From `ngOnInit`, not the constructor: an input has no value until then,
* and still nothing has been flushed.
*/
private answerWithStatus(): void {
const { maintenance } = this.data();
const response = this.response;
if (!response || response.headersSent) {
return;
}
if (maintenance) {
response.statusCode = 503;
if (maintenance.retry_after) {
response.setHeader('Retry-After', String(maintenance.retry_after));
}
response.setHeader('Cache-Control', 'no-store');
} else if (!this.seo()) {
response.statusCode = 404;
}
}
/**
* Only this side renders the record, but only the API knows that
* `/proyectos/x` and `/en/projects/x` are the same one — hence `alternates`.
* Languages without a translation are absent from it rather than pointing at
* a 404.
*/
private linkAlternates(
path: string | undefined,
alternates: Alternates,
): void {
const head = this.document.head;
head
.querySelectorAll(
'link[rel="alternate"][hreflang], link[rel="canonical"]',
)
.forEach((link) => link.remove());
const absolute = (href: string) =>
this.origin ? `${this.origin}${href}` : href;
const add = (attributes: Record<string, string>) => {
const link = this.document.createElement('link');
for (const [name, value] of Object.entries(attributes)) {
link.setAttribute(name, value);
}
head.appendChild(link);
};
if (path) {
add({ rel: 'canonical', href: absolute(path) });
}
for (const [code, href] of Object.entries(alternates)) {
if (href) {
add({ rel: 'alternate', hreflang: code, href: absolute(href) });
}
}
}
}
/** The origin a visitor used, as the reverse proxy in front of this app reports it. */
function originOf(request: ReturnType<typeof injectRequest>): string | null {
if (!request) {
return null;
}
const first = (value: string | string[] | undefined) =>
(Array.isArray(value) ? value[0] : value)?.split(',')[0]?.trim();
const host =
first(request.headers['x-forwarded-host']) ?? first(request.headers.host);
// Behind the proxy the header is always there; without one this is a
// developer's own machine, which does not speak TLS.
const protocol =
first(request.headers['x-forwarded-proto']) ??
(/^(localhost|127\.)/.test(host ?? '') ? 'http' : 'https');
return host ? `${protocol}://${host}` : null;
}

View File

@@ -16,6 +16,18 @@ import type { Copy } from '../core/copy';
import type { PublicProject } from '../core/api.types'; import type { PublicProject } from '../core/api.types';
import { ApiImage } from './api-image'; import { ApiImage } from './api-image';
/** The API's five project states onto the status chip's five tones. */
export const PROJECT_STATUS_TONES: Record<
PublicProject['status'],
'success' | 'error' | 'pending' | 'info' | 'disabled'
> = {
idea: 'info',
active: 'success',
paused: 'pending',
completed: 'info',
archived: 'disabled',
};
@Component({ @Component({
selector: 'app-project-card', selector: 'app-project-card',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@@ -70,7 +82,13 @@ import { ApiImage } from './api-image';
</pi-card-top-content> </pi-card-top-content>
<pi-card-center-content> <pi-card-center-content>
<h3 bold class="landing-card__title">{{ project().title }}</h3> <h3 bold class="landing-card__title">
@if (project().path; as path) {
<a class="landing-card__title-link" [href]="path">{{ project().title }}</a>
} @else {
{{ project().title }}
}
</h3>
@if (project().tagline; as tagline) { @if (project().tagline; as tagline) {
<p s pi-color color="grey">{{ tagline }}</p> <p s pi-color color="grey">{{ tagline }}</p>
} }
@@ -130,19 +148,7 @@ export class ProjectCard {
() => (this.project().repositories ?? []).find((repo) => repo.url)?.url ?? null () => (this.project().repositories ?? []).find((repo) => repo.url)?.url ?? null
); );
/** The API's five project states onto the chip's five tones. */ protected readonly statusTone = computed(
protected readonly statusTone = computed(() => { () => PROJECT_STATUS_TONES[this.project().status]
const tones: Record< );
PublicProject['status'],
'success' | 'error' | 'pending' | 'info' | 'disabled'
> = {
idea: 'info',
active: 'success',
paused: 'pending',
completed: 'info',
archived: 'disabled',
};
return tones[this.project().status];
});
} }

View File

@@ -0,0 +1,89 @@
@use './detail';
:host {
display: block;
}
.project-gallery {
display: grid;
gap: 1rem;
@media (min-width: 640px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.project-gallery__item {
margin: 0;
figcaption {
margin-top: 0.5rem;
font-size: 0.875rem;
color: color-mix(in srgb, currentColor 68%, transparent);
}
}
// The `<img>` belongs to `app-api-image`, so a scoped rule cannot reach it.
.project-gallery__item ::ng-deep img {
display: block;
width: 100%;
height: auto;
border-radius: var(--pi-border-radius-medium, 0.75rem);
}
.project-team,
.project-repos {
display: grid;
gap: 0.875rem;
margin: 0;
padding: 0;
list-style: none;
}
.project-team__member {
display: flex;
align-items: center;
gap: 0.75rem;
color: inherit;
text-decoration: none;
> span {
display: flex;
flex-direction: column;
font-size: 0.875rem;
}
&:hover .project-team__name {
text-decoration: underline;
}
}
.project-team__name {
font-weight: 600;
font-size: 0.9375rem;
}
.project-repos li {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
a {
color: var(--pi-color-primary);
font-weight: 600;
word-break: break-word;
}
}
.project-repos__stats {
font-size: 0.8125rem;
}
// `@pi/ui`'s reset spaces every `li + li`; these lists are spaced by `gap`.
.project-team,
.project-repos {
> li {
margin: 0;
}
}

View File

@@ -0,0 +1,335 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import {
PiActionButtonComponent,
PiAvatarComponent,
PiChipComponent,
PiChipStatusComponent,
PiTextColorDirective,
PiTextLargeDirective,
PiTextWeightBoldDirective,
} from '@pi/ui';
import type { Locale } from '../core/api';
import type { PublicProject } from '../core/api.types';
import type { Copy } from '../core/copy';
import { ApiImage } from './api-image';
import { ContactCta } from './contact-cta';
import { PROJECT_STATUS_TONES } from './project-card';
/**
* A project on its own address: what it is, why it was built the way it was,
* what went wrong, who made it, and where the code is.
*
* `description`, `decisions` and `postmortem` are HTML from the panel's rich
* editor and go through Angular's sanitiser, as a page's body does.
*/
@Component({
selector: 'app-project-detail',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ApiImage,
ContactCta,
PiActionButtonComponent,
PiAvatarComponent,
PiChipComponent,
PiChipStatusComponent,
PiTextColorDirective,
PiTextLargeDirective,
PiTextWeightBoldDirective,
],
styleUrl: './project-detail.scss',
template: `
<header class="content-hero">
<div class="landing-shell">
<p class="landing-eyebrow">{{ copy().project.details }}</p>
<h1 bold class="content-hero__title">{{ project().title }}</h1>
@if (project().tagline; as tagline) {
<p l pi-color color="grey" class="content-hero__lead">
{{ tagline }}
</p>
}
<ul class="content-hero__chips">
<li>
<pi-chip-status
size="small"
[status]="statusTone()"
[label]="copy().projectStatus[project().status]"
/>
</li>
@if (project().needs_help) {
<li>
<pi-chip
size="small"
appearance="outline"
color="accent"
[label]="copy().labels.needsHelp"
/>
</li>
}
@for (
technology of project().technologies ?? [];
track technology.id
) {
<li>
<pi-chip
size="small"
appearance="outline"
[label]="technology.name"
/>
</li>
}
</ul>
@if (project().demo_url || repositoryUrl()) {
<div class="content-hero__actions">
@if (project().demo_url; as demo) {
<a [href]="demo" target="_blank" rel="noopener">
<pi-action-button
color="primary"
[text]="copy().labels.demo"
[hasIcon]="true"
/>
</a>
}
@if (repositoryUrl(); as repository) {
<a [href]="repository" target="_blank" rel="noopener">
<pi-action-button
appearance="outline"
color="grey"
[text]="copy().labels.repository"
[hasIcon]="true"
/>
</a>
}
</div>
}
</div>
</header>
<div class="landing-shell content-page__layout">
<article class="content-page__main">
@if (project().cover_image; as cover) {
<figure class="content-page__media">
<app-api-image
[image]="cover"
sizes="(min-width: 1024px) 46rem, 100vw"
loading="eager"
imgClass="content-page__image"
/>
</figure>
}
@if (project().description; as description) {
<div class="content-page__body" [innerHTML]="description"></div>
}
@if (project().decisions; as decisions) {
<section class="content-page__section">
<h2>{{ copy().project.decisions }}</h2>
<div class="content-page__body" [innerHTML]="decisions"></div>
</section>
}
@if (project().postmortem; as postmortem) {
<section class="content-page__section">
<h2>{{ copy().project.postmortem }}</h2>
<div class="content-page__body" [innerHTML]="postmortem"></div>
</section>
}
@if ((project().gallery ?? []).length > 0) {
<section class="content-page__section">
<h2>{{ copy().project.gallery }}</h2>
<div class="project-gallery">
@for (image of project().gallery; track image.url) {
<figure class="project-gallery__item">
<app-api-image
[image]="image"
sizes="(min-width: 1024px) 23rem, 100vw"
imgClass="project-gallery__image"
/>
@if (image.caption) {
<figcaption>{{ image.caption }}</figcaption>
}
</figure>
}
</div>
</section>
}
</article>
<aside class="content-page__aside">
<div class="landing-card content-page__panel">
<dl class="content-page__facts">
<div>
<dt>{{ copy().project.status }}</dt>
<dd>{{ copy().projectStatus[project().status] }}</dd>
</div>
@if (project().client; as client) {
<div>
<dt>{{ copy().project.client }}</dt>
<dd>
@if (client.url) {
<a [href]="client.url" target="_blank" rel="noopener">{{
client.name
}}</a>
} @else {
{{ client.name }}
}
</dd>
</div>
}
@if (period(); as period) {
<div>
<dt>{{ copy().project.period }}</dt>
<dd>{{ period }}</dd>
</div>
}
@if (project().lead; as lead) {
<div>
<dt>{{ copy().project.lead }}</dt>
<dd>
<a [href]="lead.path">{{ lead.name }}</a>
</dd>
</div>
}
</dl>
</div>
@if ((project().calls ?? []).length > 0) {
<nav [attr.aria-label]="copy().project.openCalls">
<p class="landing-eyebrow">{{ copy().project.openCalls }}</p>
<ul class="content-page__related">
@for (call of project().calls; track call.id) {
<li>
<a class="landing-card content-page__card" [href]="call.path">
<span class="content-page__card-title">{{
call.title
}}</span>
@if (call.hours_per_week) {
<span class="content-page__card-text">
{{ call.hours_per_week }}
{{ copy().labels.hoursPerWeek }}
</span>
}
</a>
</li>
}
</ul>
</nav>
}
@if ((project().members ?? []).length > 0) {
<div class="landing-card content-page__panel">
<h2>{{ copy().project.team }}</h2>
<ul class="project-team">
@for (member of project().members; track member.id) {
<li>
<a class="project-team__member" [href]="member.path">
<pi-avatar
size="small"
shape="circle"
color="primary"
icon="user"
[image]="member.avatar?.url"
[alt]="member.avatar?.alt"
[name]="member.name"
/>
<span>
<span class="project-team__name">{{ member.name }}</span>
@if (member.role) {
<span class="content-page__muted">{{
member.role
}}</span>
}
</span>
</a>
</li>
}
</ul>
</div>
}
@if ((project().repositories ?? []).length > 0) {
<div class="landing-card content-page__panel">
<h2>{{ copy().project.repositories }}</h2>
<ul class="project-repos">
@for (repository of project().repositories; track repository.id) {
<li>
@if (repository.url) {
<a [href]="repository.url" target="_blank" rel="noopener">{{
repository.full_name
}}</a>
} @else {
<span>{{ repository.full_name }}</span>
}
<span class="content-page__muted project-repos__stats">
★ {{ repository.stars }} · {{ repository.forks }}
{{ copy().project.forks }}
@if (repository.primary_language) {
· {{ repository.primary_language }}
}
</span>
@if (repository.is_archived) {
<pi-chip
size="small"
appearance="outline"
[label]="copy().project.archived"
/>
} @else if (repository.is_dormant) {
<pi-chip
size="small"
appearance="outline"
[label]="copy().project.dormant"
/>
}
</li>
}
</ul>
</div>
}
<app-contact-cta [copy]="copy()" [locale]="locale()" />
</aside>
</div>
`,
})
export class ProjectDetail {
readonly project = input.required<PublicProject>();
readonly copy = input.required<Copy>();
readonly locale = input.required<Locale>();
protected readonly statusTone = computed(
() => PROJECT_STATUS_TONES[this.project().status],
);
protected readonly repositoryUrl = computed(
() =>
(this.project().repositories ?? []).find((repository) => repository.url)
?.url ?? null,
);
/** "marzo de 2025 – junio de 2026", or just the start while it runs. */
protected readonly period = computed(() => {
const { started_at, ended_at } = this.project();
const format = (date?: string) =>
date
? new Intl.DateTimeFormat(this.locale(), {
month: 'long',
year: 'numeric',
}).format(new Date(date))
: null;
const [start, end] = [format(started_at), format(ended_at)];
return start && end ? `${start} – ${end}` : (start ?? end);
});
}

View File

@@ -0,0 +1,103 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { copyFor } from '../core/copy';
import type { RoutedPageData, RoutedRecord } from '../core/routed-page';
import { ContentPage } from './content-page';
import { MemberDetail } from './member-detail';
import { PageShell, type PageSeo } from './page-shell';
import { ProjectDetail } from './project-detail';
/**
* A page reached by its address, whatever it holds: the shell around it, and
* the body for its type inside.
*/
@Component({
selector: 'app-routed-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ContentPage, MemberDetail, PageShell, ProjectDetail],
template: `
<app-page-shell [data]="data()" [seo]="seo()">
@if (data().found; as found) {
@switch (found.type) {
@case ('page') {
<app-content-page
[page]="found.record"
[pages]="data().pages"
[copy]="copy()"
[locale]="data().locale"
/>
}
@case ('project') {
<app-project-detail
[project]="found.record"
[copy]="copy()"
[locale]="data().locale"
/>
}
@case ('member') {
<app-member-detail
[member]="found.record"
[copy]="copy()"
[locale]="data().locale"
/>
}
}
}
</app-page-shell>
`,
})
export class RoutedPage {
readonly data = input.required<RoutedPageData>();
protected readonly copy = computed(() => copyFor(this.data().locale));
protected readonly seo = computed(() => seoFor(this.data().found));
}
/** What each type puts in the document head. Null when nothing was found. */
export function seoFor(found: RoutedRecord | null): PageSeo | null {
if (!found) {
return null;
}
switch (found.type) {
case 'page': {
const page = found.record;
return {
title: page.meta_title || `Pireon — ${page.title}`,
description: page.meta_description || page.excerpt || '',
path: page.path,
alternates: page.alternates ?? {},
type: 'article',
};
}
case 'project': {
const project = found.record;
return {
title: project.meta_title || `Pireon — ${project.title}`,
description: project.meta_description || project.tagline || '',
path: project.path,
alternates: project.alternates ?? {},
type: 'article',
};
}
case 'member': {
const member = found.record;
return {
title: `Pireon — ${member.name}`,
description: member.headline || member.bio?.slice(0, 160) || '',
path: member.path,
alternates: member.alternates ?? {},
type: 'profile',
};
}
}
}

View File

@@ -1,6 +1,6 @@
import { defineEventHandler, getRequestURL, setResponseStatus } from 'h3'; import { defineEventHandler, getRequestURL, setResponseStatus } from 'h3';
import { contentRoute } from '../../app/core/api'; import { resolveRoute } from '../../app/core/routes';
/** /**
* Answers 404 for addresses this app does not serve. * Answers 404 for addresses this app does not serve.
@@ -8,7 +8,7 @@ import { contentRoute } from '../../app/core/api';
* The status cannot be set from the catch-all page's `load`: Analog resolves * The status cannot be set from the catch-all page's `load`: Analog resolves
* loaders through an internal request, so anything set on that response never * loaders through an internal request, so anything set on that response never
* reaches the browser. Here it can be, and the route table is small enough to * reaches the browser. Here it can be, and the route table is small enough to
* state outright — the landing, plus the pages the panel publishes. * state outright — the landing, plus the addresses records live at.
*/ */
const KNOWN_PATHS = new Set(['/', '/index.html']); const KNOWN_PATHS = new Set(['/', '/index.html']);
@@ -30,11 +30,11 @@ export default defineEventHandler((event) => {
return; return;
} }
// Shaped like a content page (`/sobre-nosotros`, `/en/about-us`). Only the // Shaped like a record's address (`/sobre-nosotros`, `/proyectos/x`,
// API knows whether it exists, and asking it here would be a second request // `/en/team/x`). Only the API knows whether it exists, and asking it here
// for what the page asks anyway — so the page component sets the 404 itself // would be a second request for what the page asks anyway — so the page
// when the API has no such page. See `ui/content-page.ts`. // sets the 404 itself when the API has nothing there. See `ui/page-shell.ts`.
if (contentRoute(pathname)) { if (resolveRoute(pathname)) {
return; return;
} }

View File

@@ -221,6 +221,19 @@ html {
font-size: 1.125rem; font-size: 1.125rem;
} }
// A card's title leads to the record's own page. The card itself stays
// unlinked: it also holds links of its own (demo, repository) and a link
// inside a link is invalid.
.landing-card__title-link {
color: inherit;
text-decoration: none;
&:hover,
&:focus-visible {
text-decoration: underline;
}
}
.landing-card__media { .landing-card__media {
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;
overflow: hidden; overflow: hidden;
@@ -237,10 +250,17 @@ html {
.landing-card__chips { .landing-card__chips {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center;
gap: 0.375rem; gap: 0.375rem;
margin: 0.75rem 0 0; margin: 0.75rem 0 0;
padding: 0; padding: 0;
list-style: none; list-style: none;
// `@pi/ui`'s reset gives every `li + li` a top margin — a rhythm for a list
// of prose. In a row spaced by `gap` it dropped every chip but the first.
> li {
margin: 0;
}
} }
.landing-card__chips--center { .landing-card__chips--center {

View File

@@ -103,6 +103,47 @@ const data = {
], ],
}; };
// Detail endpoints: what a project's or a member's own page asks for. Richer
// than the listings — gallery, prose, team — because that is what the detail
// endpoints add.
const prose = (topic) =>
`<p>${topic} empezó como una necesidad concreta de una asociación y acabó siendo una herramienta que usan varias.</p>` +
'<ul><li>Pensado para que lo mantenga gente que no programa.</li><li>Con pruebas y documentación desde el primer día.</li></ul>';
const team = data['/public/v1/members'].slice(0, 3).map((m, i) => ({
...m,
role: ['Responsable', 'Frontend', 'Backend'][i],
}));
for (const listed of data['/public/v1/projects']) {
data[`/public/v1/projects/${listed.slug}`] = {
...listed,
description: prose(listed.title),
decisions: '<p>Elegimos renderizar en el servidor porque la mayoría de visitas llegan desde móviles modestos.</p>',
postmortem: '<p>Subestimamos la importación de datos antiguos: nos llevó el doble de lo previsto.</p>',
meta_title: `${listed.title} · Pireon`,
meta_description: listed.tagline,
started_at: '2025-03-01T00:00:00Z',
ended_at: listed.status === 'completed' ? '2026-06-01T00:00:00Z' : null,
client: data['/public/v1/clients'][0],
members: team,
lead: team[0],
calls: data['/public/v1/calls'].slice(0, listed.id % 2 ? 1 : 0).map((c) => ({ ...c, path: `/colabora/${c.slug}` })),
alternates: { es: `/proyectos/${listed.slug}`, en: `/en/projects/${listed.slug}`, ca: `/ca/projectes/${listed.slug}` },
};
}
for (const listed of data['/public/v1/members']) {
data[`/public/v1/members/${listed.slug}`] = {
...listed,
bio: 'Programa desde hace años en proyectos de todo tipo.\nAhora dedica unas horas a la semana al colectivo, sobre todo a revisar código y acompañar a quien empieza.',
joined_at: '2024-09-01T00:00:00Z',
technologies: technologies.slice(0, 4),
projects: data['/public/v1/projects'].slice(0, 2).map((p) => ({ ...p, path: `/proyectos/${p.slug}` })),
alternates: { es: `/equipo/${listed.slug}`, en: `/en/team/${listed.slug}`, ca: `/ca/equip/${listed.slug}` },
};
}
createServer((req, res) => { createServer((req, res) => {
const path = new URL(req.url, 'http://x').pathname.replace(/^\/api/, ''); const path = new URL(req.url, 'http://x').pathname.replace(/^\/api/, '');
const payload = data[path]; const payload = data[path];