Compare commits

..

12 Commits

Author SHA1 Message Date
pigui
9af6b5aff9 Give the content pages the landing's look
Moving from the landing to a page read as changing sites: a narrow
centred column on a flat background, light headings, no cards. The
page now opens like the landing's hero — the same two lights, the same
left edge as the logo, a bold title with the excerpt as its lead — and
the body's headings are bold like every section title on the landing.

The lights move into a shared mixin (ui/_lights.scss) so the hero and
the pages cannot drift apart. On a page they sit on the whole <main>
with `overflow: clip`, so they fade out on their own instead of ending
in a hard edge right where the text starts, and `clip` rather than
`hidden` keeps the sticky side column working.

A side column keeps a page from being a dead end: "keep reading" cards
for the other pages, its own category first, and a card to get in
touch, both on the landing's card surface.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:38:04 +02:00
pigui
80a1924519 Serve the pages the panel publishes
The landing was one address. It now also answers on the pages the panel
publishes -- "Sobre nosotros" and the like -- at the addresses the API
itself chooses: `path` and `alternates` put the default language
unprefixed and every other one under its code, so `/sobre-nosotros`,
`/en/about-us`, `/ca/qui-som` are the routes. `core/api.ts#contentRoute`
is the single rule for that shape, and `/es/…` is deliberately not one
of them, so a page has one address rather than two.

Two routes share one load function, because Analog wants a `.server.ts`
beside each `.page.ts`: `[slug]` for the default language, and
`[locale]/[slug]` for the rest.

Whether a slug exists is the API's answer, not something the route table
knows, so the 404 middleware lets page-shaped addresses through and
`ui/content-page.ts` sets the status itself from `ngOnInit` -- the same
`injectResponse()` the landing uses for its 503, and `ngOnInit` because
an input has no value in the constructor. Body HTML comes from the
panel's editor and goes through Angular's sanitiser.

The header shows the featured pages beside the section anchors; the
footer lists them all. On a page the language switcher follows the API's
`alternates`, and a language with no translation leads to the landing in
that language instead of a 404.

Verified: lint, 20 unit tests, build, and 6 e2e -- including a new one
that a page-shaped address the API does not know answers 404 with
`robots: noindex`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 09:55:10 +02:00
pigui
47c9af682c Remove the build credit from the footer
Drops "Hecho con Angular, Analog y Nx." and the API link beside it,
leaving the copyright line on its own.

`footer.built` goes with it in all three locales. So does `footer.api`,
which was already dead -- declared and translated, never rendered.

The row's flex and space-between existed only to push the credit to the
far end, so they go too.

Verified in es/en/ca: no trace of the credit or the docs link in the
served HTML, and the copyright line renders in each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 15:37:55 +02:00
pigui
7bb40d32f1 Drop the gradient focus ring, which doubled every field's left edge
The doubled edge was not the textarea's alone -- it was on every field,
and the previous commit's explanation of why the inputs were exempt was
wrong. I asserted that at 999px the ring and the outline clamp to the
same curve without measuring it. They do not:

  ring     999px -> a 24px circle      (half the field's height)
  outline  999px -> a 12x24 ellipse    (leading piece width x half height)

The ring is one continuous rounded rectangle over the whole field; the
outline beneath it is split into three pieces and its leading piece is
12px wide. No radius makes a full-width curve agree with a 12px-wide
one, so there was nothing to tune -- the ring had to go. It was mine,
lifted from the library's `gradient` theme onto a shell that does not
use it, and `alternative` already expresses focus through the outline's
own colour.

The textarea's radius goes back to 1.25rem, since it was only lowered to
dodge the clamp.

Verified at 3x, one curve per field: valid+focus violet, error+focus
red, no ring anywhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 14:06:59 +02:00
pigui
4d2a8dd26b Fix the doubled edge down the left of the textarea
Focusing the textarea while it had an error drew two red curves down its
left side, a couple of pixels apart. Not a positioning problem -- the
ring and the outline share the same box to the pixel. It is clamping.

The outline's leading piece is 12px wide, so the 20px corner radius it
was given got clamped to 12, while the focus ring, drawn across the full
width, kept a true 20px curve. Same declared radius, two different
curves. At 10px nothing is clamped and they land on top of each other.

The single-line fields never showed this: at 999px both the outline and
the ring clamp to half the field's height, so they already agreed.

Verified at 2x: one continuous outline, ring and leading piece both
reporting 10px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 13:31:58 +02:00
pigui
3316ac89fd Stop drawing an errored field twice when it takes focus
Focusing a field that already had an error painted two rings at once:
the notched outline stayed in the error colour and the gradient focus
ring went on top of it in the brand's violet-to-cyan. Two rings, two
colours, one field -- and on the textarea at two different radii, which
is what made it visible enough to report.

The ring itself is worth keeping; it is how focus reads everywhere else.
It just has to agree with the state it decorates, so while the field is
invalid it is built from the warn colour. The result is one red
indication that brightens on focus, and valid fields keep the brand
gradient.

Verified in both themes: an errored focused field's ring and outline are
both the warn colour, a valid focused one still gets the brand gradient.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 13:15:07 +02:00
pigui
216987bb0c Group each error message with the field it belongs to
On the deployed site "Falta el nombre" sat 0px below its own field and
6px above the *next* field's label, so the three lines read as one block
and the message looked like a caption for "HORAS POR SEMANA".

Material renders a field's message flush underneath it, so the space
between rows has to be visibly larger than the space inside one. The
form's row gap goes from 1.25rem to 2rem: 0px to its own field, 18px to
the next label.

Measured against https://pireon.dev before and after. The earlier fixes
in this series are already live there and verified correct on the
deployed build -- labels uppercase at weight 700, the checkbox message
red at 12px on its own line, the textarea's ring at 20px, and no growth
on submit. This one needs a redeploy to show.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 13:04:32 +02:00
pigui
6004110fcf Stop the form moving when it reports errors
Pressing Enviar on an incomplete form pushed everything below each
errored field downwards. Material's subscript strip is 10px tall while a
field is valid and 24px once it carries a message, so the form grew by
14px per field at the moment of the click -- the submit button moved out
from under the cursor. The taller height is now reserved up front.

The consent message had two problems of its own. It rendered as an
inline span straight after the label, so it read as part of the
sentence: "Acepto que guardéis mis datos para responderme. Hace falta tu
consentimiento". And nothing in @pi/ui styles `.pi-checkbox__error`, so
it inherited the body colour and size -- ordinary text sitting beside
four red ones. It now gets its own reserved line, in the warn colour at
the same 12px as every other message.

Verified in both themes: the form grows 0px on submit, and all five
messages match at 12px in the warn colour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 09:51:47 +02:00
pigui
7557894b49 Keep field labels stable on focus, and fix the textarea's ring
Focusing a field visibly reflowed its label: "MENSAJE *" turned into
"Mensaje *" in a lighter face under the cursor. @pi/ui applies the
theme's label typography under `:not(.mdc-text-field--focused)`, so
text-transform, letter-spacing and weight all fall away the moment a
field takes focus. Restated without that guard. Colour is left alone --
Material tints the label on focus and that part is wanted.

The textarea was also drawn twice, once in the error colour and once in
the gradient, the two outlines offset from each other. Both take their
radius from `--pi-theme-field-radius`, which the `alternative` shell
sets to 999px: right for a 48px input, a stadium on a 112px textarea. A
fixed radius makes the two shapes coincide.

Both only appear in states the previous fix did not check -- it verified
the resting form only. Measured now in error+focus: all four labels
uppercase at weight 700, and the textarea's ring and outline both at
20px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 09:08:54 +02:00
pigui
25d0fe500b Fix the hours field's label sitting on the outline
The label read "Horas por semana" struck through by the field's own
border: 5px above the wrapper instead of 18, straddling the 2px outline.

Angular Material measures an outlined field's floating label once on
init and writes the result to the element as an inline transform. This
field is the only one inside an @if, so it is measured before it has any
geometry and keeps the unfloated offset. @pi/ui already publishes the
right values for every other field and its rule matches this one too --
it simply loses to an inline style, so the fix restates the library's
own numbers with `!important`, which is what outranks one.

It also gains a placeholder, which every other field already had.

The e2e config now derives the dev server's port from BASE_URL instead
of pinning 4200. With `reuseExistingServer`, an unrelated project's dev
server on that port gets adopted silently and all five tests fail with
"element(s) not found" while the page under test was never served --
which is exactly what happened while verifying this.

Verified: all four labels now land at -18 with a 48px box, light and
dark; lint, 15 unit tests, build and 5 e2e green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 00:13:12 +02:00
pigui
68ad014df0 Stop publishing a host port
`docker compose up` failed with "port is already allocated": 3000 was
taken by an unrelated project on the same machine. Publishing it was
never needed — in production the only thing that has to reach this
service is the reverse proxy, which does so over the compose network as
`landing:3000`.

`expose` replaces `ports`, and a commented `ports` block stays for the
cases that do want a host binding. LANDING_PORT is kept but re-labelled
as only mattering then.

Verified: the container binds nothing on the host, answers 200 with the
full page from another container on the network, and stays healthy —
its health check was always internal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 21:59:42 +02:00
pigui
a226cbddbd Fail the image build when the Nexus token is missing
`docker compose build` without NEXUS_TOKEN produced a Nexus 401 forty
seconds in, worded as a credentials problem when it was really an unset
variable. The mount's `required=true` asserts the secret was *declared*,
not that it holds anything, so an empty one sailed past it and npm sent
an empty `_authToken`.

The install moves to tools/docker-install-deps.sh, which checks the
secret before touching the network and fails in under a second with the
three ways to supply it. It is a file rather than inline shell because
BuildKit echoes an inline command twice on failure and buries the
message.

Also ignore .env. It carries NEXUS_TOKEN, compose reads it at build
time, and it was not gitignored — .env.example stays tracked. Nothing
had been committed: the only prior commit predates all of this.

Verified: empty token fails in 0.2s with the message; .env and the
environment variable both build; the image carries no trace of the
token and serves the full page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 21:56:11 +02:00
31 changed files with 1263 additions and 117 deletions

View File

@@ -11,6 +11,9 @@ tmp
.claude
docs
landing-e2e
# tools/ IS needed: the build stage copies docker-install-deps.sh from it.
# The mock API servers in there are dev-only but too small to be worth excluding.
**/*.spec.ts
test-output
.env

View File

@@ -1,10 +1,21 @@
# Base URL of the Pireon API, without a trailing slash. Read on the server
# only: the browser never talks to the API directly.
# Copy to `.env`. Compose reads it on its own — for the runtime settings below
# and for the build-time token. `.env` is gitignored; this file is not.
# Base URL of the Pireon API, without a trailing slash. Read on the server only:
# the browser never talks to the API directly.
PIREON_API_URL=https://landing-api.pireon.dev/api
# Host port the container is published on.
# Only used if you uncomment `ports` in docker-compose.yml. By default the
# container is not published on the host at all: the reverse proxy reaches it
# over the compose network as `landing:3000`, and publishing a host port only
# invites a collision with whatever else is on that number.
LANDING_PORT=3000
# Only needed to *build* the image: `@pi/ui` comes from the private Nexus.
# Not read at runtime, and never baked into a layer — see Dockerfile.
# Needed only to *build* the image: `@pi/ui` comes from the private Nexus.
# Locally this is the value already sitting in ~/.npmrc:
#
# grep nexus.pireon.dev ~/.npmrc
#
# Never baked into a layer — it is mounted as a BuildKit secret. An empty value
# fails the build immediately with an explanation rather than a Nexus 401.
NEXUS_TOKEN=

10
.gitignore vendored
View File

@@ -46,3 +46,13 @@ Thumbs.db
.nx/polygraph
.angular
# Secrets. `.env` holds NEXUS_TOKEN, which compose reads at build time, so it
# must never be committed — `.env.example` is the one that is.
.env
.env.*
!.env.example
# A per-machine npmrc would carry a Nexus token; the committed one at the repo
# root deliberately holds none.
.npmrc.local

View File

@@ -19,14 +19,12 @@ COPY package.json package-lock.json .npmrc ./
# `@pi/ui` comes from the private Nexus, so the install needs a token — passed
# as a BuildKit secret rather than a build ARG, which would be readable forever
# in the image history. The npmrc it lands in is written and removed inside one
# layer, so it leaves no trace even in this discarded stage.
# in the image history. The script checks the secret actually holds something
# before spending four minutes finding out from Nexus.
COPY tools/docker-install-deps.sh ./tools/
RUN --mount=type=secret,id=nexus_token,required=true \
sh -eu -c '\
printf "//nexus.pireon.dev/repository/npm-releases/:_authToken=%s\n" \
"$(cat /run/secrets/nexus_token)" > /tmp/npmrc; \
NPM_CONFIG_USERCONFIG=/tmp/npmrc npm ci --no-audit --no-fund; \
rm -f /tmp/npmrc'
sh tools/docker-install-deps.sh
COPY nx.json tsconfig.base.json eslint.config.mjs ./
COPY landing ./landing

View File

@@ -18,10 +18,25 @@ npm run e2e # Playwright end-to-end tests
## Deploying
```sh
NEXUS_TOKEN=… docker compose build
cp .env.example .env # then fill in NEXUS_TOKEN
docker compose build
docker compose up -d
```
Compose reads `.env` on its own, for the build token as well as the runtime
settings. `.env` is gitignored; `.env.example` is the one that is committed. If
you would rather not keep the token on disk:
```sh
NEXUS_TOKEN=… docker compose build
```
An empty or missing token fails the build in under a second with a message
saying so. That check is there because the mount's own `required=true` only
asserts the secret was *declared* — an empty one passes it and then surfaces a
minute later as a Nexus 401 that reads like a credentials problem when it is
really a forgotten variable.
Two stages. The build one runs Nx and the Angular compiler over the whole
workspace and is thrown away; only `dist/landing/analog` crosses into the
runtime image, because Nitro vendors its own dependencies into that output —
@@ -56,15 +71,20 @@ API were in maintenance. `core/api.ts` gives each call an 8-second budget, which
is what stands between a slow upstream and a hung render.
The container runs with a read-only filesystem and `no-new-privileges`, with a
tmpfs at `/tmp` for Nitro's scratch space. Put a reverse proxy in front for TLS
and let it pass response headers through untouched: the app answers 503 with
tmpfs at `/tmp` for Nitro's scratch space. The container publishes no host port: the reverse proxy reaches it over the
compose network as `landing:3000`, and binding a host port only invites a
collision with whatever else is on that number. `docker-compose.yml` carries a
commented `ports` block for when you do want one.
Put that proxy in front for TLS and let it pass response headers through
untouched: the app answers 503 with
`Retry-After` during maintenance and 404 for addresses it does not serve, and
both matter to crawlers and CDNs.
| Variable | Where | Default |
| --- | --- | --- |
| `PIREON_API_URL` | runtime | `https://landing-api.pireon.dev/api` |
| `LANDING_PORT` | runtime, host side | `3000` |
| `LANDING_PORT` | only if you publish a host port | `3000` |
| `NEXUS_TOKEN` | **build only** | — |
Copy `.env.example` to `.env`; compose reads it.
@@ -130,8 +150,11 @@ 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
pages/(home).server.ts one load function, ten parallel calls
core/content-page.ts the load function both content-page routes share
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
ui/ presentational components, one per payload type
(thin wrappers over `@pi/ui`), each with its `.scss`
server/
@@ -162,7 +185,10 @@ cached maintenance page outlives the maintenance. That status is set from the
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.
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.
**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.
@@ -176,6 +202,18 @@ 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 `<html lang>` 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`.
Featured pages go in the header after the section anchors; every page is in the
footer.
## Rendering and caching
Nothing is prerendered: the page is assembled from content the panel publishes

View File

@@ -24,8 +24,20 @@ services:
- nexus_token
image: pireon/landing:latest
restart: unless-stopped
ports:
- '${LANDING_PORT:-3000}:3000'
# No `ports`. Publishing on the host collides with whatever else is using
# that number on the machine, and in production the only thing that needs
# to reach this is the reverse proxy — which does so over the compose
# network, by service name, on the port below.
#
# proxy_pass http://landing:3000;
#
# To reach it from the host anyway (a quick check, or a deployment with no
# proxy in front), uncomment this and set LANDING_PORT to a free port:
#
# ports:
# - '${LANDING_PORT:-3000}:3000'
expose:
- '3000'
environment:
# Where the public API lives. The only setting the app actually reads.
PIREON_API_URL: ${PIREON_API_URL:-https://landing-api.pireon.dev/api}

View File

@@ -12,6 +12,19 @@ const here = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(here, '..');
const baseURL = process.env['BASE_URL'] || 'http://localhost:4200';
/**
* The dev server is started on whatever port `BASE_URL` names, rather than
* always 4200.
*
* `reuseExistingServer` means Playwright will happily adopt whatever is already
* listening there — and if that is a different project's dev server, every test
* fails with "element(s) not found" while the real cause is that the page under
* test was never served. Set BASE_URL to move both halves at once:
*
* BASE_URL=http://localhost:4280 npx nx e2e landing-e2e
*/
const port = new URL(baseURL).port || '4200';
export default defineConfig({
testDir: './src',
outputDir: resolve(workspaceRoot, 'dist/.playwright/landing-e2e/test-output'),
@@ -25,7 +38,7 @@ export default defineConfig({
trace: 'on-first-retry',
},
webServer: {
command: 'npx nx run landing:serve',
command: `npx nx run landing:serve --port=${port}`,
url: baseURL,
reuseExistingServer: true,
timeout: 180_000,

View File

@@ -43,6 +43,16 @@ test.describe('landing', () => {
await expect(page.locator('h1')).toContainText('no existe');
});
test('answers 404 for a page the API does not have', async ({ page }) => {
// Shaped like a content page, so the middleware lets it through and the
// status has to come from the page component, once the API said no.
const response = await page.goto('/en/no-such-page');
expect(response?.status()).toBe(404);
await expect(page.locator('h1')).toContainText('no existe');
await expect(page.locator('meta[name="robots"]')).toHaveAttribute('content', 'noindex');
});
test('refuses an incomplete contact submission without calling the API', async ({
page,
}) => {

View File

@@ -1,6 +1,12 @@
import { describe, expect, it } from 'vitest';
import { DEFAULT_LOCALE, localeFromUrl, resolveLocale } from './api';
import {
contentRoute,
DEFAULT_LOCALE,
homeHref,
localeFromUrl,
resolveLocale,
} from './api';
describe('resolveLocale', () => {
it('accepts the locales the API declares', () => {
@@ -28,3 +34,36 @@ describe('localeFromUrl', () => {
expect(localeFromUrl(undefined)).toBe(DEFAULT_LOCALE);
});
});
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

@@ -12,6 +12,7 @@ import type {
PublicClient,
PublicIssue,
PublicMember,
PublicPage,
PublicProject,
PublicService,
PublicTalk,
@@ -73,6 +74,42 @@ 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'
@@ -91,6 +128,8 @@ export interface ApiResult<T> {
data: T | null;
maintenance: MaintenanceNotice | null;
error: string | null;
/** The HTTP status, when there was a response — a 404 means something else than an outage. */
status: number | null;
}
interface GetOptions {
@@ -137,6 +176,7 @@ async function get<T>(path: string, options: GetOptions = {}): Promise<ApiResult
return {
data: null,
error: null,
status: 503,
maintenance: {
message: body?.message ?? 'El sitio está en mantenimiento.',
maintenance: true,
@@ -149,16 +189,26 @@ async function get<T>(path: string, options: GetOptions = {}): Promise<ApiResult
}
if (!response.ok) {
return { data: null, maintenance: null, error: `HTTP ${response.status}` };
return {
data: null,
maintenance: null,
error: `HTTP ${response.status}`,
status: response.status,
};
}
const body = (await response.json()) as { data: T };
return { data: body.data ?? null, maintenance: null, error: null };
return {
data: body.data ?? null,
maintenance: null,
error: null,
status: response.status,
};
} catch (cause) {
const error = cause instanceof Error ? cause.message : 'Unknown error';
return { data: null, maintenance: null, error };
return { data: null, maintenance: null, error, status: null };
} finally {
clearTimeout(timer);
}
@@ -205,6 +255,13 @@ export const pireonApi = {
goodFirstIssues: (query?: GetOptions['query']) =>
list<PublicIssue>('/good-first-issues', { query }),
pages: (lang: Locale, query?: GetOptions['query']) =>
list<PublicPage>('/pages', { lang, query }),
/** By slug, which only resolves in the language it belongs to. */
page: (lang: Locale, slug: string) =>
get<PublicPage>(`/pages/${encodeURIComponent(slug)}`, { lang }),
} as const;
/** POST bodies the public API accepts. Only two endpoints write. */

View File

@@ -0,0 +1,56 @@
/**
* 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

@@ -78,6 +78,7 @@ export interface Copy {
email: string;
organisation: string;
hours: string;
hoursHint: string;
message: string;
messageHint: string;
consent: string;
@@ -88,7 +89,8 @@ export interface Copy {
error: string;
closed: string;
};
footer: { built: string; api: string; rights: string };
footer: { rights: string; pages: string };
page: { skip: string; related: string; ctaTitle: string; ctaLead: string };
maintenance: { title: string; back: string; until: string };
}
@@ -167,6 +169,7 @@ const es: Copy = {
email: 'Correo',
organisation: 'Organización',
hours: 'Horas por semana',
hoursHint: 'Por ejemplo, 4',
message: 'Mensaje',
messageHint: 'Mínimo 20 caracteres.',
consent: 'Acepto que guardéis mis datos para responderme.',
@@ -178,9 +181,14 @@ const es: Copy = {
closed: 'El formulario está cerrado temporalmente.',
},
footer: {
built: 'Hecho con Angular, Analog y Nx.',
api: 'Contenido servido por la API pública de Pireon.',
rights: 'Todos los derechos reservados.',
pages: 'Más sobre Pireon',
},
page: {
skip: 'Saltar al contenido',
related: 'Sigue leyendo',
ctaTitle: '¿Hablamos?',
ctaLead: 'Si algo de esto encaja con lo que buscas, escríbenos y te respondemos.',
},
maintenance: {
title: 'Estamos de mantenimiento',
@@ -264,6 +272,7 @@ const en: Copy = {
email: 'E-mail',
organisation: 'Organisation',
hours: 'Hours per week',
hoursHint: 'For example, 4',
message: 'Message',
messageHint: 'At least 20 characters.',
consent: 'I agree to have my details stored so you can reply.',
@@ -275,9 +284,14 @@ const en: Copy = {
closed: 'The form is temporarily closed.',
},
footer: {
built: 'Built with Angular, Analog and Nx.',
api: 'Content served by the Pireon public API.',
rights: 'All rights reserved.',
pages: 'More about Pireon',
},
page: {
skip: 'Skip to content',
related: 'Keep reading',
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.',
},
maintenance: {
title: 'We are under maintenance',
@@ -361,6 +375,7 @@ const ca: Copy = {
email: 'Correu',
organisation: 'Organització',
hours: 'Hores per setmana',
hoursHint: 'Per exemple, 4',
message: 'Missatge',
messageHint: 'Mínim 20 caràcters.',
consent: 'Accepto que deseu les meves dades per respondre’m.',
@@ -372,9 +387,14 @@ const ca: Copy = {
closed: 'El formulari està tancat temporalment.',
},
footer: {
built: 'Fet amb Angular, Analog i Nx.',
api: 'Contingut servit per l’API pública de Pireon.',
rights: 'Tots els drets reservats.',
pages: 'Més sobre Pireon',
},
page: {
skip: 'Salta al contingut',
related: 'Continua llegint',
ctaTitle: 'En parlem?',
ctaLead: "Si alguna cosa d'aquestes encaixa amb el que busques, escriu-nos i et respondrem.",
},
maintenance: {
title: 'Estem de manteniment',

View File

@@ -17,6 +17,7 @@ import {
PiChipComponent,
} from '@pi/ui';
import { pageNavLinks } from '../core/content-page';
import { copyFor } from '../core/copy';
import { CallCard } from '../ui/call-card';
import { ClientLogo } from '../ui/client-logo';
@@ -267,7 +268,7 @@ export const routeMeta: RouteMeta = {
</app-section>
</main>
<app-site-footer [copy]="copy()" />
<app-site-footer [copy]="copy()" [pages]="data().pages" />
}
`,
})
@@ -318,7 +319,14 @@ export default class HomePage {
},
];
return candidates.filter((link) => link.visible);
// After the sections: the featured pages the panel publishes, such as
// "Sobre nosotros", which live on their own addresses.
return [
...candidates
.filter((link) => link.visible)
.map(({ anchor, label }) => ({ anchor, label })),
...pageNavLinks(data.pages),
];
});
/**

View File

@@ -21,6 +21,7 @@ import type {
PublicClient,
PublicIssue,
PublicMember,
PublicPage,
PublicProject,
PublicService,
PublicTalk,
@@ -41,6 +42,8 @@ export interface LandingData {
calls: PublicCall[];
issues: PublicIssue[];
talks: PublicTalk[];
/** The pages the panel publishes: featured ones in the nav, all of them in the footer. */
pages: PublicPage[];
}
export const load = async ({ req }: PageServerLoad): Promise<LandingData> => {
@@ -60,6 +63,7 @@ export const load = async ({ req }: PageServerLoad): Promise<LandingData> => {
calls,
issues,
talks,
pages,
] = await Promise.all([
pireonApi.site(locale),
pireonApi.services(locale),
@@ -71,6 +75,7 @@ export const load = async ({ req }: PageServerLoad): Promise<LandingData> => {
pireonApi.calls(locale),
pireonApi.goodFirstIssues({ limit: 6 }),
pireonApi.talks(locale, { limit: 6 }),
pireonApi.pages(locale, { per_page: 100 }),
]);
return {
@@ -88,5 +93,6 @@ export const load = async ({ req }: PageServerLoad): Promise<LandingData> => {
calls: calls.data ?? [],
issues: issues.data ?? [],
talks: talks.data ?? [],
pages: pages.data ?? [],
};
};

View File

@@ -0,0 +1,18 @@
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

@@ -0,0 +1,20 @@
/**
* 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

@@ -0,0 +1,18 @@
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-default',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ContentPage],
template: `<app-content-page [data]="data()" />`,
})
export default class DefaultLocaleContentPage {
protected readonly data = toSignal(injectLoad<typeof load>(), {
requireSync: true,
});
}

View File

@@ -0,0 +1,12 @@
/**
* 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,53 @@
// The two soft lights behind the top of a page, shared by the landing's hero
// and the content pages so moving from one to the other does not feel like
// changing sites.
//
// Lights rather than an image: nothing to download, and nothing that dates the
// page when the brand shifts. `isolate` keeps the negative z-index inside the
// element instead of behind the page background.
//
// `$overflow: clip` for a tall element with sticky children: `hidden` makes the
// element a scroll container, and `position: sticky` inside one stops
// following the page.
@mixin glow($overflow: hidden) {
position: relative;
isolation: isolate;
overflow: $overflow;
&::before,
&::after {
content: '';
position: absolute;
z-index: -1;
border-radius: 50%;
pointer-events: none;
filter: blur(80px);
}
&::before {
top: -14rem;
left: 50%;
width: 64rem;
height: 38rem;
transform: translateX(-58%);
opacity: 0.5;
background: radial-gradient(
closest-side,
var(--pi-color-primary),
transparent
);
}
&::after {
top: 4rem;
right: -8rem;
width: 26rem;
height: 26rem;
opacity: 0.35;
background: radial-gradient(
closest-side,
var(--pi-color-accent),
transparent
);
}
}

View File

@@ -1,7 +1,14 @@
// The gap has to beat the distance from a field to its own error message,
// which Material renders flush underneath it — 0px. At 1.25rem the message
// ended up 6px from the *next* field's label and 0px from its own field, so
// "Falta el nombre" read as the caption of "HORAS POR SEMANA" rather than of
// the field above it. The three lines have to group with the field they belong
// to, which means the space between rows must be visibly larger than the space
// inside one.
.landing-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
gap: 2rem;
}
.landing-form__kind {
@@ -28,3 +35,98 @@
margin: 0;
color: var(--pi-color-warn, #d64545);
}
// The hours field sits inside an @if, and that is what breaks its label.
//
// Angular Material measures an outlined field's floating label once, on init,
// and writes the result to the element as an inline
// `transform: var(--mat-mdc-form-field-label-transform, translateY(-50%) …)`.
// A field created inside a conditional block is measured before it has any
// geometry, so the value it keeps is the unfloated one: the label ends up 5px
// above the wrapper instead of 18, straddling the 2px outline and struck
// through by it.
//
// `@pi/ui` already publishes the right geometry for every other field
// (`translateY(-3rem) scale(1)`, a 48px label box), and that rule matches this
// one too — it simply loses to the inline style. `!important` is what beats an
// inline declaration, and it is restating the library's own values, not
// inventing new ones.
:host ::ng-deep .pi-field.pi-form-field__number {
.mdc-notched-outline .mdc-floating-label {
height: var(--pi-spacing-xxxx-large) !important;
}
.mdc-text-field:not(.mat-form-field-no-animations) .mdc-floating-label {
transform: translateY(-3rem) scale(1) !important;
}
}
// Two things the library gets wrong once a field is in a real state.
//
// 1. The label changes shape when you focus it. `@pi/ui` applies the theme's
// label typography under `:not(.mdc-text-field--focused)`, so text-transform,
// letter-spacing and weight all fall away the moment the field takes focus:
// "MENSAJE *" becomes "Mensaje *" in a lighter face, and the label visibly
// reflows under the cursor. Restated here without that guard. Colour is
// deliberately left alone — Material tints the label on focus, and that part
// is wanted.
//
// 2. The focus ring is a stadium on the textarea. Both the notched outline and
// the gradient ring take their radius from `--pi-theme-field-radius`, which
// the `alternative` shell sets to 999px. That is right for a 48px-tall input
// and wrong for a 112px-tall textarea: the two shapes stop coinciding and
// the field is drawn twice, once in the error colour and once in the
// gradient, offset from each other. A fixed radius makes them agree again.
:host ::ng-deep {
.pi-field .mdc-text-field:not(.mdc-text-field--disabled) .mdc-floating-label {
text-transform: var(--pi-theme-field-label-transform, none);
letter-spacing: var(--pi-theme-field-label-letter-spacing, normal);
font-weight: var(--pi-theme-field-label-weight, inherit);
}
// A stadium is right for a 48px input and wrong for a 112px textarea, which
// is all this is now — with the focus ring gone there is no second shape to
// agree with.
.pi-field.pi-form-field__textarea {
--pi-theme-field-radius: 1.25rem;
}
}
// Error states, which move the form around and read wrong.
//
// 1. Submitting an incomplete form shoved everything below it downwards.
// Material's subscript strip is 10px tall while a field is valid and 24px
// once it carries a message, so every errored field grew the form by 14px at
// once — the fields under the cursor jumped out from under it. Reserving the
// taller height up front costs 14px of whitespace and buys a form that does
// not move.
//
// 2. `pi-checkbox` renders its message as an inline span straight after the
// label, so it ran on as part of the sentence: "Acepto que guardéis mis
// datos para responderme. Hace falta tu consentimiento". It belongs on its
// own line, like every other field's message.
:host ::ng-deep {
.pi-field .mat-mdc-form-field-subscript-wrapper {
min-height: 1.5rem;
}
// Its own line, in space reserved for it. Left in flow it would push the
// submit button 25px down at the exact moment you press it — the same jump
// the reserved subscript above removes for the fields.
.pi-checkbox {
position: relative;
padding-bottom: 1.25rem;
}
// Nothing in the library styles this class, so the message inherited the
// body colour and read as ordinary text next to four red ones.
.pi-checkbox__error {
display: block;
position: absolute;
left: 0;
bottom: 0;
color: var(--pi-color-warn);
// `mat-error` renders the other four at 12px; this one inherited 15px.
font-size: var(--pi-font-size-x-small, 0.75rem);
}
}

View File

@@ -115,7 +115,17 @@ type Kind = 'contribute' | 'request';
<pi-label [isRequired]="true">{{ copy().form.organisation }}</pi-label>
</pi-text-field>
} @else {
<pi-number-field [formField]="contact.hours_per_week">
<!--
The placeholder is not decoration. Without one, Material leaves
this label in its unfloated position and writes an inline
translateY(-50%) that drops the text straight onto the outline's
top border, struck through by it. Every other field here has a
placeholder, which is why this was the only one that looked wrong.
-->
<pi-number-field
[formField]="contact.hours_per_week"
[placeholder]="copy().form.hoursHint"
>
<pi-label>{{ copy().form.hours }}</pi-label>
</pi-number-field>
}

View File

@@ -0,0 +1,215 @@
@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);
}
// 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

@@ -0,0 +1,333 @@
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 { ApiImage } from './api-image';
import { MaintenanceScreen } from './maintenance-screen';
import { SiteFooter } from './site-footer';
import { SiteHeader } from './site-header';
/**
* 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
* who run the site, and goes through Angular's sanitiser on the way in: the
* headings, lists, links and emphasis the editor produces survive it, and
* nothing that could run does.
*/
@Component({
selector: 'app-content-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ApiImage,
MaintenanceScreen,
NotFoundPage,
PiActionButtonComponent,
PiTextColorDirective,
PiTextLargeDirective,
PiTextWeightBoldDirective,
SiteFooter,
SiteHeader,
],
styleUrl: './content-page.scss',
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">
<div class="landing-shell">
@if (page.category?.name; as category) {
<p class="landing-eyebrow">{{ category }}</p>
}
<h1 bold class="content-hero__title">{{ page.title }}</h1>
@if (page.excerpt) {
<p l pi-color color="grey" class="content-hero__lead">
{{ page.excerpt }}
</p>
}
</div>
</header>
<div class="landing-shell content-page__layout">
<article class="content-page__main">
@if (page.featured_image; as image) {
<figure class="content-page__media">
<app-api-image
[image]="image"
sizes="(min-width: 1024px) 46rem, 100vw"
loading="eager"
imgClass="content-page__image"
/>
</figure>
}
<div class="content-page__body" [innerHTML]="page.body ?? ''"></div>
</article>
<aside class="content-page__aside">
@if (related().length > 0) {
<nav [attr.aria-label]="copy().page.related">
<p class="landing-eyebrow">{{ copy().page.related }}</p>
<ul class="content-page__related">
@for (item of related(); track item.id) {
<li>
<a
class="landing-card content-page__card"
[href]="item.path"
>
<span class="content-page__card-title">{{
item.title
}}</span>
@if (item.excerpt) {
<span class="content-page__card-text">{{
item.excerpt
}}</span>
}
</a>
</li>
}
</ul>
</nav>
}
<div class="landing-card content-page__cta">
<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>
</div>
</main>
<app-site-footer [copy]="copy()" [pages]="data().pages" />
} @else {
<app-not-found />
}
`,
})
export class ContentPage implements OnInit {
readonly data = input.required<ContentPageData>();
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));
/**
* 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 sameCategory = (other: (typeof pages)[number]) =>
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<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

@@ -1,52 +1,13 @@
@use './lights';
:host {
display: block;
}
.landing-hero {
position: relative;
isolation: isolate;
@include lights.glow;
padding-block: 5rem 3rem;
overflow: hidden;
// Two soft lights rather than a hero image: nothing to download, and nothing
// that dates the page when the brand shifts. `isolate` on the section keeps
// the negative z-index inside it instead of behind the page background.
&::before,
&::after {
content: '';
position: absolute;
z-index: -1;
border-radius: 50%;
pointer-events: none;
filter: blur(80px);
}
&::before {
top: -14rem;
left: 50%;
width: 64rem;
height: 38rem;
transform: translateX(-58%);
opacity: 0.5;
background: radial-gradient(
closest-side,
var(--pi-color-primary),
transparent
);
}
&::after {
top: 4rem;
right: -8rem;
width: 26rem;
height: 26rem;
opacity: 0.35;
background: radial-gradient(
closest-side,
var(--pi-color-accent),
transparent
);
}
@media (min-width: 768px) {
padding-block: 7rem 4rem;

View File

@@ -2,19 +2,27 @@
padding-block: 2.5rem 3rem;
}
// One line now. The flex row and its space-between existed to push the credit
// to the far end; with a single child they only complicate the rule.
.landing-footer__row {
display: flex;
flex-direction: column;
gap: 0.5rem;
justify-content: space-between;
margin-top: 1.5rem;
p {
margin: 0;
}
}
@media (min-width: 768px) {
flex-direction: row;
align-items: center;
.landing-footer__pages {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.5rem;
margin-top: 1.5rem;
a {
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}

View File

@@ -1,10 +1,16 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import {
PiDividerComponent,
PiTextColorDirective,
PiTextSmallDirective,
} from '@pi/ui';
import type { PublicPage } from '../core/api.types';
import type { Copy } from '../core/copy';
@Component({
@@ -16,16 +22,21 @@ import type { Copy } from '../core/copy';
<footer class="landing-footer">
<div class="landing-shell">
<pi-divider color="grey" width="large" />
@if (links().length > 0) {
<nav
class="landing-footer__pages"
[attr.aria-label]="copy().footer.pages"
>
@for (link of links(); track link.href) {
<a s pi-color color="grey" [href]="link.href">{{ link.label }}</a>
}
</nav>
}
<div class="landing-footer__row">
<p s pi-color color="grey">© {{ year }} Pireon. {{ copy().footer.rights }}</p>
<p s pi-color color="grey">
{{ copy().footer.built }}
<a
href="https://landing-api.pireon.dev/docs/api"
target="_blank"
rel="noopener noreferrer"
>API ↗</a
>
© {{ year }} Pireon. {{ copy().footer.rights }}
</p>
</div>
</div>
@@ -34,6 +45,17 @@ import type { Copy } from '../core/copy';
})
export class SiteFooter {
readonly copy = input.required<Copy>();
/** Every published page — the footer is where the ones not featured in the header are found. */
readonly pages = input<PublicPage[]>([]);
protected readonly links = computed(() =>
this.pages()
.filter((page) => page.path && page.title)
.map((page) => ({
href: page.path as string,
label: page.title as string,
})),
);
protected readonly year = new Date().getFullYear();
}

View File

@@ -20,9 +20,14 @@ import { AppearanceService } from '../core/appearance';
import type { Copy } from '../core/copy';
import type { Locale, SitePayload } from '../core/api';
/**
* A section of the landing (`anchor`) or another address altogether (`href`),
* such as one of the pages the panel publishes.
*/
export interface NavLink {
anchor: string;
label: string;
anchor?: string;
href?: string;
}
@Component({
@@ -37,7 +42,7 @@ export interface NavLink {
template: `
<header class="landing-header">
<div class="landing-shell landing-header__inner">
<a class="landing-brand" href="#top">
<a class="landing-brand" [href]="brandHref()">
<img
class="landing-brand__mark"
src="/logo.png"
@@ -50,8 +55,8 @@ export interface NavLink {
</a>
<nav class="landing-header__nav" aria-label="Principal">
@for (link of links(); track link.anchor) {
<a [href]="'#' + link.anchor">{{ link.label }}</a>
@for (link of links(); track link.label) {
<a [href]="hrefOf(link)">{{ link.label }}</a>
}
</nav>
@@ -102,8 +107,8 @@ export interface NavLink {
@if (open()) {
<nav id="mobile-nav" class="landing-header__mobile">
<div class="landing-shell">
@for (link of links(); track link.anchor) {
<a [href]="'#' + link.anchor" (click)="open.set(false)">{{
@for (link of links(); track link.label) {
<a [href]="hrefOf(link)" (click)="open.set(false)">{{
link.label
}}</a>
}
@@ -126,7 +131,7 @@ export interface NavLink {
<div class="landing-header__mobile-locales">
@for (option of locales(); track option.code) {
<a
[href]="'/?lang=' + option.code"
[href]="localeHref(option.code)"
[attr.hreflang]="option.code"
[attr.aria-current]="option.code === locale() ? 'true' : null"
[class.is-active]="option.code === locale()"
@@ -146,6 +151,15 @@ export class SiteHeader {
readonly locale = input.required<Locale>();
readonly links = input.required<NavLink[]>();
readonly locales = input.required<SitePayload['locales']>();
/** Where the logo leads: the top of the landing when on it, the landing itself elsewhere. */
readonly brandHref = input('#top');
/**
* The same page in each language, as the API's `alternates` spells it. Null
* on the landing, which answers on `?lang=` instead. A language missing from
* it has no translation of this page, so the switcher leads to the landing
* in that language rather than to a 404.
*/
readonly alternates = input<Partial<Record<string, string>> | null>(null);
readonly navigate = output<string>();
@@ -171,6 +185,14 @@ export class SiteHeader {
return;
}
this.document.defaultView?.location.assign(`/?lang=${next}`);
this.document.defaultView?.location.assign(this.localeHref(next));
}
protected localeHref(code: string): string {
return this.alternates()?.[code] ?? `/?lang=${code}`;
}
protected hrefOf(link: NavLink): string {
return link.href ?? `#${link.anchor}`;
}
}

View File

@@ -1,12 +1,14 @@
import { defineEventHandler, getRequestURL, setResponseStatus } from 'h3';
import { contentRoute } from '../../app/core/api';
/**
* Answers 404 for addresses this app does not serve.
*
* 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 is one page.
* state outright — the landing, plus the pages the panel publishes.
*/
const KNOWN_PATHS = new Set(['/', '/index.html']);
@@ -28,5 +30,13 @@ 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)) {
return;
}
setResponseStatus(event, 404);
});

View File

@@ -92,19 +92,14 @@ html {
@include brand-ramp('highlighted', #6c2fb5, #ffffff);
}
// The `gradient` theme's signature — a gradient focus ring on fields — is a
// token, so it can be lifted onto the `alternative` shell without swapping
// themes. `_mat-form-field.scss` reads it; the flat border is turned off so it
// does not show through the ring.
.pi-shell__alternative {
--pi-theme-field-focus-gradient: linear-gradient(
120deg,
var(--pi-color-primary),
var(--pi-color-accent),
var(--pi-color-highlighted)
);
--pi-theme-field-border-color-focus: transparent;
}
// The `gradient` theme's focus ring is deliberately NOT lifted onto this shell.
//
// It is drawn as one continuous rounded rectangle over the whole field, while
// the outline underneath is split into three pieces. The leading piece is 12px
// wide, so a large radius clamps to a 12x24 ellipse there while the ring keeps
// a true 24px circle — two curves that cannot be made to agree at any radius,
// showing as a doubled edge down the left of every field. Focus is left to the
// outline's own colour, which is what `alternative` already defines.
html,
body {

52
tools/docker-install-deps.sh Executable file
View File

@@ -0,0 +1,52 @@
#!/bin/sh
# Installs the workspace's dependencies inside the image.
#
# Split out of the Dockerfile so a failure prints this file's own message
# instead of BuildKit echoing a twenty-line inline shell twice.
set -eu
TOKEN_FILE=/run/secrets/nexus_token
NEXUS_PATH=//nexus.pireon.dev/repository/npm-releases/
# `required=true` on the mount only asserts the secret was declared, not that it
# holds anything. An empty one sails past it and surfaces four hundred packages
# later as a Nexus 401 that reads like a credentials problem.
if [ ! -s "$TOKEN_FILE" ]; then
cat >&2 <<'MSG'
ERROR: the nexus_token build secret is empty.
@pi/ui is published to the private Nexus, so the install needs a token.
compose: NEXUS_TOKEN=... docker compose build
or put NEXUS_TOKEN in .env, which compose reads on its own
docker: docker build --secret id=nexus_token,env=NEXUS_TOKEN .
Locally the value is the one already in ~/.npmrc:
grep nexus.pireon.dev ~/.npmrc
MSG
exit 1
fi
# Written and removed inside a single layer, so it leaves no trace even in this
# stage — which is discarded anyway.
NPMRC=/tmp/npmrc
printf '%s:_authToken=%s\n' "$NEXUS_PATH" "$(cat "$TOKEN_FILE")" > "$NPMRC"
if NPM_CONFIG_USERCONFIG="$NPMRC" npm ci --no-audit --no-fund; then
rm -f "$NPMRC"
else
status=$?
rm -f "$NPMRC"
if [ "$status" -ne 0 ]; then
echo "" >&2
echo "npm ci failed. If the error above is a 401 from nexus.pireon.dev," >&2
echo "the token was present but is not valid for npm-releases." >&2
echo "" >&2
fi
exit "$status"
fi

View File

@@ -45,7 +45,21 @@ const member = (id, name, slug, founder) => ({
alternates: {}, path: `/equipo/${slug}`,
});
const page = (id, title, slug, featured) => ({
id, locale: 'es', title, slug, status: 'published', is_featured: featured,
excerpt: 'Una página publicada desde el panel, con su propio título, extracto y cuerpo.',
body: '<h2>Un apartado</h2><p>Texto de la página, tal como sale del editor del panel.</p><ul><li>Una lista</li><li>con dos elementos</li></ul>',
meta_title: `${title} · Pireon`, meta_description: 'Descripción para buscadores.',
published_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
category: { id: 1, name: 'El colectivo', slug: 'colectivo', color: '#4f46e5', position: 1 },
alternates: { es: `/${slug}`, en: `/en/${slug}-en`, ca: `/ca/${slug}-ca` }, path: `/${slug}`,
});
const pages = [page(1, 'Sobre nosotros', 'sobre-nosotros', true), page(2, 'Contacto', 'contacto', false)];
const data = {
'/public/v1/pages': pages,
...Object.fromEntries(pages.map((p) => [`/public/v1/pages/${p.slug}`, p])),
'/public/v1/site': {
locales: [
{ code: 'es', name: 'Español', flag: '🇪🇸', is_default: true },