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,30 @@
import { describe, expect, it } from 'vitest';
import { DEFAULT_LOCALE, localeFromUrl, resolveLocale } from './api';
describe('resolveLocale', () => {
it('accepts the locales the API declares', () => {
expect(resolveLocale('ca')).toBe('ca');
expect(resolveLocale('en')).toBe('en');
});
it('falls back to the application default for anything else', () => {
expect(resolveLocale('fr')).toBe(DEFAULT_LOCALE);
expect(resolveLocale(undefined)).toBe(DEFAULT_LOCALE);
expect(resolveLocale(42)).toBe(DEFAULT_LOCALE);
});
});
describe('localeFromUrl', () => {
it('reads ?lang from a path-only URL', () => {
expect(localeFromUrl('/api/_analog/pages/-home-?lang=ca')).toBe('ca');
});
it('ignores an unsupported language rather than passing it on', () => {
expect(localeFromUrl('/?lang=de')).toBe(DEFAULT_LOCALE);
});
it('survives a missing URL', () => {
expect(localeFromUrl(undefined)).toBe(DEFAULT_LOCALE);
});
});

257
landing/src/app/core/api.ts Normal file
View File

@@ -0,0 +1,257 @@
/**
* Server-side client for the Pireon public API.
*
* Only `/public/v1/*` is used: unauthenticated, read-only, and already
* published on the public website. Everything here runs on the server —
* page `load` functions and Nitro routes — so the browser never talks to
* the API directly and CORS never enters the picture.
*/
import type {
PublicApiPayloads,
PublicCall,
PublicClient,
PublicIssue,
PublicMember,
PublicProject,
PublicService,
PublicTalk,
PublicTechnology,
} from './api.types';
/** The `/site` payload, widened to the extra stats the live API answers with. */
export interface SitePayload {
locales: { code: string; name: string; flag: string; is_default: boolean }[];
default_locale: string;
content_version: number;
submissions_enabled: boolean;
stats: Partial<
Record<
| 'projects'
| 'active_projects'
| 'members'
| 'open_calls'
| 'services'
| 'clients'
| 'good_first_issues'
| 'stars',
number
>
>;
}
/** Every `/public/v1` endpoint answers this while the site is in maintenance. */
export interface MaintenanceNotice {
message: string;
maintenance: true;
locale: string;
since: string | null;
until: string | null;
retry_after: number | null;
}
export const SUPPORTED_LOCALES = ['es', 'en', 'ca'] as const;
export type Locale = (typeof SUPPORTED_LOCALES)[number];
export const DEFAULT_LOCALE: Locale = 'es';
export function resolveLocale(value: unknown): Locale {
return SUPPORTED_LOCALES.includes(value as Locale)
? (value as Locale)
: DEFAULT_LOCALE;
}
/**
* The locale a request asks for. Only `?lang=` is honoured: the loader runs
* behind an internal request that carries no `Accept-Language`, and a page
* that varied on a header while being cached with `s-maxage` would serve the
* wrong language to the second visitor anyway.
*/
export function localeFromUrl(url: string | undefined): Locale {
const requested = new URL(url ?? '/', 'http://localhost').searchParams.get(
'lang'
);
return resolveLocale(requested);
}
function apiBase(): string {
const configured =
typeof process !== 'undefined'
? process.env?.['PIREON_API_URL'] ?? process.env?.['NITRO_PIREON_API_URL']
: undefined;
return (configured || 'https://landing-api.pireon.dev/api').replace(/\/+$/, '');
}
/**
* The outcome of one call. Nothing throws: a landing page that loses its
* projects section because the API blinked is still a landing page, so
* every failure is data the template can decide what to do about.
*/
export interface ApiResult<T> {
data: T | null;
maintenance: MaintenanceNotice | null;
error: string | null;
}
interface GetOptions {
lang?: Locale;
query?: Record<string, string | number | boolean | undefined>;
/** Milliseconds before the call is abandoned. Keeps SSR from hanging. */
timeout?: number;
}
function buildUrl(path: string, options: GetOptions): string {
const url = new URL(`${apiBase()}/public/v1${path}`);
if (options.lang) {
url.searchParams.set('lang', options.lang);
}
for (const [key, value] of Object.entries(options.query ?? {})) {
if (value !== undefined && value !== null && value !== '') {
url.searchParams.set(key, String(value));
}
}
return url.toString();
}
async function get<T>(path: string, options: GetOptions = {}): Promise<ApiResult<T>> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), options.timeout ?? 8000);
try {
const response = await fetch(buildUrl(path, options), {
headers: {
Accept: 'application/json',
'Accept-Language': options.lang ?? DEFAULT_LOCALE,
},
signal: controller.signal,
});
if (response.status === 503) {
const body = (await response.json().catch(() => null)) as
| Partial<MaintenanceNotice>
| null;
return {
data: null,
error: null,
maintenance: {
message: body?.message ?? 'El sitio está en mantenimiento.',
maintenance: true,
locale: body?.locale ?? options.lang ?? DEFAULT_LOCALE,
since: body?.since ?? null,
until: body?.until ?? null,
retry_after: Number(response.headers.get('Retry-After')) || null,
},
};
}
if (!response.ok) {
return { data: null, maintenance: null, error: `HTTP ${response.status}` };
}
const body = (await response.json()) as { data: T };
return { data: body.data ?? null, maintenance: null, error: null };
} catch (cause) {
const error = cause instanceof Error ? cause.message : 'Unknown error';
return { data: null, maintenance: null, error };
} finally {
clearTimeout(timer);
}
}
/** Unwraps a collection call, so an outage renders an empty section rather than a crash. */
async function list<T>(path: string, options: GetOptions = {}): Promise<ApiResult<T[]>> {
const result = await get<T[]>(path, options);
return { ...result, data: result.data ?? [] };
}
export const pireonApi = {
site: (lang: Locale) => get<SitePayload>('/site', { lang }),
services: (lang: Locale, query?: GetOptions['query']) =>
list<PublicService>('/services', { lang, query }),
projects: (lang: Locale, query?: GetOptions['query']) =>
list<PublicProject>('/projects', { lang, query }),
members: (lang: Locale, query?: GetOptions['query']) =>
list<PublicMember>('/members', { lang, query }),
calls: (lang: Locale, query?: GetOptions['query']) =>
list<PublicCall>('/calls', { lang, query }),
clients: (lang: Locale, query?: GetOptions['query']) =>
list<PublicClient>('/clients', { lang, query }),
testimonials: (lang: Locale, query?: GetOptions['query']) =>
list<PublicApiPayloads['/public/v1/testimonials'][number]>('/testimonials', {
lang,
query,
}),
talks: (lang: Locale, query?: GetOptions['query']) =>
list<PublicTalk>('/talks', { lang, query }),
radar: (lang: Locale) => list<PublicTechnology>('/radar', { lang }),
technologies: (lang: Locale, query?: GetOptions['query']) =>
list<PublicTechnology>('/technologies', { lang, query }),
goodFirstIssues: (query?: GetOptions['query']) =>
list<PublicIssue>('/good-first-issues', { query }),
} as const;
/** POST bodies the public API accepts. Only two endpoints write. */
export interface SubmissionPayload {
name: string;
email: string;
message: string;
organisation?: string | null;
hours_per_week?: number | null;
links?: Record<string, string>;
consent: boolean;
kind?: 'contribute' | 'request';
[honeypot: string]: unknown;
}
export interface SubmissionReceipt {
data?: { id: number; kind: string; status: string; duplicate?: boolean };
message?: string;
errors?: Record<string, string[]>;
}
export interface SubmissionOutcome {
status: number;
body: SubmissionReceipt;
}
/**
* Forwards a submission. Throttling on the API is per e-mail address plus a
* site-wide ceiling — deliberately not per IP — precisely because an SSR
* frontend like this one sends every message from the same address.
*/
export async function postSubmission(
path: '/contact' | `/calls/${string}/apply`,
payload: SubmissionPayload,
lang: Locale
): Promise<SubmissionOutcome> {
const response = await fetch(buildUrl(path, { lang }), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'Accept-Language': lang,
},
body: JSON.stringify(payload),
});
const body = (await response.json().catch(() => ({}))) as SubmissionReceipt;
return { status: response.status, body };
}

View File

@@ -0,0 +1,401 @@
/**
* Pireon Admin API — the public payloads.
*
* Generated from openapi.yaml by `php artisan api:types`. Do not edit:
* change the specification instead, and the contract test will hold the
* API to it.
*/
/**
* The same record's path in each language that has a translation. Feed
* these straight into `<link rel="alternate" hreflang>`. Languages with
* no translation are absent, so no advertised URL ever 404s.
*/
export type Alternates = Record<string, string>;
/**
* Everything needed to render an image well server-side. A path alone
* was never enough: without `width`/`height` the page reflows as images
* arrive, without `srcset` every visitor downloads the original, and
* without `alt` the page is not accessible.
*
* Degrades on purpose — an upload whose derived versions are not ready
* yet still carries a `url`, with the rest null, so the frontend renders
* a plain `<img>` rather than a gap.
*/
export interface Image {
/**
* Absolute, because the frontend is on another host.
*/
url: string;
/**
* Empty string when the image is decorative — that is what `alt=""`
* means — never absent. Follows the requested language.
*/
alt: string;
width?: number;
height?: number;
aspect_ratio?: number;
mime?: string;
/**
* Tiny blurred data URI to paint while the real image loads.
*/
placeholder?: string;
/**
* Keyed by format, ready to use verbatim — build a `<picture>` with
* AVIF first and WebP behind it. Only widths that actually exist are
* listed, and an image is never scaled up.
*/
srcset?: Record<string, string>;
}
/**
* A call for contributors. The commitment fields are the point: time is
* the scarce resource in a volunteer collective, and stating it plainly
* is what lets someone decide.
*/
export interface PublicCall {
id: number;
locale: string;
title: string;
slug: string;
description?: string;
/**
* The explicit exchange offered for unpaid work.
*/
what_you_get?: string;
status: 'open' | 'filled' | 'closed';
hours_per_week?: number;
duration_weeks?: number;
total_hours?: number;
seniority?: 'any' | 'junior' | 'mid' | 'senior';
closes_at?: string;
project: PublicProject;
technologies: PublicTechnology[];
alternates: Alternates;
path?: string;
}
export interface PublicCategory {
id: number;
name?: string;
slug?: string;
description?: string;
color?: string;
position: number;
pages_count: number;
}
export interface PublicClient {
id: number;
/**
* A proper noun, so it is the same in every language.
*/
name: string;
slug: string;
sector?: string;
url?: string;
/**
* Its alt text is the organisation's name.
*/
logo?: Image;
projects_count: number;
}
export interface PublicIssue {
number: number;
title: string;
url: string;
labels: string[];
is_good_first_issue: boolean;
is_help_wanted: boolean;
/**
* Nobody has claimed it. A claimed issue is not an invitation.
*/
is_available: boolean;
comments: number;
created_at?: string;
repository: { full_name: string; url?: string; primary_language?: string; project?: { title: string; slug: string } };
}
export interface PublicMember {
id: number;
name: string;
slug: string;
headline?: string;
bio?: string;
avatar?: Image;
github_username?: string;
links: Record<string, string>;
is_founder: boolean;
/**
* False for people who have left; their work is kept.
*/
is_current: boolean;
joined_at?: string;
left_at?: string;
/**
* Their role on a project, when nested under one.
*/
role?: string;
technologies: PublicTechnology[];
projects: PublicProject[];
alternates: Alternates;
path: string;
}
export interface PublicPage {
id: number;
locale: string;
title?: string;
slug?: string;
excerpt?: string;
body?: string;
meta_title?: string;
meta_description?: string;
status: string;
is_featured: boolean;
featured_image?: Image;
published_at?: string;
/**
* When the record leaves the site. Enforced by the query itself, so
* it holds whether or not anything is running. Use it to expire a
* cache downstream at the same moment.
*/
unpublish_at?: string;
updated_at: string;
category: PublicCategory;
alternates: Alternates;
path?: string;
}
export interface PublicProject {
id: number;
locale: string;
title: string;
slug: string;
tagline?: string;
/**
* Detail endpoint only.
*/
description?: string;
/**
* Why the technical choices were made. Detail endpoint only.
*/
decisions?: string;
/**
* What went wrong and what was learned. Detail endpoint only.
*/
postmortem?: string;
/**
* What a search engine or a chat preview should show instead of the
* title. Detail endpoint only.
*/
meta_title?: string;
/**
* Detail endpoint only.
*/
meta_description?: string;
status: 'idea' | 'active' | 'paused' | 'completed' | 'archived';
is_featured: boolean;
/**
* Flagged directly, or carrying at least one open call.
*/
needs_help: boolean;
cover_image?: Image;
/**
* Screenshots and before-and-after, in the order they were arranged
* in the panel. **Detail endpoint only** — a listing of twenty
* projects has no use for every screenshot of each one.
*/
gallery: (Image & { caption?: string })[];
demo_url?: string;
/**
* The NGO or association the work serves. Used to be two free-text
* fields, which made the same organisation two different things.
*/
client?: PublicClient;
services: PublicService[];
testimonials: PublicTestimonial[];
talks: PublicTalk[];
started_at?: string;
ended_at?: string;
published_at?: string;
/**
* When the record leaves the site. Enforced by the query itself, so
* it holds whether or not anything is running. Use it to expire a
* cache downstream at the same moment.
*/
unpublish_at?: string;
updated_at: string;
technologies: PublicTechnology[];
members: PublicMember[];
lead: PublicMember;
calls: PublicCall[];
repositories: PublicRepository[];
alternates: Alternates;
path?: string;
}
/**
* Mirrored from the git provider and refreshed hourly.
*/
export interface PublicRepository {
id: number;
provider: 'github' | 'gitlab' | 'gitea';
full_name: string;
url?: string;
description?: string;
primary_language?: string;
/**
* Bytes of code per language.
*/
languages: Record<string, number>;
topics: string[];
license?: string;
stars: number;
forks: number;
contributors_count: number;
open_issues_count: number;
is_archived: boolean;
/**
* Nothing pushed for six months. Lets the frontend avoid presenting
* dead work as live.
*/
is_dormant: boolean;
pushed_at?: string;
synced_at?: string;
good_first_issues: PublicIssue[];
}
export interface PublicService {
id: number;
locale: string;
title: string;
slug: string;
tagline?: string;
/**
* Detail endpoint only.
*/
description?: string;
/**
* What a search engine or a chat preview should show instead of the
* title. Detail endpoint only.
*/
meta_title?: string;
/**
* Detail endpoint only.
*/
meta_description?: string;
/**
* A Heroicons name, e.g. `heroicon-o-code-bracket`.
*/
icon?: string;
is_featured: boolean;
position: number;
/**
* So a cache downstream can tell whether its copy is still the
* current one.
*/
updated_at?: string;
/**
* The published work that backs the offer. Detail endpoint only.
*/
projects: PublicProject[];
technologies: PublicTechnology[];
testimonials: PublicTestimonial[];
alternates: Alternates;
path?: string;
}
export interface PublicTalk {
id: number;
/**
* Not translated: the name of a talk given once, in one language.
*/
title: string;
language: string;
summary?: string;
event: string;
city?: string;
held_at?: string;
video_url?: string;
slides_url?: string;
speaker?: PublicMember;
}
export interface PublicTechnology {
id: number;
/**
* Not translated — technology names are proper nouns.
*/
name: string;
slug: string;
category: 'language' | 'framework' | 'datastore' | 'infrastructure' | 'tool' | 'practice';
color?: string;
icon?: string;
/**
* The technology's own logo. Its `alt` is the technology's name —
* there is nothing to write and nothing to get wrong.
*/
logo?: Image;
website?: string;
/**
* Radar placement. Null for technologies used but not placed.
*/
ring?: 'adopt' | 'trial' | 'assess' | 'hold';
description?: string;
/**
* Why it sits in that ring.
*/
radar_note?: string;
projects_count: number;
members_count: number;
}
export interface PublicTestimonial {
id: number;
quote: string;
/**
* The language it was actually said in.
*/
original_locale: string;
/**
* False when the quote you are reading was translated by us. Say so —
* "translated from Spanish" — rather than implying they phrased it
* that way.
*/
is_original: boolean;
author: { name: string; role?: string; avatar?: Image };
given_at?: string;
client?: PublicClient;
}
/**
* Either a plain string (applies to the active language) or an object
* keyed by locale.
*/
export type TranslatedField = Record<string, string>;
/** What each public endpoint answers with, under `data`. */
export interface PublicApiPayloads {
'/public/v1/site': { locales: ({ code: string; name: string; flag: string; is_default: boolean })[]; default_locale: string; content_version: number; submissions_enabled: boolean; stats: { projects: number; active_projects: number; members: number; open_calls: number; good_first_issues: number; stars: number } };
'/public/v1/slugs': Record<string, ({ id: number; updated_at: string; alternates: Record<string, string> })[]>;
'/public/v1/redirects': ({ from: string; to: string; status: 301 | 302; locale?: string })[];
'/public/v1/projects': PublicProject[];
'/public/v1/projects/{slug}': PublicProject;
'/public/v1/members': PublicMember[];
'/public/v1/members/{slug}': PublicMember;
'/public/v1/calls': PublicCall[];
'/public/v1/calls/{slug}': PublicCall;
'/public/v1/services': PublicService[];
'/public/v1/services/{slug}': PublicService;
'/public/v1/clients': PublicClient[];
'/public/v1/testimonials': PublicTestimonial[];
'/public/v1/talks': PublicTalk[];
'/public/v1/pages': PublicPage[];
'/public/v1/pages/{slug}': PublicPage;
'/public/v1/technologies': PublicTechnology[];
'/public/v1/radar': PublicTechnology[];
'/public/v1/good-first-issues': PublicIssue[];
}

View File

@@ -0,0 +1,92 @@
/**
* Dark mode, owned here rather than by the library.
*
* `PiThemeService` already toggles `html.dark-mode` and persists the choice,
* but its `readStoredDarkMode()` is `!!localStorage.getItem(key)` while
* `persist()` writes the boolean straight through — so turning dark mode *off*
* stores the string `"false"`, which reads back as `true`. Once a visitor had
* ever switched it on, they could never switch it off again.
*
* This service keeps its own key with an explicit value, honours the operating
* system on a first visit, and drives `PiThemeService` so the class the
* library's own effect writes stays in agreement.
*/
import {
computed,
inject,
Injectable,
makeEnvironmentProviders,
PLATFORM_ID,
provideAppInitializer,
type EnvironmentProviders,
} from '@angular/core';
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import { PiThemeService } from '@pi/ui';
/** Read by the inline script in `index.html` too — keep the two in step. */
export const APPEARANCE_KEY = 'landing-appearance';
@Injectable({ providedIn: 'root' })
export class AppearanceService {
private readonly theme = inject(PiThemeService);
private readonly document = inject(DOCUMENT);
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
readonly dark = computed(() => this.theme.darkMode());
/**
* Runs once at startup. The server has no way to know, so it renders light
* and the inline script in `index.html` has already put the class on
* `<html>` before first paint — this only brings the service into line.
*/
restore(): void {
this.theme.setDarkMode(this.preferred());
}
toggle(): void {
this.set(!this.dark());
}
set(dark: boolean): void {
this.theme.setDarkMode(dark);
if (this.isBrowser) {
try {
this.window()?.localStorage.setItem(APPEARANCE_KEY, dark ? 'dark' : 'light');
} catch {
// Private browsing, or storage switched off. The choice simply does
// not outlive the visit.
}
}
}
private preferred(): boolean {
if (!this.isBrowser) {
return false;
}
const view = this.window();
try {
const stored = view?.localStorage.getItem(APPEARANCE_KEY);
if (stored === 'dark' || stored === 'light') {
return stored === 'dark';
}
} catch {
// Fall through to the system preference.
}
return view?.matchMedia('(prefers-color-scheme: dark)').matches ?? false;
}
private window(): (Window & typeof globalThis) | null {
return this.document.defaultView;
}
}
export function provideAppearance(): EnvironmentProviders {
return makeEnvironmentProviders([
provideAppInitializer(() => inject(AppearanceService).restore()),
]);
}

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { SUPPORTED_LOCALES } from './api';
import { copyFor } from './copy';
/**
* The dictionaries are hand-written, so the thing worth testing is that none
* of them drifted: a missing key renders as an empty heading in production
* and nowhere else.
*/
describe('copyFor', () => {
const reference = copyFor('es');
function leafPaths(value: unknown, prefix = ''): string[] {
if (typeof value !== 'object' || value === null) {
return [prefix];
}
return Object.entries(value).flatMap(([key, child]) =>
leafPaths(child, prefix ? `${prefix}.${key}` : key)
);
}
it.each(SUPPORTED_LOCALES)('has every key in %s', (locale) => {
expect(leafPaths(copyFor(locale)).sort()).toEqual(leafPaths(reference).sort());
});
it('never answers with an empty string', () => {
for (const locale of SUPPORTED_LOCALES) {
const empties = leafPaths(copyFor(locale)).filter((path) => {
const value = path
.split('.')
.reduce<unknown>(
(node, key) => (node as Record<string, unknown>)[key],
copyFor(locale)
);
return typeof value === 'string' && value.trim() === '';
});
expect(empties).toEqual([]);
}
});
it('falls back to Spanish for a locale it does not know', () => {
expect(copyFor('pt' as never)).toBe(reference);
});
});

View File

@@ -0,0 +1,390 @@
/**
* The chrome's own words. Content arrives from the API already resolved to
* one language; headings, labels and button text are ours, so they live here
* and are picked with the same locale.
*/
import type { Locale } from './api';
export interface Copy {
nav: {
services: string;
projects: string;
team: string;
contribute: string;
contact: string;
darkMode: string;
lightMode: string;
};
hero: {
eyebrow: string;
title: string;
highlight: string;
lead: string;
primaryCta: string;
secondaryCta: string;
};
stats: Record<
| 'projects'
| 'active_projects'
| 'members'
| 'open_calls'
| 'services'
| 'clients'
| 'good_first_issues'
| 'stars',
string
>;
sections: {
services: { title: string; lead: string };
projects: { title: string; lead: string };
clients: { title: string; lead: string };
testimonials: { title: string; lead: string };
radar: { title: string; lead: string };
team: { title: string; lead: string };
calls: { title: string; lead: string };
issues: { title: string; lead: string };
talks: { title: string; lead: string };
contact: { title: string; lead: string };
};
projectStatus: Record<
'idea' | 'active' | 'paused' | 'completed' | 'archived',
string
>;
rings: Record<'adopt' | 'trial' | 'assess' | 'hold', string>;
labels: {
demo: string;
repository: string;
stars: string;
needsHelp: string;
founder: string;
alumni: string;
hoursPerWeek: string;
weeks: string;
seniority: string;
closes: string;
apply: string;
watchTalk: string;
slides: string;
openIssue: string;
comments: string;
empty: string;
seeMore: string;
};
form: {
kind: string;
kindContribute: string;
kindRequest: string;
name: string;
email: string;
organisation: string;
hours: string;
message: string;
messageHint: string;
consent: string;
submit: string;
sending: string;
success: string;
duplicate: string;
error: string;
closed: string;
};
footer: { built: string; api: string; rights: string };
maintenance: { title: string; back: string; until: string };
}
const es: Copy = {
nav: {
services: 'Servicios',
projects: 'Proyectos',
team: 'Equipo',
contribute: 'Colabora',
contact: 'Contacto',
darkMode: 'Modo oscuro',
lightMode: 'Modo claro',
},
hero: {
eyebrow: 'Colectivo de desarrollo',
title: 'Construimos software',
highlight: 'en abierto',
lead: 'Proyectos reales, código público y gente que enseña lo que aprende. Si tienes una idea que empujar o unas horas que aportar, este es el sitio.',
primaryCta: 'Ver proyectos',
secondaryCta: 'Hablemos',
},
stats: {
projects: 'Proyectos',
active_projects: 'En marcha',
members: 'Personas',
open_calls: 'Vacantes abiertas',
services: 'Servicios',
clients: 'Clientes',
good_first_issues: 'Issues para empezar',
stars: 'Estrellas',
},
sections: {
services: { title: 'Qué hacemos', lead: 'Lo que alguien nos pediría.' },
projects: { title: 'Proyectos', lead: 'Lo que ya hemos construido.' },
clients: { title: 'Con quién trabajamos', lead: 'Organizaciones detrás del trabajo.' },
testimonials: { title: 'Lo que dicen', lead: 'Palabras de quien nos contrató.' },
radar: { title: 'Radar técnico', lead: 'Qué adoptamos, qué probamos y qué dejamos pasar.' },
team: { title: 'Quiénes somos', lead: 'Las personas detrás de todo esto.' },
calls: { title: 'Buscamos gente', lead: 'Vacantes abiertas ahora mismo.' },
issues: { title: 'Primeras contribuciones', lead: 'Issues pensadas para empezar hoy.' },
talks: { title: 'Charlas', lead: 'Lo que hemos contado fuera.' },
contact: { title: 'Escríbenos', lead: 'Cuéntanos qué necesitas o qué te apetece hacer.' },
},
projectStatus: {
idea: 'Idea',
active: 'Activo',
paused: 'En pausa',
completed: 'Terminado',
archived: 'Archivado',
},
rings: { adopt: 'Adoptar', trial: 'Probar', assess: 'Evaluar', hold: 'Esperar' },
labels: {
demo: 'Demo',
repository: 'Repositorio',
stars: 'estrellas',
needsHelp: 'Busca ayuda',
founder: 'Fundador/a',
alumni: 'Alumni',
hoursPerWeek: 'h/semana',
weeks: 'semanas',
seniority: 'Nivel',
closes: 'Cierra el',
apply: 'Me interesa',
watchTalk: 'Ver charla',
slides: 'Diapositivas',
openIssue: 'Abrir issue',
comments: 'comentarios',
empty: 'Todavía no hay nada publicado aquí.',
seeMore: 'Ver más',
},
form: {
kind: 'Escribes para…',
kindContribute: 'Aportar tiempo',
kindRequest: 'Pedir ayuda para un proyecto',
name: 'Nombre',
email: 'Correo',
organisation: 'Organización',
hours: 'Horas por semana',
message: 'Mensaje',
messageHint: 'Mínimo 20 caracteres.',
consent: 'Acepto que guardéis mis datos para responderme.',
submit: 'Enviar',
sending: 'Enviando…',
success: 'Mensaje recibido. Te contestamos en cuanto podamos.',
duplicate: 'Ya teníamos este mensaje. No hace falta reenviarlo.',
error: 'No hemos podido enviarlo. Inténtalo en un rato.',
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.',
},
maintenance: {
title: 'Estamos de mantenimiento',
back: 'Volvemos en un rato.',
until: 'Volvemos el',
},
};
const en: Copy = {
nav: {
services: 'Services',
projects: 'Projects',
team: 'Team',
contribute: 'Contribute',
contact: 'Contact',
darkMode: 'Dark mode',
lightMode: 'Light mode',
},
hero: {
eyebrow: 'A development collective',
title: 'We build software',
highlight: 'in the open',
lead: 'Real projects, public code and people who teach what they learn. If you have an idea to push or a few hours to give, you are in the right place.',
primaryCta: 'See projects',
secondaryCta: "Let's talk",
},
stats: {
projects: 'Projects',
active_projects: 'In progress',
members: 'People',
open_calls: 'Open calls',
services: 'Services',
clients: 'Clients',
good_first_issues: 'Good first issues',
stars: 'Stars',
},
sections: {
services: { title: 'What we do', lead: 'What somebody would be asking for.' },
projects: { title: 'Projects', lead: 'What we have already built.' },
clients: { title: 'Who we work with', lead: 'The organisations behind the work.' },
testimonials: { title: 'What they said', lead: 'Words from the people who hired us.' },
radar: { title: 'Tech radar', lead: 'What we adopt, trial, assess and hold.' },
team: { title: 'Who we are', lead: 'The people behind all of this.' },
calls: { title: 'We are looking for people', lead: 'Calls open right now.' },
issues: { title: 'First contributions', lead: 'Issues meant to be picked up today.' },
talks: { title: 'Talks', lead: 'What we have told elsewhere.' },
contact: { title: 'Write to us', lead: 'Tell us what you need, or what you feel like building.' },
},
projectStatus: {
idea: 'Idea',
active: 'Active',
paused: 'Paused',
completed: 'Completed',
archived: 'Archived',
},
rings: { adopt: 'Adopt', trial: 'Trial', assess: 'Assess', hold: 'Hold' },
labels: {
demo: 'Demo',
repository: 'Repository',
stars: 'stars',
needsHelp: 'Needs help',
founder: 'Founder',
alumni: 'Alumni',
hoursPerWeek: 'h/week',
weeks: 'weeks',
seniority: 'Level',
closes: 'Closes on',
apply: "I'm interested",
watchTalk: 'Watch talk',
slides: 'Slides',
openIssue: 'Open issue',
comments: 'comments',
empty: 'Nothing published here yet.',
seeMore: 'See more',
},
form: {
kind: 'You are writing to…',
kindContribute: 'Give some time',
kindRequest: 'Ask for help with a project',
name: 'Name',
email: 'E-mail',
organisation: 'Organisation',
hours: 'Hours per week',
message: 'Message',
messageHint: 'At least 20 characters.',
consent: 'I agree to have my details stored so you can reply.',
submit: 'Send',
sending: 'Sending…',
success: 'Message received. We will get back to you as soon as we can.',
duplicate: 'We already had this message. No need to send it again.',
error: 'We could not send it. Please try again in a while.',
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.',
},
maintenance: {
title: 'We are under maintenance',
back: 'Back in a while.',
until: 'Back on',
},
};
const ca: Copy = {
nav: {
services: 'Serveis',
projects: 'Projectes',
team: 'Equip',
contribute: 'Col·labora',
contact: 'Contacte',
darkMode: 'Mode fosc',
lightMode: 'Mode clar',
},
hero: {
eyebrow: 'Col·lectiu de desenvolupament',
title: 'Construïm programari',
highlight: 'en obert',
lead: 'Projectes reals, codi públic i gent que ensenya el que aprèn. Si tens una idea per empènyer o unes hores per aportar, aquest és el lloc.',
primaryCta: 'Veure projectes',
secondaryCta: 'Parlem-ne',
},
stats: {
projects: 'Projectes',
active_projects: 'En marxa',
members: 'Persones',
open_calls: 'Vacants obertes',
services: 'Serveis',
clients: 'Clients',
good_first_issues: 'Issues per començar',
stars: 'Estrelles',
},
sections: {
services: { title: 'Què fem', lead: 'El que algú ens demanaria.' },
projects: { title: 'Projectes', lead: 'El que ja hem construït.' },
clients: { title: 'Amb qui treballem', lead: 'Organitzacions darrere de la feina.' },
testimonials: { title: 'Què en diuen', lead: 'Paraules de qui ens ha contractat.' },
radar: { title: 'Radar tècnic', lead: 'Què adoptem, què provem i què deixem passar.' },
team: { title: 'Qui som', lead: 'Les persones darrere de tot això.' },
calls: { title: 'Busquem gent', lead: 'Vacants obertes ara mateix.' },
issues: { title: 'Primeres contribucions', lead: 'Issues pensades per començar avui.' },
talks: { title: 'Xerrades', lead: 'El que hem explicat a fora.' },
contact: { title: 'Escriu-nos', lead: 'Explica què necessites o què et ve de gust fer.' },
},
projectStatus: {
idea: 'Idea',
active: 'Actiu',
paused: 'En pausa',
completed: 'Acabat',
archived: 'Arxivat',
},
rings: { adopt: 'Adoptar', trial: 'Provar', assess: 'Avaluar', hold: 'Esperar' },
labels: {
demo: 'Demo',
repository: 'Repositori',
stars: 'estrelles',
needsHelp: 'Busca ajuda',
founder: 'Fundador/a',
alumni: 'Alumni',
hoursPerWeek: 'h/setmana',
weeks: 'setmanes',
seniority: 'Nivell',
closes: 'Tanca el',
apply: "M'interessa",
watchTalk: 'Veure xerrada',
slides: 'Diapositives',
openIssue: 'Obrir issue',
comments: 'comentaris',
empty: 'Encara no hi ha res publicat aquí.',
seeMore: 'Veure més',
},
form: {
kind: 'Escrius per…',
kindContribute: 'Aportar temps',
kindRequest: 'Demanar ajuda per a un projecte',
name: 'Nom',
email: 'Correu',
organisation: 'Organització',
hours: 'Hores per setmana',
message: 'Missatge',
messageHint: 'Mínim 20 caràcters.',
consent: 'Accepto que deseu les meves dades per respondre’m.',
submit: 'Enviar',
sending: 'Enviant…',
success: 'Missatge rebut. Et contestem tan aviat com puguem.',
duplicate: 'Ja teníem aquest missatge. No cal reenviar-lo.',
error: 'No hem pogut enviar-lo. Torna-ho a provar d’aquí una estona.',
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.',
},
maintenance: {
title: 'Estem de manteniment',
back: 'Tornem d’aquí una estona.',
until: 'Tornem el',
},
};
const dictionaries: Record<Locale, Copy> = { es, en, ca };
export function copyFor(locale: Locale): Copy {
return dictionaries[locale] ?? dictionaries.es;
}

View File

@@ -0,0 +1,53 @@
/**
* Which breakpoint the viewport is in, as a signal.
*
* `pi-carousel` takes a `device` and decides how many items fit from it. The
* library ships `DeviceConfigsFacade` for this, but nothing in it observes the
* window — an app is expected to call `setDevice()` itself — so this reads the
* width directly and keeps the value up to date.
*
* The server has no viewport. It answers `DESKTOP`, which is what the carousel
* renders widest: a phone briefly sees three testimonials side by side rather
* than one blank frame, and hydration corrects it.
*/
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import {
DestroyRef,
inject,
PLATFORM_ID,
signal,
type Signal,
} from '@angular/core';
import type { Device } from '@pi/ui';
const TABLET_FROM = 640;
const DESKTOP_FROM = 1024;
function deviceFor(width: number): Device {
if (width >= DESKTOP_FROM) {
return 'DESKTOP';
}
return width >= TABLET_FROM ? 'TABLET' : 'MOBILE';
}
export function injectDevice(): Signal<Device> {
const document = inject(DOCUMENT);
const isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
const view = document.defaultView;
const device = signal<Device>(
isBrowser && view ? deviceFor(view.innerWidth) : 'DESKTOP'
);
if (isBrowser && view) {
const onResize = () => device.set(deviceFor(view.innerWidth));
view.addEventListener('resize', onResize, { passive: true });
inject(DestroyRef).onDestroy(() =>
view.removeEventListener('resize', onResize)
);
}
return device.asReadonly();
}

View File

@@ -0,0 +1,121 @@
/**
* The landing's icon set, inlined.
*
* `@pi/ui` ships no SVGs: `pi-icon` renders a `mat-icon` whose name is looked
* up in Angular Material's registry, and the library's own `IconRegistryService`
* registers every name as a URL under `/assets/icons/`. That means one HTTP
* request per icon — and during SSR those requests have no origin to resolve
* against, so every one of them fails and the server HTML comes out without
* icons. Registered as literals instead, they are just strings: no network,
* and the icon is in the first byte the browser receives.
*
* The same files are still on disk under `public/assets/icons` for the
* `type="custom"` variants of `pi-icon` / `pi-icon-button`, which render an
* `<img>` rather than reading the registry.
*/
import {
inject,
makeEnvironmentProviders,
provideAppInitializer,
type EnvironmentProviders,
} from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import { MatIconRegistry } from '@angular/material/icon';
const LANDING_ICONS: Record<string, string> = {
'arrow-forward':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h15M13 6l6 6-6 6"/></svg>',
'briefcase':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M9 7V5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2M3 12h18"/></svg>',
'buildings':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21V8l6-3v16M9 21h12V11l-6-2M13 21v-4h4v4"/><path d="M6 11h.01M6 15h.01"/></svg>',
'calendar':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/></svg>',
'chat':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a8 8 0 0 1-11.6 7.1L4 20.5l1.4-5.2A8 8 0 1 1 21 12Z"/></svg>',
'check-rounded':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="m8 12.2 2.7 2.8L16 9.5"/></svg>',
'check':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m4.5 12.5 5 5 10-11"/></svg>',
'chevron-back':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m14.5 6-6 6 6 6"/></svg>',
'chevron-down':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9.5 6 6 6-6"/></svg>',
'chevron-forward':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m9.5 6 6 6-6 6"/></svg>',
'chevron-up':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m6 14.5 6-6 6 6"/></svg>',
'close-rounded':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="m9 9 6 6M15 9l-6 6"/></svg>',
'close':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6l12 12M18 6 6 18"/></svg>',
'coins':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="6.5" rx="7" ry="3"/><path d="M5 6.5v5c0 1.7 3.1 3 7 3s7-1.3 7-3v-5M5 11.5v5c0 1.7 3.1 3 7 3s7-1.3 7-3v-5"/></svg>',
'document':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8l-5-5Z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></svg>',
'email':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3.5 7 7.4 5.3a2 2 0 0 0 2.2 0L20.5 7"/></svg>',
'exclamation':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5.5M12 16.3h.01"/></svg>',
'focus':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M3 8V5a2 2 0 0 1 2-2h3M16 3h3a2 2 0 0 1 2 2v3M21 16v3a2 2 0 0 1-2 2h-3M8 21H5a2 2 0 0 1-2-2v-3"/></svg>',
'globe':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18Z"/></svg>',
'graphic':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20V4M4 20h16"/><path d="m7.5 15 3.5-4 3 2.6L20 7"/></svg>',
'grid':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7.5" height="7.5" rx="1.5"/><rect x="13.5" y="3" width="7.5" height="7.5" rx="1.5"/><rect x="3" y="13.5" width="7.5" height="7.5" rx="1.5"/><rect x="13.5" y="13.5" width="7.5" height="7.5" rx="1.5"/></svg>',
'info':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 11v5.5M12 7.7h.01"/></svg>',
'light-bulb':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6M10 21h4"/><path d="M12 3a6 6 0 0 0-3.5 10.9c.6.4 1 1.1 1 1.9v.2h5v-.2c0-.8.4-1.5 1-1.9A6 6 0 0 0 12 3Z"/></svg>',
'link':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.1.1l2.9-2.9a5 5 0 0 0-7.1-7.1L11 4.9"/><path d="M14 11a5 5 0 0 0-7.1-.1L4 13.8a5 5 0 0 0 7.1 7.1l1.9-1.9"/></svg>',
'list':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6h13M8 12h13M8 18h13M3.5 6h.01M3.5 12h.01M3.5 18h.01"/></svg>',
'location':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21s7-5.6 7-11a7 7 0 1 0-14 0c0 5.4 7 11 7 11Z"/><circle cx="12" cy="10" r="2.5"/></svg>',
'moon':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13.4A8 8 0 1 1 10.6 4a6.6 6.6 0 0 0 9.4 9.4Z"/></svg>',
'play-arrow':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8 5.5v13l10-6.5-10-6.5Z"/></svg>',
'search':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="6.5"/><path d="m16 16 4.5 4.5"/></svg>',
'star-fill':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="m12 3.6 2.5 5.2 5.7.8-4.1 4 1 5.6-5.1-2.7-5.1 2.7 1-5.6-4.1-4 5.7-.8L12 3.6Z"/></svg>',
'sun':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.3 5.3l1.4 1.4M17.3 17.3l1.4 1.4M18.7 5.3l-1.4 1.4M6.7 17.3l-1.4 1.4"/></svg>',
'support':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M9.5 9.5 5.6 5.6M14.5 9.5l3.9-3.9M14.5 14.5l3.9 3.9M9.5 14.5l-3.9 3.9"/><circle cx="12" cy="12" r="3.5"/></svg>',
'time-detail':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5.2l3.2 2"/></svg>',
'user':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21v-1a6 6 0 0 1 6-6h4a6 6 0 0 1 6 6v1"/></svg>',
'verification-list':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 4h9a2 2 0 0 1 2 2v13a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V8"/><path d="M4 5.5 5.8 7.5 9 3.8M9 11h7M9 15h5"/></svg>',
};
/** Names that resolve. Anything else must not reach `pi-icon`. */
export const SHIPPED_ICONS = new Set(Object.keys(LANDING_ICONS));
/**
* Services carry a free-form `icon` from the panel: it may be one of these
* names, an emoji, or something nobody has drawn yet. A miss in the registry
* throws rather than degrading, so nothing from the API reaches it unchecked.
*/
export function resolveIcon(name: string | undefined, fallback: string): string {
return name && SHIPPED_ICONS.has(name) ? name : fallback;
}
export function provideLandingIcons(): EnvironmentProviders {
return makeEnvironmentProviders([
provideAppInitializer(() => {
const registry = inject(MatIconRegistry);
const sanitizer = inject(DomSanitizer);
for (const [name, svg] of Object.entries(LANDING_ICONS)) {
registry.addSvgIconLiteral(name, sanitizer.bypassSecurityTrustHtml(svg));
}
}),
]);
}

View File

@@ -0,0 +1,71 @@
/**
* The handful of strings `@pi/ui` translates on its own — a field's error
* message, a chart legend, a close label.
*
* They are provided synchronously rather than through the library's
* `providePiTranslate()`, which fetches `./assets/i18n/*.json` over HTTP: on
* the server that request has no origin to resolve against, so it fails on
* every render and the strings would only appear after hydration. The
* landing's own copy lives in `copy.ts` for the same reason.
*/
import {
provideTranslateService,
TranslateService,
type TranslationObject,
} from '@ngx-translate/core';
import {
inject,
makeEnvironmentProviders,
provideAppInitializer,
type EnvironmentProviders,
} from '@angular/core';
import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type Locale } from './api';
const STRINGS: Record<Locale, TranslationObject> = {
es: {
common: {
close: 'Cerrar',
edit: 'Editar',
legend: 'Leyenda',
min: 'Mín',
max: 'Máx',
confidentialInfo: 'Información confidencial',
},
},
en: {
common: {
close: 'Close',
edit: 'Edit',
legend: 'Legend',
min: 'Min',
max: 'Max',
confidentialInfo: 'Confidential information',
},
},
ca: {
common: {
close: 'Tancar',
edit: 'Editar',
legend: 'Llegenda',
min: 'Mín',
max: 'Màx',
confidentialInfo: 'Informació confidencial',
},
},
};
export function providePiStrings(): EnvironmentProviders {
return makeEnvironmentProviders([
provideTranslateService({ fallbackLang: DEFAULT_LOCALE }),
provideAppInitializer(() => {
const translate = inject(TranslateService);
for (const locale of SUPPORTED_LOCALES) {
translate.setTranslation(locale, STRINGS[locale]);
}
translate.use(DEFAULT_LOCALE);
}),
]);
}