From 439f951f2e868b205ce620317e34ee7829f4b8dc Mon Sep 17 00:00:00 2001 From: pigui Date: Tue, 29 Sep 2026 14:57:13 +0200 Subject: [PATCH] Give projects and members their own pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 49 ++- landing-e2e/src/landing.spec.ts | 17 + landing/src/app/core/api.spec.ts | 40 +- landing/src/app/core/api.ts | 42 +-- landing/src/app/core/content-page.ts | 56 --- landing/src/app/core/copy.ts | 92 +++++ landing/src/app/core/routed-page.ts | 89 +++++ landing/src/app/core/routes.spec.ts | 85 +++++ landing/src/app/core/routes.ts | 71 ++++ landing/src/app/pages/(home).page.ts | 2 +- .../{[slug].page.ts => [...path].page.ts} | 12 +- landing/src/app/pages/[...path].server.ts | 18 + landing/src/app/pages/[locale]/[slug].page.ts | 18 - .../src/app/pages/[locale]/[slug].server.ts | 20 - landing/src/app/pages/[slug].server.ts | 12 - landing/src/app/ui/_detail.scss | 290 +++++++++++++++ landing/src/app/ui/contact-cta.ts | 72 ++++ landing/src/app/ui/content-page.scss | 212 +---------- landing/src/app/ui/content-page.ts | 347 ++++-------------- landing/src/app/ui/member-card.ts | 46 ++- landing/src/app/ui/member-detail.scss | 54 +++ landing/src/app/ui/member-detail.ts | 182 +++++++++ .../not-found.page.scss => ui/not-found.scss} | 0 .../not-found.ts} | 16 +- landing/src/app/ui/page-shell.scss | 26 ++ landing/src/app/ui/page-shell.ts | 238 ++++++++++++ landing/src/app/ui/project-card.ts | 38 +- landing/src/app/ui/project-detail.scss | 89 +++++ landing/src/app/ui/project-detail.ts | 335 +++++++++++++++++ landing/src/app/ui/routed-page.ts | 103 ++++++ .../src/server/middleware/not-found-status.ts | 14 +- landing/src/styles.scss | 20 + tools/mock-api.mjs | 41 +++ 33 files changed, 2001 insertions(+), 745 deletions(-) delete mode 100644 landing/src/app/core/content-page.ts create mode 100644 landing/src/app/core/routed-page.ts create mode 100644 landing/src/app/core/routes.spec.ts create mode 100644 landing/src/app/core/routes.ts rename landing/src/app/pages/{[slug].page.ts => [...path].page.ts} (56%) create mode 100644 landing/src/app/pages/[...path].server.ts delete mode 100644 landing/src/app/pages/[locale]/[slug].page.ts delete mode 100644 landing/src/app/pages/[locale]/[slug].server.ts delete mode 100644 landing/src/app/pages/[slug].server.ts create mode 100644 landing/src/app/ui/_detail.scss create mode 100644 landing/src/app/ui/contact-cta.ts create mode 100644 landing/src/app/ui/member-detail.scss create mode 100644 landing/src/app/ui/member-detail.ts rename landing/src/app/{pages/not-found.page.scss => ui/not-found.scss} (100%) rename landing/src/app/{pages/[...not-found].page.ts => ui/not-found.ts} (79%) create mode 100644 landing/src/app/ui/page-shell.scss create mode 100644 landing/src/app/ui/page-shell.ts create mode 100644 landing/src/app/ui/project-detail.scss create mode 100644 landing/src/app/ui/project-detail.ts create mode 100644 landing/src/app/ui/routed-page.ts diff --git a/README.md b/README.md index e401dad..99b8308 100644 --- a/README.md +++ b/README.md @@ -150,11 +150,13 @@ landing/src/ core/copy.ts the chrome's own words, in es / en / ca core/icons.ts the icon set, inlined and registered as literals 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).page.ts the landing itself - pages/[slug].page.ts a page the panel publishes, default language - pages/[locale]/[slug].page.ts the same, in any other language + pages/[...path].page.ts everything else: pages, projects, members, 404 + 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 (thin wrappers over `@pi/ui`), each with its `.scss` 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 internal request Analog resolves loaders through and never reaches the browser. 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 -exists: the middleware lets those through and `ui/content-page.ts` sets the 404 -itself from `ngOnInit`, once its input has arrived. +addresses shaped like a record, where only the API knows whether it exists: the +middleware lets those through and `ui/page-shell.ts` sets the 404 itself from +`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 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 plain links, and `` follows. -**Content pages are the exception**, because the API decides their address: a -page's `path` and `alternates` put the default language unprefixed and every -other one under its code — `/sobre-nosotros`, `/en/about-us`, `/ca/qui-som` — -so those are the routes that answer. `core/api.ts#contentRoute` is the one rule -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. -The same `alternates` become `hreflang` links, with a `canonical`. +**Records are the exception**, because the API decides their address: a +record's `path` and `alternates` put the default language unprefixed and every +other one under its code, pages at the root and everything else under a section +named in that language: + +``` +/sobre-nosotros /en/about-us /ca/qui-som +/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 footer. diff --git a/landing-e2e/src/landing.spec.ts b/landing-e2e/src/landing.spec.ts index ba6ab55..f921803 100644 --- a/landing-e2e/src/landing.spec.ts +++ b/landing-e2e/src/landing.spec.ts @@ -53,6 +53,23 @@ test.describe('landing', () => { 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 ({ page, }) => { diff --git a/landing/src/app/core/api.spec.ts b/landing/src/app/core/api.spec.ts index 20ce504..84d9689 100644 --- a/landing/src/app/core/api.spec.ts +++ b/landing/src/app/core/api.spec.ts @@ -1,12 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { - contentRoute, - DEFAULT_LOCALE, - homeHref, - localeFromUrl, - resolveLocale, -} from './api'; +import { DEFAULT_LOCALE, localeFromUrl, resolveLocale } from './api'; describe('resolveLocale', () => { 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'); - }); -}); diff --git a/landing/src/app/core/api.ts b/landing/src/app/core/api.ts index f8f7c24..169751f 100644 --- a/landing/src/app/core/api.ts +++ b/landing/src/app/core/api.ts @@ -74,42 +74,6 @@ export function localeFromUrl(url: string | undefined): Locale { 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 { const configured = typeof process !== 'undefined' @@ -262,6 +226,12 @@ export const pireonApi = { /** By slug, which only resolves in the language it belongs to. */ page: (lang: Locale, slug: string) => get(`/pages/${encodeURIComponent(slug)}`, { lang }), + + project: (lang: Locale, slug: string) => + get(`/projects/${encodeURIComponent(slug)}`, { lang }), + + member: (lang: Locale, slug: string) => + get(`/members/${encodeURIComponent(slug)}`, { lang }), } as const; /** POST bodies the public API accepts. Only two endpoints write. */ diff --git a/landing/src/app/core/content-page.ts b/landing/src/app/core/content-page.ts deleted file mode 100644 index 169e897..0000000 --- a/landing/src/app/core/content-page.ts +++ /dev/null @@ -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 { - 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, - })); -} diff --git a/landing/src/app/core/copy.ts b/landing/src/app/core/copy.ts index 44df53f..80c728e 100644 --- a/landing/src/app/core/copy.ts +++ b/landing/src/app/core/copy.ts @@ -91,6 +91,29 @@ export interface Copy { }; footer: { rights: string; pages: 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 }; } @@ -190,6 +213,29 @@ const es: Copy = { ctaTitle: '¿Hablamos?', 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: { title: 'Estamos de mantenimiento', back: 'Volvemos en un rato.', @@ -293,6 +339,29 @@ const en: Copy = { 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.', }, + 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: { title: 'We are under maintenance', back: 'Back in a while.', @@ -396,6 +465,29 @@ const ca: Copy = { ctaTitle: 'En parlem?', 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: { title: 'Estem de manteniment', back: 'Tornem d’aquí una estona.', diff --git a/landing/src/app/core/routed-page.ts b/landing/src/app/core/routed-page.ts new file mode 100644 index 0000000..952bf19 --- /dev/null +++ b/landing/src/app/core/routed-page.ts @@ -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 { + 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 { + 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, + })); +} diff --git a/landing/src/app/core/routes.spec.ts b/landing/src/app/core/routes.spec.ts new file mode 100644 index 0000000..80c124e --- /dev/null +++ b/landing/src/app/core/routes.spec.ts @@ -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'); + }); +}); diff --git a/landing/src/app/core/routes.ts b/landing/src/app/core/routes.ts new file mode 100644 index 0000000..123e17d --- /dev/null +++ b/landing/src/app/core/routes.ts @@ -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, + Record +> = { + 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}`; +} diff --git a/landing/src/app/pages/(home).page.ts b/landing/src/app/pages/(home).page.ts index c07a462..605c104 100644 --- a/landing/src/app/pages/(home).page.ts +++ b/landing/src/app/pages/(home).page.ts @@ -17,7 +17,7 @@ import { PiChipComponent, } from '@pi/ui'; -import { pageNavLinks } from '../core/content-page'; +import { pageNavLinks } from '../core/routed-page'; import { copyFor } from '../core/copy'; import { CallCard } from '../ui/call-card'; import { ClientLogo } from '../ui/client-logo'; diff --git a/landing/src/app/pages/[slug].page.ts b/landing/src/app/pages/[...path].page.ts similarity index 56% rename from landing/src/app/pages/[slug].page.ts rename to landing/src/app/pages/[...path].page.ts index 336aec6..760e637 100644 --- a/landing/src/app/pages/[slug].page.ts +++ b/landing/src/app/pages/[...path].page.ts @@ -2,16 +2,16 @@ 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'; +import { RoutedPage } from '../ui/routed-page'; +import { load } from './[...path].server'; @Component({ - selector: 'app-content-page-default', + selector: 'app-route-path', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ContentPage], - template: ``, + imports: [RoutedPage], + template: ``, }) -export default class DefaultLocaleContentPage { +export default class PathRoute { protected readonly data = toSignal(injectLoad(), { requireSync: true, }); diff --git a/landing/src/app/pages/[...path].server.ts b/landing/src/app/pages/[...path].server.ts new file mode 100644 index 0000000..21598ce --- /dev/null +++ b/landing/src/app/pages/[...path].server.ts @@ -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 => + loadRoutedPage(`/${params?.['path'] ?? ''}`); diff --git a/landing/src/app/pages/[locale]/[slug].page.ts b/landing/src/app/pages/[locale]/[slug].page.ts deleted file mode 100644 index d10aadc..0000000 --- a/landing/src/app/pages/[locale]/[slug].page.ts +++ /dev/null @@ -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: ``, -}) -export default class LocalisedContentPage { - protected readonly data = toSignal(injectLoad(), { - requireSync: true, - }); -} diff --git a/landing/src/app/pages/[locale]/[slug].server.ts b/landing/src/app/pages/[locale]/[slug].server.ts deleted file mode 100644 index 8b17c31..0000000 --- a/landing/src/app/pages/[locale]/[slug].server.ts +++ /dev/null @@ -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 => { - const route = contentRoute(`/${params?.['locale']}/${params?.['slug']}`); - - return route - ? loadContentPage(route.locale, route.slug) - : loadContentPage(DEFAULT_LOCALE, undefined); -}; diff --git a/landing/src/app/pages/[slug].server.ts b/landing/src/app/pages/[slug].server.ts deleted file mode 100644 index 2a340b9..0000000 --- a/landing/src/app/pages/[slug].server.ts +++ /dev/null @@ -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 => - loadContentPage(DEFAULT_LOCALE, params?.['slug']); diff --git a/landing/src/app/ui/_detail.scss b/landing/src/app/ui/_detail.scss new file mode 100644 index 0000000..28d50eb --- /dev/null +++ b/landing/src/app/ui/_detail.scss @@ -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 `` 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; +} diff --git a/landing/src/app/ui/contact-cta.ts b/landing/src/app/ui/contact-cta.ts new file mode 100644 index 0000000..b223af0 --- /dev/null +++ b/landing/src/app/ui/contact-cta.ts @@ -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: ` +
+

{{ copy().page.ctaTitle }}

+

{{ copy().page.ctaLead }}

+ + + + +
+ `, + 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(); + readonly locale = input.required(); + + protected readonly href = computed( + () => `${homeHref(this.locale())}#contacto`, + ); +} diff --git a/landing/src/app/ui/content-page.scss b/landing/src/app/ui/content-page.scss index e0a980d..0db1d26 100644 --- a/landing/src/app/ui/content-page.scss +++ b/landing/src/app/ui/content-page.scss @@ -1,215 +1,5 @@ -@use './lights'; +@use './detail'; :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); -} - -// 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 `` 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; -} diff --git a/landing/src/app/ui/content-page.ts b/landing/src/app/ui/content-page.ts index 88f3436..45ced04 100644 --- a/landing/src/app/ui/content-page.ts +++ b/landing/src/app/ui/content-page.ts @@ -1,34 +1,23 @@ -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 { - PiActionButtonComponent, PiTextColorDirective, PiTextLargeDirective, PiTextWeightBoldDirective, } from '@pi/ui'; -import { homeHref } from '../core/api'; -import { pageNavLinks, type ContentPageData } from '../core/content-page'; -import { copyFor } from '../core/copy'; -import NotFoundPage from '../pages/[...not-found].page'; +import type { Locale } from '../core/api'; +import type { PublicPage } from '../core/api.types'; +import type { Copy } from '../core/copy'; import { ApiImage } from './api-image'; -import { MaintenanceScreen } from './maintenance-screen'; -import { SiteFooter } from './site-footer'; -import { SiteHeader } from './site-header'; +import { ContactCta } from './contact-cta'; /** - * One of the pages the panel publishes — "Sobre nosotros", "Contacto" — - * rendered on its own address. + * One of the pages the panel publishes — "Sobre nosotros", "Contacto". * * 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 @@ -40,294 +29,94 @@ import { SiteHeader } from './site-header'; changeDetection: ChangeDetectionStrategy.OnPush, imports: [ ApiImage, - MaintenanceScreen, - NotFoundPage, - PiActionButtonComponent, + ContactCta, PiTextColorDirective, PiTextLargeDirective, PiTextWeightBoldDirective, - SiteFooter, - SiteHeader, ], styleUrl: './content-page.scss', template: ` - @if (data().maintenance; as notice) { - - } @else if (data().page; as page) { - {{ copy().page.skip }} +
+
+ @if (page().category?.name; as category) { +

{{ category }}

+ } - +

{{ page().title }}

-
-
-
- @if (page.category?.name; as category) { -

{{ category }}

- } + @if (page().excerpt; as excerpt) { +

+ {{ excerpt }} +

+ } +
+
-

{{ page.title }}

+
+
+ @if (page().featured_image; as image) { +
+ +
+ } - @if (page.excerpt) { -

- {{ page.excerpt }} -

- } -
-
+
+ -
- - - -
- - - - } @else { - - } + + + `, }) -export class ContentPage implements OnInit { - readonly data = input.required(); - - 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 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)); +export class ContentPage { + readonly page = input.required(); + readonly pages = input.required(); + readonly copy = input.required(); + readonly locale = input.required(); /** * Somewhere to go next, so a page is not a dead end: the rest of its * category first, then anything else, three at most. */ protected readonly related = computed(() => { - const { page, pages } = this.data(); - const others = pages.filter( - (other) => other.id !== page?.id && other.path && other.title, + const page = this.page(); + const others = this.pages().filter( + (other) => other.id !== page.id && other.path && other.title, ); - const sameCategory = (other: (typeof pages)[number]) => - other.category?.id === page?.category?.id; + const sameCategory = (other: PublicPage) => + other.category?.id === page.category?.id; return [ ...others.filter(sameCategory), ...others.filter((other) => !sameCategory(other)), ].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, - ): 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) => { - 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): 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; } diff --git a/landing/src/app/ui/member-card.ts b/landing/src/app/ui/member-card.ts index efad36f..f252e70 100644 --- a/landing/src/app/ui/member-card.ts +++ b/landing/src/app/ui/member-card.ts @@ -54,7 +54,13 @@ import type { PublicMember } from '../core/api.types'; -

{{ member().name }}

+

+ @if (member().path; as path) { + {{ member().name }} + } @else { + {{ member().name }} + } +

@if (member().role || member().headline; as subtitle) {

{{ subtitle }}

} @@ -93,22 +99,24 @@ export class MemberCard { readonly member = input.required(); readonly copy = input.required(); - protected readonly links = computed(() => { - const member = this.member(); - const entries = Object.entries(member.links ?? {}).map(([label, url]) => ({ - label, - url, - })); - - // The username is handed over separately, so build the URL rather than - // hope somebody also filled the links map in. - if (member.github_username && !entries.some((link) => link.label === 'github')) { - entries.unshift({ - label: 'github', - url: `https://github.com/${member.github_username}`, - }); - } - - return entries.slice(0, 3); - }); + protected readonly links = computed(() => memberLinks(this.member()).slice(0, 3)); +} + +/** 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]) => ({ + label, + url, + })); + + // The username is handed over separately, so build the URL rather than + // hope somebody also filled the links map in. + if (member.github_username && !entries.some((link) => link.label === 'github')) { + entries.unshift({ + label: 'github', + url: `https://github.com/${member.github_username}`, + }); + } + + return entries; } diff --git a/landing/src/app/ui/member-detail.scss b/landing/src/app/ui/member-detail.scss new file mode 100644 index 0000000..8f98702 --- /dev/null +++ b/landing/src/app/ui/member-detail.scss @@ -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; + } +} diff --git a/landing/src/app/ui/member-detail.ts b/landing/src/app/ui/member-detail.ts new file mode 100644 index 0000000..757dae0 --- /dev/null +++ b/landing/src/app/ui/member-detail.ts @@ -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: ` +
+
+ + +
+

{{ copy().nav.team }}

+

{{ member().name }}

+ + @if (member().headline; as headline) { +

+ {{ headline }} +

+ } + + @if (member().is_founder || !member().is_current) { +
    + @if (member().is_founder) { +
  • + +
  • + } + @if (!member().is_current) { +
  • + +
  • + } +
+ } +
+
+
+ +
+
+ @if (member().bio; as bio) { +

{{ bio }}

+ } + + @if ((member().projects ?? []).length > 0) { +
+

{{ copy().member.projects }}

+
+ @for (project of member().projects; track project.id) { + + } +
+
+ } +
+ + +
+ `, +}) +export class MemberDetail { + readonly member = input.required(); + readonly copy = input.required(); + readonly locale = input.required(); + + 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; + }); +} diff --git a/landing/src/app/pages/not-found.page.scss b/landing/src/app/ui/not-found.scss similarity index 100% rename from landing/src/app/pages/not-found.page.scss rename to landing/src/app/ui/not-found.scss diff --git a/landing/src/app/pages/[...not-found].page.ts b/landing/src/app/ui/not-found.ts similarity index 79% rename from landing/src/app/pages/[...not-found].page.ts rename to landing/src/app/ui/not-found.ts index d4f5851..b301b4a 100644 --- a/landing/src/app/pages/[...not-found].page.ts +++ b/landing/src/app/ui/not-found.ts @@ -1,5 +1,4 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; -import type { RouteMeta } from '@analogjs/router'; import { PiActionButtonComponent, PiIconComponent, @@ -10,15 +9,10 @@ import { PiTextXLargeDirective, } 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`: - * a page `load` cannot set it, because Analog resolves loaders through an - * internal request whose response never reaches the browser. + * What an address with nothing at it shows. Rendered by `PageShell`, which + * also sets the 404 status, the title and `noindex` — the middleware answers + * 404 up front for addresses no record could ever live at. * * 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 `

`, and a @@ -39,7 +33,7 @@ export const routeMeta: RouteMeta = { PiTextColorDirective, PiTextWeightBoldDirective, ], - styleUrl: './not-found.page.scss', + styleUrl: './not-found.scss', template: `
@@ -69,4 +63,4 @@ export const routeMeta: RouteMeta = {
`, }) -export default class NotFoundPage {} +export class NotFound {} diff --git a/landing/src/app/ui/page-shell.scss b/landing/src/app/ui/page-shell.scss new file mode 100644 index 0000000..935267b --- /dev/null +++ b/landing/src/app/ui/page-shell.scss @@ -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); +} diff --git a/landing/src/app/ui/page-shell.ts b/landing/src/app/ui/page-shell.ts new file mode 100644 index 0000000..5bd9ff6 --- /dev/null +++ b/landing/src/app/ui/page-shell.ts @@ -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) { + + } @else if (seo(); as seo) { + {{ copy().page.skip }} + + + +
+ +
+ + + } @else { + + } + `, +}) +export class PageShell implements OnInit { + readonly data = input.required(); + readonly seo = input.required(); + + 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) => { + 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): 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; +} diff --git a/landing/src/app/ui/project-card.ts b/landing/src/app/ui/project-card.ts index cdbb40a..c7b1a9d 100644 --- a/landing/src/app/ui/project-card.ts +++ b/landing/src/app/ui/project-card.ts @@ -16,6 +16,18 @@ import type { Copy } from '../core/copy'; import type { PublicProject } from '../core/api.types'; 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({ selector: 'app-project-card', changeDetection: ChangeDetectionStrategy.OnPush, @@ -70,7 +82,13 @@ import { ApiImage } from './api-image'; -

{{ project().title }}

+

+ @if (project().path; as path) { + {{ project().title }} + } @else { + {{ project().title }} + } +

@if (project().tagline; as tagline) {

{{ tagline }}

} @@ -130,19 +148,7 @@ export class ProjectCard { () => (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(() => { - 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]; - }); + protected readonly statusTone = computed( + () => PROJECT_STATUS_TONES[this.project().status] + ); } diff --git a/landing/src/app/ui/project-detail.scss b/landing/src/app/ui/project-detail.scss new file mode 100644 index 0000000..fa78f1e --- /dev/null +++ b/landing/src/app/ui/project-detail.scss @@ -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 `` 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; + } +} diff --git a/landing/src/app/ui/project-detail.ts b/landing/src/app/ui/project-detail.ts new file mode 100644 index 0000000..ca0fb4b --- /dev/null +++ b/landing/src/app/ui/project-detail.ts @@ -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: ` +
+
+

{{ copy().project.details }}

+ +

{{ project().title }}

+ + @if (project().tagline; as tagline) { +

+ {{ tagline }} +

+ } + +
    +
  • + +
  • + @if (project().needs_help) { +
  • + +
  • + } + @for ( + technology of project().technologies ?? []; + track technology.id + ) { +
  • + +
  • + } +
+ + @if (project().demo_url || repositoryUrl()) { +
+ @if (project().demo_url; as demo) { + + + + } + @if (repositoryUrl(); as repository) { + + + + } +
+ } +
+
+ +
+
+ @if (project().cover_image; as cover) { +
+ +
+ } + + @if (project().description; as description) { +
+ } + + @if (project().decisions; as decisions) { +
+

{{ copy().project.decisions }}

+
+
+ } + + @if (project().postmortem; as postmortem) { +
+

{{ copy().project.postmortem }}

+
+
+ } + + @if ((project().gallery ?? []).length > 0) { +
+

{{ copy().project.gallery }}

+ +
+ } +
+ + +
+ `, +}) +export class ProjectDetail { + readonly project = input.required(); + readonly copy = input.required(); + readonly locale = input.required(); + + 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); + }); +} diff --git a/landing/src/app/ui/routed-page.ts b/landing/src/app/ui/routed-page.ts new file mode 100644 index 0000000..b180c0a --- /dev/null +++ b/landing/src/app/ui/routed-page.ts @@ -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: ` + + @if (data().found; as found) { + @switch (found.type) { + @case ('page') { + + } + @case ('project') { + + } + @case ('member') { + + } + } + } + + `, +}) +export class RoutedPage { + readonly data = input.required(); + + 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', + }; + } + } +} diff --git a/landing/src/server/middleware/not-found-status.ts b/landing/src/server/middleware/not-found-status.ts index 8d08baa..bd6271b 100644 --- a/landing/src/server/middleware/not-found-status.ts +++ b/landing/src/server/middleware/not-found-status.ts @@ -1,6 +1,6 @@ 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. @@ -8,7 +8,7 @@ import { contentRoute } from '../../app/core/api'; * 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 * 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']); @@ -30,11 +30,11 @@ export default defineEventHandler((event) => { return; } - // Shaped like a content page (`/sobre-nosotros`, `/en/about-us`). Only the - // API knows whether it exists, and asking it here would be a second request - // for what the page asks anyway — so the page component sets the 404 itself - // when the API has no such page. See `ui/content-page.ts`. - if (contentRoute(pathname)) { + // Shaped like a record's address (`/sobre-nosotros`, `/proyectos/x`, + // `/en/team/x`). Only the API knows whether it exists, and asking it here + // would be a second request for what the page asks anyway — so the page + // sets the 404 itself when the API has nothing there. See `ui/page-shell.ts`. + if (resolveRoute(pathname)) { return; } diff --git a/landing/src/styles.scss b/landing/src/styles.scss index cf44a00..d903924 100644 --- a/landing/src/styles.scss +++ b/landing/src/styles.scss @@ -221,6 +221,19 @@ html { 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 { aspect-ratio: 16 / 9; overflow: hidden; @@ -237,10 +250,17 @@ html { .landing-card__chips { display: flex; flex-wrap: wrap; + align-items: center; gap: 0.375rem; margin: 0.75rem 0 0; padding: 0; 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 { diff --git a/tools/mock-api.mjs b/tools/mock-api.mjs index e6c58c4..b400da7 100644 --- a/tools/mock-api.mjs +++ b/tools/mock-api.mjs @@ -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) => + `

${topic} empezó como una necesidad concreta de una asociación y acabó siendo una herramienta que usan varias.

` + + '
  • Pensado para que lo mantenga gente que no programa.
  • Con pruebas y documentación desde el primer día.
'; + +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: '

Elegimos renderizar en el servidor porque la mayoría de visitas llegan desde móviles modestos.

', + postmortem: '

Subestimamos la importación de datos antiguos: nos llevó el doble de lo previsto.

', + 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) => { const path = new URL(req.url, 'http://x').pathname.replace(/^\/api/, ''); const payload = data[path];