Initial commit
This commit is contained in:
30
landing/src/app/core/api.spec.ts
Normal file
30
landing/src/app/core/api.spec.ts
Normal 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
257
landing/src/app/core/api.ts
Normal 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 };
|
||||
}
|
||||
401
landing/src/app/core/api.types.ts
Normal file
401
landing/src/app/core/api.types.ts
Normal 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[];
|
||||
}
|
||||
92
landing/src/app/core/appearance.ts
Normal file
92
landing/src/app/core/appearance.ts
Normal 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()),
|
||||
]);
|
||||
}
|
||||
48
landing/src/app/core/copy.spec.ts
Normal file
48
landing/src/app/core/copy.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
390
landing/src/app/core/copy.ts
Normal file
390
landing/src/app/core/copy.ts
Normal 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;
|
||||
}
|
||||
53
landing/src/app/core/device.ts
Normal file
53
landing/src/app/core/device.ts
Normal 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();
|
||||
}
|
||||
121
landing/src/app/core/icons.ts
Normal file
121
landing/src/app/core/icons.ts
Normal 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));
|
||||
}
|
||||
}),
|
||||
]);
|
||||
}
|
||||
71
landing/src/app/core/pi-strings.ts
Normal file
71
landing/src/app/core/pi-strings.ts
Normal 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);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
Reference in New Issue
Block a user