Files
landing-app/landing/src/app/ui/site-header.ts
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

199 lines
5.9 KiB
TypeScript

import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
output,
signal,
} from '@angular/core';
import { DOCUMENT } from '@angular/common';
import {
PiActionButtonComponent,
PiIconButtonComponent,
PiSegmentedControlComponent,
type SegmentedControlOption,
} from '@pi/ui';
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 {
label: string;
anchor?: string;
href?: string;
}
@Component({
selector: 'app-site-header',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
PiActionButtonComponent,
PiIconButtonComponent,
PiSegmentedControlComponent,
],
styleUrl: './site-header.scss',
template: `
<header class="landing-header">
<div class="landing-shell landing-header__inner">
<a class="landing-brand" [href]="brandHref()">
<img
class="landing-brand__mark"
src="/logo.png"
alt=""
width="256"
height="256"
decoding="async"
/>
<span>Pireon</span>
</a>
<nav class="landing-header__nav" aria-label="Principal">
@for (link of links(); track link.label) {
<a [href]="hrefOf(link)">{{ link.label }}</a>
}
</nav>
<div class="landing-header__actions">
@if (localeOptions().length > 1) {
<pi-segmented-control
class="landing-header__locales"
ariaLabel="Idioma"
[options]="localeOptions()"
[value]="locale()"
(valueChange)="switchLocale($event)"
/>
}
<pi-icon-button
class="landing-header__appearance"
iconType="custom"
color="grey"
[icon]="appearance.dark() ? 'sun' : 'moon'"
[attr.aria-label]="
appearance.dark() ? copy().nav.lightMode : copy().nav.darkMode
"
[attr.aria-pressed]="appearance.dark()"
(execute)="appearance.toggle()"
/>
<pi-action-button
class="landing-header__cta"
color="primary"
[text]="copy().nav.contact"
(execute)="navigate.emit('#contacto')"
/>
<button
type="button"
class="landing-header__toggle"
aria-label="Menú"
[attr.aria-expanded]="open()"
aria-controls="mobile-nav"
(click)="open.set(!open())"
>
<span></span><span></span><span></span>
</button>
</div>
</div>
@if (open()) {
<nav id="mobile-nav" class="landing-header__mobile">
<div class="landing-shell">
@for (link of links(); track link.label) {
<a [href]="hrefOf(link)" (click)="open.set(false)">{{
link.label
}}</a>
}
<!--
The segmented control is hidden on this width, so the languages
come back here as plain links rather than being unreachable on a
phone.
-->
<button
type="button"
class="landing-header__mobile-appearance"
[attr.aria-pressed]="appearance.dark()"
(click)="appearance.toggle()"
>
{{ appearance.dark() ? copy().nav.lightMode : copy().nav.darkMode }}
</button>
@if (locales().length > 1) {
<div class="landing-header__mobile-locales">
@for (option of locales(); track option.code) {
<a
[href]="localeHref(option.code)"
[attr.hreflang]="option.code"
[attr.aria-current]="option.code === locale() ? 'true' : null"
[class.is-active]="option.code === locale()"
>{{ option.code.toUpperCase() }}</a
>
}
</div>
}
</div>
</nav>
}
</header>
`,
})
export class SiteHeader {
readonly copy = input.required<Copy>();
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>();
protected readonly appearance = inject(AppearanceService);
private readonly document = inject(DOCUMENT);
protected readonly open = signal(false);
protected readonly localeOptions = computed<SegmentedControlOption<Locale>[]>(
() =>
this.locales().map((option) => ({
value: option.code as Locale,
label: option.code.toUpperCase(),
}))
);
/**
* A full navigation rather than a client-side one: the page's content is
* resolved from `?lang=` on the server, so the server has to render it again.
*/
protected switchLocale(next: Locale | undefined): void {
if (!next || next === this.locale()) {
return;
}
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}`;
}
}