Compare commits
8 Commits
9af6b5aff9
...
240ad149e7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
240ad149e7 | ||
|
|
fea60a168c | ||
|
|
86b7485b32 | ||
|
|
399b8d5450 | ||
|
|
daacc2c5d6 | ||
|
|
06eeefc144 | ||
|
|
b5681a12ac | ||
|
|
439f951f2e |
125
README.md
125
README.md
@@ -150,21 +150,32 @@ landing/src/
|
||||
core/copy.ts the chrome's own words, in es / en / ca
|
||||
core/icons.ts the icon set, inlined and registered as literals
|
||||
core/pi-strings.ts the strings `@pi/ui` translates on its own
|
||||
core/content-page.ts the load function both content-page routes share
|
||||
core/routes.ts which record an address belongs to (resolveRoute)
|
||||
core/routed-page.ts the load function for every address but the landing
|
||||
pages/(home).server.ts one load function, eleven parallel calls
|
||||
pages/(home).page.ts the landing itself
|
||||
pages/[slug].page.ts a page the panel publishes, default language
|
||||
pages/[locale]/[slug].page.ts the same, in any other language
|
||||
pages/[...path].page.ts everything else: pages, projects, members, 404
|
||||
ui/page-shell.ts header, footer, lights, 404/503 and SEO around a record
|
||||
ui/routed-page.ts picks the body for the record's type
|
||||
ui/ presentational components, one per payload type
|
||||
(thin wrappers over `@pi/ui`), each with its `.scss`
|
||||
server/
|
||||
routes/api/contact.post.ts forwards the contact form to the API
|
||||
routes/api/calls/[slug]/apply.post.ts forwards an application to one call
|
||||
routes/sitemap.xml.get.ts every published address, from the API's /slugs
|
||||
routes/robots.txt.get.ts points crawlers at the sitemap
|
||||
routes/blog/rss.xml.get.ts the blog as RSS, one feed per language
|
||||
middleware/redirects.ts 301s for slugs renamed in the panel
|
||||
middleware/not-found-status.ts
|
||||
styles.scss Material theme, `@pi/ui` sheets, shared primitives
|
||||
```
|
||||
|
||||
**Nothing renders from the browser.** All reads happen in the page's `load`
|
||||
function, and the one write goes through this app's own `/api/contact` handler.
|
||||
function, and the two writes go through this app's own handlers —
|
||||
`/api/contact` for the general form, `/api/calls/{slug}/apply` for a call's —
|
||||
which share one check (`core/submission.ts`) before spending a round trip. An
|
||||
application to a call sends no `kind`: the API refuses one there, because
|
||||
answering a call is always an offer of time.
|
||||
That is what the API expects: it throttles submissions per e-mail address
|
||||
rather than per IP *because* an SSR frontend posts on the visitor's behalf, and
|
||||
keeping the API host out of the browser means CORS never comes up.
|
||||
@@ -186,9 +197,13 @@ page component through `injectResponse()` (`@analogjs/router/tokens`), which is
|
||||
the *render* request's response; the one a page `load` sees belongs to the
|
||||
internal request Analog resolves loaders through and never reaches the browser.
|
||||
That is also why the 404 status comes from middleware instead — except for
|
||||
addresses shaped like a content page, where only the API knows whether the page
|
||||
exists: the middleware lets those through and `ui/content-page.ts` sets the 404
|
||||
itself from `ngOnInit`, once its input has arrived.
|
||||
addresses shaped like a record, where only the API knows whether it exists: the
|
||||
middleware lets those through and `ui/page-shell.ts` sets the 404 itself from
|
||||
`ngOnInit`, once its input has arrived.
|
||||
|
||||
**`@pi/ui`'s reset spaces every `li + li`** with a top margin — right for a
|
||||
list of prose, wrong for a row of chips laid out with `gap`, where it dropped
|
||||
every chip but the first. Lists spaced by `gap` reset it on their own `> li`.
|
||||
|
||||
**Empty sections disappear**, along with their entry in the nav — a young site
|
||||
shows a hero and a contact form rather than eight headings over nothing.
|
||||
@@ -202,18 +217,78 @@ page cached with `s-maxage` that varied on `Accept-Language` would hand the
|
||||
second visitor the wrong language anyway. The switcher in the header is three
|
||||
plain links, and `<html lang>` follows.
|
||||
|
||||
**Content pages are the exception**, because the API decides their address: a
|
||||
page's `path` and `alternates` put the default language unprefixed and every
|
||||
other one under its code — `/sobre-nosotros`, `/en/about-us`, `/ca/qui-som` —
|
||||
so those are the routes that answer. `core/api.ts#contentRoute` is the one rule
|
||||
for that shape; `/es/…` is not one of them, so a page has one address rather
|
||||
than two. The switcher on a page follows `alternates`, and a language the page
|
||||
has no translation for leads to the landing in that language instead of a 404.
|
||||
The same `alternates` become `hreflang` links, with a `canonical`.
|
||||
**Records are the exception**, because the API decides their address: a
|
||||
record's `path` and `alternates` put the default language unprefixed and every
|
||||
other one under its code, pages at the root and everything else under a section
|
||||
named in that language:
|
||||
|
||||
```
|
||||
/sobre-nosotros /en/about-us /ca/qui-som
|
||||
/proyectos/catalogo /en/projects/catalogo /ca/projectes/catalogo
|
||||
/equipo/ada /en/team/ada /ca/equip/ada
|
||||
/colabora/frontend /en/contribute/frontend /ca/col-labora/frontend
|
||||
/servicios/formacion /en/services/formacion /ca/serveis/formacion
|
||||
```
|
||||
|
||||
Besides records there are **listings**, pages this site composes itself —
|
||||
the tech radar at `/radar`, `/en/radar`, `/ca/radar`, and the ways to help at
|
||||
`/colabora`, `/en/contribute`, `/ca/col-labora` — the calls' own section, so it
|
||||
lists what `/colabora/{slug}` are, next to good first issues and a guide to a
|
||||
first contribution (`LISTINGS`). A panel page
|
||||
with the same slug would be shadowed by one, so they are few and deliberately
|
||||
named. Radar logos sit on a light tile in both themes: a brand's mark is not
|
||||
ours to recolour, and some (Rust's, black) would vanish in dark mode.
|
||||
|
||||
`core/routes.ts#resolveRoute` is that rule read backwards, and its `SECTIONS`
|
||||
mirror the API's `config/site.php` (`paths`) — a change there has to be made
|
||||
here too. `/es/…` is not a shape the API produces, so a record has one address
|
||||
rather than two. The switcher on a record follows `alternates`, and a language
|
||||
it has no translation for leads to the landing in that language instead of a
|
||||
404. The same `alternates` become `hreflang` links, with a `canonical`.
|
||||
|
||||
**One catch-all route** (`pages/[...path]`) serves every address but the
|
||||
landing, and `resolveRoute` sorts the shapes out. Not one route per shape:
|
||||
Analog registers each loader as an internal Nitro route, and Nitro's router
|
||||
cannot hold two parameters of different names at the same depth —
|
||||
`/:prefix/:slug` beside `/:prefix/:section/:slug` made the first unreachable.
|
||||
|
||||
Featured pages go in the header after the section anchors; every page is in the
|
||||
footer.
|
||||
|
||||
## The blog
|
||||
|
||||
Not a type of its own: **a post is a page in the panel's "Blog" category** —
|
||||
written, translated, scheduled, reviewed and reachable by the MCP server like
|
||||
any page. That category has to exist with the slug `blog` in every language
|
||||
(`core/blog.ts`), because the API filters by the slug in the language asked for.
|
||||
|
||||
- `/blog` (`/en/blog`, `/ca/blog`) lists them newest first.
|
||||
- A post shows its date and leads back to the blog; its structured data is a
|
||||
`BlogPosting`.
|
||||
- `/blog/rss.xml?lang=xx` is the feed, one per language, announced in `<head>`.
|
||||
- The footer does not list posts one by one — it would grow without end — but
|
||||
links the blog once there is any.
|
||||
|
||||
## Search engines and link previews
|
||||
|
||||
- **`/sitemap.xml`** comes from the API's `/slugs` — one request for every
|
||||
published address — plus the landing and the listings. Each address is listed
|
||||
in every language, with `hreflang` alternates and `x-default`. If the API is
|
||||
down it answers 503, not a sitemap missing half the site.
|
||||
- **`/robots.txt`** allows everything and names the sitemap, absolute, on
|
||||
whatever domain the proxy says this is.
|
||||
- **Renamed slugs redirect.** The panel writes a redirect the moment somebody
|
||||
renames one; `middleware/redirects.ts` answers the old address with a 301,
|
||||
from the API's `/redirects` list kept for a minute (and kept whole through an
|
||||
outage, so renamed links do not turn back into 404s).
|
||||
- **Every page says what it is**: title, description, `og:*` with the record's
|
||||
own picture (the logo otherwise), and schema.org JSON-LD — `Organization` on
|
||||
the landing, `WebPage`, `CreativeWork`, `Person` or `Service` elsewhere.
|
||||
|
||||
In development only `/api/*` reaches Nitro, so `/sitemap.xml`, `/robots.txt`
|
||||
and `/blog/rss.xml` answer 404 under `npm start`; build and run `npm run preview` to
|
||||
see them. Middleware does run in development.
|
||||
|
||||
## Rendering and caching
|
||||
|
||||
Nothing is prerendered: the page is assembled from content the panel publishes
|
||||
@@ -349,11 +424,21 @@ Two pieces, both of which have to survive a page that renders on the server:
|
||||
so the arrows have to be asked for, and the controls only appear at all once
|
||||
there are more items than fit — with two testimonials it degrades to a plain
|
||||
row, which is the right answer.
|
||||
- **`ui/reveal.ts`** fades each section in as it arrives. The hidden state is
|
||||
*not* in the server-rendered HTML: it only applies under `html.reveal-ready`,
|
||||
a class the directive adds at runtime, so a visitor without JavaScript reads a
|
||||
full page instead of a blank column. `prefers-reduced-motion` skips the
|
||||
observer entirely.
|
||||
- **`ui/reveal.ts`** assembles each section as it arrives: its heading first,
|
||||
then what it holds, and a grid's cards one after another (up to eight, then
|
||||
together). The hidden state is *not* in the server-rendered HTML: it only
|
||||
applies under `html.reveal-ready`, a class the directive adds at runtime, so
|
||||
a visitor without JavaScript reads a full page instead of a blank column.
|
||||
- **The hero enters on load**, in CSS (`hero.scss`), so it plays with or
|
||||
without JavaScript: eyebrow, title, lead, buttons, and the stats last, at
|
||||
0.8s. The stats count up from zero — but only if the page hydrates before
|
||||
then (`COUNT_UP_BEFORE_MS` in `stat-grid.ts`). Later than that the number has
|
||||
already been read, and dropping it to zero to count again would be a flicker.
|
||||
- **Scroll-driven, no JavaScript**: the hero's lights drift and fade as it
|
||||
scrolls away, and the header gains a shadow once the page moves
|
||||
(`animation-timeline: scroll()`). Browsers without it simply do not animate.
|
||||
|
||||
`prefers-reduced-motion` turns every one of these off.
|
||||
|
||||
`PiThemeService` owns the shell class, pinned to `alternative` in
|
||||
`app.config.ts` — the library's marketing look: pill buttons with a gradient
|
||||
|
||||
@@ -50,7 +50,62 @@ test.describe('landing', () => {
|
||||
|
||||
expect(response?.status()).toBe(404);
|
||||
await expect(page.locator('h1')).toContainText('no existe');
|
||||
await expect(page.locator('meta[name="robots"]')).toHaveAttribute('content', 'noindex');
|
||||
await expect(page.locator('meta[name="robots"]')).toHaveAttribute(
|
||||
'content',
|
||||
'noindex',
|
||||
);
|
||||
});
|
||||
|
||||
test('answers 404 for a record the API does not have, in any section', async ({
|
||||
page,
|
||||
}) => {
|
||||
for (const path of [
|
||||
'/proyectos/no-existe',
|
||||
'/en/team/nobody',
|
||||
'/ca/projectes/res',
|
||||
]) {
|
||||
const response = await page.goto(path);
|
||||
|
||||
expect(response?.status(), path).toBe(404);
|
||||
await expect(page.locator('h1')).toContainText('no existe');
|
||||
}
|
||||
});
|
||||
|
||||
test('answers 404 for a section spelled in another language', async ({
|
||||
page,
|
||||
}) => {
|
||||
// `/projects/x` is the English section without the English prefix: not an
|
||||
// address the API ever hands out.
|
||||
const response = await page.goto('/projects/anything');
|
||||
|
||||
expect(response?.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('serves the radar in every language, and not under /es', async ({
|
||||
page,
|
||||
}) => {
|
||||
for (const [path, heading] of [
|
||||
['/radar', 'Radar técnico'],
|
||||
['/en/radar', 'Tech radar'],
|
||||
['/ca/radar', 'Radar tècnic'],
|
||||
]) {
|
||||
const response = await page.goto(path);
|
||||
|
||||
expect(response?.status(), path).toBe(200);
|
||||
await expect(page.locator('h1')).toContainText(heading);
|
||||
}
|
||||
|
||||
expect((await page.goto('/es/radar'))?.status()).toBe(404);
|
||||
});
|
||||
|
||||
test("lists the ways to help on the calls' own section", async ({ page }) => {
|
||||
for (const [path, heading] of [
|
||||
['/colabora', 'Tu primera contribución'],
|
||||
['/en/contribute', 'Your first contribution'],
|
||||
]) {
|
||||
expect((await page.goto(path))?.status(), path).toBe(200);
|
||||
await expect(page.locator('h1')).toContainText(heading);
|
||||
}
|
||||
});
|
||||
|
||||
test('refuses an incomplete contact submission without calling the API', async ({
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
contentRoute,
|
||||
DEFAULT_LOCALE,
|
||||
homeHref,
|
||||
localeFromUrl,
|
||||
resolveLocale,
|
||||
} from './api';
|
||||
import { DEFAULT_LOCALE, localeFromUrl, resolveLocale } from './api';
|
||||
|
||||
describe('resolveLocale', () => {
|
||||
it('accepts the locales the API declares', () => {
|
||||
@@ -35,35 +29,3 @@ describe('localeFromUrl', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('contentRoute', () => {
|
||||
it('reads an unprefixed page as the default language', () => {
|
||||
expect(contentRoute('/sobre-nosotros')).toEqual({
|
||||
locale: DEFAULT_LOCALE,
|
||||
slug: 'sobre-nosotros',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads a prefixed page in the language it names', () => {
|
||||
expect(contentRoute('/en/about-us')).toEqual({ locale: 'en', slug: 'about-us' });
|
||||
expect(contentRoute('/ca/qui-som/')).toEqual({ locale: 'ca', slug: 'qui-som' });
|
||||
});
|
||||
|
||||
// The API never spells the default language with a prefix, so one page has
|
||||
// one address rather than two.
|
||||
it('refuses the default language as a prefix', () => {
|
||||
expect(contentRoute('/es/sobre-nosotros')).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses what is not shaped like a page', () => {
|
||||
expect(contentRoute('/')).toBeNull();
|
||||
expect(contentRoute('/fr/a-propos')).toBeNull();
|
||||
expect(contentRoute('/en/projects/catalogo')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('homeHref', () => {
|
||||
it('answers on ?lang= for every language but the default', () => {
|
||||
expect(homeHref(DEFAULT_LOCALE)).toBe('/');
|
||||
expect(homeHref('ca')).toBe('/?lang=ca');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,42 +74,6 @@ export function localeFromUrl(url: string | undefined): Locale {
|
||||
return resolveLocale(requested);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a content page lives, as the API spells it in `path` and `alternates`:
|
||||
* the default language unprefixed (`/sobre-nosotros`), every other one under
|
||||
* its code (`/en/about-us`). Only the API knows which slug belongs to which
|
||||
* language, so this only recognises the shape; whether the page exists is the
|
||||
* API's answer.
|
||||
*
|
||||
* A prefix naming the default language (`/es/sobre-nosotros`) is not a shape
|
||||
* the API ever produces, so it is not one this answers on either — one page,
|
||||
* one address.
|
||||
*/
|
||||
export function contentRoute(
|
||||
pathname: string
|
||||
): { locale: Locale; slug: string } | null {
|
||||
const segments = pathname.replace(/\/+$/, '').split('/').filter(Boolean);
|
||||
|
||||
if (segments.length === 1) {
|
||||
return { locale: DEFAULT_LOCALE, slug: segments[0] };
|
||||
}
|
||||
|
||||
if (
|
||||
segments.length === 2 &&
|
||||
segments[0] !== DEFAULT_LOCALE &&
|
||||
SUPPORTED_LOCALES.includes(segments[0] as Locale)
|
||||
) {
|
||||
return { locale: segments[0] as Locale, slug: segments[1] };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The landing itself in a given language. It answers on `?lang=`, not a prefix. */
|
||||
export function homeHref(locale: Locale): string {
|
||||
return locale === DEFAULT_LOCALE ? '/' : `/?lang=${locale}`;
|
||||
}
|
||||
|
||||
function apiBase(): string {
|
||||
const configured =
|
||||
typeof process !== 'undefined'
|
||||
@@ -221,7 +185,28 @@ async function list<T>(path: string, options: GetOptions = {}): Promise<ApiResul
|
||||
return { ...result, data: result.data ?? [] };
|
||||
}
|
||||
|
||||
/** One published record in `/slugs`: its address in every language it has. */
|
||||
export interface SlugEntry {
|
||||
id: number;
|
||||
updated_at?: string;
|
||||
alternates: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface Redirect {
|
||||
from: string;
|
||||
to: string;
|
||||
status: 301 | 302;
|
||||
/** Null: applies whatever language the visitor is browsing in. */
|
||||
locale?: string | null;
|
||||
}
|
||||
|
||||
export const pireonApi = {
|
||||
/** Every published address, grouped by type. What the sitemap is built from. */
|
||||
slugs: () => get<Record<string, SlugEntry[]>>('/slugs'),
|
||||
|
||||
/** Addresses that used to work and where they go now. Whole: there are never many. */
|
||||
redirects: () => list<Redirect>('/redirects', { timeout: 3000 }),
|
||||
|
||||
site: (lang: Locale) => get<SitePayload>('/site', { lang }),
|
||||
|
||||
services: (lang: Locale, query?: GetOptions['query']) =>
|
||||
@@ -262,6 +247,18 @@ export const pireonApi = {
|
||||
/** By slug, which only resolves in the language it belongs to. */
|
||||
page: (lang: Locale, slug: string) =>
|
||||
get<PublicPage>(`/pages/${encodeURIComponent(slug)}`, { lang }),
|
||||
|
||||
project: (lang: Locale, slug: string) =>
|
||||
get<PublicProject>(`/projects/${encodeURIComponent(slug)}`, { lang }),
|
||||
|
||||
member: (lang: Locale, slug: string) =>
|
||||
get<PublicMember>(`/members/${encodeURIComponent(slug)}`, { lang }),
|
||||
|
||||
service: (lang: Locale, slug: string) =>
|
||||
get<PublicService>(`/services/${encodeURIComponent(slug)}`, { lang }),
|
||||
|
||||
call: (lang: Locale, slug: string) =>
|
||||
get<PublicCall>(`/calls/${encodeURIComponent(slug)}`, { lang }),
|
||||
} as const;
|
||||
|
||||
/** POST bodies the public API accepts. Only two endpoints write. */
|
||||
|
||||
27
landing/src/app/core/blog.ts
Normal file
27
landing/src/app/core/blog.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* The blog is not a type of its own: a post is a page in the panel's "Blog"
|
||||
* category, written, translated, scheduled and reviewed like any other page,
|
||||
* and reachable by the MCP server the same way.
|
||||
*
|
||||
* That category has to exist with this slug in every language — the API
|
||||
* filters by the slug in the language asked for.
|
||||
*/
|
||||
import type { Locale } from './api';
|
||||
import type { PublicPage } from './api.types';
|
||||
|
||||
export const BLOG_CATEGORY: Record<Locale, string> = {
|
||||
es: 'blog',
|
||||
en: 'blog',
|
||||
ca: 'blog',
|
||||
};
|
||||
|
||||
export function isBlogPost(page: PublicPage, locale: Locale): boolean {
|
||||
return page.category?.slug === BLOG_CATEGORY[locale];
|
||||
}
|
||||
|
||||
/** Newest first, whatever order they arrived in. */
|
||||
export function byPublication(pages: PublicPage[]): PublicPage[] {
|
||||
return [...pages].sort((a, b) =>
|
||||
(b.published_at ?? '').localeCompare(a.published_at ?? ''),
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
/**
|
||||
* What a content page renders — one of the pages the panel publishes, such as
|
||||
* "Sobre nosotros" — fetched on the server in one pass.
|
||||
*
|
||||
* Shared by the two routes a page answers on, `/:slug` and `/:locale/:slug`,
|
||||
* whose `.server.ts` files only have to exist because Analog needs one next to
|
||||
* each `.page.ts`.
|
||||
*/
|
||||
import {
|
||||
pireonApi,
|
||||
type Locale,
|
||||
type MaintenanceNotice,
|
||||
type SitePayload,
|
||||
} from './api';
|
||||
import type { PublicPage } from './api.types';
|
||||
|
||||
export interface ContentPageData {
|
||||
locale: Locale;
|
||||
site: SitePayload | null;
|
||||
maintenance: MaintenanceNotice | null;
|
||||
/** Null when the API has no such page in this language: the route answers 404. */
|
||||
page: PublicPage | null;
|
||||
/** Every published page, for the header and the footer. */
|
||||
pages: PublicPage[];
|
||||
}
|
||||
|
||||
export async function loadContentPage(
|
||||
locale: Locale,
|
||||
slug: string | undefined,
|
||||
): Promise<ContentPageData> {
|
||||
const [site, page, pages] = await Promise.all([
|
||||
pireonApi.site(locale),
|
||||
slug ? pireonApi.page(locale, slug) : Promise.resolve(null),
|
||||
pireonApi.pages(locale, { per_page: 100 }),
|
||||
]);
|
||||
|
||||
return {
|
||||
locale,
|
||||
site: site.data,
|
||||
maintenance: site.maintenance,
|
||||
page: page?.data ?? null,
|
||||
pages: pages.data ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Featured pages go in the header; the rest are one click away in the footer. */
|
||||
export function pageNavLinks(
|
||||
pages: PublicPage[],
|
||||
): { href: string; label: string }[] {
|
||||
return pages
|
||||
.filter((page) => page.is_featured && page.path && page.title)
|
||||
.map((page) => ({
|
||||
href: page.path as string,
|
||||
label: page.title as string,
|
||||
}));
|
||||
}
|
||||
@@ -91,6 +91,83 @@ export interface Copy {
|
||||
};
|
||||
footer: { rights: string; pages: string };
|
||||
page: { skip: string; related: string; ctaTitle: string; ctaLead: string };
|
||||
project: {
|
||||
decisions: string;
|
||||
postmortem: string;
|
||||
gallery: string;
|
||||
details: string;
|
||||
status: string;
|
||||
client: string;
|
||||
period: string;
|
||||
lead: string;
|
||||
team: string;
|
||||
repositories: string;
|
||||
forks: string;
|
||||
dormant: string;
|
||||
archived: string;
|
||||
openCalls: string;
|
||||
technologies: string;
|
||||
};
|
||||
member: {
|
||||
projects: string;
|
||||
technologies: string;
|
||||
links: string;
|
||||
since: string;
|
||||
};
|
||||
contribute: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
lead: string;
|
||||
calls: string;
|
||||
callsLead: string;
|
||||
issues: string;
|
||||
issuesLead: string;
|
||||
noIssues: string;
|
||||
guide: string;
|
||||
steps: { title: string; text: string }[];
|
||||
otherWays: string;
|
||||
otherWaysLead: string;
|
||||
};
|
||||
blog: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
lead: string;
|
||||
empty: string;
|
||||
readMore: string;
|
||||
published: string;
|
||||
feed: string;
|
||||
};
|
||||
service: {
|
||||
eyebrow: string;
|
||||
projects: string;
|
||||
testimonials: string;
|
||||
technologies: string;
|
||||
};
|
||||
radar: {
|
||||
eyebrow: string;
|
||||
lead: string;
|
||||
ringLead: Record<'adopt' | 'trial' | 'assess' | 'hold', string>;
|
||||
categories: Record<
|
||||
'language' | 'framework' | 'datastore' | 'infrastructure' | 'tool' | 'practice',
|
||||
string
|
||||
>;
|
||||
website: string;
|
||||
projects: string;
|
||||
empty: string;
|
||||
seeAll: string;
|
||||
};
|
||||
call: {
|
||||
eyebrow: string;
|
||||
seniority: Record<'any' | 'junior' | 'mid' | 'senior', string>;
|
||||
whatYouGet: string;
|
||||
commitment: string;
|
||||
totalHours: string;
|
||||
project: string;
|
||||
apply: string;
|
||||
applyLead: string;
|
||||
unavailable: string;
|
||||
unavailableLead: string;
|
||||
};
|
||||
maintenance: { title: string; back: string; until: string };
|
||||
}
|
||||
|
||||
@@ -190,6 +267,98 @@ const es: Copy = {
|
||||
ctaTitle: '¿Hablamos?',
|
||||
ctaLead: 'Si algo de esto encaja con lo que buscas, escríbenos y te respondemos.',
|
||||
},
|
||||
project: {
|
||||
decisions: 'Decisiones técnicas',
|
||||
postmortem: 'Qué aprendimos',
|
||||
gallery: 'Galería',
|
||||
details: 'El proyecto',
|
||||
status: 'Estado',
|
||||
client: 'Para',
|
||||
period: 'Fechas',
|
||||
lead: 'Responsable',
|
||||
team: 'Equipo',
|
||||
repositories: 'Código',
|
||||
forks: 'forks',
|
||||
dormant: 'Sin actividad reciente',
|
||||
archived: 'Archivado',
|
||||
openCalls: 'Buscamos ayuda',
|
||||
technologies: 'Tecnologías',
|
||||
},
|
||||
member: {
|
||||
projects: 'Proyectos',
|
||||
technologies: 'Tecnologías',
|
||||
links: 'Enlaces',
|
||||
since: 'En el colectivo desde',
|
||||
},
|
||||
contribute: {
|
||||
eyebrow: 'Colabora',
|
||||
title: 'Tu primera contribución',
|
||||
lead: 'No hace falta ser experta ni tener mucho tiempo. Elige algo pequeño, pregunta lo que necesites y te acompañamos hasta que tu cambio esté publicado.',
|
||||
calls: 'Convocatorias abiertas',
|
||||
callsLead: 'Proyectos que buscan manos ahora mismo, con la dedicación semanal clara.',
|
||||
issues: 'Tareas para empezar',
|
||||
issuesLead: 'Issues de nuestros repositorios pensadas para quien llega por primera vez.',
|
||||
noIssues: 'Ahora mismo no hay tareas etiquetadas para empezar. Escríbenos y te buscamos una.',
|
||||
guide: 'Cómo empezar',
|
||||
steps: [
|
||||
{ title: 'Elige una tarea', text: 'Busca una etiquetada como «good first issue»: son acotadas y están bien explicadas.' },
|
||||
{ title: 'Dilo en la issue', text: 'Deja un comentario para que nadie trabaje en lo mismo. Si tienes dudas, pregúntalas ahí.' },
|
||||
{ title: 'Haz tu cambio', text: 'Crea una rama desde la principal, haz el cambio y comprueba que los tests pasan.' },
|
||||
{ title: 'Abre una pull request', text: 'Explica qué has cambiado y por qué. Enlaza la issue para que se cierre sola.' },
|
||||
{ title: 'Revisión', text: 'Alguien del colectivo revisará tu código contigo. Es una conversación, no un examen.' },
|
||||
],
|
||||
otherWays: 'Otras formas de ayudar',
|
||||
otherWaysLead: 'También necesitamos diseño, documentación, traducciones y gente que pruebe lo que hacemos. Escríbenos y lo hablamos.',
|
||||
},
|
||||
blog: {
|
||||
eyebrow: 'Blog',
|
||||
title: 'Novedades',
|
||||
lead: 'Lo que estamos haciendo, lo que hemos aprendido y lo que viene.',
|
||||
empty: 'Todavía no hemos publicado nada. Vuelve pronto.',
|
||||
readMore: 'Leer',
|
||||
published: 'Publicado el',
|
||||
feed: 'Suscríbete por RSS',
|
||||
},
|
||||
service: {
|
||||
eyebrow: 'Servicio',
|
||||
projects: 'Trabajo relacionado',
|
||||
testimonials: 'Qué dicen',
|
||||
technologies: 'Con qué lo hacemos',
|
||||
},
|
||||
radar: {
|
||||
eyebrow: 'Radar tecnológico',
|
||||
lead: 'Las herramientas con las que trabajamos y lo que opinamos de cada una: qué usamos sin dudar, qué estamos probando y qué preferimos evitar.',
|
||||
ringLead: {
|
||||
adopt: 'Lo usamos en proyectos reales y lo recomendamos sin reservas.',
|
||||
trial: 'Lo estamos probando en proyectos donde el riesgo es asumible.',
|
||||
assess: 'Nos interesa y lo estamos estudiando, sin usarlo aún en serio.',
|
||||
hold: 'Por ahora preferimos no empezar nada nuevo con esto.',
|
||||
},
|
||||
categories: {
|
||||
language: 'Lenguaje',
|
||||
framework: 'Framework',
|
||||
datastore: 'Datos',
|
||||
infrastructure: 'Infraestructura',
|
||||
tool: 'Herramienta',
|
||||
practice: 'Práctica',
|
||||
},
|
||||
website: 'Web oficial',
|
||||
projects: 'proyectos',
|
||||
empty: 'Todavía no hemos colocado ninguna tecnología en el radar.',
|
||||
seeAll: 'Ver el radar completo',
|
||||
},
|
||||
call: {
|
||||
eyebrow: 'Convocatoria',
|
||||
seniority: { any: 'Cualquier nivel', junior: 'Junior', mid: 'Intermedio', senior: 'Senior' },
|
||||
whatYouGet: 'Qué te llevas',
|
||||
commitment: 'Dedicación',
|
||||
totalHours: 'horas en total',
|
||||
project: 'Proyecto',
|
||||
apply: 'Apúntate',
|
||||
applyLead: 'Cuéntanos quién eres y qué te gustaría hacer. Te respondemos personas, no un bot.',
|
||||
unavailable: 'Esta convocatoria ya no está abierta',
|
||||
unavailableLead: 'Si te interesa colaborar igualmente, escríbenos desde el formulario general.',
|
||||
},
|
||||
maintenance: {
|
||||
title: 'Estamos de mantenimiento',
|
||||
back: 'Volvemos en un rato.',
|
||||
@@ -293,6 +462,98 @@ const en: Copy = {
|
||||
ctaTitle: 'Shall we talk?',
|
||||
ctaLead: 'If any of this fits what you are looking for, write to us and we will get back to you.',
|
||||
},
|
||||
project: {
|
||||
decisions: 'Technical decisions',
|
||||
postmortem: 'What we learned',
|
||||
gallery: 'Gallery',
|
||||
details: 'The project',
|
||||
status: 'Status',
|
||||
client: 'For',
|
||||
period: 'Dates',
|
||||
lead: 'Lead',
|
||||
team: 'Team',
|
||||
repositories: 'Code',
|
||||
forks: 'forks',
|
||||
dormant: 'No recent activity',
|
||||
archived: 'Archived',
|
||||
openCalls: 'Looking for help',
|
||||
technologies: 'Technologies',
|
||||
},
|
||||
member: {
|
||||
projects: 'Projects',
|
||||
technologies: 'Technologies',
|
||||
links: 'Links',
|
||||
since: 'In the collective since',
|
||||
},
|
||||
contribute: {
|
||||
eyebrow: 'Contribute',
|
||||
title: 'Your first contribution',
|
||||
lead: 'You do not need to be an expert or have lots of time. Pick something small, ask whatever you need and we will help you all the way until your change is live.',
|
||||
calls: 'Open calls',
|
||||
callsLead: 'Projects looking for hands right now, with the weekly commitment stated up front.',
|
||||
issues: 'Good first issues',
|
||||
issuesLead: 'Issues in our repositories meant for people contributing for the first time.',
|
||||
noIssues: 'There are no issues labelled for newcomers right now. Write to us and we will find you one.',
|
||||
guide: 'How to get started',
|
||||
steps: [
|
||||
{ title: 'Pick a task', text: 'Look for one labelled "good first issue": they are well scoped and clearly explained.' },
|
||||
{ title: 'Say so on the issue', text: 'Leave a comment so nobody works on the same thing. If you have questions, ask them there.' },
|
||||
{ title: 'Make your change', text: 'Branch off the main branch, make the change and check that the tests pass.' },
|
||||
{ title: 'Open a pull request', text: 'Explain what you changed and why. Link the issue so it closes on its own.' },
|
||||
{ title: 'Review', text: 'Someone from the collective will go through your code with you. It is a conversation, not an exam.' },
|
||||
],
|
||||
otherWays: 'Other ways to help',
|
||||
otherWaysLead: 'We also need design, documentation, translations and people to try what we build. Write to us and we will talk it over.',
|
||||
},
|
||||
blog: {
|
||||
eyebrow: 'Blog',
|
||||
title: 'News',
|
||||
lead: 'What we are working on, what we have learned and what comes next.',
|
||||
empty: 'We have not published anything yet. Come back soon.',
|
||||
readMore: 'Read',
|
||||
published: 'Published on',
|
||||
feed: 'Subscribe via RSS',
|
||||
},
|
||||
service: {
|
||||
eyebrow: 'Service',
|
||||
projects: 'Related work',
|
||||
testimonials: 'What people say',
|
||||
technologies: 'What we use',
|
||||
},
|
||||
radar: {
|
||||
eyebrow: 'Tech radar',
|
||||
lead: 'The tools we work with and what we think of each: what we use without a second thought, what we are trying out and what we would rather avoid.',
|
||||
ringLead: {
|
||||
adopt: 'We use it on real projects and recommend it without reservation.',
|
||||
trial: 'We are trying it out on projects where the risk is acceptable.',
|
||||
assess: 'It interests us and we are looking into it, without using it seriously yet.',
|
||||
hold: 'For now we would rather not start anything new with it.',
|
||||
},
|
||||
categories: {
|
||||
language: 'Language',
|
||||
framework: 'Framework',
|
||||
datastore: 'Data',
|
||||
infrastructure: 'Infrastructure',
|
||||
tool: 'Tool',
|
||||
practice: 'Practice',
|
||||
},
|
||||
website: 'Official website',
|
||||
projects: 'projects',
|
||||
empty: 'We have not placed any technology on the radar yet.',
|
||||
seeAll: 'See the full radar',
|
||||
},
|
||||
call: {
|
||||
eyebrow: 'Open call',
|
||||
seniority: { any: 'Any level', junior: 'Junior', mid: 'Mid-level', senior: 'Senior' },
|
||||
whatYouGet: 'What you get',
|
||||
commitment: 'Commitment',
|
||||
totalHours: 'hours in total',
|
||||
project: 'Project',
|
||||
apply: 'Apply',
|
||||
applyLead: 'Tell us who you are and what you would like to do. A person will answer, not a bot.',
|
||||
unavailable: 'This call is no longer open',
|
||||
unavailableLead: 'If you would like to help anyway, write to us from the general form.',
|
||||
},
|
||||
maintenance: {
|
||||
title: 'We are under maintenance',
|
||||
back: 'Back in a while.',
|
||||
@@ -396,6 +657,98 @@ const ca: Copy = {
|
||||
ctaTitle: 'En parlem?',
|
||||
ctaLead: "Si alguna cosa d'aquestes encaixa amb el que busques, escriu-nos i et respondrem.",
|
||||
},
|
||||
project: {
|
||||
decisions: 'Decisions tècniques',
|
||||
postmortem: 'Què vam aprendre',
|
||||
gallery: 'Galeria',
|
||||
details: 'El projecte',
|
||||
status: 'Estat',
|
||||
client: 'Per a',
|
||||
period: 'Dates',
|
||||
lead: 'Responsable',
|
||||
team: 'Equip',
|
||||
repositories: 'Codi',
|
||||
forks: 'forks',
|
||||
dormant: 'Sense activitat recent',
|
||||
archived: 'Arxivat',
|
||||
openCalls: 'Busquem ajuda',
|
||||
technologies: 'Tecnologies',
|
||||
},
|
||||
member: {
|
||||
projects: 'Projectes',
|
||||
technologies: 'Tecnologies',
|
||||
links: 'Enllaços',
|
||||
since: 'Al col·lectiu des de',
|
||||
},
|
||||
contribute: {
|
||||
eyebrow: 'Col·labora',
|
||||
title: 'La teva primera contribució',
|
||||
lead: "No cal ser experta ni tenir gaire temps. Tria alguna cosa petita, pregunta el que necessitis i t'acompanyem fins que el teu canvi estigui publicat.",
|
||||
calls: 'Convocatòries obertes',
|
||||
callsLead: 'Projectes que busquen mans ara mateix, amb la dedicació setmanal clara.',
|
||||
issues: 'Tasques per començar',
|
||||
issuesLead: 'Issues dels nostres repositoris pensades per a qui arriba per primera vegada.',
|
||||
noIssues: "Ara mateix no hi ha tasques etiquetades per començar. Escriu-nos i te'n buscarem una.",
|
||||
guide: 'Com començar',
|
||||
steps: [
|
||||
{ title: 'Tria una tasca', text: "Busca'n una etiquetada com a «good first issue»: són acotades i estan ben explicades." },
|
||||
{ title: 'Digues-ho a la issue', text: 'Deixa un comentari perquè ningú treballi en el mateix. Si tens dubtes, pregunta-ho allà.' },
|
||||
{ title: 'Fes el teu canvi', text: 'Crea una branca des de la principal, fes el canvi i comprova que els tests passen.' },
|
||||
{ title: 'Obre una pull request', text: 'Explica què has canviat i per què. Enllaça la issue perquè es tanqui sola.' },
|
||||
{ title: 'Revisió', text: 'Algú del col·lectiu revisarà el teu codi amb tu. És una conversa, no un examen.' },
|
||||
],
|
||||
otherWays: "Altres maneres d'ajudar",
|
||||
otherWaysLead: 'També necessitem disseny, documentació, traduccions i gent que provi el que fem. Escriu-nos i en parlem.',
|
||||
},
|
||||
blog: {
|
||||
eyebrow: 'Blog',
|
||||
title: 'Novetats',
|
||||
lead: 'El que estem fent, el que hem après i el que ve.',
|
||||
empty: 'Encara no hem publicat res. Torna aviat.',
|
||||
readMore: 'Llegir',
|
||||
published: 'Publicat el',
|
||||
feed: "Subscriu-t'hi per RSS",
|
||||
},
|
||||
service: {
|
||||
eyebrow: 'Servei',
|
||||
projects: 'Feina relacionada',
|
||||
testimonials: 'Què en diuen',
|
||||
technologies: 'Amb què ho fem',
|
||||
},
|
||||
radar: {
|
||||
eyebrow: 'Radar tecnològic',
|
||||
lead: "Les eines amb què treballem i què en pensem de cadascuna: què fem servir sense dubtar, què estem provant i què preferim evitar.",
|
||||
ringLead: {
|
||||
adopt: 'El fem servir en projectes reals i el recomanem sense reserves.',
|
||||
trial: "L'estem provant en projectes on el risc és assumible.",
|
||||
assess: "Ens interessa i l'estem estudiant, sense fer-lo servir encara seriosament.",
|
||||
hold: 'Ara per ara preferim no començar res de nou amb això.',
|
||||
},
|
||||
categories: {
|
||||
language: 'Llenguatge',
|
||||
framework: 'Framework',
|
||||
datastore: 'Dades',
|
||||
infrastructure: 'Infraestructura',
|
||||
tool: 'Eina',
|
||||
practice: 'Pràctica',
|
||||
},
|
||||
website: 'Web oficial',
|
||||
projects: 'projectes',
|
||||
empty: 'Encara no hem col·locat cap tecnologia al radar.',
|
||||
seeAll: 'Veure el radar sencer',
|
||||
},
|
||||
call: {
|
||||
eyebrow: 'Convocatòria',
|
||||
seniority: { any: 'Qualsevol nivell', junior: 'Júnior', mid: 'Intermedi', senior: 'Sènior' },
|
||||
whatYouGet: "Què t'emportes",
|
||||
commitment: 'Dedicació',
|
||||
totalHours: 'hores en total',
|
||||
project: 'Projecte',
|
||||
apply: "Apunta-t'hi",
|
||||
applyLead: "Explica'ns qui ets i què t'agradaria fer. Et responen persones, no un bot.",
|
||||
unavailable: 'Aquesta convocatòria ja no està oberta',
|
||||
unavailableLead: "Si igualment t'interessa col·laborar, escriu-nos des del formulari general.",
|
||||
},
|
||||
maintenance: {
|
||||
title: 'Estem de manteniment',
|
||||
back: 'Tornem d’aquí una estona.',
|
||||
|
||||
42
landing/src/app/core/head.spec.ts
Normal file
42
landing/src/app/core/head.spec.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { absoluteUrl, setStructuredData } from './head';
|
||||
|
||||
describe('absoluteUrl', () => {
|
||||
it('puts a path on the origin and leaves a URL alone', () => {
|
||||
expect(absoluteUrl('https://pireon.dev', '/logo.png')).toBe(
|
||||
'https://pireon.dev/logo.png',
|
||||
);
|
||||
expect(absoluteUrl('https://pireon.dev', 'https://cdn.example/a.png')).toBe(
|
||||
'https://cdn.example/a.png',
|
||||
);
|
||||
expect(absoluteUrl(null, '/logo.png')).toBe('/logo.png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setStructuredData', () => {
|
||||
it("writes one script, replacing the previous page's", () => {
|
||||
setStructuredData(document, { '@type': 'Person', name: 'Ada' });
|
||||
setStructuredData(document, { '@type': 'Service', name: 'Formación' });
|
||||
|
||||
const scripts = document.head.querySelectorAll(
|
||||
'script[type="application/ld+json"]',
|
||||
);
|
||||
|
||||
expect(scripts).toHaveLength(1);
|
||||
expect(JSON.parse(scripts[0].textContent ?? '')).toEqual({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Service',
|
||||
name: 'Formación',
|
||||
});
|
||||
});
|
||||
|
||||
it('cannot be closed early by what it carries', () => {
|
||||
setStructuredData(document, { name: '</script><script>alert(1)</script>' });
|
||||
|
||||
expect(
|
||||
document.head.querySelector('script[type="application/ld+json"]')
|
||||
?.textContent,
|
||||
).not.toContain('</script>');
|
||||
});
|
||||
});
|
||||
56
landing/src/app/core/head.ts
Normal file
56
landing/src/app/core/head.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* The parts of `<head>` that describe a page to machines rather than people:
|
||||
* the preview a chat app or social network draws, and schema.org data a
|
||||
* search engine reads. Shared by the landing and every page reached by its
|
||||
* address, so both say it the same way.
|
||||
*/
|
||||
import type { Meta } from '@angular/platform-browser';
|
||||
|
||||
const JSON_LD_ID = 'page-structured-data';
|
||||
|
||||
/** Relative paths become absolute on `origin`; absolute URLs pass through. */
|
||||
export function absoluteUrl(origin: string | null, href: string): string {
|
||||
return /^https?:\/\//.test(href) || !origin ? href : `${origin}${href}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `og:image` and the card size that goes with it. Without an image of the
|
||||
* record's own, the site's logo, as a small card rather than a stretched one.
|
||||
*/
|
||||
export function setShareImage(
|
||||
meta: Meta,
|
||||
origin: string | null,
|
||||
image?: string | null,
|
||||
): void {
|
||||
meta.updateTag({
|
||||
property: 'og:image',
|
||||
content: absoluteUrl(origin, image || '/logo.png'),
|
||||
});
|
||||
meta.updateTag({
|
||||
name: 'twitter:card',
|
||||
content: image ? 'summary_large_image' : 'summary',
|
||||
});
|
||||
}
|
||||
|
||||
/** One `<script type="application/ld+json">`, replaced rather than stacked. */
|
||||
export function setStructuredData(
|
||||
document: Document,
|
||||
data: Record<string, unknown> | null,
|
||||
): void {
|
||||
document.getElementById(JSON_LD_ID)?.remove();
|
||||
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
|
||||
script.id = JSON_LD_ID;
|
||||
script.setAttribute('type', 'application/ld+json');
|
||||
// `<` escaped so no string in the data can close the script element.
|
||||
script.textContent = JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
...data,
|
||||
}).replace(/</g, '\\u003c');
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
38
landing/src/app/core/origin.spec.ts
Normal file
38
landing/src/app/core/origin.spec.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { originFromHeaders } from './origin';
|
||||
|
||||
describe('originFromHeaders', () => {
|
||||
it('believes the proxy over the host it connected to', () => {
|
||||
expect(
|
||||
originFromHeaders({
|
||||
host: 'landing:3000',
|
||||
'x-forwarded-host': 'pireon.dev',
|
||||
'x-forwarded-proto': 'https',
|
||||
}),
|
||||
).toBe('https://pireon.dev');
|
||||
});
|
||||
|
||||
it('takes the first hop of a list', () => {
|
||||
expect(
|
||||
originFromHeaders({
|
||||
'x-forwarded-host': 'pireon.dev, internal',
|
||||
'x-forwarded-proto': 'https,http',
|
||||
}),
|
||||
).toBe('https://pireon.dev');
|
||||
});
|
||||
|
||||
it('assumes plain HTTP only on a developer machine', () => {
|
||||
expect(originFromHeaders({ host: 'localhost:4200' })).toBe(
|
||||
'http://localhost:4200',
|
||||
);
|
||||
expect(originFromHeaders({ host: 'pireon.dev' })).toBe(
|
||||
'https://pireon.dev',
|
||||
);
|
||||
});
|
||||
|
||||
it('has nothing to say without headers', () => {
|
||||
expect(originFromHeaders(undefined)).toBeNull();
|
||||
expect(originFromHeaders({})).toBeNull();
|
||||
});
|
||||
});
|
||||
27
landing/src/app/core/origin.ts
Normal file
27
landing/src/app/core/origin.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* The origin a visitor used, as the reverse proxy in front of this app reports
|
||||
* it. Absolute URLs — `canonical`, `hreflang`, the sitemap, `og:image` — need
|
||||
* one, and this app has no configured address of its own: it answers on
|
||||
* whichever domain the proxy sends it.
|
||||
*/
|
||||
export type HeaderBag = Record<string, string | string[] | undefined>;
|
||||
|
||||
export function originFromHeaders(
|
||||
headers: HeaderBag | undefined,
|
||||
): string | null {
|
||||
if (!headers) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const first = (value: string | string[] | undefined) =>
|
||||
(Array.isArray(value) ? value[0] : value)?.split(',')[0]?.trim();
|
||||
|
||||
const host = first(headers['x-forwarded-host']) ?? first(headers['host']);
|
||||
// Behind the proxy the header is always there; without one this is a
|
||||
// developer's own machine, which does not speak TLS.
|
||||
const protocol =
|
||||
first(headers['x-forwarded-proto']) ??
|
||||
(/^(localhost|127\.)/.test(host ?? '') ? 'http' : 'https');
|
||||
|
||||
return host ? `${protocol}://${host}` : null;
|
||||
}
|
||||
40
landing/src/app/core/redirects.spec.ts
Normal file
40
landing/src/app/core/redirects.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { findRedirect } from './redirects';
|
||||
|
||||
const redirects = [
|
||||
{ from: '/proyectos/viejo', to: '/proyectos/nuevo', status: 301 as const },
|
||||
{
|
||||
from: '/ca/projectes/antic/',
|
||||
to: '/ca/projectes/nou',
|
||||
status: 302 as const,
|
||||
},
|
||||
{ from: '/bucle', to: '/bucle/', status: 301 as const },
|
||||
];
|
||||
|
||||
describe('findRedirect', () => {
|
||||
it('sends an old address to the new one, with its status', () => {
|
||||
expect(findRedirect('/proyectos/viejo', redirects)).toEqual({
|
||||
to: '/proyectos/nuevo',
|
||||
status: 301,
|
||||
});
|
||||
expect(findRedirect('/ca/projectes/antic', redirects)).toEqual({
|
||||
to: '/ca/projectes/nou',
|
||||
status: 302,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a trailing slash as the same address', () => {
|
||||
expect(findRedirect('/proyectos/viejo/', redirects)?.to).toBe(
|
||||
'/proyectos/nuevo',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves every other address alone', () => {
|
||||
expect(findRedirect('/proyectos/nuevo', redirects)).toBeNull();
|
||||
});
|
||||
|
||||
it('never answers with a loop', () => {
|
||||
expect(findRedirect('/bucle', redirects)).toBeNull();
|
||||
});
|
||||
});
|
||||
28
landing/src/app/core/redirects.ts
Normal file
28
landing/src/app/core/redirects.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Which redirect, if any, an address answers with.
|
||||
*
|
||||
* The panel writes one the moment somebody renames a slug — the only moment
|
||||
* the old address is still known — so a link shared before the rename lands
|
||||
* on the record instead of a 404.
|
||||
*/
|
||||
import type { Redirect } from './api';
|
||||
|
||||
/** Trailing slashes are the same address; everything else must match exactly. */
|
||||
function normalise(path: string): string {
|
||||
return path.length > 1 ? path.replace(/\/+$/, '') : path;
|
||||
}
|
||||
|
||||
export function findRedirect(
|
||||
pathname: string,
|
||||
redirects: Redirect[],
|
||||
): Pick<Redirect, 'to' | 'status'> | null {
|
||||
const path = normalise(pathname);
|
||||
const match = redirects.find((redirect) => normalise(redirect.from) === path);
|
||||
|
||||
// One pointing at itself would loop forever in the visitor's browser.
|
||||
if (!match || normalise(match.to) === path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { to: match.to, status: match.status === 302 ? 302 : 301 };
|
||||
}
|
||||
148
landing/src/app/core/routed-page.ts
Normal file
148
landing/src/app/core/routed-page.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* What a page reached by its address renders — a content page, a project, a
|
||||
* member — fetched on the server in one pass.
|
||||
*
|
||||
* Shared by the routes that differ only in how many segments the address has
|
||||
* (`/sobre-nosotros`, `/proyectos/x`, `/en/projects/x`), whose `.server.ts`
|
||||
* files exist because Analog needs one next to each `.page.ts`.
|
||||
*/
|
||||
import {
|
||||
pireonApi,
|
||||
DEFAULT_LOCALE,
|
||||
type Locale,
|
||||
type MaintenanceNotice,
|
||||
type SitePayload,
|
||||
} from './api';
|
||||
import type {
|
||||
PublicCall,
|
||||
PublicIssue,
|
||||
PublicMember,
|
||||
PublicPage,
|
||||
PublicProject,
|
||||
PublicService,
|
||||
PublicTechnology,
|
||||
} from './api.types';
|
||||
import { BLOG_CATEGORY, byPublication, isBlogPost } from './blog';
|
||||
import { resolveRoute, type RouteMatch } from './routes';
|
||||
|
||||
/** The record, tagged with its type so the template can switch on it. */
|
||||
export type RoutedRecord =
|
||||
| { type: 'page'; record: PublicPage }
|
||||
| { type: 'project'; record: PublicProject }
|
||||
| { type: 'member'; record: PublicMember }
|
||||
| { type: 'call'; record: PublicCall }
|
||||
| { type: 'service'; record: PublicService }
|
||||
| { type: 'radar'; record: PublicTechnology[] }
|
||||
| { type: 'blog'; record: PublicPage[] }
|
||||
| {
|
||||
type: 'contribute';
|
||||
record: { calls: PublicCall[]; issues: PublicIssue[] };
|
||||
};
|
||||
|
||||
export interface RoutedPageData {
|
||||
locale: Locale;
|
||||
site: SitePayload | null;
|
||||
maintenance: MaintenanceNotice | null;
|
||||
/** Null when there is nothing at this address: the route answers 404. */
|
||||
found: RoutedRecord | null;
|
||||
/** Every published page, for the header and the footer. */
|
||||
pages: PublicPage[];
|
||||
}
|
||||
|
||||
export async function loadRoutedPage(
|
||||
pathname: string,
|
||||
): Promise<RoutedPageData> {
|
||||
const route = resolveRoute(pathname);
|
||||
const locale = route?.locale ?? DEFAULT_LOCALE;
|
||||
|
||||
const [site, found, pages] = await Promise.all([
|
||||
pireonApi.site(locale),
|
||||
route ? fetchRecord(route) : Promise.resolve(null),
|
||||
pireonApi.pages(locale, { per_page: 100 }),
|
||||
]);
|
||||
|
||||
return {
|
||||
locale,
|
||||
site: site.data,
|
||||
maintenance: site.maintenance,
|
||||
found,
|
||||
pages: pages.data ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchRecord(route: RouteMatch): Promise<RoutedRecord | null> {
|
||||
const { locale, slug } = route;
|
||||
|
||||
switch (route.type) {
|
||||
case 'page': {
|
||||
const { data } = await pireonApi.page(locale, slug);
|
||||
return data ? { type: 'page', record: data } : null;
|
||||
}
|
||||
case 'project': {
|
||||
const { data } = await pireonApi.project(locale, slug);
|
||||
return data ? { type: 'project', record: data } : null;
|
||||
}
|
||||
case 'member': {
|
||||
const { data } = await pireonApi.member(locale, slug);
|
||||
return data ? { type: 'member', record: data } : null;
|
||||
}
|
||||
case 'call': {
|
||||
const { data } = await pireonApi.call(locale, slug);
|
||||
return data ? { type: 'call', record: data } : null;
|
||||
}
|
||||
case 'service': {
|
||||
const { data } = await pireonApi.service(locale, slug);
|
||||
return data ? { type: 'service', record: data } : null;
|
||||
}
|
||||
case 'blog': {
|
||||
const { data, error } = await pireonApi.pages(locale, {
|
||||
category: BLOG_CATEGORY[locale],
|
||||
per_page: 50,
|
||||
});
|
||||
// An empty blog is still the blog: the page says so rather than 404.
|
||||
return error ? null : { type: 'blog', record: byPublication(data ?? []) };
|
||||
}
|
||||
case 'contribute': {
|
||||
const [calls, issues] = await Promise.all([
|
||||
pireonApi.calls(locale),
|
||||
pireonApi.goodFirstIssues({ limit: 30 }),
|
||||
]);
|
||||
// Nothing open is still an answer: the page says how to help anyway.
|
||||
return {
|
||||
type: 'contribute',
|
||||
record: { calls: calls.data ?? [], issues: issues.data ?? [] },
|
||||
};
|
||||
}
|
||||
case 'radar': {
|
||||
// An empty radar is still the radar: the page says so rather than 404.
|
||||
const { data, error } = await pireonApi.radar(locale);
|
||||
return error ? null : { type: 'radar', record: data ?? [] };
|
||||
}
|
||||
default:
|
||||
// A type the site has no page for yet: an address the API hands out
|
||||
// but nothing here renders, which is a 404 until it does.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Featured pages go in the header; the rest are one click away in the footer.
|
||||
* Blog posts are neither: they have the blog.
|
||||
*/
|
||||
export function pageNavLinks(
|
||||
pages: PublicPage[],
|
||||
locale: Locale,
|
||||
): { href: string; label: string }[] {
|
||||
return pages
|
||||
.filter(
|
||||
(page) =>
|
||||
page.is_featured &&
|
||||
page.path &&
|
||||
page.title &&
|
||||
!isBlogPost(page, locale),
|
||||
)
|
||||
.map((page) => ({
|
||||
href: page.path as string,
|
||||
label: page.title as string,
|
||||
}));
|
||||
}
|
||||
129
landing/src/app/core/routes.spec.ts
Normal file
129
landing/src/app/core/routes.spec.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { DEFAULT_LOCALE, SUPPORTED_LOCALES } from './api';
|
||||
import {
|
||||
homeHref,
|
||||
listingAlternates,
|
||||
listingPath,
|
||||
resolveRoute,
|
||||
SECTIONS,
|
||||
} from './routes';
|
||||
|
||||
describe('resolveRoute', () => {
|
||||
it('reads a page at the root as the default language', () => {
|
||||
expect(resolveRoute('/sobre-nosotros')).toEqual({
|
||||
type: 'page',
|
||||
locale: DEFAULT_LOCALE,
|
||||
slug: 'sobre-nosotros',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads a prefixed page in the language it names', () => {
|
||||
expect(resolveRoute('/en/about-us')).toEqual({
|
||||
type: 'page',
|
||||
locale: 'en',
|
||||
slug: 'about-us',
|
||||
});
|
||||
expect(resolveRoute('/ca/qui-som/')).toEqual({
|
||||
type: 'page',
|
||||
locale: 'ca',
|
||||
slug: 'qui-som',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads a record under its section, in each language', () => {
|
||||
expect(resolveRoute('/proyectos/catalogo')).toEqual({
|
||||
type: 'project',
|
||||
locale: 'es',
|
||||
slug: 'catalogo',
|
||||
});
|
||||
expect(resolveRoute('/en/projects/catalogo')).toEqual({
|
||||
type: 'project',
|
||||
locale: 'en',
|
||||
slug: 'catalogo',
|
||||
});
|
||||
expect(resolveRoute('/ca/equip/ada')).toEqual({
|
||||
type: 'member',
|
||||
locale: 'ca',
|
||||
slug: 'ada',
|
||||
});
|
||||
expect(resolveRoute('/ca/col-labora/frontend')).toEqual({
|
||||
type: 'call',
|
||||
locale: 'ca',
|
||||
slug: 'frontend',
|
||||
});
|
||||
});
|
||||
|
||||
// A section only means something in its own language: `/projects/x` is not
|
||||
// an address the API hands out for Spanish.
|
||||
it('refuses a section spelled in another language', () => {
|
||||
expect(resolveRoute('/projects/catalogo')).toBeNull();
|
||||
expect(resolveRoute('/en/proyectos/catalogo')).toBeNull();
|
||||
});
|
||||
|
||||
// The API never spells the default language with a prefix, so one record
|
||||
// has one address rather than two.
|
||||
it('refuses the default language as a prefix', () => {
|
||||
expect(resolveRoute('/es/sobre-nosotros')).toBeNull();
|
||||
expect(resolveRoute('/es/proyectos/catalogo')).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses what is not shaped like a record', () => {
|
||||
expect(resolveRoute('/')).toBeNull();
|
||||
expect(resolveRoute('/fr/a-propos')).toBeNull();
|
||||
expect(resolveRoute('/proyectos/catalogo/extra')).toBeNull();
|
||||
});
|
||||
|
||||
it('knows every section in every language', () => {
|
||||
for (const sections of Object.values(SECTIONS)) {
|
||||
expect(Object.keys(sections).sort()).toEqual(
|
||||
[...SUPPORTED_LOCALES].sort(),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('listings', () => {
|
||||
it('reads the radar in every language, before any page of that slug', () => {
|
||||
expect(resolveRoute('/radar')).toEqual({
|
||||
type: 'radar',
|
||||
locale: 'es',
|
||||
slug: '',
|
||||
});
|
||||
expect(resolveRoute('/ca/radar')).toEqual({
|
||||
type: 'radar',
|
||||
locale: 'ca',
|
||||
slug: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds the address resolveRoute reads back', () => {
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
expect(resolveRoute(listingPath('radar', locale))?.type).toBe('radar');
|
||||
}
|
||||
|
||||
expect(listingAlternates('radar')).toEqual({
|
||||
es: '/radar',
|
||||
en: '/en/radar',
|
||||
ca: '/ca/radar',
|
||||
});
|
||||
});
|
||||
|
||||
// The calls' section with nothing after it is the list of ways to help.
|
||||
it('reads a call section on its own as the contribute listing', () => {
|
||||
expect(resolveRoute('/colabora')).toEqual({
|
||||
type: 'contribute',
|
||||
locale: 'es',
|
||||
slug: '',
|
||||
});
|
||||
expect(resolveRoute('/en/contribute')?.type).toBe('contribute');
|
||||
expect(resolveRoute('/en/contribute/frontend')?.type).toBe('call');
|
||||
});
|
||||
});
|
||||
|
||||
describe('homeHref', () => {
|
||||
it('answers on ?lang= for every language but the default', () => {
|
||||
expect(homeHref(DEFAULT_LOCALE)).toBe('/');
|
||||
expect(homeHref('ca')).toBe('/?lang=ca');
|
||||
});
|
||||
});
|
||||
106
landing/src/app/core/routes.ts
Normal file
106
landing/src/app/core/routes.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Which record an address on this site belongs to.
|
||||
*
|
||||
* The API decides every record's address — it sends `path` and `alternates`
|
||||
* with each one — and this is the same rule read backwards. The default
|
||||
* language has no prefix, the others their code; pages sit at the root, every
|
||||
* other type under a section named in the visitor's language:
|
||||
*
|
||||
* /sobre-nosotros /en/about-us /ca/qui-som
|
||||
* /proyectos/catalogo /en/projects/catalogo /ca/projectes/catalogo
|
||||
*
|
||||
* Only the shape is known here. Whether the record exists is the API's answer.
|
||||
*/
|
||||
import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type Locale } from './api';
|
||||
|
||||
export type RecordType = 'page' | 'project' | 'member' | 'call' | 'service';
|
||||
|
||||
/** Pages this site composes itself rather than one record's: no slug. */
|
||||
export type ListingType = 'radar' | 'contribute' | 'blog';
|
||||
|
||||
/**
|
||||
* The section each type lives under, per language. The API's own
|
||||
* `config/site.php` (`paths`) is the source; a change there has to be made here
|
||||
* too, or the API will hand out addresses this site answers 404 on.
|
||||
*/
|
||||
export const SECTIONS: Record<
|
||||
Exclude<RecordType, 'page'>,
|
||||
Record<Locale, string>
|
||||
> = {
|
||||
project: { es: 'proyectos', en: 'projects', ca: 'projectes' },
|
||||
member: { es: 'equipo', en: 'team', ca: 'equip' },
|
||||
call: { es: 'colabora', en: 'contribute', ca: 'col-labora' },
|
||||
service: { es: 'servicios', en: 'services', ca: 'serveis' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Where each listing lives, per language. A panel page with the same slug
|
||||
* would be shadowed by it, so these are few and deliberately named.
|
||||
*/
|
||||
export const LISTINGS: Record<ListingType, Record<Locale, string>> = {
|
||||
radar: { es: 'radar', en: 'radar', ca: 'radar' },
|
||||
// The calls' own section, so /colabora lists what /colabora/{slug} are.
|
||||
contribute: { es: 'colabora', en: 'contribute', ca: 'col-labora' },
|
||||
blog: { es: 'blog', en: 'blog', ca: 'blog' },
|
||||
};
|
||||
|
||||
export interface RouteMatch {
|
||||
type: RecordType | ListingType;
|
||||
locale: Locale;
|
||||
/** Empty for a listing. */
|
||||
slug: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A prefix naming the default language (`/es/…`) is not a shape the API ever
|
||||
* produces, so it is not one this answers on either — one record, one address.
|
||||
*/
|
||||
export function resolveRoute(pathname: string): RouteMatch | null {
|
||||
const segments = pathname.replace(/\/+$/, '').split('/').filter(Boolean);
|
||||
const [first] = segments;
|
||||
|
||||
const prefixed =
|
||||
first !== DEFAULT_LOCALE && SUPPORTED_LOCALES.includes(first as Locale);
|
||||
|
||||
const locale = prefixed ? (first as Locale) : DEFAULT_LOCALE;
|
||||
const rest = prefixed ? segments.slice(1) : segments;
|
||||
|
||||
if (rest.length === 1) {
|
||||
const listing = (Object.keys(LISTINGS) as ListingType[]).find(
|
||||
(candidate) => LISTINGS[candidate][locale] === rest[0],
|
||||
);
|
||||
|
||||
return listing
|
||||
? { type: listing, locale, slug: '' }
|
||||
: { type: 'page', locale, slug: rest[0] };
|
||||
}
|
||||
|
||||
if (rest.length === 2) {
|
||||
const type = (Object.keys(SECTIONS) as (keyof typeof SECTIONS)[]).find(
|
||||
(candidate) => SECTIONS[candidate][locale] === rest[0],
|
||||
);
|
||||
|
||||
return type ? { type, locale, slug: rest[1] } : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A listing's address in one language, as `resolveRoute` reads it back. */
|
||||
export function listingPath(listing: ListingType, locale: Locale): string {
|
||||
return `${locale === DEFAULT_LOCALE ? '' : `/${locale}`}/${LISTINGS[listing][locale]}`;
|
||||
}
|
||||
|
||||
/** The same listing in every language, for `hreflang`. */
|
||||
export function listingAlternates(
|
||||
listing: ListingType,
|
||||
): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
SUPPORTED_LOCALES.map((locale) => [locale, listingPath(listing, locale)]),
|
||||
);
|
||||
}
|
||||
|
||||
/** The landing itself in a given language. It answers on `?lang=`, not a prefix. */
|
||||
export function homeHref(locale: Locale): string {
|
||||
return locale === DEFAULT_LOCALE ? '/' : `/?lang=${locale}`;
|
||||
}
|
||||
56
landing/src/app/core/rss.spec.ts
Normal file
56
landing/src/app/core/rss.spec.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { PublicPage } from './api.types';
|
||||
import { byPublication, isBlogPost } from './blog';
|
||||
import { buildFeed } from './rss';
|
||||
|
||||
const post = (id: number, title: string, published_at: string, slug = 'blog') =>
|
||||
({
|
||||
id,
|
||||
title,
|
||||
path: `/${title.toLowerCase().replace(/\s/g, '-')}`,
|
||||
excerpt: 'Resumen con <etiquetas> & símbolos',
|
||||
published_at,
|
||||
category: { id: 9, slug, name: 'Blog' },
|
||||
}) as unknown as PublicPage;
|
||||
|
||||
describe('blog', () => {
|
||||
it("knows a post by its category, in the visitor's language", () => {
|
||||
expect(isBlogPost(post(1, 'Hola', '2026-01-01'), 'es')).toBe(true);
|
||||
expect(isBlogPost(post(2, 'Sobre', '2026-01-01', 'colectivo'), 'es')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('orders posts newest first', () => {
|
||||
const ordered = byPublication([
|
||||
post(1, 'Viejo', '2026-01-01'),
|
||||
post(2, 'Nuevo', '2026-09-01'),
|
||||
]);
|
||||
|
||||
expect(ordered.map((p) => p.title)).toEqual(['Nuevo', 'Viejo']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFeed', () => {
|
||||
const xml = buildFeed(
|
||||
'https://pireon.dev',
|
||||
'es',
|
||||
{ title: 'Pireon — Blog', description: 'Novedades', link: '/blog' },
|
||||
[post(1, 'Primer post', '2026-09-01T10:00:00Z')],
|
||||
);
|
||||
|
||||
it('links every item absolutely, with its date', () => {
|
||||
expect(xml).toContain('<link>https://pireon.dev/primer-post</link>');
|
||||
expect(xml).toContain('<pubDate>Tue, 01 Sep 2026 10:00:00 GMT</pubDate>');
|
||||
});
|
||||
|
||||
it('escapes what it carries and parses as XML', () => {
|
||||
expect(xml).toContain('<etiquetas> & símbolos');
|
||||
expect(
|
||||
new DOMParser()
|
||||
.parseFromString(xml, 'application/xml')
|
||||
.getElementsByTagName('parsererror'),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
56
landing/src/app/core/rss.ts
Normal file
56
landing/src/app/core/rss.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* The blog as RSS 2.0, for feed readers and for anything that republishes it.
|
||||
*/
|
||||
import type { Locale } from './api';
|
||||
import type { PublicPage } from './api.types';
|
||||
|
||||
export function buildFeed(
|
||||
origin: string,
|
||||
locale: Locale,
|
||||
channel: { title: string; description: string; link: string },
|
||||
posts: PublicPage[],
|
||||
): string {
|
||||
const items = posts.map((post) => {
|
||||
const link = `${origin}${post.path ?? ''}`;
|
||||
|
||||
return [
|
||||
' <item>',
|
||||
` <title>${escape(post.title ?? '')}</title>`,
|
||||
` <link>${escape(link)}</link>`,
|
||||
` <guid isPermaLink="true">${escape(link)}</guid>`,
|
||||
...(post.published_at
|
||||
? [
|
||||
` <pubDate>${new Date(post.published_at).toUTCString()}</pubDate>`,
|
||||
]
|
||||
: []),
|
||||
...(post.excerpt
|
||||
? [` <description>${escape(post.excerpt)}</description>`]
|
||||
: []),
|
||||
' </item>',
|
||||
].join('\n');
|
||||
});
|
||||
|
||||
return [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">',
|
||||
' <channel>',
|
||||
` <title>${escape(channel.title)}</title>`,
|
||||
` <link>${escape(origin + channel.link)}</link>`,
|
||||
` <description>${escape(channel.description)}</description>`,
|
||||
` <language>${locale}</language>`,
|
||||
` <atom:link href="${escape(`${origin}/blog/rss.xml?lang=${locale}`)}" rel="self" type="application/rss+xml"/>`,
|
||||
...items,
|
||||
' </channel>',
|
||||
'</rss>',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function escape(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
49
landing/src/app/core/sitemap.spec.ts
Normal file
49
landing/src/app/core/sitemap.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildSitemap } from './sitemap';
|
||||
|
||||
const xml = buildSitemap('https://pireon.dev', {
|
||||
pages: [
|
||||
{
|
||||
id: 1,
|
||||
updated_at: '2026-09-27T20:36:14+00:00',
|
||||
alternates: {
|
||||
es: '/sobre-nosotros',
|
||||
en: '/en/about-us',
|
||||
ca: '/ca/qui-som',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe('buildSitemap', () => {
|
||||
it('lists every language of a record as its own url', () => {
|
||||
for (const path of ['/sobre-nosotros', '/en/about-us', '/ca/qui-som']) {
|
||||
expect(xml).toContain(`<loc>https://pireon.dev${path}</loc>`);
|
||||
}
|
||||
});
|
||||
|
||||
it('states every language on each url, with the default as x-default', () => {
|
||||
expect(xml).toContain(
|
||||
'hreflang="en" href="https://pireon.dev/en/about-us"',
|
||||
);
|
||||
expect(xml).toContain(
|
||||
'hreflang="x-default" href="https://pireon.dev/sobre-nosotros"',
|
||||
);
|
||||
expect(xml).toContain('<lastmod>2026-09-27T20:36:14+00:00</lastmod>');
|
||||
});
|
||||
|
||||
it('includes the landing and the listings this site composes', () => {
|
||||
expect(xml).toContain('<loc>https://pireon.dev/</loc>');
|
||||
// `&` escaped, as XML requires.
|
||||
expect(xml).toContain('<loc>https://pireon.dev/?lang=en</loc>');
|
||||
expect(xml).toContain('<loc>https://pireon.dev/radar</loc>');
|
||||
expect(xml).toContain('<loc>https://pireon.dev/ca/col-labora</loc>');
|
||||
});
|
||||
|
||||
it('is a well-formed document', () => {
|
||||
const document = new DOMParser().parseFromString(xml, 'application/xml');
|
||||
|
||||
expect(document.getElementsByTagName('parsererror')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
85
landing/src/app/core/sitemap.ts
Normal file
85
landing/src/app/core/sitemap.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* `sitemap.xml`, built from the API's `/slugs` and the addresses this site
|
||||
* composes itself.
|
||||
*
|
||||
* Each record is one `<url>` per language, and each carries every language as
|
||||
* an `xhtml:link` — how a sitemap states `hreflang` for pages whose own HTML
|
||||
* is not fetched first.
|
||||
*/
|
||||
import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type SlugEntry } from './api';
|
||||
import {
|
||||
homeHref,
|
||||
listingAlternates,
|
||||
LISTINGS,
|
||||
type ListingType,
|
||||
} from './routes';
|
||||
|
||||
interface SitemapEntry {
|
||||
alternates: Record<string, string>;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export function buildSitemap(
|
||||
origin: string,
|
||||
slugs: Record<string, SlugEntry[]>,
|
||||
): string {
|
||||
const entries: SitemapEntry[] = [
|
||||
// The landing answers on `?lang=`, not a prefix.
|
||||
{
|
||||
alternates: Object.fromEntries(
|
||||
SUPPORTED_LOCALES.map((locale) => [locale, homeHref(locale)]),
|
||||
),
|
||||
},
|
||||
...(Object.keys(LISTINGS) as ListingType[]).map((listing) => ({
|
||||
alternates: listingAlternates(listing),
|
||||
})),
|
||||
...Object.values(slugs).flat(),
|
||||
];
|
||||
|
||||
const urls = entries.flatMap((entry) =>
|
||||
Object.values(entry.alternates).map((path) => url(origin, path, entry)),
|
||||
);
|
||||
|
||||
return [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">',
|
||||
...urls,
|
||||
'</urlset>',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function url(origin: string, path: string, entry: SitemapEntry): string {
|
||||
const links = Object.entries(entry.alternates).map(
|
||||
([locale, href]) =>
|
||||
` <xhtml:link rel="alternate" hreflang="${locale}" href="${escape(origin + href)}"/>`,
|
||||
);
|
||||
|
||||
// The default language doubles as the fallback for everyone else.
|
||||
const fallback = entry.alternates[DEFAULT_LOCALE];
|
||||
|
||||
if (fallback) {
|
||||
links.push(
|
||||
` <xhtml:link rel="alternate" hreflang="x-default" href="${escape(origin + fallback)}"/>`,
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
' <url>',
|
||||
` <loc>${escape(origin + path)}</loc>`,
|
||||
...(entry.updated_at
|
||||
? [` <lastmod>${escape(entry.updated_at)}</lastmod>`]
|
||||
: []),
|
||||
...links,
|
||||
' </url>',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function escape(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
67
landing/src/app/core/submission.spec.ts
Normal file
67
landing/src/app/core/submission.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { checkSubmission } from './submission';
|
||||
|
||||
const valid = {
|
||||
name: 'Ada',
|
||||
email: 'ada@example.org',
|
||||
message: 'Me gustaría echar una mano con el frontend.',
|
||||
consent: true,
|
||||
};
|
||||
|
||||
describe('checkSubmission', () => {
|
||||
it('passes a complete contact through, trimmed', () => {
|
||||
const { payload } = checkSubmission({
|
||||
...valid,
|
||||
name: ' Ada ',
|
||||
hours_per_week: '4',
|
||||
});
|
||||
|
||||
expect(payload).toMatchObject({
|
||||
name: 'Ada',
|
||||
kind: 'contribute',
|
||||
hours_per_week: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('names every missing field at once', () => {
|
||||
const { errors } = checkSubmission({ message: 'corto' });
|
||||
|
||||
expect(Object.keys(errors ?? {}).sort()).toEqual([
|
||||
'consent',
|
||||
'email',
|
||||
'message',
|
||||
'name',
|
||||
]);
|
||||
});
|
||||
|
||||
it('asks an organisation asking for help to name itself', () => {
|
||||
expect(
|
||||
checkSubmission({ ...valid, kind: 'request' }).errors,
|
||||
).toHaveProperty('organisation');
|
||||
expect(
|
||||
checkSubmission({ ...valid, kind: 'request', organisation: 'Asociación' })
|
||||
.payload,
|
||||
).toMatchObject({
|
||||
kind: 'request',
|
||||
});
|
||||
});
|
||||
|
||||
// The API refuses a `kind` on an application to a call: it is always an
|
||||
// offer of time.
|
||||
it('sends no kind with an application to a call, whatever the body says', () => {
|
||||
const { payload } = checkSubmission(
|
||||
{ ...valid, kind: 'request' },
|
||||
{ forCall: true },
|
||||
);
|
||||
|
||||
expect(payload).toBeDefined();
|
||||
expect(payload).not.toHaveProperty('kind');
|
||||
});
|
||||
|
||||
it('keeps the honeypot, so the API can file a bot as spam', () => {
|
||||
expect(
|
||||
checkSubmission({ ...valid, website: 'http://spam' }).payload,
|
||||
).toHaveProperty('website');
|
||||
});
|
||||
});
|
||||
88
landing/src/app/core/submission.ts
Normal file
88
landing/src/app/core/submission.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* What the site's two forms send — the general contact and an application to
|
||||
* a call — checked before it costs a round trip.
|
||||
*
|
||||
* Pure, so the two server routes share it and it can be tested without a
|
||||
* request: the API would answer 422 with the same verdict, and its throttle
|
||||
* counts the attempt either way.
|
||||
*/
|
||||
import type { SubmissionPayload } from './api';
|
||||
|
||||
export interface SubmissionBody {
|
||||
name?: string;
|
||||
email?: string;
|
||||
message?: string;
|
||||
organisation?: string | null;
|
||||
hours_per_week?: number | string | null;
|
||||
kind?: 'contribute' | 'request';
|
||||
consent?: boolean;
|
||||
links?: Record<string, string>;
|
||||
/** Honeypot. Never rendered visibly; a bot that fills it is filed as spam. */
|
||||
website?: string | null;
|
||||
}
|
||||
|
||||
export type SubmissionCheck =
|
||||
| { errors: Record<string, string[]>; payload?: undefined }
|
||||
| { errors?: undefined; payload: SubmissionPayload };
|
||||
|
||||
/**
|
||||
* @param forCall An application to a call is always an offer of time: the API
|
||||
* refuses a `kind` on it, so none is sent, and no organisation is asked for.
|
||||
*/
|
||||
export function checkSubmission(
|
||||
body: SubmissionBody,
|
||||
{ forCall = false } = {},
|
||||
): SubmissionCheck {
|
||||
const kind = !forCall && body.kind === 'request' ? 'request' : 'contribute';
|
||||
|
||||
const name = body.name?.trim() ?? '';
|
||||
const email = body.email?.trim() ?? '';
|
||||
const message = body.message?.trim() ?? '';
|
||||
const organisation = body.organisation?.trim() ?? '';
|
||||
|
||||
const errors: Record<string, string[]> = {};
|
||||
|
||||
if (name.length < 2) {
|
||||
errors['name'] = ['required'];
|
||||
}
|
||||
|
||||
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
|
||||
errors['email'] = ['required'];
|
||||
}
|
||||
|
||||
// The API rejects anything shorter, and rightly: a one-word submission is
|
||||
// never anything but noise.
|
||||
if (message.length < 20) {
|
||||
errors['message'] = ['min'];
|
||||
}
|
||||
|
||||
if (kind === 'request' && organisation.length === 0) {
|
||||
errors['organisation'] = ['required'];
|
||||
}
|
||||
|
||||
if (body.consent !== true) {
|
||||
errors['consent'] = ['required'];
|
||||
}
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return { errors };
|
||||
}
|
||||
|
||||
const hours = Number(body.hours_per_week);
|
||||
|
||||
return {
|
||||
payload: {
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
consent: true,
|
||||
...(forCall ? {} : { kind }),
|
||||
organisation: organisation || null,
|
||||
hours_per_week: Number.isFinite(hours) && hours > 0 ? hours : null,
|
||||
...(body.links && Object.keys(body.links).length > 0
|
||||
? { links: body.links }
|
||||
: {}),
|
||||
...(body.website ? { website: body.website } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { Meta, Title } from '@angular/platform-browser';
|
||||
import { injectLoad, type RouteMeta } from '@analogjs/router';
|
||||
import { injectResponse } from '@analogjs/router/tokens';
|
||||
import { injectRequest, injectResponse } from '@analogjs/router/tokens';
|
||||
|
||||
import {
|
||||
PiCardCenterContentDirective,
|
||||
@@ -17,8 +17,11 @@ import {
|
||||
PiChipComponent,
|
||||
} from '@pi/ui';
|
||||
|
||||
import { pageNavLinks } from '../core/content-page';
|
||||
import { pageNavLinks } from '../core/routed-page';
|
||||
import { listingPath } from '../core/routes';
|
||||
import { copyFor } from '../core/copy';
|
||||
import { absoluteUrl, setShareImage, setStructuredData } from '../core/head';
|
||||
import { originFromHeaders } from '../core/origin';
|
||||
import { CallCard } from '../ui/call-card';
|
||||
import { ClientLogo } from '../ui/client-logo';
|
||||
import { ContactForm } from '../ui/contact-form';
|
||||
@@ -185,6 +188,9 @@ export const routeMeta: RouteMeta = {
|
||||
</pi-card>
|
||||
}
|
||||
</div>
|
||||
<p class="landing-section__more">
|
||||
<a [href]="radarHref()">{{ copy().radar.seeAll }} →</a>
|
||||
</p>
|
||||
</app-section>
|
||||
}
|
||||
|
||||
@@ -236,6 +242,9 @@ export const routeMeta: RouteMeta = {
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
<p class="landing-section__more">
|
||||
<a [href]="contributeHref()">{{ copy().contribute.title }} →</a>
|
||||
</p>
|
||||
</app-section>
|
||||
}
|
||||
|
||||
@@ -268,7 +277,7 @@ export const routeMeta: RouteMeta = {
|
||||
</app-section>
|
||||
</main>
|
||||
|
||||
<app-site-footer [copy]="copy()" [pages]="data().pages" />
|
||||
<app-site-footer [copy]="copy()" [pages]="data().pages" [locale]="data().locale" />
|
||||
}
|
||||
`,
|
||||
})
|
||||
@@ -277,12 +286,21 @@ export default class HomePage {
|
||||
private readonly title = inject(Title);
|
||||
private readonly meta = inject(Meta);
|
||||
|
||||
/** Absolute URLs for the preview tags and the organisation's data. */
|
||||
private readonly origin = originFromHeaders(injectRequest()?.headers);
|
||||
|
||||
protected readonly data = toSignal(injectLoad<typeof load>(), {
|
||||
requireSync: true,
|
||||
});
|
||||
|
||||
protected readonly copy = computed(() => copyFor(this.data().locale));
|
||||
|
||||
protected readonly radarHref = computed(() => listingPath('radar', this.data().locale));
|
||||
|
||||
protected readonly contributeHref = computed(() =>
|
||||
listingPath('contribute', this.data().locale),
|
||||
);
|
||||
|
||||
/** The switcher needs somewhere to go even if `/site` was unreachable. */
|
||||
protected readonly locales = computed(
|
||||
() =>
|
||||
@@ -325,7 +343,7 @@ export default class HomePage {
|
||||
...candidates
|
||||
.filter((link) => link.visible)
|
||||
.map(({ anchor, label }) => ({ anchor, label })),
|
||||
...pageNavLinks(data.pages),
|
||||
...pageNavLinks(data.pages, data.locale),
|
||||
];
|
||||
});
|
||||
|
||||
@@ -408,6 +426,18 @@ export default class HomePage {
|
||||
: `Pireon — ${copy.hero.title} ${copy.hero.highlight}`
|
||||
);
|
||||
this.meta.updateTag({ name: 'description', content: copy.hero.lead });
|
||||
this.meta.updateTag({ property: 'og:description', content: copy.hero.lead });
|
||||
setShareImage(this.meta, this.origin, null);
|
||||
|
||||
// Who the site is, for search engines: the one piece of structured data
|
||||
// the landing has that no record page does.
|
||||
setStructuredData(this.document, {
|
||||
'@type': 'Organization',
|
||||
name: 'Pireon',
|
||||
url: absoluteUrl(this.origin, '/'),
|
||||
logo: absoluteUrl(this.origin, '/logo.png'),
|
||||
description: copy.hero.lead,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,16 @@ import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { injectLoad } from '@analogjs/router';
|
||||
|
||||
import { ContentPage } from '../ui/content-page';
|
||||
import { load } from './[slug].server';
|
||||
import { RoutedPage } from '../ui/routed-page';
|
||||
import { load } from './[...path].server';
|
||||
|
||||
@Component({
|
||||
selector: 'app-content-page-default',
|
||||
selector: 'app-route-path',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [ContentPage],
|
||||
template: `<app-content-page [data]="data()" />`,
|
||||
imports: [RoutedPage],
|
||||
template: `<app-routed-page [data]="data()" />`,
|
||||
})
|
||||
export default class DefaultLocaleContentPage {
|
||||
export default class PathRoute {
|
||||
protected readonly data = toSignal(injectLoad<typeof load>(), {
|
||||
requireSync: true,
|
||||
});
|
||||
18
landing/src/app/pages/[...path].server.ts
Normal file
18
landing/src/app/pages/[...path].server.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Every address but the landing's: a page (`/sobre-nosotros`), a record
|
||||
* (`/proyectos/x`, `/en/team/x`), or nothing at all.
|
||||
*
|
||||
* One catch-all rather than a route per shape. Analog registers each loader as
|
||||
* an internal route, and Nitro's router cannot hold two parameters of
|
||||
* different names at the same depth — `/:prefix/:slug` beside
|
||||
* `/:prefix/:section/:slug` left the first unreachable. `resolveRoute` sorts
|
||||
* the shapes out instead, and an address it does not recognise loads as a 404.
|
||||
*/
|
||||
import type { PageServerLoad } from '@analogjs/router';
|
||||
|
||||
import { loadRoutedPage, type RoutedPageData } from '../core/routed-page';
|
||||
|
||||
export const load = async ({
|
||||
params,
|
||||
}: PageServerLoad): Promise<RoutedPageData> =>
|
||||
loadRoutedPage(`/${params?.['path'] ?? ''}`);
|
||||
@@ -1,18 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { injectLoad } from '@analogjs/router';
|
||||
|
||||
import { ContentPage } from '../../ui/content-page';
|
||||
import { load } from './[slug].server';
|
||||
|
||||
@Component({
|
||||
selector: 'app-content-page-localised',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [ContentPage],
|
||||
template: `<app-content-page [data]="data()" />`,
|
||||
})
|
||||
export default class LocalisedContentPage {
|
||||
protected readonly data = toSignal(injectLoad<typeof load>(), {
|
||||
requireSync: true,
|
||||
});
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* A content page in any other language: `/en/about-us`, `/ca/qui-som`.
|
||||
*
|
||||
* The prefix is checked with `contentRoute`, the same rule the 404 middleware
|
||||
* uses, so `/es/sobre-nosotros` or `/fr/…` never reach the API as a slug.
|
||||
*/
|
||||
import type { PageServerLoad } from '@analogjs/router';
|
||||
|
||||
import { contentRoute, DEFAULT_LOCALE } from '../../core/api';
|
||||
import { loadContentPage, type ContentPageData } from '../../core/content-page';
|
||||
|
||||
export const load = async ({
|
||||
params,
|
||||
}: PageServerLoad): Promise<ContentPageData> => {
|
||||
const route = contentRoute(`/${params?.['locale']}/${params?.['slug']}`);
|
||||
|
||||
return route
|
||||
? loadContentPage(route.locale, route.slug)
|
||||
: loadContentPage(DEFAULT_LOCALE, undefined);
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* A content page in the default language: `/sobre-nosotros`.
|
||||
*/
|
||||
import type { PageServerLoad } from '@analogjs/router';
|
||||
|
||||
import { DEFAULT_LOCALE } from '../core/api';
|
||||
import { loadContentPage, type ContentPageData } from '../core/content-page';
|
||||
|
||||
export const load = async ({
|
||||
params,
|
||||
}: PageServerLoad): Promise<ContentPageData> =>
|
||||
loadContentPage(DEFAULT_LOCALE, params?.['slug']);
|
||||
@@ -40,6 +40,11 @@
|
||||
margin-top: 1.25rem;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
// `@pi/ui`'s reset spaces every `li + li`; this list is spaced by `gap`.
|
||||
> li {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.landing-subsection {
|
||||
@@ -73,3 +78,18 @@
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
// "See the full radar" under the section's summary of it.
|
||||
.landing-section__more {
|
||||
margin: 2rem 0 0;
|
||||
|
||||
a {
|
||||
color: var(--pi-color-primary);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
290
landing/src/app/ui/_detail.scss
Normal file
290
landing/src/app/ui/_detail.scss
Normal file
@@ -0,0 +1,290 @@
|
||||
// The layout every page reached by its address shares — a content page, a
|
||||
// project, a member — so moving between them reads as one site.
|
||||
//
|
||||
// A partial each body `@use`s rather than a global sheet: component styles are
|
||||
// scoped, and each body gets its own scoped copy of these rules.
|
||||
|
||||
// The top of the page is the landing's hero, minus the stats and buttons: the
|
||||
// same left edge as the logo, the same heading weight.
|
||||
.content-hero {
|
||||
padding-block: 5rem 2.5rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding-block: 7rem 3.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.content-hero__title {
|
||||
margin: 0;
|
||||
max-width: 22ch;
|
||||
font-size: clamp(2.25rem, 5.5vw, 3.5rem);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.content-hero__lead {
|
||||
max-width: 46ch;
|
||||
margin: 1.5rem 0 0;
|
||||
}
|
||||
|
||||
// Reading column and a side column, on the landing's own grid width. The
|
||||
// column keeps prose to a readable measure; the side keeps the page from
|
||||
// ending in a dead end.
|
||||
.content-page__layout {
|
||||
display: grid;
|
||||
gap: 3rem;
|
||||
padding-bottom: 5rem;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
grid-template-columns: minmax(0, 46rem) minmax(16rem, 20rem);
|
||||
justify-content: space-between;
|
||||
gap: 4rem;
|
||||
padding-bottom: 7rem;
|
||||
}
|
||||
}
|
||||
|
||||
.content-page__media {
|
||||
margin: 0 0 2.5rem;
|
||||
overflow: hidden;
|
||||
border-radius: var(--pi-theme-card-radius, 1.5rem);
|
||||
}
|
||||
|
||||
// The `<img>` belongs to `app-api-image`, so a scoped rule cannot reach it.
|
||||
.content-page__media ::ng-deep img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.content-page__body {
|
||||
font-size: 1.0625rem;
|
||||
|
||||
// The body is HTML from the panel's editor, not this component's template,
|
||||
// so it carries none of the scoping attributes: only `::ng-deep` reaches it.
|
||||
// Confined to this container, it styles nothing else on the page.
|
||||
::ng-deep {
|
||||
// Bold, like every heading on the landing — a light heading here was the
|
||||
// one thing that made the page read as another site.
|
||||
h2 {
|
||||
margin: 3rem 0 1rem;
|
||||
font-size: clamp(1.5rem, 3vw, 1.875rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 2rem 0 0.75rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
// One rhythm for both: left alone, the library's sheet sets paragraphs
|
||||
// tighter than list items and the body reads in two different spacings.
|
||||
p,
|
||||
li {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin: 0 0 1.5rem;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0 0 0.5rem;
|
||||
|
||||
&::marker {
|
||||
color: var(--pi-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--pi-color-primary);
|
||||
}
|
||||
|
||||
> :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-page__aside {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
|
||||
// Stays in view beside a long page, under the sticky header.
|
||||
@media (min-width: 1024px) {
|
||||
position: sticky;
|
||||
top: 6rem;
|
||||
align-self: start;
|
||||
}
|
||||
}
|
||||
|
||||
.content-page__related {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
// `landing-card` is the landing's card surface and hover, drawn on a link
|
||||
// rather than on `pi-card`, which brings no padding or radius of its own here.
|
||||
.content-page__card,
|
||||
.content-page__cta {
|
||||
display: block;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-radius: var(--pi-theme-card-radius, 1.5rem);
|
||||
box-shadow: var(--pi-theme-card-shadow, none);
|
||||
}
|
||||
|
||||
.content-page__card {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.content-page__card-title {
|
||||
display: block;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.content-page__card-text {
|
||||
display: -webkit-box;
|
||||
margin-top: 0.375rem;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
// Relative to the card's own text colour, so it holds up in dark mode too,
|
||||
// where the grey token is nearly the background.
|
||||
color: color-mix(in srgb, currentColor 68%, transparent);
|
||||
}
|
||||
|
||||
.content-page__cta {
|
||||
padding: 1.5rem;
|
||||
|
||||
// The call to action is not a link as a whole, so it does not lift.
|
||||
&:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.5rem 0 1.25rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.content-page__cta-title {
|
||||
margin: 0;
|
||||
font-size: 1.375rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
// Hero actions — a demo, a repository — and the chips under them.
|
||||
.content-hero__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 2rem;
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.content-hero__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
margin: 1.5rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
// A section inside the reading column, under its own bold heading.
|
||||
.content-page__section {
|
||||
margin-top: 3.5rem;
|
||||
|
||||
> h2 {
|
||||
margin: 0 0 1.25rem;
|
||||
font-size: clamp(1.5rem, 3vw, 1.875rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
}
|
||||
|
||||
.content-page__main > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
// A card in the side column that holds facts rather than a link.
|
||||
.content-page__panel {
|
||||
display: block;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-radius: var(--pi-theme-card-radius, 1.5rem);
|
||||
box-shadow: var(--pi-theme-card-shadow, none);
|
||||
|
||||
&:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
// Label and value, one pair a row.
|
||||
.content-page__facts {
|
||||
display: grid;
|
||||
gap: 0.875rem;
|
||||
margin: 0;
|
||||
|
||||
dt {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: color-mix(in srgb, currentColor 60%, transparent);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0.125rem 0 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--pi-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.content-page__muted {
|
||||
color: color-mix(in srgb, currentColor 68%, transparent);
|
||||
}
|
||||
|
||||
// Lists this layout spaces with `gap`. `@pi/ui`'s reset adds a top margin to
|
||||
// every `li + li`, which suits a list of prose and staggers everything else.
|
||||
.content-hero__chips,
|
||||
.content-page__related {
|
||||
> li {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.content-hero__chips {
|
||||
align-items: center;
|
||||
}
|
||||
95
landing/src/app/ui/blog-page.scss
Normal file
95
landing/src/app/ui/blog-page.scss
Normal file
@@ -0,0 +1,95 @@
|
||||
@use './detail';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.blog-feed {
|
||||
margin: 1.25rem 0 0;
|
||||
|
||||
a {
|
||||
color: var(--pi-color-primary);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.blog-body {
|
||||
padding-bottom: 6rem;
|
||||
}
|
||||
|
||||
.blog-list {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
// `@pi/ui`'s reset spaces every `li + li`; this list is spaced by `gap`.
|
||||
> li {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.blog-card {
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
border-radius: var(--pi-theme-card-radius, 1.5rem);
|
||||
box-shadow: var(--pi-theme-card-shadow, none);
|
||||
}
|
||||
|
||||
// The image column only when there is an image: without one the text would
|
||||
// sit in the image's column with the rest of the card empty.
|
||||
.blog-card--media {
|
||||
@media (min-width: 768px) {
|
||||
grid-template-columns: minmax(0, 20rem) minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.blog-card__media ::ng-deep img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.blog-card__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1.5rem 1.75rem;
|
||||
}
|
||||
|
||||
.blog-card__date {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.blog-card__title {
|
||||
margin: 0;
|
||||
font-size: 1.375rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.blog-card__excerpt {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.blog-card__more {
|
||||
margin-top: auto;
|
||||
padding-top: 0.5rem;
|
||||
color: var(--pi-color-primary);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
114
landing/src/app/ui/blog-page.ts
Normal file
114
landing/src/app/ui/blog-page.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
import {
|
||||
PiNoteComponent,
|
||||
PiTextColorDirective,
|
||||
PiTextLargeDirective,
|
||||
PiTextWeightBoldDirective,
|
||||
} from '@pi/ui';
|
||||
|
||||
import type { Locale } from '../core/api';
|
||||
import type { PublicPage } from '../core/api.types';
|
||||
import type { Copy } from '../core/copy';
|
||||
import { ApiImage } from './api-image';
|
||||
|
||||
/**
|
||||
* The blog: the pages in the panel's "Blog" category, newest first, each
|
||||
* leading to its own address like any page.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-blog-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
ApiImage,
|
||||
PiNoteComponent,
|
||||
PiTextColorDirective,
|
||||
PiTextLargeDirective,
|
||||
PiTextWeightBoldDirective,
|
||||
],
|
||||
styleUrl: './blog-page.scss',
|
||||
template: `
|
||||
<header class="content-hero">
|
||||
<div class="landing-shell">
|
||||
<p class="landing-eyebrow">{{ copy().blog.eyebrow }}</p>
|
||||
<h1 bold class="content-hero__title">{{ copy().blog.title }}</h1>
|
||||
<p l pi-color color="grey" class="content-hero__lead">
|
||||
{{ copy().blog.lead }}
|
||||
</p>
|
||||
<p class="blog-feed">
|
||||
<a [href]="'/blog/rss.xml?lang=' + locale()">{{
|
||||
copy().blog.feed
|
||||
}}</a>
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="landing-shell blog-body">
|
||||
@if (posts().length > 0) {
|
||||
<ul class="blog-list">
|
||||
@for (post of posts(); track post.id) {
|
||||
<li
|
||||
class="landing-card blog-card"
|
||||
[class.blog-card--media]="!!post.featured_image"
|
||||
>
|
||||
@if (post.featured_image; as image) {
|
||||
<a
|
||||
class="blog-card__media"
|
||||
[href]="post.path"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<app-api-image
|
||||
[image]="image"
|
||||
sizes="(min-width: 1024px) 24rem, 100vw"
|
||||
/>
|
||||
</a>
|
||||
}
|
||||
<div class="blog-card__body">
|
||||
@if (post.published_at) {
|
||||
<time
|
||||
class="content-page__muted blog-card__date"
|
||||
[attr.datetime]="post.published_at"
|
||||
>
|
||||
{{ date(post.published_at) }}
|
||||
</time>
|
||||
}
|
||||
<h2 class="blog-card__title">
|
||||
<a [href]="post.path">{{ post.title }}</a>
|
||||
</h2>
|
||||
@if (post.excerpt; as excerpt) {
|
||||
<p class="content-page__muted blog-card__excerpt">
|
||||
{{ excerpt }}
|
||||
</p>
|
||||
}
|
||||
<a class="blog-card__more" [href]="post.path"
|
||||
>{{ copy().blog.readMore }} →</a
|
||||
>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<pi-note
|
||||
status="info"
|
||||
appearance="fill"
|
||||
gap="xs"
|
||||
[title]="copy().blog.empty"
|
||||
[text]="''"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class BlogPage {
|
||||
readonly posts = input.required<PublicPage[]>();
|
||||
readonly copy = input.required<Copy>();
|
||||
readonly locale = input.required<Locale>();
|
||||
|
||||
protected date(value: string): string {
|
||||
return new Intl.DateTimeFormat(this.locale(), {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
}
|
||||
@@ -51,13 +51,19 @@ import type { PublicCall } from '../core/api.types';
|
||||
<pi-chip size="small" appearance="outline" [label]="label" />
|
||||
}
|
||||
@if (call().seniority; as seniority) {
|
||||
<pi-chip size="small" appearance="outline" [label]="seniority" />
|
||||
<pi-chip size="small" appearance="outline" [label]="copy().call.seniority[seniority]" />
|
||||
}
|
||||
</div>
|
||||
</pi-card-top-content>
|
||||
|
||||
<pi-card-center-content>
|
||||
<h3 bold class="landing-card__title">{{ call().title }}</h3>
|
||||
<h3 bold class="landing-card__title">
|
||||
@if (call().path; as path) {
|
||||
<a class="landing-card__title-link" [href]="path">{{ call().title }}</a>
|
||||
} @else {
|
||||
{{ call().title }}
|
||||
}
|
||||
</h3>
|
||||
@if (call().project?.title; as project) {
|
||||
<p xs pi-color color="grey">{{ project }}</p>
|
||||
}
|
||||
@@ -82,14 +88,28 @@ import type { PublicCall } from '../core/api.types';
|
||||
>{{ copy().labels.closes }} {{ closes }}</span
|
||||
>
|
||||
}
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="primary"
|
||||
appearance="outline"
|
||||
[hasIcon]="true"
|
||||
[text]="copy().labels.apply"
|
||||
(execute)="apply.emit()"
|
||||
/>
|
||||
<!-- To the call's own page and its form when it has one; to the
|
||||
landing's general form otherwise. -->
|
||||
@if (call().path; as path) {
|
||||
<a class="landing-card__button-link" [href]="path">
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="primary"
|
||||
appearance="outline"
|
||||
[hasIcon]="true"
|
||||
[text]="copy().labels.apply"
|
||||
/>
|
||||
</a>
|
||||
} @else {
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="primary"
|
||||
appearance="outline"
|
||||
[hasIcon]="true"
|
||||
[text]="copy().labels.apply"
|
||||
(execute)="apply.emit()"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</pi-card-bottom-content>
|
||||
</pi-card>
|
||||
|
||||
25
landing/src/app/ui/call-detail.scss
Normal file
25
landing/src/app/ui/call-detail.scss
Normal file
@@ -0,0 +1,25 @@
|
||||
@use './detail';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
// A textarea's line breaks are the writer's paragraphs.
|
||||
.call-text {
|
||||
margin: 0;
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.7;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.call-project-link {
|
||||
color: var(--pi-color-primary);
|
||||
}
|
||||
|
||||
.call-form-lead {
|
||||
margin: -0.5rem 0 1.5rem;
|
||||
}
|
||||
|
||||
.call-closed-link {
|
||||
margin: 1rem 0 0;
|
||||
}
|
||||
224
landing/src/app/ui/call-detail.ts
Normal file
224
landing/src/app/ui/call-detail.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
PiChipComponent,
|
||||
PiNoteComponent,
|
||||
PiTextColorDirective,
|
||||
PiTextLargeDirective,
|
||||
PiTextWeightBoldDirective,
|
||||
} from '@pi/ui';
|
||||
|
||||
import type { Locale } from '../core/api';
|
||||
import type { PublicCall } from '../core/api.types';
|
||||
import type { Copy } from '../core/copy';
|
||||
import { homeHref } from '../core/routes';
|
||||
import { ContactForm } from './contact-form';
|
||||
|
||||
/**
|
||||
* A call for help on its own address, with the form to answer it.
|
||||
*
|
||||
* `description` and `what_you_get` come from textareas, not a rich editor, so
|
||||
* they are printed as text with their line breaks kept. A call that is no
|
||||
* longer open still has an address — a link to it may be out there — but no
|
||||
* form: the API would refuse the application anyway.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-call-detail',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
ContactForm,
|
||||
PiChipComponent,
|
||||
PiNoteComponent,
|
||||
PiTextColorDirective,
|
||||
PiTextLargeDirective,
|
||||
PiTextWeightBoldDirective,
|
||||
],
|
||||
styleUrl: './call-detail.scss',
|
||||
template: `
|
||||
<header class="content-hero">
|
||||
<div class="landing-shell">
|
||||
<p class="landing-eyebrow">{{ copy().call.eyebrow }}</p>
|
||||
|
||||
<h1 bold class="content-hero__title">{{ call().title }}</h1>
|
||||
|
||||
@if (call().project?.title; as project) {
|
||||
<p l pi-color color="grey" class="content-hero__lead">
|
||||
@if (call().project.path; as path) {
|
||||
<a class="call-project-link" [href]="path">{{ project }}</a>
|
||||
} @else {
|
||||
{{ project }}
|
||||
}
|
||||
</p>
|
||||
}
|
||||
|
||||
<ul class="content-hero__chips">
|
||||
@if (hours(); as label) {
|
||||
<li>
|
||||
<pi-chip
|
||||
size="small"
|
||||
color="accent"
|
||||
icon="time-detail"
|
||||
[label]="label"
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
@if (weeks(); as label) {
|
||||
<li>
|
||||
<pi-chip size="small" appearance="outline" [label]="label" />
|
||||
</li>
|
||||
}
|
||||
@if (call().seniority; as seniority) {
|
||||
<li>
|
||||
<pi-chip
|
||||
size="small"
|
||||
appearance="outline"
|
||||
[label]="copy().call.seniority[seniority]"
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
@for (technology of call().technologies ?? []; track technology.id) {
|
||||
<li>
|
||||
<pi-chip
|
||||
size="small"
|
||||
appearance="outline"
|
||||
[label]="technology.name"
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="landing-shell content-page__layout">
|
||||
<article class="content-page__main">
|
||||
@if (call().description; as description) {
|
||||
<p class="call-text">{{ description }}</p>
|
||||
}
|
||||
|
||||
@if (call().what_you_get; as whatYouGet) {
|
||||
<section class="content-page__section">
|
||||
<h2>{{ copy().call.whatYouGet }}</h2>
|
||||
<p class="call-text">{{ whatYouGet }}</p>
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="content-page__section" id="apuntate">
|
||||
@if (isOpen()) {
|
||||
<h2>{{ copy().call.apply }}</h2>
|
||||
<p class="content-page__muted call-form-lead">
|
||||
{{ copy().call.applyLead }}
|
||||
</p>
|
||||
<app-contact-form
|
||||
[copy]="copy()"
|
||||
[locale]="locale()"
|
||||
[callSlug]="call().slug"
|
||||
/>
|
||||
} @else {
|
||||
<pi-note
|
||||
status="info"
|
||||
appearance="fill"
|
||||
gap="xs"
|
||||
[title]="copy().call.unavailable"
|
||||
[text]="copy().call.unavailableLead"
|
||||
/>
|
||||
<p class="call-closed-link">
|
||||
<a class="call-project-link" [href]="contactHref()"
|
||||
>{{ copy().nav.contact }} →</a
|
||||
>
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
</article>
|
||||
|
||||
<aside class="content-page__aside">
|
||||
<div class="landing-card content-page__panel">
|
||||
<dl class="content-page__facts">
|
||||
@if (commitment(); as commitment) {
|
||||
<div>
|
||||
<dt>{{ copy().call.commitment }}</dt>
|
||||
<dd>{{ commitment }}</dd>
|
||||
</div>
|
||||
}
|
||||
@if (call().seniority; as seniority) {
|
||||
<div>
|
||||
<dt>{{ copy().labels.seniority }}</dt>
|
||||
<dd>{{ copy().call.seniority[seniority] }}</dd>
|
||||
</div>
|
||||
}
|
||||
@if (closesAt(); as closes) {
|
||||
<div>
|
||||
<dt>{{ copy().labels.closes }}</dt>
|
||||
<dd>{{ closes }}</dd>
|
||||
</div>
|
||||
}
|
||||
@if (call().project; as project) {
|
||||
<div>
|
||||
<dt>{{ copy().call.project }}</dt>
|
||||
<dd>
|
||||
@if (project.path) {
|
||||
<a [href]="project.path">{{ project.title }}</a>
|
||||
} @else {
|
||||
{{ project.title }}
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class CallDetail {
|
||||
readonly call = input.required<PublicCall>();
|
||||
readonly copy = input.required<Copy>();
|
||||
readonly locale = input.required<Locale>();
|
||||
|
||||
protected readonly isOpen = computed(() => this.call().status === 'open');
|
||||
|
||||
protected readonly contactHref = computed(
|
||||
() => `${homeHref(this.locale())}#contacto`,
|
||||
);
|
||||
|
||||
// `pi-chip-value` renders the number and its unit as adjacent spans with no
|
||||
// separation, so the label is built here, as on the landing's card.
|
||||
protected readonly hours = computed(() => {
|
||||
const hours = this.call().hours_per_week;
|
||||
|
||||
return hours ? `${hours} ${this.copy().labels.hoursPerWeek}` : null;
|
||||
});
|
||||
|
||||
protected readonly weeks = computed(() => {
|
||||
const weeks = this.call().duration_weeks;
|
||||
|
||||
return weeks ? `${weeks} ${this.copy().labels.weeks}` : null;
|
||||
});
|
||||
|
||||
/** "4 h/semana · 8 semanas · 32 horas en total", with whatever is known. */
|
||||
protected readonly commitment = computed(() => {
|
||||
const total = this.call().total_hours;
|
||||
const parts = [
|
||||
this.hours(),
|
||||
this.weeks(),
|
||||
total ? `${total} ${this.copy().call.totalHours}` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return parts.length > 0 ? parts.join(' · ') : null;
|
||||
});
|
||||
|
||||
protected readonly closesAt = computed(() => {
|
||||
const value = this.call().closes_at;
|
||||
|
||||
return value
|
||||
? new Intl.DateTimeFormat(this.locale(), {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}).format(new Date(value))
|
||||
: null;
|
||||
});
|
||||
}
|
||||
72
landing/src/app/ui/contact-cta.ts
Normal file
72
landing/src/app/ui/contact-cta.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { PiActionButtonComponent, PiTextColorDirective } from '@pi/ui';
|
||||
|
||||
import type { Locale } from '../core/api';
|
||||
import type { Copy } from '../core/copy';
|
||||
import { homeHref } from '../core/routes';
|
||||
|
||||
/**
|
||||
* "Shall we talk?" at the end of a page's side column, so no page is a dead
|
||||
* end. The form lives on the landing; this leads to it.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-contact-cta',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PiActionButtonComponent, PiTextColorDirective],
|
||||
template: `
|
||||
<div class="landing-card contact-cta">
|
||||
<h2 class="contact-cta__title">{{ copy().page.ctaTitle }}</h2>
|
||||
<p pi-color color="grey">{{ copy().page.ctaLead }}</p>
|
||||
<!-- An anchor around the library's button, which renders a <button>:
|
||||
a real link, so it works before hydration and opens in a new tab. -->
|
||||
<a class="contact-cta__link" [href]="href()">
|
||||
<pi-action-button
|
||||
color="primary"
|
||||
[text]="copy().nav.contact"
|
||||
[hasIcon]="true"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
.contact-cta {
|
||||
display: block;
|
||||
padding: 1.5rem;
|
||||
border-radius: var(--pi-theme-card-radius, 1.5rem);
|
||||
box-shadow: var(--pi-theme-card-shadow, none);
|
||||
|
||||
&:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.5rem 0 1.25rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.contact-cta__title {
|
||||
margin: 0;
|
||||
font-size: 1.375rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.contact-cta__link {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class ContactCta {
|
||||
readonly copy = input.required<Copy>();
|
||||
readonly locale = input.required<Locale>();
|
||||
|
||||
protected readonly href = computed(
|
||||
() => `${homeHref(this.locale())}#contacto`,
|
||||
);
|
||||
}
|
||||
@@ -84,14 +84,17 @@ type Kind = 'contribute' | 'request';
|
||||
/>
|
||||
} @else {
|
||||
<form class="landing-form" (ngSubmit)="send()" novalidate>
|
||||
<fieldset class="landing-form__kind">
|
||||
<legend>{{ copy().form.kind }}</legend>
|
||||
<pi-segmented-control
|
||||
[ariaLabel]="copy().form.kind"
|
||||
[options]="kinds()"
|
||||
[(value)]="kind"
|
||||
/>
|
||||
</fieldset>
|
||||
<!-- An application to a call is always an offer of time. -->
|
||||
@if (!callSlug()) {
|
||||
<fieldset class="landing-form__kind">
|
||||
<legend>{{ copy().form.kind }}</legend>
|
||||
<pi-segmented-control
|
||||
[ariaLabel]="copy().form.kind"
|
||||
[options]="kinds()"
|
||||
[(value)]="kind"
|
||||
/>
|
||||
</fieldset>
|
||||
}
|
||||
|
||||
<div class="landing-form__row">
|
||||
<pi-text-field [formField]="contact.name" [placeholder]="copy().form.name">
|
||||
@@ -177,6 +180,8 @@ export class ContactForm {
|
||||
readonly copy = input.required<Copy>();
|
||||
readonly locale = input.required<Locale>();
|
||||
readonly enabled = input(true);
|
||||
/** Set, the form applies to that call instead of writing in generally. */
|
||||
readonly callSlug = input<string | null>(null);
|
||||
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
@@ -305,10 +310,17 @@ export class ContactForm {
|
||||
});
|
||||
}
|
||||
|
||||
private endpoint(): string {
|
||||
const slug = this.callSlug();
|
||||
const path = slug ? `/api/calls/${encodeURIComponent(slug)}/apply` : '/api/contact';
|
||||
|
||||
return `${path}?lang=${this.locale()}`;
|
||||
}
|
||||
|
||||
private post(payload: unknown): Promise<SubmissionReceipt> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.http
|
||||
.post<SubmissionReceipt>(`/api/contact?lang=${this.locale()}`, payload)
|
||||
.post<SubmissionReceipt>(this.endpoint(), payload)
|
||||
.subscribe({ next: resolve, error: reject });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,215 +1,19 @@
|
||||
@use './lights';
|
||||
@use './detail';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.landing-skip {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
|
||||
&:focus {
|
||||
left: 1rem;
|
||||
top: 1rem;
|
||||
z-index: 100;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--pi-color-white, #fff);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
// The lights sit on the whole page rather than on its top: on the hero alone
|
||||
// they end in a hard edge where it does, and here the text starts right there.
|
||||
// A page is tall enough for them to fade out on their own.
|
||||
.content-page {
|
||||
@include lights.glow($overflow: clip);
|
||||
}
|
||||
|
||||
// The top of the page is the landing's hero, minus the stats and buttons: the
|
||||
// same left edge as the logo, the same heading weight.
|
||||
.content-hero {
|
||||
padding-block: 5rem 2.5rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding-block: 7rem 3.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.content-hero__title {
|
||||
margin: 0;
|
||||
max-width: 22ch;
|
||||
font-size: clamp(2.25rem, 5.5vw, 3.5rem);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.content-hero__lead {
|
||||
max-width: 46ch;
|
||||
margin: 1.5rem 0 0;
|
||||
}
|
||||
|
||||
// Reading column and a side column, on the landing's own grid width. The
|
||||
// column keeps prose to a readable measure; the side keeps the page from
|
||||
// ending in a dead end.
|
||||
.content-page__layout {
|
||||
display: grid;
|
||||
gap: 3rem;
|
||||
padding-bottom: 5rem;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
grid-template-columns: minmax(0, 46rem) minmax(16rem, 20rem);
|
||||
justify-content: space-between;
|
||||
gap: 4rem;
|
||||
padding-bottom: 7rem;
|
||||
}
|
||||
}
|
||||
|
||||
.content-page__media {
|
||||
margin: 0 0 2.5rem;
|
||||
overflow: hidden;
|
||||
border-radius: var(--pi-theme-card-radius, 1.5rem);
|
||||
}
|
||||
|
||||
// The `<img>` belongs to `app-api-image`, so a scoped rule cannot reach it.
|
||||
.content-page__media ::ng-deep img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.content-page__body {
|
||||
font-size: 1.0625rem;
|
||||
|
||||
// The body is HTML from the panel's editor, not this component's template,
|
||||
// so it carries none of the scoping attributes: only `::ng-deep` reaches it.
|
||||
// Confined to this container, it styles nothing else on the page.
|
||||
::ng-deep {
|
||||
// Bold, like every heading on the landing — a light heading here was the
|
||||
// one thing that made the page read as another site.
|
||||
h2 {
|
||||
margin: 3rem 0 1rem;
|
||||
font-size: clamp(1.5rem, 3vw, 1.875rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 2rem 0 0.75rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
// One rhythm for both: left alone, the library's sheet sets paragraphs
|
||||
// tighter than list items and the body reads in two different spacings.
|
||||
p,
|
||||
li {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin: 0 0 1.5rem;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
&::marker {
|
||||
color: var(--pi-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--pi-color-primary);
|
||||
}
|
||||
|
||||
> :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-page__aside {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
|
||||
// Stays in view beside a long page, under the sticky header.
|
||||
@media (min-width: 1024px) {
|
||||
position: sticky;
|
||||
top: 6rem;
|
||||
align-self: start;
|
||||
}
|
||||
}
|
||||
|
||||
.content-page__related {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
// `landing-card` is the landing's card surface and hover, drawn on a link
|
||||
// rather than on `pi-card`, which brings no padding or radius of its own here.
|
||||
.content-page__card,
|
||||
.content-page__cta {
|
||||
display: block;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-radius: var(--pi-theme-card-radius, 1.5rem);
|
||||
box-shadow: var(--pi-theme-card-shadow, none);
|
||||
}
|
||||
|
||||
.content-page__card {
|
||||
.content-page__eyebrow-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.content-page__card-title {
|
||||
display: block;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.content-page__card-text {
|
||||
display: -webkit-box;
|
||||
margin-top: 0.375rem;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
// Relative to the card's own text colour, so it holds up in dark mode too,
|
||||
// where the grey token is nearly the background.
|
||||
color: color-mix(in srgb, currentColor 68%, transparent);
|
||||
}
|
||||
|
||||
.content-page__cta {
|
||||
padding: 1.5rem;
|
||||
|
||||
// The call to action is not a link as a whole, so it does not lift.
|
||||
&:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.5rem 0 1.25rem;
|
||||
line-height: 1.6;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.content-page__cta-title {
|
||||
margin: 0;
|
||||
font-size: 1.375rem;
|
||||
font-weight: 700;
|
||||
.content-page__date {
|
||||
margin: 1rem 0 0;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
@@ -1,34 +1,25 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
type OnInit,
|
||||
} from '@angular/core';
|
||||
import { Meta, Title } from '@angular/platform-browser';
|
||||
import { injectRequest, injectResponse } from '@analogjs/router/tokens';
|
||||
import {
|
||||
PiActionButtonComponent,
|
||||
PiTextColorDirective,
|
||||
PiTextLargeDirective,
|
||||
PiTextWeightBoldDirective,
|
||||
} from '@pi/ui';
|
||||
|
||||
import { homeHref } from '../core/api';
|
||||
import { pageNavLinks, type ContentPageData } from '../core/content-page';
|
||||
import { copyFor } from '../core/copy';
|
||||
import NotFoundPage from '../pages/[...not-found].page';
|
||||
import type { Locale } from '../core/api';
|
||||
import type { PublicPage } from '../core/api.types';
|
||||
import { isBlogPost } from '../core/blog';
|
||||
import type { Copy } from '../core/copy';
|
||||
import { listingPath } from '../core/routes';
|
||||
import { ApiImage } from './api-image';
|
||||
import { MaintenanceScreen } from './maintenance-screen';
|
||||
import { SiteFooter } from './site-footer';
|
||||
import { SiteHeader } from './site-header';
|
||||
import { ContactCta } from './contact-cta';
|
||||
|
||||
/**
|
||||
* One of the pages the panel publishes — "Sobre nosotros", "Contacto" —
|
||||
* rendered on its own address.
|
||||
* One of the pages the panel publishes — "Sobre nosotros", "Contacto".
|
||||
*
|
||||
* The body arrives as HTML from the panel's rich editor, written by the people
|
||||
* who run the site, and goes through Angular's sanitiser on the way in: the
|
||||
@@ -40,294 +31,128 @@ import { SiteHeader } from './site-header';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
ApiImage,
|
||||
MaintenanceScreen,
|
||||
NotFoundPage,
|
||||
PiActionButtonComponent,
|
||||
ContactCta,
|
||||
PiTextColorDirective,
|
||||
PiTextLargeDirective,
|
||||
PiTextWeightBoldDirective,
|
||||
SiteFooter,
|
||||
SiteHeader,
|
||||
],
|
||||
styleUrl: './content-page.scss',
|
||||
template: `
|
||||
@if (data().maintenance; as notice) {
|
||||
<app-maintenance-screen
|
||||
[notice]="notice"
|
||||
[copy]="copy()"
|
||||
[locale]="data().locale"
|
||||
/>
|
||||
} @else if (data().page; as page) {
|
||||
<a class="landing-skip" href="#contenido">{{ copy().page.skip }}</a>
|
||||
<header class="content-hero">
|
||||
<div class="landing-shell">
|
||||
@if (isPost()) {
|
||||
<!-- A post leads back to the blog it belongs to. -->
|
||||
<p class="landing-eyebrow">
|
||||
<a class="content-page__eyebrow-link" [href]="blogHref()"
|
||||
>← {{ copy().blog.eyebrow }}</a
|
||||
>
|
||||
</p>
|
||||
} @else if (page().category?.name; as category) {
|
||||
<p class="landing-eyebrow">{{ category }}</p>
|
||||
}
|
||||
|
||||
<app-site-header
|
||||
[copy]="copy()"
|
||||
[locale]="data().locale"
|
||||
[links]="navLinks()"
|
||||
[locales]="locales()"
|
||||
[brandHref]="home()"
|
||||
[alternates]="page.alternates"
|
||||
(navigate)="goHome($event)"
|
||||
/>
|
||||
<h1 bold class="content-hero__title">{{ page().title }}</h1>
|
||||
|
||||
<main id="contenido" class="content-page">
|
||||
<header class="content-hero">
|
||||
<div class="landing-shell">
|
||||
@if (page.category?.name; as category) {
|
||||
<p class="landing-eyebrow">{{ category }}</p>
|
||||
}
|
||||
@if (isPost() && page().published_at; as published) {
|
||||
<p class="content-page__muted content-page__date">
|
||||
{{ copy().blog.published }}
|
||||
<time [attr.datetime]="published">{{ publishedOn() }}</time>
|
||||
</p>
|
||||
}
|
||||
|
||||
<h1 bold class="content-hero__title">{{ page.title }}</h1>
|
||||
@if (page().excerpt; as excerpt) {
|
||||
<p l pi-color color="grey" class="content-hero__lead">
|
||||
{{ excerpt }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (page.excerpt) {
|
||||
<p l pi-color color="grey" class="content-hero__lead">
|
||||
{{ page.excerpt }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
<div class="landing-shell content-page__layout">
|
||||
<article class="content-page__main">
|
||||
@if (page().featured_image; as image) {
|
||||
<figure class="content-page__media">
|
||||
<app-api-image
|
||||
[image]="image"
|
||||
sizes="(min-width: 1024px) 46rem, 100vw"
|
||||
loading="eager"
|
||||
imgClass="content-page__image"
|
||||
/>
|
||||
</figure>
|
||||
}
|
||||
|
||||
<div class="landing-shell content-page__layout">
|
||||
<article class="content-page__main">
|
||||
@if (page.featured_image; as image) {
|
||||
<figure class="content-page__media">
|
||||
<app-api-image
|
||||
[image]="image"
|
||||
sizes="(min-width: 1024px) 46rem, 100vw"
|
||||
loading="eager"
|
||||
imgClass="content-page__image"
|
||||
/>
|
||||
</figure>
|
||||
}
|
||||
<div class="content-page__body" [innerHTML]="page().body ?? ''"></div>
|
||||
</article>
|
||||
|
||||
<div class="content-page__body" [innerHTML]="page.body ?? ''"></div>
|
||||
</article>
|
||||
<aside class="content-page__aside">
|
||||
@if (related().length > 0) {
|
||||
<nav [attr.aria-label]="copy().page.related">
|
||||
<p class="landing-eyebrow">{{ copy().page.related }}</p>
|
||||
<ul class="content-page__related">
|
||||
@for (item of related(); track item.id) {
|
||||
<li>
|
||||
<a class="landing-card content-page__card" [href]="item.path">
|
||||
<span class="content-page__card-title">{{
|
||||
item.title
|
||||
}}</span>
|
||||
@if (item.excerpt) {
|
||||
<span class="content-page__card-text">{{
|
||||
item.excerpt
|
||||
}}</span>
|
||||
}
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
}
|
||||
|
||||
<aside class="content-page__aside">
|
||||
@if (related().length > 0) {
|
||||
<nav [attr.aria-label]="copy().page.related">
|
||||
<p class="landing-eyebrow">{{ copy().page.related }}</p>
|
||||
<ul class="content-page__related">
|
||||
@for (item of related(); track item.id) {
|
||||
<li>
|
||||
<a
|
||||
class="landing-card content-page__card"
|
||||
[href]="item.path"
|
||||
>
|
||||
<span class="content-page__card-title">{{
|
||||
item.title
|
||||
}}</span>
|
||||
@if (item.excerpt) {
|
||||
<span class="content-page__card-text">{{
|
||||
item.excerpt
|
||||
}}</span>
|
||||
}
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
}
|
||||
|
||||
<div class="landing-card content-page__cta">
|
||||
<h2 class="content-page__cta-title">
|
||||
{{ copy().page.ctaTitle }}
|
||||
</h2>
|
||||
<p pi-color color="grey">{{ copy().page.ctaLead }}</p>
|
||||
<pi-action-button
|
||||
color="primary"
|
||||
[text]="copy().nav.contact"
|
||||
[hasIcon]="true"
|
||||
(execute)="goHome('#contacto')"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<app-site-footer [copy]="copy()" [pages]="data().pages" />
|
||||
} @else {
|
||||
<app-not-found />
|
||||
}
|
||||
<app-contact-cta [copy]="copy()" [locale]="locale()" />
|
||||
</aside>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ContentPage implements OnInit {
|
||||
readonly data = input.required<ContentPageData>();
|
||||
export class ContentPage {
|
||||
readonly page = input.required<PublicPage>();
|
||||
readonly pages = input.required<PublicPage[]>();
|
||||
readonly copy = input.required<Copy>();
|
||||
readonly locale = input.required<Locale>();
|
||||
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly title = inject(Title);
|
||||
private readonly meta = inject(Meta);
|
||||
protected readonly isPost = computed(() =>
|
||||
isBlogPost(this.page(), this.locale()),
|
||||
);
|
||||
|
||||
/** Absolute, because `hreflang` and `canonical` want one. Null in the browser, where it is not needed. */
|
||||
private readonly origin = originOf(injectRequest());
|
||||
protected readonly blogHref = computed(() =>
|
||||
listingPath('blog', this.locale()),
|
||||
);
|
||||
|
||||
/** Taken here, in the injection context; used once the input has arrived. */
|
||||
private readonly response = injectResponse();
|
||||
protected readonly publishedOn = computed(() => {
|
||||
const published = this.page().published_at;
|
||||
|
||||
protected readonly copy = computed(() => copyFor(this.data().locale));
|
||||
protected readonly home = computed(() => homeHref(this.data().locale));
|
||||
protected readonly navLinks = computed(() => pageNavLinks(this.data().pages));
|
||||
return published
|
||||
? new Intl.DateTimeFormat(this.locale(), {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}).format(new Date(published))
|
||||
: '';
|
||||
});
|
||||
|
||||
/**
|
||||
* Somewhere to go next, so a page is not a dead end: the rest of its
|
||||
* category first, then anything else, three at most.
|
||||
*/
|
||||
protected readonly related = computed(() => {
|
||||
const { page, pages } = this.data();
|
||||
const others = pages.filter(
|
||||
(other) => other.id !== page?.id && other.path && other.title,
|
||||
const page = this.page();
|
||||
const others = this.pages().filter(
|
||||
(other) => other.id !== page.id && other.path && other.title,
|
||||
);
|
||||
const sameCategory = (other: (typeof pages)[number]) =>
|
||||
other.category?.id === page?.category?.id;
|
||||
const sameCategory = (other: PublicPage) =>
|
||||
other.category?.id === page.category?.id;
|
||||
|
||||
return [
|
||||
...others.filter(sameCategory),
|
||||
...others.filter((other) => !sameCategory(other)),
|
||||
].slice(0, 3);
|
||||
});
|
||||
|
||||
protected readonly locales = computed(
|
||||
() =>
|
||||
this.data().site?.locales ?? [
|
||||
{ code: 'es', name: 'Español', flag: '', is_default: true },
|
||||
],
|
||||
);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.answerWithStatus();
|
||||
}
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const { page, locale, maintenance } = this.data();
|
||||
|
||||
this.document.documentElement.lang = locale;
|
||||
|
||||
if (maintenance) {
|
||||
return;
|
||||
}
|
||||
|
||||
// What the catch-all route says through its `routeMeta`, which only
|
||||
// applies when the router picked that route — not when this one had to
|
||||
// fall back to it.
|
||||
if (!page) {
|
||||
this.title.setTitle('Pireon — Página no encontrada');
|
||||
this.meta.updateTag({ name: 'robots', content: 'noindex' });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.title.setTitle(page.meta_title || `Pireon — ${page.title}`);
|
||||
this.meta.updateTag({
|
||||
name: 'description',
|
||||
content: page.meta_description || page.excerpt || '',
|
||||
});
|
||||
this.meta.updateTag({
|
||||
property: 'og:title',
|
||||
content: page.meta_title || page.title || '',
|
||||
});
|
||||
this.meta.updateTag({ property: 'og:type', content: 'article' });
|
||||
|
||||
this.linkAlternates(page.path, page.alternates ?? {});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The header's contact button and section anchors point at the landing, not
|
||||
* at this page — there is no `#contacto` here to scroll to.
|
||||
*/
|
||||
protected goHome(anchor: string): void {
|
||||
this.document.defaultView?.location.assign(`${this.home()}${anchor}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 404 for a slug the API does not know in this language, 503 during
|
||||
* maintenance. Set on the render request's response, for the reason the
|
||||
* landing sets its 503 the same way: the one a `load` sees never reaches the
|
||||
* browser. The middleware leaves page-shaped addresses alone precisely so
|
||||
* this can decide.
|
||||
*
|
||||
* From `ngOnInit`, not the constructor: an input has no value until then,
|
||||
* and still nothing has been flushed.
|
||||
*/
|
||||
private answerWithStatus(): void {
|
||||
const { maintenance, page } = this.data();
|
||||
const response = this.response;
|
||||
|
||||
if (!response || response.headersSent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (maintenance) {
|
||||
response.statusCode = 503;
|
||||
|
||||
if (maintenance.retry_after) {
|
||||
response.setHeader('Retry-After', String(maintenance.retry_after));
|
||||
}
|
||||
|
||||
response.setHeader('Cache-Control', 'no-store');
|
||||
} else if (!page) {
|
||||
response.statusCode = 404;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Only this side renders the page, but only the API knows that
|
||||
* `/sobre-nosotros` and `/en/about-us` are the same one — hence `alternates`.
|
||||
* Languages without a translation are absent from it rather than pointing at
|
||||
* a 404.
|
||||
*/
|
||||
private linkAlternates(
|
||||
path: string | undefined,
|
||||
alternates: Record<string, string>,
|
||||
): void {
|
||||
const head = this.document.head;
|
||||
|
||||
head
|
||||
.querySelectorAll(
|
||||
'link[rel="alternate"][hreflang], link[rel="canonical"]',
|
||||
)
|
||||
.forEach((link) => link.remove());
|
||||
|
||||
const absolute = (href: string) =>
|
||||
this.origin ? `${this.origin}${href}` : href;
|
||||
|
||||
const add = (attributes: Record<string, string>) => {
|
||||
const link = this.document.createElement('link');
|
||||
|
||||
for (const [name, value] of Object.entries(attributes)) {
|
||||
link.setAttribute(name, value);
|
||||
}
|
||||
|
||||
head.appendChild(link);
|
||||
};
|
||||
|
||||
if (path) {
|
||||
add({ rel: 'canonical', href: absolute(path) });
|
||||
}
|
||||
|
||||
for (const [code, href] of Object.entries(alternates)) {
|
||||
add({ rel: 'alternate', hreflang: code, href: absolute(href) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The origin a visitor used, as the reverse proxy in front of this app reports it. */
|
||||
function originOf(request: ReturnType<typeof injectRequest>): string | null {
|
||||
if (!request) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const first = (value: string | string[] | undefined) =>
|
||||
(Array.isArray(value) ? value[0] : value)?.split(',')[0]?.trim();
|
||||
|
||||
const host =
|
||||
first(request.headers['x-forwarded-host']) ?? first(request.headers.host);
|
||||
// Behind the proxy the header is always there; without one this is a
|
||||
// developer's own machine, which does not speak TLS.
|
||||
const protocol =
|
||||
first(request.headers['x-forwarded-proto']) ??
|
||||
(/^(localhost|127\.)/.test(host ?? '') ? 'http' : 'https');
|
||||
|
||||
return host ? `${protocol}://${host}` : null;
|
||||
}
|
||||
|
||||
96
landing/src/app/ui/contribute-page.scss
Normal file
96
landing/src/app/ui/contribute-page.scss
Normal file
@@ -0,0 +1,96 @@
|
||||
@use './detail';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.contribute-body {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.contribute-section__head {
|
||||
max-width: 42rem;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.5rem, 3vw, 1.875rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.contribute-grid {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
margin-bottom: 4rem;
|
||||
|
||||
@media (min-width: 640px) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
// The shared layout minus its own side padding: this sits inside the shell.
|
||||
.contribute-layout {
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.contribute-issues,
|
||||
.contribute-steps {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
// `@pi/ui`'s reset spaces every `li + li`; these lists are spaced by `gap`.
|
||||
> li {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.contribute-steps {
|
||||
counter-reset: step;
|
||||
gap: 1rem;
|
||||
|
||||
li {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
padding-left: 2.25rem;
|
||||
counter-increment: step;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.5;
|
||||
|
||||
&::before {
|
||||
content: counter(step);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--pi-color-primary) 14%, transparent);
|
||||
color: var(--pi-color-primary);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.contribute-other {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
125
landing/src/app/ui/contribute-page.ts
Normal file
125
landing/src/app/ui/contribute-page.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
import {
|
||||
PiNoteComponent,
|
||||
PiTextColorDirective,
|
||||
PiTextLargeDirective,
|
||||
PiTextWeightBoldDirective,
|
||||
} from '@pi/ui';
|
||||
|
||||
import type { Locale } from '../core/api';
|
||||
import type { PublicCall, PublicIssue } from '../core/api.types';
|
||||
import type { Copy } from '../core/copy';
|
||||
import { CallCard } from './call-card';
|
||||
import { ContactCta } from './contact-cta';
|
||||
import { IssueRow } from './issue-row';
|
||||
|
||||
/**
|
||||
* Every way in, on one address: the calls that are open, the issues labelled
|
||||
* for newcomers across the collective's repositories, and how a first
|
||||
* contribution goes from picking one to seeing it merged.
|
||||
*
|
||||
* The guide is always there. With no call open and no issue labelled, it is
|
||||
* still the answer to "how do I help?", and the contact card says what to do
|
||||
* next.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-contribute-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CallCard,
|
||||
ContactCta,
|
||||
IssueRow,
|
||||
PiNoteComponent,
|
||||
PiTextColorDirective,
|
||||
PiTextLargeDirective,
|
||||
PiTextWeightBoldDirective,
|
||||
],
|
||||
styleUrl: './contribute-page.scss',
|
||||
template: `
|
||||
<header class="content-hero">
|
||||
<div class="landing-shell">
|
||||
<p class="landing-eyebrow">{{ copy().contribute.eyebrow }}</p>
|
||||
<h1 bold class="content-hero__title">{{ copy().contribute.title }}</h1>
|
||||
<p l pi-color color="grey" class="content-hero__lead">
|
||||
{{ copy().contribute.lead }}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="landing-shell contribute-body">
|
||||
@if (calls().length > 0) {
|
||||
<section class="contribute-section">
|
||||
<header class="contribute-section__head">
|
||||
<h2>{{ copy().contribute.calls }}</h2>
|
||||
<p class="content-page__muted">{{ copy().contribute.callsLead }}</p>
|
||||
</header>
|
||||
<div class="contribute-grid">
|
||||
@for (call of calls(); track call.id) {
|
||||
<app-call-card
|
||||
[call]="call"
|
||||
[copy]="copy()"
|
||||
[locale]="locale()"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<div class="content-page__layout contribute-layout">
|
||||
<section class="contribute-section">
|
||||
<header class="contribute-section__head">
|
||||
<h2>{{ copy().contribute.issues }}</h2>
|
||||
<p class="content-page__muted">
|
||||
{{ copy().contribute.issuesLead }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@if (issues().length > 0) {
|
||||
<ul class="contribute-issues">
|
||||
@for (issue of issues(); track issue.url) {
|
||||
<li><app-issue-row [issue]="issue" [copy]="copy()" /></li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<pi-note
|
||||
status="info"
|
||||
appearance="fill"
|
||||
gap="xs"
|
||||
[title]="copy().contribute.noIssues"
|
||||
[text]="''"
|
||||
/>
|
||||
}
|
||||
</section>
|
||||
|
||||
<aside class="content-page__aside">
|
||||
<div class="landing-card content-page__panel">
|
||||
<h2>{{ copy().contribute.guide }}</h2>
|
||||
<ol class="contribute-steps">
|
||||
@for (step of copy().contribute.steps; track step.title) {
|
||||
<li>
|
||||
<strong>{{ step.title }}</strong>
|
||||
<span class="content-page__muted">{{ step.text }}</span>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="landing-card content-page__panel">
|
||||
<h2>{{ copy().contribute.otherWays }}</h2>
|
||||
<p class="contribute-other">
|
||||
{{ copy().contribute.otherWaysLead }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<app-contact-cta [copy]="copy()" [locale]="locale()" />
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ContributePage {
|
||||
readonly calls = input.required<PublicCall[]>();
|
||||
readonly issues = input.required<PublicIssue[]>();
|
||||
readonly copy = input.required<Copy>();
|
||||
readonly locale = input.required<Locale>();
|
||||
}
|
||||
@@ -44,3 +44,88 @@
|
||||
gap: 0.75rem;
|
||||
margin-top: 2.25rem;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entrance.
|
||||
//
|
||||
// The first thing on the page, so it plays on load rather than on scroll, and
|
||||
// in CSS rather than from the directive: it runs whether or not JavaScript
|
||||
// ever does, and `both` keeps each piece hidden until its turn and shown after.
|
||||
//
|
||||
// The stats come last, at 0.8s. `StatGrid` counts its numbers up from zero
|
||||
// only if it hydrates before then — see `COUNT_UP_BEFORE_MS` there.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@keyframes hero-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(22px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.landing-hero .landing-eyebrow,
|
||||
.landing-hero__title,
|
||||
.landing-hero__lead,
|
||||
.landing-hero__actions,
|
||||
app-stat-grid {
|
||||
animation: hero-in 0.8s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.landing-hero__title {
|
||||
animation-delay: 0.08s;
|
||||
}
|
||||
|
||||
.landing-hero__lead {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.landing-hero__actions {
|
||||
animation-delay: 0.32s;
|
||||
}
|
||||
|
||||
app-stat-grid {
|
||||
display: block;
|
||||
animation-delay: 0.8s;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The lights drift and fade as the hero scrolls away, tied to the scroll
|
||||
// itself rather than to a listener. Where the browser has no scroll-driven
|
||||
// animations the lights simply stay put.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@keyframes hero-light-a {
|
||||
to {
|
||||
opacity: 0.15;
|
||||
transform: translateX(-58%) translateY(9rem) scale(1.15);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hero-light-b {
|
||||
to {
|
||||
opacity: 0.1;
|
||||
transform: translate(-6rem, 5rem);
|
||||
}
|
||||
}
|
||||
|
||||
@supports (animation-timeline: scroll()) {
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.landing-hero::before,
|
||||
.landing-hero::after {
|
||||
animation-timeline: scroll(root);
|
||||
animation-range: 0 90vh;
|
||||
animation-fill-mode: both;
|
||||
animation-timing-function: linear;
|
||||
}
|
||||
|
||||
.landing-hero::before {
|
||||
animation-name: hero-light-a;
|
||||
}
|
||||
|
||||
.landing-hero::after {
|
||||
animation-name: hero-light-b;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,13 @@ import type { PublicMember } from '../core/api.types';
|
||||
</pi-card-top-content>
|
||||
|
||||
<pi-card-center-content>
|
||||
<h3 bold class="landing-card__title">{{ member().name }}</h3>
|
||||
<h3 bold class="landing-card__title">
|
||||
@if (member().path; as path) {
|
||||
<a class="landing-card__title-link" [href]="path">{{ member().name }}</a>
|
||||
} @else {
|
||||
{{ member().name }}
|
||||
}
|
||||
</h3>
|
||||
@if (member().role || member().headline; as subtitle) {
|
||||
<p s pi-color color="grey">{{ subtitle }}</p>
|
||||
}
|
||||
@@ -93,22 +99,24 @@ export class MemberCard {
|
||||
readonly member = input.required<PublicMember>();
|
||||
readonly copy = input.required<Copy>();
|
||||
|
||||
protected readonly links = computed(() => {
|
||||
const member = this.member();
|
||||
const entries = Object.entries(member.links ?? {}).map(([label, url]) => ({
|
||||
label,
|
||||
url,
|
||||
}));
|
||||
|
||||
// The username is handed over separately, so build the URL rather than
|
||||
// hope somebody also filled the links map in.
|
||||
if (member.github_username && !entries.some((link) => link.label === 'github')) {
|
||||
entries.unshift({
|
||||
label: 'github',
|
||||
url: `https://github.com/${member.github_username}`,
|
||||
});
|
||||
}
|
||||
|
||||
return entries.slice(0, 3);
|
||||
});
|
||||
protected readonly links = computed(() => memberLinks(this.member()).slice(0, 3));
|
||||
}
|
||||
|
||||
/** A member's links, GitHub first when there is a username for it. */
|
||||
export function memberLinks(member: PublicMember): { label: string; url: string }[] {
|
||||
const entries = Object.entries(member.links ?? {}).map(([label, url]) => ({
|
||||
label,
|
||||
url,
|
||||
}));
|
||||
|
||||
// The username is handed over separately, so build the URL rather than
|
||||
// hope somebody also filled the links map in.
|
||||
if (member.github_username && !entries.some((link) => link.label === 'github')) {
|
||||
entries.unshift({
|
||||
label: 'github',
|
||||
url: `https://github.com/${member.github_username}`,
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
54
landing/src/app/ui/member-detail.scss
Normal file
54
landing/src/app/ui/member-detail.scss
Normal file
@@ -0,0 +1,54 @@
|
||||
@use './detail';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.member-hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
|
||||
@media (min-width: 640px) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
// A textarea's line breaks are the writer's paragraphs.
|
||||
.member-bio {
|
||||
margin: 0;
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.7;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.member-projects {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
|
||||
@media (min-width: 640px) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.member-links {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.member-technologies {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
// `@pi/ui`'s reset spaces every `li + li`; these lists are spaced by `gap`.
|
||||
.member-links {
|
||||
> li {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
182
landing/src/app/ui/member-detail.ts
Normal file
182
landing/src/app/ui/member-detail.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
PiAvatarComponent,
|
||||
PiChipComponent,
|
||||
PiTextColorDirective,
|
||||
PiTextLargeDirective,
|
||||
PiTextWeightBoldDirective,
|
||||
} from '@pi/ui';
|
||||
|
||||
import type { Locale } from '../core/api';
|
||||
import type { PublicMember } from '../core/api.types';
|
||||
import type { Copy } from '../core/copy';
|
||||
import { ContactCta } from './contact-cta';
|
||||
import { memberLinks } from './member-card';
|
||||
import { ProjectCard } from './project-card';
|
||||
|
||||
/**
|
||||
* Someone in the collective, on their own address: who they are, what they
|
||||
* work with, and what they have worked on.
|
||||
*
|
||||
* The bio is plain text from a textarea, not HTML, so it is printed as text
|
||||
* with its line breaks kept rather than handed to `innerHTML`.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-member-detail',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
ContactCta,
|
||||
PiAvatarComponent,
|
||||
PiChipComponent,
|
||||
PiTextColorDirective,
|
||||
PiTextLargeDirective,
|
||||
PiTextWeightBoldDirective,
|
||||
ProjectCard,
|
||||
],
|
||||
styleUrl: './member-detail.scss',
|
||||
template: `
|
||||
<header class="content-hero">
|
||||
<div class="landing-shell member-hero">
|
||||
<pi-avatar
|
||||
class="member-hero__avatar"
|
||||
size="x-large"
|
||||
shape="circle"
|
||||
color="primary"
|
||||
icon="user"
|
||||
[image]="member().avatar?.url"
|
||||
[alt]="member().avatar?.alt"
|
||||
[name]="member().name"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<p class="landing-eyebrow">{{ copy().nav.team }}</p>
|
||||
<h1 bold class="content-hero__title">{{ member().name }}</h1>
|
||||
|
||||
@if (member().headline; as headline) {
|
||||
<p l pi-color color="grey" class="content-hero__lead">
|
||||
{{ headline }}
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (member().is_founder || !member().is_current) {
|
||||
<ul class="content-hero__chips">
|
||||
@if (member().is_founder) {
|
||||
<li>
|
||||
<pi-chip
|
||||
size="small"
|
||||
color="primary"
|
||||
[label]="copy().labels.founder"
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
@if (!member().is_current) {
|
||||
<li>
|
||||
<pi-chip
|
||||
size="small"
|
||||
appearance="outline"
|
||||
[label]="copy().labels.alumni"
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="landing-shell content-page__layout">
|
||||
<article class="content-page__main">
|
||||
@if (member().bio; as bio) {
|
||||
<p class="member-bio">{{ bio }}</p>
|
||||
}
|
||||
|
||||
@if ((member().projects ?? []).length > 0) {
|
||||
<section class="content-page__section">
|
||||
<h2>{{ copy().member.projects }}</h2>
|
||||
<div class="member-projects">
|
||||
@for (project of member().projects; track project.id) {
|
||||
<app-project-card [project]="project" [copy]="copy()" />
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</article>
|
||||
|
||||
<aside class="content-page__aside">
|
||||
@if (since() || links().length > 0) {
|
||||
<div class="landing-card content-page__panel">
|
||||
<dl class="content-page__facts">
|
||||
@if (since(); as since) {
|
||||
<div>
|
||||
<dt>{{ copy().member.since }}</dt>
|
||||
<dd>{{ since }}</dd>
|
||||
</div>
|
||||
}
|
||||
@if (links().length > 0) {
|
||||
<div>
|
||||
<dt>{{ copy().member.links }}</dt>
|
||||
<dd>
|
||||
<ul class="member-links">
|
||||
@for (link of links(); track link.url) {
|
||||
<li>
|
||||
<a
|
||||
[href]="link.url"
|
||||
target="_blank"
|
||||
rel="noopener me"
|
||||
>{{ link.label }}</a
|
||||
>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if ((member().technologies ?? []).length > 0) {
|
||||
<div class="landing-card content-page__panel">
|
||||
<h2>{{ copy().member.technologies }}</h2>
|
||||
<ul class="content-hero__chips member-technologies">
|
||||
@for (technology of member().technologies; track technology.id) {
|
||||
<li>
|
||||
<pi-chip
|
||||
size="small"
|
||||
appearance="outline"
|
||||
[label]="technology.name"
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-contact-cta [copy]="copy()" [locale]="locale()" />
|
||||
</aside>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class MemberDetail {
|
||||
readonly member = input.required<PublicMember>();
|
||||
readonly copy = input.required<Copy>();
|
||||
readonly locale = input.required<Locale>();
|
||||
|
||||
protected readonly links = computed(() => memberLinks(this.member()));
|
||||
|
||||
protected readonly since = computed(() => {
|
||||
const joined = this.member().joined_at;
|
||||
|
||||
return joined
|
||||
? new Intl.DateTimeFormat(this.locale(), {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}).format(new Date(joined))
|
||||
: null;
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import type { RouteMeta } from '@analogjs/router';
|
||||
import {
|
||||
PiActionButtonComponent,
|
||||
PiIconComponent,
|
||||
@@ -10,15 +9,10 @@ import {
|
||||
PiTextXLargeDirective,
|
||||
} from '@pi/ui';
|
||||
|
||||
export const routeMeta: RouteMeta = {
|
||||
title: 'Pireon — Página no encontrada',
|
||||
meta: [{ name: 'robots', content: 'noindex' }],
|
||||
};
|
||||
|
||||
/**
|
||||
* The status itself comes from `src/server/middleware/not-found-status.ts`:
|
||||
* a page `load` cannot set it, because Analog resolves loaders through an
|
||||
* internal request whose response never reaches the browser.
|
||||
* What an address with nothing at it shows. Rendered by `PageShell`, which
|
||||
* also sets the 404 status, the title and `noindex` — the middleware answers
|
||||
* 404 up front for addresses no record could ever live at.
|
||||
*
|
||||
* Composed from the library's parts rather than from `pi-empty-state`, which
|
||||
* would have been the obvious fit: it renders its title as an `<h4>`, and a
|
||||
@@ -39,7 +33,7 @@ export const routeMeta: RouteMeta = {
|
||||
PiTextColorDirective,
|
||||
PiTextWeightBoldDirective,
|
||||
],
|
||||
styleUrl: './not-found.page.scss',
|
||||
styleUrl: './not-found.scss',
|
||||
template: `
|
||||
<main class="not-found">
|
||||
<div class="landing-shell not-found__inner">
|
||||
@@ -69,4 +63,4 @@ export const routeMeta: RouteMeta = {
|
||||
</main>
|
||||
`,
|
||||
})
|
||||
export default class NotFoundPage {}
|
||||
export class NotFound {}
|
||||
26
landing/src/app/ui/page-shell.scss
Normal file
26
landing/src/app/ui/page-shell.scss
Normal file
@@ -0,0 +1,26 @@
|
||||
@use './lights';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.landing-skip {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
|
||||
&:focus {
|
||||
left: 1rem;
|
||||
top: 1rem;
|
||||
z-index: 100;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--pi-color-white, #fff);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
// The lights sit on the whole page rather than on its top: on the hero alone
|
||||
// they end in a hard edge where it does, and here the text starts right there.
|
||||
// A page is tall enough for them to fade out on their own.
|
||||
.content-page {
|
||||
@include lights.glow($overflow: clip);
|
||||
}
|
||||
271
landing/src/app/ui/page-shell.ts
Normal file
271
landing/src/app/ui/page-shell.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
type OnInit,
|
||||
} from '@angular/core';
|
||||
import { Meta, Title } from '@angular/platform-browser';
|
||||
import { injectRequest, injectResponse } from '@analogjs/router/tokens';
|
||||
|
||||
import type { Alternates } from '../core/api.types';
|
||||
import { copyFor } from '../core/copy';
|
||||
import { absoluteUrl, setShareImage, setStructuredData } from '../core/head';
|
||||
import { originFromHeaders } from '../core/origin';
|
||||
import { pageNavLinks, type RoutedPageData } from '../core/routed-page';
|
||||
import { homeHref } from '../core/routes';
|
||||
import { MaintenanceScreen } from './maintenance-screen';
|
||||
import { NotFound } from './not-found';
|
||||
import { SiteFooter } from './site-footer';
|
||||
import { SiteHeader } from './site-header';
|
||||
|
||||
/** What the document head says about the record on the page. */
|
||||
export interface PageSeo {
|
||||
title: string;
|
||||
description: string;
|
||||
path?: string;
|
||||
alternates: Alternates;
|
||||
type: 'article' | 'profile' | 'website';
|
||||
/** The record's own picture, for link previews. The site's logo otherwise. */
|
||||
image?: string | null;
|
||||
/** The RSS feed this page belongs to, announced in `<head>`. */
|
||||
feed?: string;
|
||||
/** schema.org data for the record; `url` is added here, absolute. */
|
||||
jsonLd?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything around a page reached by its address: header, footer, the
|
||||
* landing's lights, and the answers that are not a page at all — maintenance
|
||||
* and 404.
|
||||
*
|
||||
* The record itself is projected in by `RoutedPage`, which knows its type.
|
||||
* `seo` doubles as "was anything found": null is a 404.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-page-shell',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [MaintenanceScreen, NotFound, SiteFooter, SiteHeader],
|
||||
styleUrl: './page-shell.scss',
|
||||
template: `
|
||||
@if (data().maintenance; as notice) {
|
||||
<app-maintenance-screen
|
||||
[notice]="notice"
|
||||
[copy]="copy()"
|
||||
[locale]="data().locale"
|
||||
/>
|
||||
} @else if (seo(); as seo) {
|
||||
<a class="landing-skip" href="#contenido">{{ copy().page.skip }}</a>
|
||||
|
||||
<app-site-header
|
||||
[copy]="copy()"
|
||||
[locale]="data().locale"
|
||||
[links]="navLinks()"
|
||||
[locales]="locales()"
|
||||
[brandHref]="home()"
|
||||
[alternates]="seo.alternates"
|
||||
(navigate)="goHome($event)"
|
||||
/>
|
||||
|
||||
<main id="contenido" class="content-page">
|
||||
<ng-content />
|
||||
</main>
|
||||
|
||||
<app-site-footer
|
||||
[copy]="copy()"
|
||||
[pages]="data().pages"
|
||||
[locale]="data().locale"
|
||||
/>
|
||||
} @else {
|
||||
<app-not-found />
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class PageShell implements OnInit {
|
||||
readonly data = input.required<RoutedPageData>();
|
||||
readonly seo = input.required<PageSeo | null>();
|
||||
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly title = inject(Title);
|
||||
private readonly meta = inject(Meta);
|
||||
|
||||
/** Absolute, because `hreflang` and `canonical` want one. Null in the browser, where it is not needed. */
|
||||
private readonly origin = originFromHeaders(injectRequest()?.headers);
|
||||
|
||||
/** Taken here, in the injection context; used once the inputs have arrived. */
|
||||
private readonly response = injectResponse();
|
||||
|
||||
protected readonly copy = computed(() => copyFor(this.data().locale));
|
||||
protected readonly home = computed(() => homeHref(this.data().locale));
|
||||
protected readonly navLinks = computed(() =>
|
||||
pageNavLinks(this.data().pages, this.data().locale),
|
||||
);
|
||||
|
||||
protected readonly locales = computed(
|
||||
() =>
|
||||
this.data().site?.locales ?? [
|
||||
{ code: 'es', name: 'Español', flag: '', is_default: true },
|
||||
],
|
||||
);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.answerWithStatus();
|
||||
}
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const { locale, maintenance } = this.data();
|
||||
const seo = this.seo();
|
||||
|
||||
this.document.documentElement.lang = locale;
|
||||
|
||||
if (maintenance) {
|
||||
return;
|
||||
}
|
||||
|
||||
// What the catch-all route says through its `routeMeta`, which only
|
||||
// applies when the router picked that route — not when this one had to
|
||||
// fall back to it.
|
||||
if (!seo) {
|
||||
this.title.setTitle('Pireon — Página no encontrada');
|
||||
this.meta.updateTag({ name: 'robots', content: 'noindex' });
|
||||
setStructuredData(this.document, null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.title.setTitle(seo.title);
|
||||
this.meta.updateTag({ name: 'description', content: seo.description });
|
||||
this.meta.updateTag({ property: 'og:title', content: seo.title });
|
||||
this.meta.updateTag({
|
||||
property: 'og:description',
|
||||
content: seo.description,
|
||||
});
|
||||
this.meta.updateTag({ property: 'og:type', content: seo.type });
|
||||
|
||||
if (seo.path) {
|
||||
this.meta.updateTag({
|
||||
property: 'og:url',
|
||||
content: absoluteUrl(this.origin, seo.path),
|
||||
});
|
||||
}
|
||||
|
||||
setShareImage(this.meta, this.origin, seo.image);
|
||||
setStructuredData(
|
||||
this.document,
|
||||
seo.jsonLd
|
||||
? {
|
||||
...seo.jsonLd,
|
||||
...(seo.path ? { url: absoluteUrl(this.origin, seo.path) } : {}),
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
this.linkAlternates(seo.path, seo.alternates ?? {});
|
||||
this.linkFeed(seo.feed);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The header's contact button and section anchors point at the landing, not
|
||||
* at this page — there is no `#contacto` here to scroll to.
|
||||
*/
|
||||
protected goHome(anchor: string): void {
|
||||
this.document.defaultView?.location.assign(`${this.home()}${anchor}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 404 when the API has nothing at this address in this language, 503 during
|
||||
* maintenance. Set on the render request's response, for the reason the
|
||||
* landing sets its 503 the same way: the one a `load` sees never reaches the
|
||||
* browser. The middleware leaves record-shaped addresses alone precisely so
|
||||
* this can decide.
|
||||
*
|
||||
* From `ngOnInit`, not the constructor: an input has no value until then,
|
||||
* and still nothing has been flushed.
|
||||
*/
|
||||
private answerWithStatus(): void {
|
||||
const { maintenance } = this.data();
|
||||
const response = this.response;
|
||||
|
||||
if (!response || response.headersSent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (maintenance) {
|
||||
response.statusCode = 503;
|
||||
|
||||
if (maintenance.retry_after) {
|
||||
response.setHeader('Retry-After', String(maintenance.retry_after));
|
||||
}
|
||||
|
||||
response.setHeader('Cache-Control', 'no-store');
|
||||
} else if (!this.seo()) {
|
||||
response.statusCode = 404;
|
||||
}
|
||||
}
|
||||
|
||||
/** `<link rel="alternate" type="application/rss+xml">`, one at most. */
|
||||
private linkFeed(feed: string | undefined): void {
|
||||
this.document.head
|
||||
.querySelector('link[type="application/rss+xml"]')
|
||||
?.remove();
|
||||
|
||||
if (!feed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const link = this.document.createElement('link');
|
||||
|
||||
link.setAttribute('rel', 'alternate');
|
||||
link.setAttribute('type', 'application/rss+xml');
|
||||
link.setAttribute('title', 'Pireon');
|
||||
link.setAttribute('href', absoluteUrl(this.origin, feed));
|
||||
this.document.head.appendChild(link);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only this side renders the record, but only the API knows that
|
||||
* `/proyectos/x` and `/en/projects/x` are the same one — hence `alternates`.
|
||||
* Languages without a translation are absent from it rather than pointing at
|
||||
* a 404.
|
||||
*/
|
||||
private linkAlternates(
|
||||
path: string | undefined,
|
||||
alternates: Alternates,
|
||||
): void {
|
||||
const head = this.document.head;
|
||||
|
||||
head
|
||||
.querySelectorAll(
|
||||
'link[rel="alternate"][hreflang], link[rel="canonical"]',
|
||||
)
|
||||
.forEach((link) => link.remove());
|
||||
|
||||
const absolute = (href: string) =>
|
||||
this.origin ? `${this.origin}${href}` : href;
|
||||
|
||||
const add = (attributes: Record<string, string>) => {
|
||||
const link = this.document.createElement('link');
|
||||
|
||||
for (const [name, value] of Object.entries(attributes)) {
|
||||
link.setAttribute(name, value);
|
||||
}
|
||||
|
||||
head.appendChild(link);
|
||||
};
|
||||
|
||||
if (path) {
|
||||
add({ rel: 'canonical', href: absolute(path) });
|
||||
}
|
||||
|
||||
for (const [code, href] of Object.entries(alternates)) {
|
||||
if (href) {
|
||||
add({ rel: 'alternate', hreflang: code, href: absolute(href) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,18 @@ import type { Copy } from '../core/copy';
|
||||
import type { PublicProject } from '../core/api.types';
|
||||
import { ApiImage } from './api-image';
|
||||
|
||||
/** The API's five project states onto the status chip's five tones. */
|
||||
export const PROJECT_STATUS_TONES: Record<
|
||||
PublicProject['status'],
|
||||
'success' | 'error' | 'pending' | 'info' | 'disabled'
|
||||
> = {
|
||||
idea: 'info',
|
||||
active: 'success',
|
||||
paused: 'pending',
|
||||
completed: 'info',
|
||||
archived: 'disabled',
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-project-card',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
@@ -70,7 +82,13 @@ import { ApiImage } from './api-image';
|
||||
</pi-card-top-content>
|
||||
|
||||
<pi-card-center-content>
|
||||
<h3 bold class="landing-card__title">{{ project().title }}</h3>
|
||||
<h3 bold class="landing-card__title">
|
||||
@if (project().path; as path) {
|
||||
<a class="landing-card__title-link" [href]="path">{{ project().title }}</a>
|
||||
} @else {
|
||||
{{ project().title }}
|
||||
}
|
||||
</h3>
|
||||
@if (project().tagline; as tagline) {
|
||||
<p s pi-color color="grey">{{ tagline }}</p>
|
||||
}
|
||||
@@ -130,19 +148,7 @@ export class ProjectCard {
|
||||
() => (this.project().repositories ?? []).find((repo) => repo.url)?.url ?? null
|
||||
);
|
||||
|
||||
/** The API's five project states onto the chip's five tones. */
|
||||
protected readonly statusTone = computed(() => {
|
||||
const tones: Record<
|
||||
PublicProject['status'],
|
||||
'success' | 'error' | 'pending' | 'info' | 'disabled'
|
||||
> = {
|
||||
idea: 'info',
|
||||
active: 'success',
|
||||
paused: 'pending',
|
||||
completed: 'info',
|
||||
archived: 'disabled',
|
||||
};
|
||||
|
||||
return tones[this.project().status];
|
||||
});
|
||||
protected readonly statusTone = computed(
|
||||
() => PROJECT_STATUS_TONES[this.project().status]
|
||||
);
|
||||
}
|
||||
|
||||
89
landing/src/app/ui/project-detail.scss
Normal file
89
landing/src/app/ui/project-detail.scss
Normal file
@@ -0,0 +1,89 @@
|
||||
@use './detail';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.project-gallery {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
|
||||
@media (min-width: 640px) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.project-gallery__item {
|
||||
margin: 0;
|
||||
|
||||
figcaption {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: color-mix(in srgb, currentColor 68%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
// The `<img>` belongs to `app-api-image`, so a scoped rule cannot reach it.
|
||||
.project-gallery__item ::ng-deep img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--pi-border-radius-medium, 0.75rem);
|
||||
}
|
||||
|
||||
.project-team,
|
||||
.project-repos {
|
||||
display: grid;
|
||||
gap: 0.875rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.project-team__member {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
|
||||
> span {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
&:hover .project-team__name {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.project-team__name {
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.project-repos li {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
|
||||
a {
|
||||
color: var(--pi-color-primary);
|
||||
font-weight: 600;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.project-repos__stats {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
// `@pi/ui`'s reset spaces every `li + li`; these lists are spaced by `gap`.
|
||||
.project-team,
|
||||
.project-repos {
|
||||
> li {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
335
landing/src/app/ui/project-detail.ts
Normal file
335
landing/src/app/ui/project-detail.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
PiActionButtonComponent,
|
||||
PiAvatarComponent,
|
||||
PiChipComponent,
|
||||
PiChipStatusComponent,
|
||||
PiTextColorDirective,
|
||||
PiTextLargeDirective,
|
||||
PiTextWeightBoldDirective,
|
||||
} from '@pi/ui';
|
||||
|
||||
import type { Locale } from '../core/api';
|
||||
import type { PublicProject } from '../core/api.types';
|
||||
import type { Copy } from '../core/copy';
|
||||
import { ApiImage } from './api-image';
|
||||
import { ContactCta } from './contact-cta';
|
||||
import { PROJECT_STATUS_TONES } from './project-card';
|
||||
|
||||
/**
|
||||
* A project on its own address: what it is, why it was built the way it was,
|
||||
* what went wrong, who made it, and where the code is.
|
||||
*
|
||||
* `description`, `decisions` and `postmortem` are HTML from the panel's rich
|
||||
* editor and go through Angular's sanitiser, as a page's body does.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-project-detail',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
ApiImage,
|
||||
ContactCta,
|
||||
PiActionButtonComponent,
|
||||
PiAvatarComponent,
|
||||
PiChipComponent,
|
||||
PiChipStatusComponent,
|
||||
PiTextColorDirective,
|
||||
PiTextLargeDirective,
|
||||
PiTextWeightBoldDirective,
|
||||
],
|
||||
styleUrl: './project-detail.scss',
|
||||
template: `
|
||||
<header class="content-hero">
|
||||
<div class="landing-shell">
|
||||
<p class="landing-eyebrow">{{ copy().project.details }}</p>
|
||||
|
||||
<h1 bold class="content-hero__title">{{ project().title }}</h1>
|
||||
|
||||
@if (project().tagline; as tagline) {
|
||||
<p l pi-color color="grey" class="content-hero__lead">
|
||||
{{ tagline }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<ul class="content-hero__chips">
|
||||
<li>
|
||||
<pi-chip-status
|
||||
size="small"
|
||||
[status]="statusTone()"
|
||||
[label]="copy().projectStatus[project().status]"
|
||||
/>
|
||||
</li>
|
||||
@if (project().needs_help) {
|
||||
<li>
|
||||
<pi-chip
|
||||
size="small"
|
||||
appearance="outline"
|
||||
color="accent"
|
||||
[label]="copy().labels.needsHelp"
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
@for (
|
||||
technology of project().technologies ?? [];
|
||||
track technology.id
|
||||
) {
|
||||
<li>
|
||||
<pi-chip
|
||||
size="small"
|
||||
appearance="outline"
|
||||
[label]="technology.name"
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
@if (project().demo_url || repositoryUrl()) {
|
||||
<div class="content-hero__actions">
|
||||
@if (project().demo_url; as demo) {
|
||||
<a [href]="demo" target="_blank" rel="noopener">
|
||||
<pi-action-button
|
||||
color="primary"
|
||||
[text]="copy().labels.demo"
|
||||
[hasIcon]="true"
|
||||
/>
|
||||
</a>
|
||||
}
|
||||
@if (repositoryUrl(); as repository) {
|
||||
<a [href]="repository" target="_blank" rel="noopener">
|
||||
<pi-action-button
|
||||
appearance="outline"
|
||||
color="grey"
|
||||
[text]="copy().labels.repository"
|
||||
[hasIcon]="true"
|
||||
/>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="landing-shell content-page__layout">
|
||||
<article class="content-page__main">
|
||||
@if (project().cover_image; as cover) {
|
||||
<figure class="content-page__media">
|
||||
<app-api-image
|
||||
[image]="cover"
|
||||
sizes="(min-width: 1024px) 46rem, 100vw"
|
||||
loading="eager"
|
||||
imgClass="content-page__image"
|
||||
/>
|
||||
</figure>
|
||||
}
|
||||
|
||||
@if (project().description; as description) {
|
||||
<div class="content-page__body" [innerHTML]="description"></div>
|
||||
}
|
||||
|
||||
@if (project().decisions; as decisions) {
|
||||
<section class="content-page__section">
|
||||
<h2>{{ copy().project.decisions }}</h2>
|
||||
<div class="content-page__body" [innerHTML]="decisions"></div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (project().postmortem; as postmortem) {
|
||||
<section class="content-page__section">
|
||||
<h2>{{ copy().project.postmortem }}</h2>
|
||||
<div class="content-page__body" [innerHTML]="postmortem"></div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if ((project().gallery ?? []).length > 0) {
|
||||
<section class="content-page__section">
|
||||
<h2>{{ copy().project.gallery }}</h2>
|
||||
<div class="project-gallery">
|
||||
@for (image of project().gallery; track image.url) {
|
||||
<figure class="project-gallery__item">
|
||||
<app-api-image
|
||||
[image]="image"
|
||||
sizes="(min-width: 1024px) 23rem, 100vw"
|
||||
imgClass="project-gallery__image"
|
||||
/>
|
||||
@if (image.caption) {
|
||||
<figcaption>{{ image.caption }}</figcaption>
|
||||
}
|
||||
</figure>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</article>
|
||||
|
||||
<aside class="content-page__aside">
|
||||
<div class="landing-card content-page__panel">
|
||||
<dl class="content-page__facts">
|
||||
<div>
|
||||
<dt>{{ copy().project.status }}</dt>
|
||||
<dd>{{ copy().projectStatus[project().status] }}</dd>
|
||||
</div>
|
||||
@if (project().client; as client) {
|
||||
<div>
|
||||
<dt>{{ copy().project.client }}</dt>
|
||||
<dd>
|
||||
@if (client.url) {
|
||||
<a [href]="client.url" target="_blank" rel="noopener">{{
|
||||
client.name
|
||||
}}</a>
|
||||
} @else {
|
||||
{{ client.name }}
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
@if (period(); as period) {
|
||||
<div>
|
||||
<dt>{{ copy().project.period }}</dt>
|
||||
<dd>{{ period }}</dd>
|
||||
</div>
|
||||
}
|
||||
@if (project().lead; as lead) {
|
||||
<div>
|
||||
<dt>{{ copy().project.lead }}</dt>
|
||||
<dd>
|
||||
<a [href]="lead.path">{{ lead.name }}</a>
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
@if ((project().calls ?? []).length > 0) {
|
||||
<nav [attr.aria-label]="copy().project.openCalls">
|
||||
<p class="landing-eyebrow">{{ copy().project.openCalls }}</p>
|
||||
<ul class="content-page__related">
|
||||
@for (call of project().calls; track call.id) {
|
||||
<li>
|
||||
<a class="landing-card content-page__card" [href]="call.path">
|
||||
<span class="content-page__card-title">{{
|
||||
call.title
|
||||
}}</span>
|
||||
@if (call.hours_per_week) {
|
||||
<span class="content-page__card-text">
|
||||
{{ call.hours_per_week }}
|
||||
{{ copy().labels.hoursPerWeek }}
|
||||
</span>
|
||||
}
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
}
|
||||
|
||||
@if ((project().members ?? []).length > 0) {
|
||||
<div class="landing-card content-page__panel">
|
||||
<h2>{{ copy().project.team }}</h2>
|
||||
<ul class="project-team">
|
||||
@for (member of project().members; track member.id) {
|
||||
<li>
|
||||
<a class="project-team__member" [href]="member.path">
|
||||
<pi-avatar
|
||||
size="small"
|
||||
shape="circle"
|
||||
color="primary"
|
||||
icon="user"
|
||||
[image]="member.avatar?.url"
|
||||
[alt]="member.avatar?.alt"
|
||||
[name]="member.name"
|
||||
/>
|
||||
<span>
|
||||
<span class="project-team__name">{{ member.name }}</span>
|
||||
@if (member.role) {
|
||||
<span class="content-page__muted">{{
|
||||
member.role
|
||||
}}</span>
|
||||
}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if ((project().repositories ?? []).length > 0) {
|
||||
<div class="landing-card content-page__panel">
|
||||
<h2>{{ copy().project.repositories }}</h2>
|
||||
<ul class="project-repos">
|
||||
@for (repository of project().repositories; track repository.id) {
|
||||
<li>
|
||||
@if (repository.url) {
|
||||
<a [href]="repository.url" target="_blank" rel="noopener">{{
|
||||
repository.full_name
|
||||
}}</a>
|
||||
} @else {
|
||||
<span>{{ repository.full_name }}</span>
|
||||
}
|
||||
<span class="content-page__muted project-repos__stats">
|
||||
★ {{ repository.stars }} · {{ repository.forks }}
|
||||
{{ copy().project.forks }}
|
||||
@if (repository.primary_language) {
|
||||
· {{ repository.primary_language }}
|
||||
}
|
||||
</span>
|
||||
@if (repository.is_archived) {
|
||||
<pi-chip
|
||||
size="small"
|
||||
appearance="outline"
|
||||
[label]="copy().project.archived"
|
||||
/>
|
||||
} @else if (repository.is_dormant) {
|
||||
<pi-chip
|
||||
size="small"
|
||||
appearance="outline"
|
||||
[label]="copy().project.dormant"
|
||||
/>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-contact-cta [copy]="copy()" [locale]="locale()" />
|
||||
</aside>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ProjectDetail {
|
||||
readonly project = input.required<PublicProject>();
|
||||
readonly copy = input.required<Copy>();
|
||||
readonly locale = input.required<Locale>();
|
||||
|
||||
protected readonly statusTone = computed(
|
||||
() => PROJECT_STATUS_TONES[this.project().status],
|
||||
);
|
||||
|
||||
protected readonly repositoryUrl = computed(
|
||||
() =>
|
||||
(this.project().repositories ?? []).find((repository) => repository.url)
|
||||
?.url ?? null,
|
||||
);
|
||||
|
||||
/** "marzo de 2025 – junio de 2026", or just the start while it runs. */
|
||||
protected readonly period = computed(() => {
|
||||
const { started_at, ended_at } = this.project();
|
||||
const format = (date?: string) =>
|
||||
date
|
||||
? new Intl.DateTimeFormat(this.locale(), {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}).format(new Date(date))
|
||||
: null;
|
||||
|
||||
const [start, end] = [format(started_at), format(ended_at)];
|
||||
|
||||
return start && end ? `${start} – ${end}` : (start ?? end);
|
||||
});
|
||||
}
|
||||
146
landing/src/app/ui/radar-page.scss
Normal file
146
landing/src/app/ui/radar-page.scss
Normal file
@@ -0,0 +1,146 @@
|
||||
@use './detail';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.radar-jump {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.75rem;
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.radar-body {
|
||||
padding-bottom: 6rem;
|
||||
}
|
||||
|
||||
.radar-ring {
|
||||
// Clear of the sticky header when reached through an anchor.
|
||||
scroll-margin-top: 5.5rem;
|
||||
|
||||
& + & {
|
||||
margin-top: 4.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.radar-ring__head {
|
||||
max-width: 42rem;
|
||||
margin-bottom: 1.75rem;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.75rem, 3.5vw, 2.25rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.radar-grid {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
@media (min-width: 640px) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
// `@pi/ui`'s reset spaces every `li + li`; this grid is spaced by `gap`.
|
||||
> li {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.radar-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
padding: 1.5rem;
|
||||
border-radius: var(--pi-theme-card-radius, 1.5rem);
|
||||
box-shadow: var(--pi-theme-card-shadow, none);
|
||||
}
|
||||
|
||||
.radar-card__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
// Light in both themes, on purpose: see the component's comment.
|
||||
.radar-logo {
|
||||
display: grid;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: 0.875rem;
|
||||
background: #fff;
|
||||
box-shadow:
|
||||
0 0 0 1px
|
||||
color-mix(in srgb, var(--brand, var(--pi-color-primary)) 22%, transparent),
|
||||
0 6px 16px -8px
|
||||
color-mix(in srgb, var(--brand, var(--pi-color-primary)) 55%, transparent);
|
||||
}
|
||||
|
||||
.radar-logo ::ng-deep img {
|
||||
display: block;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.radar-logo__initial {
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem;
|
||||
color: var(--brand, var(--pi-color-primary));
|
||||
}
|
||||
|
||||
.radar-card__name {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.radar-card__category {
|
||||
margin: 0.125rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.radar-card__text,
|
||||
.radar-card__note {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.radar-card__note {
|
||||
padding-left: 0.75rem;
|
||||
border-left: 2px solid var(--pi-color-accent);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.radar-card__footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin: auto 0 0;
|
||||
font-size: 0.875rem;
|
||||
|
||||
a {
|
||||
color: var(--pi-color-primary);
|
||||
}
|
||||
}
|
||||
161
landing/src/app/ui/radar-page.ts
Normal file
161
landing/src/app/ui/radar-page.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
PiChipComponent,
|
||||
PiNoteComponent,
|
||||
PiTextColorDirective,
|
||||
PiTextLargeDirective,
|
||||
PiTextWeightBoldDirective,
|
||||
} from '@pi/ui';
|
||||
|
||||
import type { PublicTechnology } from '../core/api.types';
|
||||
import type { Copy } from '../core/copy';
|
||||
import { ApiImage } from './api-image';
|
||||
|
||||
type Ring = NonNullable<PublicTechnology['ring']>;
|
||||
|
||||
const RINGS: Ring[] = ['adopt', 'trial', 'assess', 'hold'];
|
||||
|
||||
/**
|
||||
* The tech radar on its own address: every technology the collective has
|
||||
* placed, by ring, with its logo, what it is and why it sits where it does.
|
||||
*
|
||||
* Logos sit on a light tile in both themes. A brand's own mark is not ours to
|
||||
* recolour, and some — Rust's, black — would otherwise vanish in dark mode.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-radar-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
ApiImage,
|
||||
PiChipComponent,
|
||||
PiNoteComponent,
|
||||
PiTextColorDirective,
|
||||
PiTextLargeDirective,
|
||||
PiTextWeightBoldDirective,
|
||||
],
|
||||
styleUrl: './radar-page.scss',
|
||||
template: `
|
||||
<header class="content-hero">
|
||||
<div class="landing-shell">
|
||||
<p class="landing-eyebrow">{{ copy().radar.eyebrow }}</p>
|
||||
<h1 bold class="content-hero__title">
|
||||
{{ copy().sections.radar.title }}
|
||||
</h1>
|
||||
<p l pi-color color="grey" class="content-hero__lead">
|
||||
{{ copy().radar.lead }}
|
||||
</p>
|
||||
|
||||
@if (rings().length > 0) {
|
||||
<nav class="radar-jump" [attr.aria-label]="copy().radar.eyebrow">
|
||||
@for (group of rings(); track group.ring) {
|
||||
<a [href]="'#' + group.ring">
|
||||
<pi-chip
|
||||
size="small"
|
||||
appearance="outline"
|
||||
[label]="
|
||||
copy().rings[group.ring] + ' · ' + group.technologies.length
|
||||
"
|
||||
/>
|
||||
</a>
|
||||
}
|
||||
</nav>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="landing-shell radar-body">
|
||||
@for (group of rings(); track group.ring) {
|
||||
<section class="radar-ring" [id]="group.ring">
|
||||
<header class="radar-ring__head">
|
||||
<h2>{{ copy().rings[group.ring] }}</h2>
|
||||
<p class="content-page__muted">
|
||||
{{ copy().radar.ringLead[group.ring] }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ul class="radar-grid">
|
||||
@for (technology of group.technologies; track technology.id) {
|
||||
<li class="landing-card radar-card">
|
||||
<div class="radar-card__top">
|
||||
<span
|
||||
class="radar-logo"
|
||||
[style.--brand]="technology.color || null"
|
||||
>
|
||||
@if (technology.logo; as logo) {
|
||||
<app-api-image
|
||||
[image]="logo"
|
||||
sizes="48px"
|
||||
imgClass="radar-logo__image"
|
||||
/>
|
||||
} @else {
|
||||
<span class="radar-logo__initial" aria-hidden="true">{{
|
||||
technology.name[0]
|
||||
}}</span>
|
||||
}
|
||||
</span>
|
||||
<div>
|
||||
<h3 class="radar-card__name">{{ technology.name }}</h3>
|
||||
<p class="content-page__muted radar-card__category">
|
||||
{{ copy().radar.categories[technology.category] }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (technology.description; as description) {
|
||||
<p class="radar-card__text">{{ description }}</p>
|
||||
}
|
||||
|
||||
@if (technology.radar_note; as note) {
|
||||
<p class="radar-card__note">{{ note }}</p>
|
||||
}
|
||||
|
||||
@if (technology.website || technology.projects_count > 0) {
|
||||
<p class="radar-card__footer content-page__muted">
|
||||
@if (technology.website; as website) {
|
||||
<a [href]="website" target="_blank" rel="noopener">{{
|
||||
copy().radar.website
|
||||
}}</a>
|
||||
}
|
||||
@if (technology.projects_count > 0) {
|
||||
<span
|
||||
>{{ technology.projects_count }}
|
||||
{{ copy().radar.projects }}</span
|
||||
>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
} @empty {
|
||||
<pi-note
|
||||
status="info"
|
||||
appearance="fill"
|
||||
gap="xs"
|
||||
[title]="copy().radar.empty"
|
||||
[text]="''"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class RadarPage {
|
||||
readonly technologies = input.required<PublicTechnology[]>();
|
||||
readonly copy = input.required<Copy>();
|
||||
|
||||
/** The rings that have anything in them, in the radar's own order. */
|
||||
protected readonly rings = computed(() =>
|
||||
RINGS.map((ring) => ({
|
||||
ring,
|
||||
technologies: this.technologies().filter(
|
||||
(technology) => technology.ring === ring,
|
||||
),
|
||||
})).filter((group) => group.technologies.length > 0),
|
||||
);
|
||||
}
|
||||
285
landing/src/app/ui/routed-page.ts
Normal file
285
landing/src/app/ui/routed-page.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
|
||||
import { copyFor, type Copy } from '../core/copy';
|
||||
import { listingAlternates, listingPath } from '../core/routes';
|
||||
import { memberLinks } from './member-card';
|
||||
import { isBlogPost } from '../core/blog';
|
||||
import type { Locale } from '../core/api';
|
||||
import { RadarPage } from './radar-page';
|
||||
import { ServiceDetail } from './service-detail';
|
||||
import { BlogPage } from './blog-page';
|
||||
import { CallDetail } from './call-detail';
|
||||
import type { RoutedPageData, RoutedRecord } from '../core/routed-page';
|
||||
import { ContentPage } from './content-page';
|
||||
import { ContributePage } from './contribute-page';
|
||||
import { MemberDetail } from './member-detail';
|
||||
import { PageShell, type PageSeo } from './page-shell';
|
||||
import { ProjectDetail } from './project-detail';
|
||||
|
||||
/**
|
||||
* A page reached by its address, whatever it holds: the shell around it, and
|
||||
* the body for its type inside.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-routed-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
BlogPage,
|
||||
CallDetail,
|
||||
ContentPage,
|
||||
ContributePage,
|
||||
MemberDetail,
|
||||
PageShell,
|
||||
ProjectDetail,
|
||||
RadarPage,
|
||||
ServiceDetail,
|
||||
],
|
||||
template: `
|
||||
<app-page-shell [data]="data()" [seo]="seo()">
|
||||
@if (data().found; as found) {
|
||||
@switch (found.type) {
|
||||
@case ('page') {
|
||||
<app-content-page
|
||||
[page]="found.record"
|
||||
[pages]="data().pages"
|
||||
[copy]="copy()"
|
||||
[locale]="data().locale"
|
||||
/>
|
||||
}
|
||||
@case ('project') {
|
||||
<app-project-detail
|
||||
[project]="found.record"
|
||||
[copy]="copy()"
|
||||
[locale]="data().locale"
|
||||
/>
|
||||
}
|
||||
@case ('call') {
|
||||
<app-call-detail
|
||||
[call]="found.record"
|
||||
[copy]="copy()"
|
||||
[locale]="data().locale"
|
||||
/>
|
||||
}
|
||||
@case ('service') {
|
||||
<app-service-detail
|
||||
[service]="found.record"
|
||||
[copy]="copy()"
|
||||
[locale]="data().locale"
|
||||
/>
|
||||
}
|
||||
@case ('blog') {
|
||||
<app-blog-page
|
||||
[posts]="found.record"
|
||||
[copy]="copy()"
|
||||
[locale]="data().locale"
|
||||
/>
|
||||
}
|
||||
@case ('contribute') {
|
||||
<app-contribute-page
|
||||
[calls]="found.record.calls"
|
||||
[issues]="found.record.issues"
|
||||
[copy]="copy()"
|
||||
[locale]="data().locale"
|
||||
/>
|
||||
}
|
||||
@case ('radar') {
|
||||
<app-radar-page [technologies]="found.record" [copy]="copy()" />
|
||||
}
|
||||
@case ('member') {
|
||||
<app-member-detail
|
||||
[member]="found.record"
|
||||
[copy]="copy()"
|
||||
[locale]="data().locale"
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
</app-page-shell>
|
||||
`,
|
||||
})
|
||||
export class RoutedPage {
|
||||
readonly data = input.required<RoutedPageData>();
|
||||
|
||||
protected readonly copy = computed(() => copyFor(this.data().locale));
|
||||
|
||||
protected readonly seo = computed(() =>
|
||||
seoFor(this.data().found, this.copy(), this.data().locale),
|
||||
);
|
||||
}
|
||||
|
||||
/** What each type puts in the document head. Null when nothing was found. */
|
||||
export function seoFor(
|
||||
found: RoutedRecord | null,
|
||||
copy: Copy,
|
||||
locale: Locale,
|
||||
): PageSeo | null {
|
||||
if (!found) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (found.type) {
|
||||
case 'page': {
|
||||
const page = found.record;
|
||||
const post = isBlogPost(page, locale);
|
||||
|
||||
return {
|
||||
title: page.meta_title || `Pireon — ${page.title}`,
|
||||
description: page.meta_description || page.excerpt || '',
|
||||
path: page.path,
|
||||
alternates: page.alternates ?? {},
|
||||
type: 'article',
|
||||
image: page.featured_image?.url,
|
||||
feed: post ? `/blog/rss.xml?lang=${locale}` : undefined,
|
||||
jsonLd: post
|
||||
? {
|
||||
'@type': 'BlogPosting',
|
||||
headline: page.title,
|
||||
description: page.excerpt,
|
||||
inLanguage: page.locale,
|
||||
datePublished: page.published_at,
|
||||
dateModified: page.updated_at,
|
||||
image: page.featured_image?.url,
|
||||
publisher: { '@type': 'Organization', name: 'Pireon' },
|
||||
}
|
||||
: {
|
||||
'@type': 'WebPage',
|
||||
name: page.title,
|
||||
description: page.excerpt,
|
||||
inLanguage: page.locale,
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'project': {
|
||||
const project = found.record;
|
||||
|
||||
return {
|
||||
title: project.meta_title || `Pireon — ${project.title}`,
|
||||
description: project.meta_description || project.tagline || '',
|
||||
path: project.path,
|
||||
alternates: project.alternates ?? {},
|
||||
type: 'article',
|
||||
image: project.cover_image?.url,
|
||||
jsonLd: {
|
||||
'@type': 'CreativeWork',
|
||||
name: project.title,
|
||||
description: project.tagline,
|
||||
inLanguage: project.locale,
|
||||
dateCreated: project.started_at,
|
||||
dateModified: project.updated_at,
|
||||
keywords:
|
||||
(project.technologies ?? [])
|
||||
.map((technology) => technology.name)
|
||||
.join(', ') || undefined,
|
||||
creator: (project.members ?? []).map((member) => ({
|
||||
'@type': 'Person',
|
||||
name: member.name,
|
||||
})),
|
||||
...(project.client
|
||||
? { funder: { '@type': 'Organization', name: project.client.name } }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'member': {
|
||||
const member = found.record;
|
||||
|
||||
return {
|
||||
title: `Pireon — ${member.name}`,
|
||||
description: member.headline || member.bio?.slice(0, 160) || '',
|
||||
path: member.path,
|
||||
alternates: member.alternates ?? {},
|
||||
type: 'profile',
|
||||
image: member.avatar?.url,
|
||||
jsonLd: {
|
||||
'@type': 'Person',
|
||||
name: member.name,
|
||||
jobTitle: member.headline,
|
||||
description: member.bio,
|
||||
image: member.avatar?.url,
|
||||
sameAs: memberLinks(member).map((link) => link.url),
|
||||
memberOf: { '@type': 'Organization', name: 'Pireon' },
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'service': {
|
||||
const service = found.record;
|
||||
|
||||
return {
|
||||
title: service.meta_title || `Pireon — ${service.title}`,
|
||||
description: service.meta_description || service.tagline || '',
|
||||
path: service.path,
|
||||
alternates: service.alternates ?? {},
|
||||
type: 'article',
|
||||
jsonLd: {
|
||||
'@type': 'Service',
|
||||
name: service.title,
|
||||
description: service.tagline,
|
||||
provider: { '@type': 'Organization', name: 'Pireon' },
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'blog':
|
||||
return {
|
||||
title: `Pireon — ${copy.blog.title}`,
|
||||
description: copy.blog.lead,
|
||||
path: listingPath('blog', locale),
|
||||
alternates: listingAlternates('blog'),
|
||||
type: 'website',
|
||||
feed: `/blog/rss.xml?lang=${locale}`,
|
||||
jsonLd: {
|
||||
'@type': 'Blog',
|
||||
name: copy.blog.title,
|
||||
description: copy.blog.lead,
|
||||
},
|
||||
};
|
||||
case 'contribute':
|
||||
return {
|
||||
title: `Pireon — ${copy.contribute.title}`,
|
||||
description: copy.contribute.lead,
|
||||
path: listingPath('contribute', locale),
|
||||
alternates: listingAlternates('contribute'),
|
||||
type: 'website',
|
||||
jsonLd: {
|
||||
'@type': 'WebPage',
|
||||
name: copy.contribute.title,
|
||||
description: copy.contribute.lead,
|
||||
},
|
||||
};
|
||||
case 'radar':
|
||||
return {
|
||||
title: `Pireon — ${copy.sections.radar.title}`,
|
||||
description: copy.radar.lead,
|
||||
path: listingPath('radar', locale),
|
||||
alternates: listingAlternates('radar'),
|
||||
type: 'website',
|
||||
jsonLd: {
|
||||
'@type': 'WebPage',
|
||||
name: copy.sections.radar.title,
|
||||
description: copy.radar.lead,
|
||||
},
|
||||
};
|
||||
case 'call': {
|
||||
const call = found.record;
|
||||
const project = call.project?.title;
|
||||
|
||||
return {
|
||||
title: `Pireon — ${call.title}${project ? ` · ${project}` : ''}`,
|
||||
description: call.description?.slice(0, 160) || '',
|
||||
path: call.path,
|
||||
alternates: call.alternates ?? {},
|
||||
type: 'article',
|
||||
image: call.project?.cover_image?.url,
|
||||
jsonLd: {
|
||||
'@type': 'WebPage',
|
||||
name: call.title,
|
||||
description: call.description,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,13 @@ import type { PublicService } from '../core/api.types';
|
||||
</pi-card-top-content>
|
||||
|
||||
<pi-card-center-content>
|
||||
<h3 bold class="landing-card__title">{{ service().title }}</h3>
|
||||
<h3 bold class="landing-card__title">
|
||||
@if (service().path; as path) {
|
||||
<a class="landing-card__title-link" [href]="path">{{ service().title }}</a>
|
||||
} @else {
|
||||
{{ service().title }}
|
||||
}
|
||||
</h3>
|
||||
@if (service().tagline; as tagline) {
|
||||
<p s pi-color color="grey">{{ tagline }}</p>
|
||||
}
|
||||
|
||||
31
landing/src/app/ui/service-detail.scss
Normal file
31
landing/src/app/ui/service-detail.scss
Normal file
@@ -0,0 +1,31 @@
|
||||
@use './detail';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.service-icon {
|
||||
display: block;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
// A textarea's line breaks are the writer's paragraphs.
|
||||
.service-text {
|
||||
margin: 0;
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.7;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.service-grid {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
|
||||
@media (min-width: 640px) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.service-technologies {
|
||||
margin-top: 0;
|
||||
}
|
||||
128
landing/src/app/ui/service-detail.ts
Normal file
128
landing/src/app/ui/service-detail.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
PiChipComponent,
|
||||
PiIconComponent,
|
||||
PiTextColorDirective,
|
||||
PiTextLargeDirective,
|
||||
PiTextWeightBoldDirective,
|
||||
} from '@pi/ui';
|
||||
|
||||
import type { Locale } from '../core/api';
|
||||
import type { PublicService } from '../core/api.types';
|
||||
import type { Copy } from '../core/copy';
|
||||
import { resolveIcon } from '../core/icons';
|
||||
import { ContactCta } from './contact-cta';
|
||||
import { ProjectCard } from './project-card';
|
||||
import { TestimonialCard } from './testimonial-card';
|
||||
|
||||
/**
|
||||
* A service on its own address, and the published work that backs it: the
|
||||
* projects it was delivered on and what the people it was for said.
|
||||
*
|
||||
* The description comes from a textarea, so it is printed as text with its
|
||||
* line breaks kept.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-service-detail',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
ContactCta,
|
||||
PiChipComponent,
|
||||
PiIconComponent,
|
||||
PiTextColorDirective,
|
||||
PiTextLargeDirective,
|
||||
PiTextWeightBoldDirective,
|
||||
ProjectCard,
|
||||
TestimonialCard,
|
||||
],
|
||||
styleUrl: './service-detail.scss',
|
||||
template: `
|
||||
<header class="content-hero">
|
||||
<div class="landing-shell">
|
||||
<pi-icon
|
||||
class="service-icon"
|
||||
[icon]="icon()"
|
||||
size="x-large"
|
||||
color="primary"
|
||||
/>
|
||||
<p class="landing-eyebrow">{{ copy().service.eyebrow }}</p>
|
||||
<h1 bold class="content-hero__title">{{ service().title }}</h1>
|
||||
|
||||
@if (service().tagline; as tagline) {
|
||||
<p l pi-color color="grey" class="content-hero__lead">
|
||||
{{ tagline }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="landing-shell content-page__layout">
|
||||
<article class="content-page__main">
|
||||
@if (service().description; as description) {
|
||||
<p class="service-text">{{ description }}</p>
|
||||
}
|
||||
|
||||
@if ((service().projects ?? []).length > 0) {
|
||||
<section class="content-page__section">
|
||||
<h2>{{ copy().service.projects }}</h2>
|
||||
<div class="service-grid">
|
||||
@for (project of service().projects; track project.id) {
|
||||
<app-project-card [project]="project" [copy]="copy()" />
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if ((service().testimonials ?? []).length > 0) {
|
||||
<section class="content-page__section">
|
||||
<h2>{{ copy().service.testimonials }}</h2>
|
||||
<div class="service-grid">
|
||||
@for (
|
||||
testimonial of service().testimonials;
|
||||
track testimonial.id
|
||||
) {
|
||||
<app-testimonial-card [testimonial]="testimonial" />
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</article>
|
||||
|
||||
<aside class="content-page__aside">
|
||||
@if ((service().technologies ?? []).length > 0) {
|
||||
<div class="landing-card content-page__panel">
|
||||
<h2>{{ copy().service.technologies }}</h2>
|
||||
<ul class="content-hero__chips service-technologies">
|
||||
@for (technology of service().technologies; track technology.id) {
|
||||
<li>
|
||||
<pi-chip
|
||||
size="small"
|
||||
appearance="outline"
|
||||
[label]="technology.name"
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-contact-cta [copy]="copy()" [locale]="locale()" />
|
||||
</aside>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ServiceDetail {
|
||||
readonly service = input.required<PublicService>();
|
||||
readonly copy = input.required<Copy>();
|
||||
readonly locale = input.required<Locale>();
|
||||
|
||||
/** The panel lets anyone type anything here, emoji included. */
|
||||
protected readonly icon = computed(() =>
|
||||
resolveIcon(this.service().icon, 'support'),
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
} from '@pi/ui';
|
||||
|
||||
import type { PublicPage } from '../core/api.types';
|
||||
import { DEFAULT_LOCALE, type Locale } from '../core/api';
|
||||
import { isBlogPost } from '../core/blog';
|
||||
import type { Copy } from '../core/copy';
|
||||
import { listingPath } from '../core/routes';
|
||||
|
||||
@Component({
|
||||
selector: 'app-site-footer',
|
||||
@@ -47,15 +50,34 @@ export class SiteFooter {
|
||||
readonly copy = input.required<Copy>();
|
||||
/** Every published page — the footer is where the ones not featured in the header are found. */
|
||||
readonly pages = input<PublicPage[]>([]);
|
||||
readonly locale = input<Locale>(DEFAULT_LOCALE);
|
||||
|
||||
protected readonly links = computed(() =>
|
||||
this.pages()
|
||||
.filter((page) => page.path && page.title)
|
||||
.map((page) => ({
|
||||
href: page.path as string,
|
||||
label: page.title as string,
|
||||
})),
|
||||
);
|
||||
/**
|
||||
* Every page but the blog's posts, which would grow the footer without end:
|
||||
* those are one link, to the blog, once there is any.
|
||||
*/
|
||||
protected readonly links = computed(() => {
|
||||
const locale = this.locale();
|
||||
const pages = this.pages().filter((page) => page.path && page.title);
|
||||
const hasPosts = pages.some((page) => isBlogPost(page, locale));
|
||||
|
||||
return [
|
||||
...pages
|
||||
.filter((page) => !isBlogPost(page, locale))
|
||||
.map((page) => ({
|
||||
href: page.path as string,
|
||||
label: page.title as string,
|
||||
})),
|
||||
...(hasPosts
|
||||
? [
|
||||
{
|
||||
href: listingPath('blog', locale),
|
||||
label: this.copy().blog.eyebrow,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
});
|
||||
|
||||
protected readonly year = new Date().getFullYear();
|
||||
}
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
background-color: color-mix(in srgb, var(--pi-color-white, #fff) 88%, transparent);
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--pi-color-white, #fff) 88%,
|
||||
transparent
|
||||
);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid var(--pi-color-grey-200, #e4e4ea);
|
||||
}
|
||||
@@ -160,3 +164,24 @@
|
||||
color: var(--pi-color-grey-600, #5b5b6b);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
// A shadow under the header once the page moves, so it reads as floating over
|
||||
// the content rather than as part of it. Tied to the scroll in CSS; without
|
||||
// scroll-driven animations the border alone does the job, as before.
|
||||
@keyframes header-lift {
|
||||
to {
|
||||
border-bottom-color: transparent;
|
||||
box-shadow: 0 10px 30px -18px
|
||||
color-mix(in srgb, var(--pi-color-primary) 45%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@supports (animation-timeline: scroll()) {
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.landing-header {
|
||||
animation: header-lift linear both;
|
||||
animation-timeline: scroll(root);
|
||||
animation-range: 0 5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,38 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
||||
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
|
||||
import {
|
||||
afterNextRender,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
inject,
|
||||
input,
|
||||
PLATFORM_ID,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { PiStatCardComponent } from '@pi/ui';
|
||||
|
||||
import type { Copy } from '../core/copy';
|
||||
import type { Locale, SitePayload } from '../core/api';
|
||||
|
||||
/**
|
||||
* The hero's stats appear 0.8s after load (`hero.scss`). Counting up is only
|
||||
* worth it if it can start from zero before anyone has read the number: after
|
||||
* that, dropping a figure already on screen to 0 is a flicker, not an effect.
|
||||
*/
|
||||
const COUNT_UP_BEFORE_MS = 800;
|
||||
|
||||
const COUNT_UP_DURATION_MS = 1400;
|
||||
|
||||
/**
|
||||
* The headline numbers from `/site`, as `pi-stat-card`. Zeroes are dropped
|
||||
* rather than shown: "0 proyectos" says less than nothing, and a young site
|
||||
* has plenty of them.
|
||||
*
|
||||
* The numbers count up from zero as they come into view. The server renders
|
||||
* the real figures, so without JavaScript — or with reduced motion — they are
|
||||
* simply there.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-stat-grid',
|
||||
@@ -34,6 +59,20 @@ export class StatGrid {
|
||||
readonly copy = input.required<Copy>();
|
||||
readonly locale = input.required<Locale>();
|
||||
|
||||
private readonly host = inject(ElementRef<HTMLElement>)
|
||||
.nativeElement as HTMLElement;
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
/** 0 → 1 while counting; 1 on the server and whenever there is no animation. */
|
||||
private readonly progress = signal(1);
|
||||
|
||||
constructor() {
|
||||
if (isPlatformBrowser(inject(PLATFORM_ID))) {
|
||||
afterNextRender(() => this.countUpWhenSeen());
|
||||
}
|
||||
}
|
||||
|
||||
protected readonly stats = computed(() => {
|
||||
const raw = this.site()?.stats ?? {};
|
||||
const labels = this.copy().stats;
|
||||
@@ -60,7 +99,70 @@ export class StatGrid {
|
||||
icon: entry.icon,
|
||||
color: entry.color,
|
||||
label: labels[entry.key],
|
||||
value: format.format(raw[entry.key] ?? 0),
|
||||
value: format.format(
|
||||
Math.round((raw[entry.key] ?? 0) * this.progress()),
|
||||
),
|
||||
}));
|
||||
});
|
||||
|
||||
private countUpWhenSeen(): void {
|
||||
const view = this.document.defaultView;
|
||||
|
||||
if (
|
||||
!view ||
|
||||
typeof IntersectionObserver === 'undefined' ||
|
||||
view.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onScreen = this.host.getBoundingClientRect().top < view.innerHeight;
|
||||
|
||||
if (onScreen) {
|
||||
// In the hero at the top of the page: start with its entrance, or not
|
||||
// at all if the number has already been on screen.
|
||||
const wait = COUNT_UP_BEFORE_MS - view.performance.now();
|
||||
|
||||
if (wait > 0) {
|
||||
this.progress.set(0);
|
||||
view.setTimeout(() => this.countUp(view), wait);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Further down — a reload that restored the scroll position: count when
|
||||
// it arrives.
|
||||
this.progress.set(0);
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
observer.disconnect();
|
||||
this.countUp(view);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.4 },
|
||||
);
|
||||
|
||||
observer.observe(this.host);
|
||||
this.destroyRef.onDestroy(() => observer.disconnect());
|
||||
}
|
||||
|
||||
private countUp(view: Window): void {
|
||||
const started = view.performance.now();
|
||||
|
||||
const tick = (now: number) => {
|
||||
const elapsed = Math.min(1, (now - started) / COUNT_UP_DURATION_MS);
|
||||
|
||||
// Ease out: fast at first, settling on the real figure.
|
||||
this.progress.set(1 - Math.pow(1 - elapsed, 3));
|
||||
|
||||
if (elapsed < 1) {
|
||||
view.requestAnimationFrame(tick);
|
||||
}
|
||||
};
|
||||
|
||||
view.requestAnimationFrame(tick);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineEventHandler, getRequestURL, setResponseStatus } from 'h3';
|
||||
|
||||
import { contentRoute } from '../../app/core/api';
|
||||
import { resolveRoute } from '../../app/core/routes';
|
||||
|
||||
/**
|
||||
* Answers 404 for addresses this app does not serve.
|
||||
@@ -8,7 +8,7 @@ import { contentRoute } from '../../app/core/api';
|
||||
* The status cannot be set from the catch-all page's `load`: Analog resolves
|
||||
* loaders through an internal request, so anything set on that response never
|
||||
* reaches the browser. Here it can be, and the route table is small enough to
|
||||
* state outright — the landing, plus the pages the panel publishes.
|
||||
* state outright — the landing, plus the addresses records live at.
|
||||
*/
|
||||
const KNOWN_PATHS = new Set(['/', '/index.html']);
|
||||
|
||||
@@ -30,11 +30,11 @@ export default defineEventHandler((event) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Shaped like a content page (`/sobre-nosotros`, `/en/about-us`). Only the
|
||||
// API knows whether it exists, and asking it here would be a second request
|
||||
// for what the page asks anyway — so the page component sets the 404 itself
|
||||
// when the API has no such page. See `ui/content-page.ts`.
|
||||
if (contentRoute(pathname)) {
|
||||
// Shaped like a record's address (`/sobre-nosotros`, `/proyectos/x`,
|
||||
// `/en/team/x`). Only the API knows whether it exists, and asking it here
|
||||
// would be a second request for what the page asks anyway — so the page
|
||||
// sets the 404 itself when the API has nothing there. See `ui/page-shell.ts`.
|
||||
if (resolveRoute(pathname)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
59
landing/src/server/middleware/redirects.ts
Normal file
59
landing/src/server/middleware/redirects.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Answers an address the panel has retired with a redirect to where the
|
||||
* record lives now, instead of the 404 it would otherwise be.
|
||||
*
|
||||
* The list comes from the API whole — there are never many — and is kept for
|
||||
* a minute, so a page view costs no extra request. If the API does not answer,
|
||||
* the last list that arrived is kept: an outage should not turn every renamed
|
||||
* link back into a 404.
|
||||
*/
|
||||
import { defineEventHandler, getRequestURL, sendRedirect } from 'h3';
|
||||
|
||||
import { pireonApi, type Redirect } from '../../app/core/api';
|
||||
import { findRedirect } from '../../app/core/redirects';
|
||||
|
||||
const TTL_MS = 60_000;
|
||||
|
||||
let cached: Redirect[] = [];
|
||||
let loadedAt = 0;
|
||||
let pending: Promise<void> | null = null;
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
const { data, error } = await pireonApi.redirects();
|
||||
|
||||
if (!error && data) {
|
||||
cached = data;
|
||||
}
|
||||
|
||||
loadedAt = Date.now();
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
if (event.method !== 'GET' && event.method !== 'HEAD') {
|
||||
return;
|
||||
}
|
||||
|
||||
const { pathname } = getRequestURL(event);
|
||||
|
||||
// The app's own endpoints and files are never renamed in the panel.
|
||||
if (
|
||||
/^\/(api|_analog|assets|@|src|node_modules)\//.test(pathname) ||
|
||||
/\.[a-z0-9]+$/i.test(pathname)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Date.now() - loadedAt > TTL_MS) {
|
||||
pending ??= refresh().finally(() => {
|
||||
pending = null;
|
||||
});
|
||||
|
||||
await pending;
|
||||
}
|
||||
|
||||
const redirect = findRedirect(pathname, cached);
|
||||
|
||||
if (redirect) {
|
||||
return sendRedirect(event, redirect.to, redirect.status);
|
||||
}
|
||||
});
|
||||
49
landing/src/server/routes/api/calls/[slug]/apply.post.ts
Normal file
49
landing/src/server/routes/api/calls/[slug]/apply.post.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* An application to one call, forwarded to the API for the reason the general
|
||||
* form is (see `contact.post.ts`).
|
||||
*
|
||||
* The slug travels as it came: the API resolves it in the request's language
|
||||
* and answers 404 for a call that is not open, which is passed on as it is.
|
||||
*/
|
||||
import {
|
||||
defineEventHandler,
|
||||
getQuery,
|
||||
getRouterParam,
|
||||
readBody,
|
||||
setResponseStatus,
|
||||
} from 'h3';
|
||||
|
||||
import { postSubmission, resolveLocale } from '../../../../../app/core/api';
|
||||
import {
|
||||
checkSubmission,
|
||||
type SubmissionBody,
|
||||
} from '../../../../../app/core/submission';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const slug = getRouterParam(event, 'slug') ?? '';
|
||||
const body = (await readBody<SubmissionBody>(event)) ?? {};
|
||||
const locale = resolveLocale(getQuery(event)['lang']);
|
||||
const { errors, payload } = checkSubmission(body, { forCall: true });
|
||||
|
||||
if (errors) {
|
||||
setResponseStatus(event, 422);
|
||||
|
||||
return { errors, message: 'Validation failed' };
|
||||
}
|
||||
|
||||
try {
|
||||
const outcome = await postSubmission(
|
||||
`/calls/${encodeURIComponent(slug)}/apply`,
|
||||
payload,
|
||||
locale,
|
||||
);
|
||||
|
||||
setResponseStatus(event, outcome.status);
|
||||
|
||||
return outcome.body;
|
||||
} catch {
|
||||
setResponseStatus(event, 502);
|
||||
|
||||
return { message: 'upstream_unreachable' };
|
||||
}
|
||||
});
|
||||
@@ -1,90 +1,30 @@
|
||||
/**
|
||||
* The browser's only write path.
|
||||
* The general form's write path.
|
||||
*
|
||||
* The public API throttles submissions per e-mail address rather than per IP
|
||||
* precisely because an SSR frontend sends everything from one address, so the
|
||||
* form posts here and this handler forwards it. Keeping the API host out of
|
||||
* the browser also keeps CORS out of the picture.
|
||||
*/
|
||||
import { defineEventHandler, readBody, setResponseStatus, getQuery } from 'h3';
|
||||
import { defineEventHandler, getQuery, readBody, setResponseStatus } from 'h3';
|
||||
|
||||
import { postSubmission, resolveLocale } from '../../../app/core/api';
|
||||
import {
|
||||
postSubmission,
|
||||
resolveLocale,
|
||||
type SubmissionPayload,
|
||||
} from '../../../app/core/api';
|
||||
|
||||
interface ContactBody {
|
||||
name?: string;
|
||||
email?: string;
|
||||
message?: string;
|
||||
organisation?: string | null;
|
||||
hours_per_week?: number | string | null;
|
||||
kind?: 'contribute' | 'request';
|
||||
consent?: boolean;
|
||||
links?: Record<string, string>;
|
||||
/** Honeypot. Never rendered visibly; a bot that fills it is filed as spam. */
|
||||
website?: string | null;
|
||||
}
|
||||
checkSubmission,
|
||||
type SubmissionBody,
|
||||
} from '../../../app/core/submission';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = (await readBody<ContactBody>(event)) ?? {};
|
||||
const body = (await readBody<SubmissionBody>(event)) ?? {};
|
||||
const locale = resolveLocale(getQuery(event)['lang']);
|
||||
const kind = body.kind === 'request' ? 'request' : 'contribute';
|
||||
const { errors, payload } = checkSubmission(body);
|
||||
|
||||
const name = body.name?.trim() ?? '';
|
||||
const email = body.email?.trim() ?? '';
|
||||
const message = body.message?.trim() ?? '';
|
||||
const organisation = body.organisation?.trim() ?? '';
|
||||
|
||||
const errors: Record<string, string[]> = {};
|
||||
|
||||
if (name.length < 2) {
|
||||
errors['name'] = ['required'];
|
||||
}
|
||||
|
||||
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
|
||||
errors['email'] = ['required'];
|
||||
}
|
||||
|
||||
// The API rejects anything shorter, and rightly: a one-word submission is
|
||||
// never anything but noise.
|
||||
if (message.length < 20) {
|
||||
errors['message'] = ['min'];
|
||||
}
|
||||
|
||||
if (kind === 'request' && organisation.length === 0) {
|
||||
errors['organisation'] = ['required'];
|
||||
}
|
||||
|
||||
if (body.consent !== true) {
|
||||
errors['consent'] = ['required'];
|
||||
}
|
||||
|
||||
// Caught here rather than spent on a round trip: the API would answer 422
|
||||
// with the same verdict, and its throttle counts the attempt either way.
|
||||
if (Object.keys(errors).length > 0) {
|
||||
if (errors) {
|
||||
setResponseStatus(event, 422);
|
||||
|
||||
return { errors, message: 'Validation failed' };
|
||||
}
|
||||
|
||||
const hours = Number(body.hours_per_week);
|
||||
|
||||
const payload: SubmissionPayload = {
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
consent: true,
|
||||
kind,
|
||||
organisation: organisation || null,
|
||||
hours_per_week: Number.isFinite(hours) && hours > 0 ? hours : null,
|
||||
...(body.links && Object.keys(body.links).length > 0
|
||||
? { links: body.links }
|
||||
: {}),
|
||||
...(body.website ? { website: body.website } : {}),
|
||||
};
|
||||
|
||||
try {
|
||||
const outcome = await postSubmission('/contact', payload, locale);
|
||||
|
||||
|
||||
54
landing/src/server/routes/blog/rss.xml.get.ts
Normal file
54
landing/src/server/routes/blog/rss.xml.get.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* `/blog/rss.xml?lang=xx`: the blog as RSS, in one language — a feed reader
|
||||
* subscribes to one, and a post only has the addresses of the languages it was
|
||||
* translated into.
|
||||
*/
|
||||
import {
|
||||
defineEventHandler,
|
||||
getQuery,
|
||||
getRequestHeaders,
|
||||
setResponseHeader,
|
||||
setResponseStatus,
|
||||
} from 'h3';
|
||||
|
||||
import { pireonApi, resolveLocale } from '../../../app/core/api';
|
||||
import { BLOG_CATEGORY, byPublication } from '../../../app/core/blog';
|
||||
import { copyFor } from '../../../app/core/copy';
|
||||
import { originFromHeaders } from '../../../app/core/origin';
|
||||
import { listingPath } from '../../../app/core/routes';
|
||||
import { buildFeed } from '../../../app/core/rss';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const locale = resolveLocale(getQuery(event)['lang']);
|
||||
const origin = originFromHeaders(getRequestHeaders(event)) ?? '';
|
||||
const copy = copyFor(locale);
|
||||
|
||||
const { data, error } = await pireonApi.pages(locale, {
|
||||
category: BLOG_CATEGORY[locale],
|
||||
per_page: 50,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setResponseStatus(event, 503);
|
||||
setResponseHeader(event, 'Cache-Control', 'no-store');
|
||||
|
||||
return 'Feed temporarily unavailable';
|
||||
}
|
||||
|
||||
setResponseHeader(
|
||||
event,
|
||||
'Content-Type',
|
||||
'application/rss+xml; charset=utf-8',
|
||||
);
|
||||
|
||||
return buildFeed(
|
||||
origin,
|
||||
locale,
|
||||
{
|
||||
title: `Pireon — ${copy.blog.title}`,
|
||||
description: copy.blog.lead,
|
||||
link: listingPath('blog', locale),
|
||||
},
|
||||
byPublication(data ?? []),
|
||||
);
|
||||
});
|
||||
21
landing/src/server/routes/robots.txt.get.ts
Normal file
21
landing/src/server/routes/robots.txt.get.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Everything is crawlable, and the sitemap says where everything is. Absolute,
|
||||
* because robots.txt requires it, from whichever domain this is served on.
|
||||
*/
|
||||
import { defineEventHandler, getRequestHeaders, setResponseHeader } from 'h3';
|
||||
|
||||
import { originFromHeaders } from '../../app/core/origin';
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const origin = originFromHeaders(getRequestHeaders(event)) ?? '';
|
||||
|
||||
setResponseHeader(event, 'Content-Type', 'text/plain; charset=utf-8');
|
||||
|
||||
return [
|
||||
'User-agent: *',
|
||||
'Allow: /',
|
||||
'',
|
||||
`Sitemap: ${origin}/sitemap.xml`,
|
||||
'',
|
||||
].join('\n');
|
||||
});
|
||||
34
landing/src/server/routes/sitemap.xml.get.ts
Normal file
34
landing/src/server/routes/sitemap.xml.get.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* `/sitemap.xml`, from the API's `/slugs` — one request for every published
|
||||
* address, instead of paging through each collection.
|
||||
*
|
||||
* Cached like any page: a record published a minute ago can wait a minute to
|
||||
* be listed. If the API is down the answer is a 503 rather than a sitemap
|
||||
* with half the site missing from it, which a crawler would believe.
|
||||
*/
|
||||
import {
|
||||
defineEventHandler,
|
||||
getRequestHeaders,
|
||||
setResponseHeader,
|
||||
setResponseStatus,
|
||||
} from 'h3';
|
||||
|
||||
import { pireonApi } from '../../app/core/api';
|
||||
import { originFromHeaders } from '../../app/core/origin';
|
||||
import { buildSitemap } from '../../app/core/sitemap';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const origin = originFromHeaders(getRequestHeaders(event)) ?? '';
|
||||
const { data } = await pireonApi.slugs();
|
||||
|
||||
if (!data) {
|
||||
setResponseStatus(event, 503);
|
||||
setResponseHeader(event, 'Cache-Control', 'no-store');
|
||||
|
||||
return 'Sitemap temporarily unavailable';
|
||||
}
|
||||
|
||||
setResponseHeader(event, 'Content-Type', 'application/xml; charset=utf-8');
|
||||
|
||||
return buildSitemap(origin, data);
|
||||
});
|
||||
@@ -221,6 +221,23 @@ html {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
// A card's title leads to the record's own page. The card itself stays
|
||||
// unlinked: it also holds links of its own (demo, repository) and a link
|
||||
// inside a link is invalid.
|
||||
.landing-card__button-link {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.landing-card__title-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.landing-card__media {
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
@@ -237,10 +254,17 @@ html {
|
||||
.landing-card__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
margin: 0.75rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
// `@pi/ui`'s reset gives every `li + li` a top margin — a rhythm for a list
|
||||
// of prose. In a row spaced by `gap` it dropped every chip but the first.
|
||||
> li {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.landing-card__chips--center {
|
||||
@@ -448,25 +472,69 @@ html.dark-mode .pi-shell {
|
||||
// so a visitor without JavaScript — or one whose hydration failed — reads a
|
||||
// fully visible page instead of a blank column. The media query is a second
|
||||
// belt: the directive already skips the observer when motion is reduced.
|
||||
//
|
||||
// A section does not fade as one block: its heading arrives first, then what
|
||||
// it holds, and the cards of a grid one after another — so reading down the
|
||||
// page, each section assembles itself as you reach it.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
html.reveal-ready .reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(18px);
|
||||
transition:
|
||||
opacity 0.55s ease-out,
|
||||
transform 0.55s ease-out;
|
||||
}
|
||||
$reveal-ease: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
|
||||
html.reveal-ready .reveal.is-revealed {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
html.reveal-ready .reveal {
|
||||
.landing-section__head,
|
||||
.landing-section__head ~ *,
|
||||
.landing-grid > *,
|
||||
.landing-issues > li {
|
||||
opacity: 0;
|
||||
transform: translateY(24px);
|
||||
transition:
|
||||
opacity 0.6s ease-out,
|
||||
transform 0.7s $reveal-ease;
|
||||
}
|
||||
|
||||
// Cards also grow into place, which reads as arriving rather than sliding.
|
||||
.landing-grid > * {
|
||||
transform: translateY(28px) scale(0.97);
|
||||
}
|
||||
|
||||
&.is-revealed {
|
||||
.landing-section__head,
|
||||
.landing-section__head ~ *,
|
||||
.landing-grid > *,
|
||||
.landing-issues > li {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.landing-section__head ~ * {
|
||||
transition-delay: 0.12s;
|
||||
}
|
||||
|
||||
// One after another; past the eighth they arrive together, so a long grid
|
||||
// does not keep the reader waiting for its last row.
|
||||
@for $i from 1 through 8 {
|
||||
.landing-grid > :nth-child(#{$i}),
|
||||
.landing-issues > li:nth-child(#{$i}) {
|
||||
transition-delay: #{0.15 + ($i - 1) * 0.08}s;
|
||||
}
|
||||
}
|
||||
|
||||
.landing-grid > :nth-child(n + 9),
|
||||
.landing-issues > li:nth-child(n + 9) {
|
||||
transition-delay: 0.8s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html.reveal-ready .reveal {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
transition: none;
|
||||
.landing-section__head,
|
||||
.landing-section__head ~ *,
|
||||
.landing-grid > *,
|
||||
.landing-issues > li {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,19 @@ const page = (id, title, slug, featured) => ({
|
||||
alternates: { es: `/${slug}`, en: `/en/${slug}-en`, ca: `/ca/${slug}-ca` }, path: `/${slug}`,
|
||||
});
|
||||
|
||||
const pages = [page(1, 'Sobre nosotros', 'sobre-nosotros', true), page(2, 'Contacto', 'contacto', false)];
|
||||
const post = (id, title, slug, published_at) => ({
|
||||
...page(id, title, slug, false),
|
||||
published_at,
|
||||
excerpt: 'Lo que hemos aprendido este mes, contado sin prisa y con ejemplos de código.',
|
||||
category: { id: 9, name: 'Blog', slug: 'blog', position: 3 },
|
||||
});
|
||||
|
||||
const pages = [
|
||||
page(1, 'Sobre nosotros', 'sobre-nosotros', true),
|
||||
page(2, 'Contacto', 'contacto', false),
|
||||
post(3, 'Por qué volvimos a renderizar en el servidor', 'volvimos-al-servidor', '2026-09-20T09:00:00Z'),
|
||||
post(4, 'Nuestro primer año como colectivo', 'primer-ano', '2026-06-01T09:00:00Z'),
|
||||
];
|
||||
|
||||
const data = {
|
||||
'/public/v1/pages': pages,
|
||||
@@ -103,12 +115,106 @@ const data = {
|
||||
],
|
||||
};
|
||||
|
||||
// Detail endpoints: what a project's or a member's own page asks for. Richer
|
||||
// than the listings — gallery, prose, team — because that is what the detail
|
||||
// endpoints add.
|
||||
const prose = (topic) =>
|
||||
`<p>${topic} empezó como una necesidad concreta de una asociación y acabó siendo una herramienta que usan varias.</p>` +
|
||||
'<ul><li>Pensado para que lo mantenga gente que no programa.</li><li>Con pruebas y documentación desde el primer día.</li></ul>';
|
||||
|
||||
const team = data['/public/v1/members'].slice(0, 3).map((m, i) => ({
|
||||
...m,
|
||||
role: ['Responsable', 'Frontend', 'Backend'][i],
|
||||
}));
|
||||
|
||||
for (const listed of data['/public/v1/projects']) {
|
||||
data[`/public/v1/projects/${listed.slug}`] = {
|
||||
...listed,
|
||||
description: prose(listed.title),
|
||||
decisions: '<p>Elegimos renderizar en el servidor porque la mayoría de visitas llegan desde móviles modestos.</p>',
|
||||
postmortem: '<p>Subestimamos la importación de datos antiguos: nos llevó el doble de lo previsto.</p>',
|
||||
meta_title: `${listed.title} · Pireon`,
|
||||
meta_description: listed.tagline,
|
||||
started_at: '2025-03-01T00:00:00Z',
|
||||
ended_at: listed.status === 'completed' ? '2026-06-01T00:00:00Z' : null,
|
||||
client: data['/public/v1/clients'][0],
|
||||
members: team,
|
||||
lead: team[0],
|
||||
calls: data['/public/v1/calls'].slice(0, listed.id % 2 ? 1 : 0).map((c) => ({ ...c, path: `/colabora/${c.slug}` })),
|
||||
alternates: { es: `/proyectos/${listed.slug}`, en: `/en/projects/${listed.slug}`, ca: `/ca/projectes/${listed.slug}` },
|
||||
};
|
||||
}
|
||||
|
||||
// Calls link to their own page, and one of them is closed so the page can
|
||||
// show what a visitor sees when a link to a filled call is still out there.
|
||||
for (const call of data['/public/v1/calls']) {
|
||||
call.path = `/colabora/${call.slug}`;
|
||||
call.alternates = { es: call.path, en: `/en/contribute/${call.slug}`, ca: `/ca/col-labora/${call.slug}` };
|
||||
data[`/public/v1/calls/${call.slug}`] = {
|
||||
...call,
|
||||
status: call.id === 2 ? 'filled' : 'open',
|
||||
what_you_get: 'Revisiones de código con alguien del colectivo.\nUn proyecto real en tu portfolio.',
|
||||
total_hours: call.hours_per_week * (call.duration_weeks ?? 4),
|
||||
project: { ...call.project, path: `/proyectos/${call.project.slug}` },
|
||||
};
|
||||
}
|
||||
|
||||
for (const service of data['/public/v1/services']) {
|
||||
service.path = `/servicios/${service.slug}`;
|
||||
data[`/public/v1/services/${service.slug}`] = {
|
||||
...service,
|
||||
description: 'Empezamos escuchando: qué hace hoy la organización y qué le sobra.\nDespués construimos lo mínimo que resuelve el problema, y lo dejamos documentado para que lo pueda mantener otra persona.',
|
||||
meta_description: service.tagline,
|
||||
projects: data['/public/v1/projects'].slice(0, 2).map((p) => ({ ...p, path: `/proyectos/${p.slug}` })),
|
||||
technologies: technologies.slice(0, 4),
|
||||
testimonials: data['/public/v1/testimonials'].slice(0, 2),
|
||||
alternates: { es: service.path, en: `/en/services/${service.slug}`, ca: `/ca/serveis/${service.slug}` },
|
||||
};
|
||||
}
|
||||
|
||||
for (const listed of data['/public/v1/members']) {
|
||||
data[`/public/v1/members/${listed.slug}`] = {
|
||||
...listed,
|
||||
bio: 'Programa desde hace años en proyectos de todo tipo.\nAhora dedica unas horas a la semana al colectivo, sobre todo a revisar código y acompañar a quien empieza.',
|
||||
joined_at: '2024-09-01T00:00:00Z',
|
||||
technologies: technologies.slice(0, 4),
|
||||
projects: data['/public/v1/projects'].slice(0, 2).map((p) => ({ ...p, path: `/proyectos/${p.slug}` })),
|
||||
alternates: { es: `/equipo/${listed.slug}`, en: `/en/team/${listed.slug}`, ca: `/ca/equip/${listed.slug}` },
|
||||
};
|
||||
}
|
||||
|
||||
// What the sitemap is built from, and one renamed project to exercise the
|
||||
// redirect middleware with.
|
||||
data['/public/v1/slugs'] = {
|
||||
pages: data['/public/v1/pages'].map((p) => ({ id: p.id, updated_at: p.updated_at, alternates: p.alternates })),
|
||||
projects: data['/public/v1/projects'].map((p) => ({
|
||||
id: p.id,
|
||||
updated_at: p.updated_at,
|
||||
alternates: data[`/public/v1/projects/${p.slug}`].alternates,
|
||||
})),
|
||||
};
|
||||
data['/public/v1/redirects'] = [{ from: '/proyectos/catalogo-viejo', to: '/proyectos/catalogo', status: 301, locale: null }];
|
||||
|
||||
createServer((req, res) => {
|
||||
const path = new URL(req.url, 'http://x').pathname.replace(/^\/api/, '');
|
||||
const payload = data[path];
|
||||
const url = new URL(req.url, 'http://x');
|
||||
const path = url.pathname.replace(/^\/api/, '');
|
||||
let payload = data[path];
|
||||
|
||||
// The one filter the frontend relies on: pages by category slug.
|
||||
if (path === '/public/v1/pages' && url.searchParams.get('category')) {
|
||||
payload = payload.filter((p) => p.category?.slug === url.searchParams.get('category'));
|
||||
}
|
||||
|
||||
res.setHeader('content-type', 'application/json');
|
||||
|
||||
// The two write endpoints: accept whatever arrives, as the real API does
|
||||
// for a valid submission.
|
||||
if (req.method === 'POST') {
|
||||
res.statusCode = 201;
|
||||
res.end(JSON.stringify({ data: { id: 1, kind: 'contribute', status: 'new' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload === undefined) {
|
||||
res.statusCode = 404;
|
||||
res.end(JSON.stringify({ message: 'not found' }));
|
||||
|
||||
Reference in New Issue
Block a user