Initial commit

This commit is contained in:
pigui
2026-08-30 21:11:59 +02:00
commit 3f930bb46a
168 changed files with 35708 additions and 0 deletions

View File

@@ -0,0 +1,176 @@
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';
export interface NavLink {
anchor: string;
label: 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="#top">
<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.anchor) {
<a [href]="'#' + link.anchor">{{ 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.anchor) {
<a [href]="'#' + link.anchor" (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]="'/?lang=' + 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']>();
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(`/?lang=${next}`);
}
}