Compare commits
13 Commits
240ad149e7
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
289af6c3a1 | ||
|
|
1ee4f98a9e | ||
|
|
3366f57e14 | ||
|
|
334640d938 | ||
|
|
63d4410296 | ||
|
|
a3783222c8 | ||
|
|
bca3e4bcc6 | ||
|
|
918465a8ab | ||
|
|
e9082b90ee | ||
|
|
aea8f5675d | ||
|
|
e2e90d5512 | ||
|
|
e5d91d673a | ||
|
|
5ea9914583 |
20
README.md
20
README.md
@@ -131,6 +131,15 @@ pass on the server:
|
||||
| Charlas | `/talks?limit=6` |
|
||||
| Contact form | `POST /contact` |
|
||||
|
||||
A project page also asks TaskStream, when the project has a board there
|
||||
(`taskstream.showcase_url` in its payload): roadmap (with how many want each
|
||||
epic), releases, the sprint under way, tickets labelled `good-first-issue`, and
|
||||
the most voted bug reports and ideas from the public, with a link to send one
|
||||
while the board takes them. It is public, answers within four seconds or the
|
||||
page goes without those sections, and its links open the board (voting needs
|
||||
signing in there). `npm run mock` serves a showcase for the «Catálogo abierto»
|
||||
project, so every section can be seen without TaskStream.
|
||||
|
||||
`landing/openapi.yaml` is the specification the app was written against, and
|
||||
`landing/src/app/core/api.types.ts` is the API's own generated type file. Both
|
||||
are copies — refresh them from the API when the contract moves:
|
||||
@@ -265,9 +274,16 @@ any page. That category has to exist with the slug `blog` in every language
|
||||
- `/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`.
|
||||
- A post shows who signs it (`byline`, linked to their profile) and its tags,
|
||||
each linking to `/blog?tag=…`. The blog offers the tags in use as a row of
|
||||
links and narrows to one with `?tag=` — plain links, so a tag's listing can
|
||||
be shared and works before any script runs. Tags and bylines are set in the
|
||||
panel.
|
||||
- `/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.
|
||||
- Once there is a post, the blog is in the header menu (desktop and mobile)
|
||||
and the landing shows the three latest under "Novedades"; the footer links
|
||||
it too, but does not list posts one by one — it would grow without end.
|
||||
A blog reachable only from the footer turned out to be one nobody found.
|
||||
|
||||
## Search engines and link previews
|
||||
|
||||
|
||||
@@ -108,6 +108,15 @@ test.describe('landing', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('answers 404 for a technology it does not know', async ({ page }) => {
|
||||
// Shaped like a technology address, so the middleware lets it through and
|
||||
// the status comes from the page once the collection had no such slug.
|
||||
const response = await page.goto('/tecnologias/no-such-technology');
|
||||
|
||||
expect(response?.status()).toBe(404);
|
||||
await expect(page.locator('h1')).toContainText('no existe');
|
||||
});
|
||||
|
||||
test('refuses an incomplete contact submission without calling the API', async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
4369
landing/openapi.yaml
4369
landing/openapi.yaml
File diff suppressed because it is too large
Load Diff
@@ -238,6 +238,39 @@ export const pireonApi = {
|
||||
technologies: (lang: Locale, query?: GetOptions['query']) =>
|
||||
list<PublicTechnology>('/technologies', { lang, query }),
|
||||
|
||||
/**
|
||||
* One technology, by slug.
|
||||
*
|
||||
* The public API has no `/technologies/{slug}` — that route does not exist,
|
||||
* so asking for one answers "route not found" rather than "no such record".
|
||||
* This filters the collection instead: a couple of dozen records in a single
|
||||
* request the route's `s-maxage` already caches.
|
||||
*
|
||||
* `in_use: false` because the parameter defaults to true, which returns only
|
||||
* technologies attached to a project — none of them, today.
|
||||
*
|
||||
* A failed call and an unknown slug have to stay distinguishable: the first
|
||||
* is an outage, the second is a 404. So the collection's `error` is passed
|
||||
* through untouched and only a successful, complete answer can conclude that
|
||||
* a technology does not exist.
|
||||
*/
|
||||
technology: async (
|
||||
lang: Locale,
|
||||
slug: string
|
||||
): Promise<ApiResult<PublicTechnology>> => {
|
||||
const all = await list<PublicTechnology>('/technologies', {
|
||||
lang,
|
||||
query: { in_use: false },
|
||||
});
|
||||
|
||||
return {
|
||||
...all,
|
||||
data: all.error
|
||||
? null
|
||||
: (all.data?.find((technology) => technology.slug === slug) ?? null),
|
||||
};
|
||||
},
|
||||
|
||||
goodFirstIssues: (query?: GetOptions['query']) =>
|
||||
list<PublicIssue>('/good-first-issues', { query }),
|
||||
|
||||
|
||||
@@ -165,6 +165,14 @@ export interface PublicPage {
|
||||
unpublish_at?: string;
|
||||
updated_at: string;
|
||||
category: PublicCategory;
|
||||
/**
|
||||
* The ones named in this language, to link to the blog filtered by each.
|
||||
*/
|
||||
tags: ({ name: string; slug: string })[];
|
||||
/**
|
||||
* The member who signs it, when they have a public profile.
|
||||
*/
|
||||
byline?: { name: string; slug: string; headline?: string; avatar?: Image; path: string };
|
||||
alternates: Alternates;
|
||||
path?: string;
|
||||
}
|
||||
@@ -233,6 +241,10 @@ export interface PublicProject {
|
||||
lead: PublicMember;
|
||||
calls: PublicCall[];
|
||||
repositories: PublicRepository[];
|
||||
/**
|
||||
* Its board in TaskStream, if it has one. `showcase_url` answers without a token: roadmap, releases, the sprint under way and tickets labelled good-first-issue.
|
||||
*/
|
||||
taskstream?: { key: string; url: string; web_url: string; showcase_url: string };
|
||||
alternates: Alternates;
|
||||
path?: string;
|
||||
}
|
||||
@@ -268,6 +280,21 @@ export interface PublicRepository {
|
||||
good_first_issues: PublicIssue[];
|
||||
}
|
||||
|
||||
export interface PublicSearchResult {
|
||||
type: 'page' | 'project' | 'service' | 'call' | 'member' | 'technology';
|
||||
id: number;
|
||||
title: string;
|
||||
/**
|
||||
* Plain text around the first match.
|
||||
*/
|
||||
excerpt: string;
|
||||
path?: string;
|
||||
/**
|
||||
* Higher is a better match. Only comparable within one response.
|
||||
*/
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface PublicService {
|
||||
id: number;
|
||||
locale: string;
|
||||
@@ -382,6 +409,8 @@ export interface PublicApiPayloads {
|
||||
'/public/v1/site': { locales: ({ code: string; name: string; flag: string; is_default: boolean })[]; default_locale: string; content_version: number; submissions_enabled: boolean; stats: { projects: number; active_projects: number; members: number; open_calls: number; good_first_issues: number; stars: number } };
|
||||
'/public/v1/slugs': Record<string, ({ id: number; updated_at: string; alternates: Record<string, string> })[]>;
|
||||
'/public/v1/redirects': ({ from: string; to: string; status: 301 | 302; locale?: string })[];
|
||||
'/public/v1/search': PublicSearchResult[];
|
||||
'/public/v1/tags': ({ name: string; slug: string; count: number })[];
|
||||
'/public/v1/projects': PublicProject[];
|
||||
'/public/v1/projects/{slug}': PublicProject;
|
||||
'/public/v1/members': PublicMember[];
|
||||
|
||||
35
landing/src/app/core/blog.spec.ts
Normal file
35
landing/src/app/core/blog.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { PublicPage } from './api.types';
|
||||
import { tagHref, taggedWith, tagsOf } from './blog';
|
||||
|
||||
const post = (id: number, tags: { name: string; slug: string }[]) =>
|
||||
({ id, tags }) as unknown as PublicPage;
|
||||
|
||||
describe('blog tags', () => {
|
||||
const posts = [
|
||||
post(1, [{ name: 'Angular', slug: 'angular' }]),
|
||||
post(2, [
|
||||
{ name: 'Angular', slug: 'angular' },
|
||||
{ name: 'Accesibilidad', slug: 'accesibilidad' },
|
||||
]),
|
||||
post(3, []),
|
||||
];
|
||||
|
||||
it('lists each tag once, most used first', () => {
|
||||
expect(tagsOf(posts)).toEqual([
|
||||
{ name: 'Angular', slug: 'angular', count: 2 },
|
||||
{ name: 'Accesibilidad', slug: 'accesibilidad', count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('narrows the posts to a tag, or keeps them all', () => {
|
||||
expect(taggedWith(posts, 'accesibilidad').map((p) => p.id)).toEqual([2]);
|
||||
expect(taggedWith(posts, null)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('links to the blog in each language', () => {
|
||||
expect(tagHref('es', 'angular')).toBe('/blog?tag=angular');
|
||||
expect(tagHref('en', 'a b')).toBe('/en/blog?tag=a%20b');
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
import type { Locale } from './api';
|
||||
import type { PublicPage } from './api.types';
|
||||
import { listingPath } from './routes';
|
||||
|
||||
export const BLOG_CATEGORY: Record<Locale, string> = {
|
||||
es: 'blog',
|
||||
@@ -25,3 +26,44 @@ export function byPublication(pages: PublicPage[]): PublicPage[] {
|
||||
(b.published_at ?? '').localeCompare(a.published_at ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
export interface BlogTag {
|
||||
name: string;
|
||||
slug: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** The blog, showing only the posts with this tag. */
|
||||
export function tagHref(locale: Locale, slug: string): string {
|
||||
return `${listingPath('blog', locale)}?tag=${encodeURIComponent(slug)}`;
|
||||
}
|
||||
|
||||
/** Every tag on these posts, most used first, then by name. */
|
||||
export function tagsOf(posts: PublicPage[]): BlogTag[] {
|
||||
const tags = new Map<string, BlogTag>();
|
||||
|
||||
for (const post of posts) {
|
||||
for (const tag of post.tags ?? []) {
|
||||
const known = tags.get(tag.slug);
|
||||
tags.set(tag.slug, {
|
||||
name: tag.name,
|
||||
slug: tag.slug,
|
||||
count: (known?.count ?? 0) + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...tags.values()].sort(
|
||||
(a, b) => b.count - a.count || a.name.localeCompare(b.name),
|
||||
);
|
||||
}
|
||||
|
||||
/** The posts carrying a tag, or all of them when there is none. */
|
||||
export function taggedWith(
|
||||
posts: PublicPage[],
|
||||
tag: string | null,
|
||||
): PublicPage[] {
|
||||
return tag
|
||||
? posts.filter((post) => (post.tags ?? []).some((t) => t.slug === tag))
|
||||
: posts;
|
||||
}
|
||||
|
||||
@@ -107,6 +107,21 @@ export interface Copy {
|
||||
archived: string;
|
||||
openCalls: string;
|
||||
technologies: string;
|
||||
roadmap: string;
|
||||
roadmapLead: string;
|
||||
releases: string;
|
||||
released: string;
|
||||
planned: string;
|
||||
sprint: string;
|
||||
goodFirst: string;
|
||||
goodFirstLead: string;
|
||||
board: string;
|
||||
done: string;
|
||||
proposals: string;
|
||||
proposalsLead: string;
|
||||
propose: string;
|
||||
voteOne: string;
|
||||
voteMany: string;
|
||||
};
|
||||
member: {
|
||||
projects: string;
|
||||
@@ -136,6 +151,12 @@ export interface Copy {
|
||||
readMore: string;
|
||||
published: string;
|
||||
feed: string;
|
||||
seeAll: string;
|
||||
by: string;
|
||||
tags: string;
|
||||
allTags: string;
|
||||
tagged: string;
|
||||
noneTagged: string;
|
||||
};
|
||||
service: {
|
||||
eyebrow: string;
|
||||
@@ -143,6 +164,14 @@ export interface Copy {
|
||||
testimonials: string;
|
||||
technologies: string;
|
||||
};
|
||||
technology: {
|
||||
eyebrow: string;
|
||||
ringTitle: string;
|
||||
website: string;
|
||||
usedIn: string;
|
||||
membersUsing: string;
|
||||
back: string;
|
||||
};
|
||||
radar: {
|
||||
eyebrow: string;
|
||||
lead: string;
|
||||
@@ -283,6 +312,21 @@ const es: Copy = {
|
||||
archived: 'Archivado',
|
||||
openCalls: 'Buscamos ayuda',
|
||||
technologies: 'Tecnologías',
|
||||
roadmap: 'Hoja de ruta',
|
||||
roadmapLead: 'Lo que viene, según nuestro tablero.',
|
||||
releases: 'Versiones',
|
||||
released: 'Publicada',
|
||||
planned: 'Prevista',
|
||||
sprint: 'Ahora mismo',
|
||||
goodFirst: 'Para empezar a colaborar',
|
||||
goodFirstLead: 'Tareas pensadas para quien llega nuevo.',
|
||||
board: 'Ver el tablero',
|
||||
done: 'Hecho',
|
||||
proposals: 'Ideas y errores',
|
||||
proposalsLead: 'Lo que más se pide. Para votar, entra en el tablero.',
|
||||
propose: 'Proponer una idea o avisar de un error',
|
||||
voteOne: '1 voto',
|
||||
voteMany: '{n} votos',
|
||||
},
|
||||
member: {
|
||||
projects: 'Proyectos',
|
||||
@@ -318,6 +362,12 @@ const es: Copy = {
|
||||
readMore: 'Leer',
|
||||
published: 'Publicado el',
|
||||
feed: 'Suscríbete por RSS',
|
||||
seeAll: 'Ver todas las novedades',
|
||||
by: 'Por',
|
||||
tags: 'Temas',
|
||||
allTags: 'Todo',
|
||||
tagged: 'Entradas sobre «{tag}»',
|
||||
noneTagged: 'No hay entradas con esta etiqueta.',
|
||||
},
|
||||
service: {
|
||||
eyebrow: 'Servicio',
|
||||
@@ -325,6 +375,14 @@ const es: Copy = {
|
||||
testimonials: 'Qué dicen',
|
||||
technologies: 'Con qué lo hacemos',
|
||||
},
|
||||
technology: {
|
||||
eyebrow: 'Tecnología',
|
||||
ringTitle: 'En el radar',
|
||||
website: 'Web oficial',
|
||||
usedIn: 'proyectos la usan',
|
||||
membersUsing: 'personas la usan',
|
||||
back: 'Ver el radar completo',
|
||||
},
|
||||
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.',
|
||||
@@ -478,6 +536,21 @@ const en: Copy = {
|
||||
archived: 'Archived',
|
||||
openCalls: 'Looking for help',
|
||||
technologies: 'Technologies',
|
||||
roadmap: 'Roadmap',
|
||||
roadmapLead: 'What is coming, from our board.',
|
||||
releases: 'Releases',
|
||||
released: 'Released',
|
||||
planned: 'Planned',
|
||||
sprint: 'Right now',
|
||||
goodFirst: 'Good first issues',
|
||||
goodFirstLead: 'Tasks meant for newcomers.',
|
||||
board: 'See the board',
|
||||
done: 'Done',
|
||||
proposals: 'Ideas and bugs',
|
||||
proposalsLead: 'What people ask for most. To vote, sign in on the board.',
|
||||
propose: 'Suggest an idea or report a bug',
|
||||
voteOne: '1 vote',
|
||||
voteMany: '{n} votes',
|
||||
},
|
||||
member: {
|
||||
projects: 'Projects',
|
||||
@@ -513,6 +586,12 @@ const en: Copy = {
|
||||
readMore: 'Read',
|
||||
published: 'Published on',
|
||||
feed: 'Subscribe via RSS',
|
||||
seeAll: 'See all the news',
|
||||
by: 'By',
|
||||
tags: 'Topics',
|
||||
allTags: 'Everything',
|
||||
tagged: 'Posts about “{tag}”',
|
||||
noneTagged: 'There are no posts with this tag.',
|
||||
},
|
||||
service: {
|
||||
eyebrow: 'Service',
|
||||
@@ -520,6 +599,14 @@ const en: Copy = {
|
||||
testimonials: 'What people say',
|
||||
technologies: 'What we use',
|
||||
},
|
||||
technology: {
|
||||
eyebrow: 'Technology',
|
||||
ringTitle: 'On the radar',
|
||||
website: 'Official site',
|
||||
usedIn: 'projects use it',
|
||||
membersUsing: 'people use it',
|
||||
back: 'See the whole radar',
|
||||
},
|
||||
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.',
|
||||
@@ -673,6 +760,21 @@ const ca: Copy = {
|
||||
archived: 'Arxivat',
|
||||
openCalls: 'Busquem ajuda',
|
||||
technologies: 'Tecnologies',
|
||||
roadmap: 'Full de ruta',
|
||||
roadmapLead: 'El que ve, segons el nostre tauler.',
|
||||
releases: 'Versions',
|
||||
released: 'Publicada',
|
||||
planned: 'Prevista',
|
||||
sprint: 'Ara mateix',
|
||||
goodFirst: 'Per començar a col·laborar',
|
||||
goodFirstLead: 'Tasques pensades per a qui arriba de nou.',
|
||||
board: 'Veure el tauler',
|
||||
done: 'Fet',
|
||||
proposals: 'Idees i errors',
|
||||
proposalsLead: 'El que més es demana. Per votar, entra al tauler.',
|
||||
propose: "Proposar una idea o avisar d'un error",
|
||||
voteOne: '1 vot',
|
||||
voteMany: '{n} vots',
|
||||
},
|
||||
member: {
|
||||
projects: 'Projectes',
|
||||
@@ -708,6 +810,12 @@ const ca: Copy = {
|
||||
readMore: 'Llegir',
|
||||
published: 'Publicat el',
|
||||
feed: "Subscriu-t'hi per RSS",
|
||||
seeAll: 'Veure totes les novetats',
|
||||
by: 'Per',
|
||||
tags: 'Temes',
|
||||
allTags: 'Tot',
|
||||
tagged: 'Entrades sobre «{tag}»',
|
||||
noneTagged: 'No hi ha entrades amb aquesta etiqueta.',
|
||||
},
|
||||
service: {
|
||||
eyebrow: 'Servei',
|
||||
@@ -715,6 +823,14 @@ const ca: Copy = {
|
||||
testimonials: 'Què en diuen',
|
||||
technologies: 'Amb què ho fem',
|
||||
},
|
||||
technology: {
|
||||
eyebrow: 'Tecnologia',
|
||||
ringTitle: 'Al radar',
|
||||
website: 'Web oficial',
|
||||
usedIn: 'projectes la fan servir',
|
||||
membersUsing: 'persones la fan servir',
|
||||
back: 'Veure el radar complet',
|
||||
},
|
||||
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.",
|
||||
|
||||
@@ -23,15 +23,22 @@ import type {
|
||||
PublicTechnology,
|
||||
} from './api.types';
|
||||
import { BLOG_CATEGORY, byPublication, isBlogPost } from './blog';
|
||||
import { resolveRoute, type RouteMatch } from './routes';
|
||||
import { copyFor } from './copy';
|
||||
import { listingPath, resolveRoute, type RouteMatch } from './routes';
|
||||
import {
|
||||
fetchShowcase,
|
||||
hasContent,
|
||||
type ProjectBoard,
|
||||
} from './taskstream';
|
||||
|
||||
/** 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: 'project'; record: PublicProject; board: ProjectBoard | null }
|
||||
| { type: 'member'; record: PublicMember }
|
||||
| { type: 'call'; record: PublicCall }
|
||||
| { type: 'service'; record: PublicService }
|
||||
| { type: 'technology'; record: PublicTechnology }
|
||||
| { type: 'radar'; record: PublicTechnology[] }
|
||||
| { type: 'blog'; record: PublicPage[] }
|
||||
| {
|
||||
@@ -80,7 +87,9 @@ async function fetchRecord(route: RouteMatch): Promise<RoutedRecord | null> {
|
||||
}
|
||||
case 'project': {
|
||||
const { data } = await pireonApi.project(locale, slug);
|
||||
return data ? { type: 'project', record: data } : null;
|
||||
return data
|
||||
? { type: 'project', record: data, board: await boardOf(data) }
|
||||
: null;
|
||||
}
|
||||
case 'member': {
|
||||
const { data } = await pireonApi.member(locale, slug);
|
||||
@@ -94,6 +103,10 @@ async function fetchRecord(route: RouteMatch): Promise<RoutedRecord | null> {
|
||||
const { data } = await pireonApi.service(locale, slug);
|
||||
return data ? { type: 'service', record: data } : null;
|
||||
}
|
||||
case 'technology': {
|
||||
const { data } = await pireonApi.technology(locale, slug);
|
||||
return data ? { type: 'technology', record: data } : null;
|
||||
}
|
||||
case 'blog': {
|
||||
const { data, error } = await pireonApi.pages(locale, {
|
||||
category: BLOG_CATEGORY[locale],
|
||||
@@ -127,13 +140,15 @@ async function fetchRecord(route: RouteMatch): Promise<RoutedRecord | null> {
|
||||
|
||||
/**
|
||||
* Featured pages go in the header; the rest are one click away in the footer.
|
||||
* Blog posts are neither: they have the blog.
|
||||
* Blog posts are neither: they have the blog, which joins the header as soon
|
||||
* as there is a post in it — a blog reachable only from the footer is one
|
||||
* nobody finds.
|
||||
*/
|
||||
export function pageNavLinks(
|
||||
pages: PublicPage[],
|
||||
locale: Locale,
|
||||
): { href: string; label: string }[] {
|
||||
return pages
|
||||
const featured = pages
|
||||
.filter(
|
||||
(page) =>
|
||||
page.is_featured &&
|
||||
@@ -145,4 +160,26 @@ export function pageNavLinks(
|
||||
href: page.path as string,
|
||||
label: page.title as string,
|
||||
}));
|
||||
|
||||
const hasPosts = pages.some((page) => isBlogPost(page, locale));
|
||||
|
||||
return hasPosts
|
||||
? [
|
||||
...featured,
|
||||
{
|
||||
href: listingPath('blog', locale),
|
||||
label: copyFor(locale).blog.eyebrow,
|
||||
},
|
||||
]
|
||||
: featured;
|
||||
}
|
||||
|
||||
/** The project's TaskStream board, when it has one with something to show. */
|
||||
async function boardOf(project: PublicProject): Promise<ProjectBoard | null> {
|
||||
const board = project.taskstream;
|
||||
if (!board?.showcase_url) return null;
|
||||
const showcase = await fetchShowcase(board.showcase_url);
|
||||
return showcase && hasContent(showcase)
|
||||
? { url: board.url, webUrl: board.web_url, showcase }
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
listingPath,
|
||||
resolveRoute,
|
||||
SECTIONS,
|
||||
technologyAlternates,
|
||||
technologyPath,
|
||||
} from './routes';
|
||||
|
||||
describe('resolveRoute', () => {
|
||||
@@ -121,6 +123,48 @@ describe('listings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('technologies', () => {
|
||||
it('reads one under its section, in each language', () => {
|
||||
expect(resolveRoute('/tecnologias/angular')).toEqual({
|
||||
type: 'technology',
|
||||
locale: 'es',
|
||||
slug: 'angular',
|
||||
});
|
||||
expect(resolveRoute('/en/technologies/angular')).toEqual({
|
||||
type: 'technology',
|
||||
locale: 'en',
|
||||
slug: 'angular',
|
||||
});
|
||||
expect(resolveRoute('/ca/tecnologies/angular')).toEqual({
|
||||
type: 'technology',
|
||||
locale: 'ca',
|
||||
slug: 'angular',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds the address resolveRoute reads back', () => {
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
const path = technologyPath('angular', locale);
|
||||
|
||||
expect(resolveRoute(path)).toEqual({
|
||||
type: 'technology',
|
||||
locale,
|
||||
slug: 'angular',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// The slug is a proper noun the API does not translate, so the three
|
||||
// addresses differ only by the prefix.
|
||||
it('keeps the same slug in every language', () => {
|
||||
expect(technologyAlternates('angular')).toEqual({
|
||||
es: '/tecnologias/angular',
|
||||
en: '/en/technologies/angular',
|
||||
ca: '/ca/tecnologies/angular',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('homeHref', () => {
|
||||
it('answers on ?lang= for every language but the default', () => {
|
||||
expect(homeHref(DEFAULT_LOCALE)).toBe('/');
|
||||
|
||||
@@ -13,7 +13,13 @@
|
||||
*/
|
||||
import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type Locale } from './api';
|
||||
|
||||
export type RecordType = 'page' | 'project' | 'member' | 'call' | 'service';
|
||||
export type RecordType =
|
||||
| 'page'
|
||||
| 'project'
|
||||
| 'member'
|
||||
| 'call'
|
||||
| 'service'
|
||||
| 'technology';
|
||||
|
||||
/** Pages this site composes itself rather than one record's: no slug. */
|
||||
export type ListingType = 'radar' | 'contribute' | 'blog';
|
||||
@@ -31,6 +37,11 @@ export const SECTIONS: Record<
|
||||
member: { es: 'equipo', en: 'team', ca: 'equip' },
|
||||
call: { es: 'colabora', en: 'contribute', ca: 'col-labora' },
|
||||
service: { es: 'servicios', en: 'services', ca: 'serveis' },
|
||||
// The one section the API does not know about. It gives technologies no
|
||||
// `path` and no `alternates`, and `/slugs` cannot carry them — its type
|
||||
// vocabulary is a closed enum. So this address is the site's own. Should the
|
||||
// API ever publish one, this is where the two have to be reconciled.
|
||||
technology: { es: 'tecnologias', en: 'technologies', ca: 'tecnologies' },
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -100,6 +111,26 @@ export function listingAlternates(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A technology's address in one language, as `resolveRoute` reads it back.
|
||||
*
|
||||
* Unlike every other record, the slug is the same in all three: a technology's
|
||||
* name is a proper noun, so the API does not translate it and the addresses
|
||||
* differ only by the prefix.
|
||||
*/
|
||||
export function technologyPath(slug: string, locale: Locale): string {
|
||||
const prefix = locale === DEFAULT_LOCALE ? '' : `/${locale}`;
|
||||
|
||||
return `${prefix}/${SECTIONS.technology[locale]}/${slug}`;
|
||||
}
|
||||
|
||||
/** The same technology in every language, for `hreflang`. */
|
||||
export function technologyAlternates(slug: string): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
SUPPORTED_LOCALES.map((locale) => [locale, technologyPath(slug, 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}`;
|
||||
|
||||
@@ -14,7 +14,16 @@ const xml = buildSitemap('https://pireon.dev', {
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}, [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Angular',
|
||||
slug: 'angular',
|
||||
category: 'framework',
|
||||
projects_count: 0,
|
||||
members_count: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
describe('buildSitemap', () => {
|
||||
it('lists every language of a record as its own url', () => {
|
||||
@@ -41,6 +50,21 @@ describe('buildSitemap', () => {
|
||||
expect(xml).toContain('<loc>https://pireon.dev/ca/col-labora</loc>');
|
||||
});
|
||||
|
||||
// The API gives technologies no address and `/slugs` cannot carry them, so
|
||||
// these are composed here and have to be in the sitemap all the same.
|
||||
it('includes the technology pages this site composes', () => {
|
||||
expect(xml).toContain('<loc>https://pireon.dev/tecnologias/angular</loc>');
|
||||
expect(xml).toContain(
|
||||
'<loc>https://pireon.dev/en/technologies/angular</loc>',
|
||||
);
|
||||
expect(xml).toContain(
|
||||
'<loc>https://pireon.dev/ca/tecnologies/angular</loc>',
|
||||
);
|
||||
expect(xml).toContain(
|
||||
'hreflang="ca" href="https://pireon.dev/ca/tecnologies/angular"',
|
||||
);
|
||||
});
|
||||
|
||||
it('is a well-formed document', () => {
|
||||
const document = new DOMParser().parseFromString(xml, 'application/xml');
|
||||
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
* is not fetched first.
|
||||
*/
|
||||
import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type SlugEntry } from './api';
|
||||
import type { PublicTechnology } from './api.types';
|
||||
import {
|
||||
homeHref,
|
||||
listingAlternates,
|
||||
LISTINGS,
|
||||
technologyAlternates,
|
||||
type ListingType,
|
||||
} from './routes';
|
||||
|
||||
@@ -22,6 +24,7 @@ interface SitemapEntry {
|
||||
export function buildSitemap(
|
||||
origin: string,
|
||||
slugs: Record<string, SlugEntry[]>,
|
||||
technologies: PublicTechnology[] = [],
|
||||
): string {
|
||||
const entries: SitemapEntry[] = [
|
||||
// The landing answers on `?lang=`, not a prefix.
|
||||
@@ -34,6 +37,12 @@ export function buildSitemap(
|
||||
alternates: listingAlternates(listing),
|
||||
})),
|
||||
...Object.values(slugs).flat(),
|
||||
// Technologies are absent from `/slugs` — the API gives them no address,
|
||||
// so this site composes theirs. No `<lastmod>`: the payload has no
|
||||
// `updated_at` to report.
|
||||
...technologies.map((technology) => ({
|
||||
alternates: technologyAlternates(technology.slug),
|
||||
})),
|
||||
];
|
||||
|
||||
const urls = entries.flatMap((entry) =>
|
||||
|
||||
60
landing/src/app/core/taskstream.spec.ts
Normal file
60
landing/src/app/core/taskstream.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { boardLink, fetchShowcase, hasContent } from './taskstream';
|
||||
|
||||
const showcase = {
|
||||
project: { id: 'p', key: 'WEB', name: 'Web', description: null },
|
||||
activeSprint: null,
|
||||
roadmap: [],
|
||||
releases: [],
|
||||
goodFirst: [
|
||||
{ key: 'WEB-9', title: 'Typo', type: 'TASK', path: '/p/WEB/issues/WEB-9' },
|
||||
],
|
||||
};
|
||||
|
||||
describe('TaskStream showcase', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('reads a showcase, and goes without it when TaskStream fails', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(JSON.stringify(showcase))),
|
||||
);
|
||||
expect(await fetchShowcase('https://api.ts/showcase')).toEqual(showcase);
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response('nope', { status: 404 })),
|
||||
);
|
||||
expect(await fetchShowcase('https://api.ts/showcase')).toBeNull();
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
throw new Error('down');
|
||||
}),
|
||||
);
|
||||
expect(await fetchShowcase('https://api.ts/showcase')).toBeNull();
|
||||
});
|
||||
|
||||
it('builds links into the board and knows an empty one', () => {
|
||||
expect(
|
||||
boardLink('https://taskstream.pireon.dev/', '/p/WEB/issues/WEB-9'),
|
||||
).toBe('https://taskstream.pireon.dev/p/WEB/issues/WEB-9');
|
||||
expect(hasContent(showcase)).toBe(true);
|
||||
expect(hasContent({ ...showcase, goodFirst: [] })).toBe(false);
|
||||
});
|
||||
|
||||
it('counts a board that takes proposals as something to show', () => {
|
||||
const empty = { ...showcase, goodFirst: [] };
|
||||
expect(
|
||||
hasContent({
|
||||
...empty,
|
||||
proposals: { open: true, path: '/p/WEB/proponer', top: [] },
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasContent({ ...empty, proposals: { open: false, path: null, top: [] } }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
104
landing/src/app/core/taskstream.ts
Normal file
104
landing/src/app/core/taskstream.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* A project's board in TaskStream, the collective's issue tracker: its public
|
||||
* showcase (roadmap, releases, the sprint under way, tickets for newcomers
|
||||
* and the proposals people send and vote for). Fetched on the server; a slow or absent TaskStream only means
|
||||
* the project page goes without those sections.
|
||||
*/
|
||||
|
||||
export interface TaskStreamShowcase {
|
||||
project: {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
};
|
||||
activeSprint: {
|
||||
name: string;
|
||||
goal: string | null;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
percent: number;
|
||||
} | null;
|
||||
roadmap: {
|
||||
key: string;
|
||||
title: string;
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
percent: number;
|
||||
done: boolean;
|
||||
/** How many want it; older TaskStreams leave it out. */
|
||||
votes?: number;
|
||||
path: string;
|
||||
}[];
|
||||
releases: {
|
||||
name: string;
|
||||
status: 'PLANNED' | 'RELEASED' | 'ARCHIVED';
|
||||
targetDate: string | null;
|
||||
releasedAt: string | null;
|
||||
percent: number;
|
||||
notes: string | null;
|
||||
}[];
|
||||
goodFirst: { key: string; title: string; type: string; path: string }[];
|
||||
/** Bug reports and ideas from anyone; older TaskStreams leave it out. */
|
||||
proposals?: {
|
||||
/** Whether it takes them now. */
|
||||
open: boolean;
|
||||
/** The form in TaskStream's web app, while it takes them. */
|
||||
path: string | null;
|
||||
/** Open ones, most voted first. */
|
||||
top: {
|
||||
key: string;
|
||||
title: string;
|
||||
type: string;
|
||||
path: string;
|
||||
votes: number;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
/** What the page renders: the showcase plus where its links go. */
|
||||
export interface ProjectBoard {
|
||||
url: string;
|
||||
webUrl: string;
|
||||
showcase: TaskStreamShowcase;
|
||||
}
|
||||
|
||||
export async function fetchShowcase(
|
||||
showcaseUrl: string,
|
||||
timeout = 4000,
|
||||
): Promise<TaskStreamShowcase | null> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeout);
|
||||
try {
|
||||
const response = await fetch(showcaseUrl, {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const body = (await response.json()) as Partial<TaskStreamShowcase>;
|
||||
return Array.isArray(body.roadmap) && Array.isArray(body.goodFirst)
|
||||
? (body as TaskStreamShowcase)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** Something worth showing: a board with nothing on it says nothing. */
|
||||
export function hasContent(showcase: TaskStreamShowcase): boolean {
|
||||
return (
|
||||
showcase.roadmap.length > 0 ||
|
||||
showcase.releases.length > 0 ||
|
||||
showcase.goodFirst.length > 0 ||
|
||||
showcase.activeSprint !== null ||
|
||||
!!showcase.proposals?.open ||
|
||||
(showcase.proposals?.top.length ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
/** A showcase path as an address in TaskStream's web app. */
|
||||
export function boardLink(webUrl: string, path: string): string {
|
||||
return `${webUrl.replace(/\/$/, '')}${path}`;
|
||||
}
|
||||
@@ -17,11 +17,13 @@ import {
|
||||
PiChipComponent,
|
||||
} from '@pi/ui';
|
||||
|
||||
import { byPublication, isBlogPost } from '../core/blog';
|
||||
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 { TechnologyChip } from '../ui/technology-chip';
|
||||
import { CallCard } from '../ui/call-card';
|
||||
import { ClientLogo } from '../ui/client-logo';
|
||||
import { ContactForm } from '../ui/contact-form';
|
||||
@@ -29,6 +31,7 @@ import { Hero } from '../ui/hero';
|
||||
import { IssueRow } from '../ui/issue-row';
|
||||
import { MaintenanceScreen } from '../ui/maintenance-screen';
|
||||
import { MemberCard } from '../ui/member-card';
|
||||
import { PostCard } from '../ui/post-card';
|
||||
import { ProjectCard } from '../ui/project-card';
|
||||
import { Section } from '../ui/section';
|
||||
import { ServiceCard } from '../ui/service-card';
|
||||
@@ -65,6 +68,7 @@ export const routeMeta: RouteMeta = {
|
||||
IssueRow,
|
||||
MaintenanceScreen,
|
||||
MemberCard,
|
||||
PostCard,
|
||||
ProjectCard,
|
||||
Section,
|
||||
ServiceCard,
|
||||
@@ -72,6 +76,7 @@ export const routeMeta: RouteMeta = {
|
||||
SiteHeader,
|
||||
TalkCard,
|
||||
TestimonialCarousel,
|
||||
TechnologyChip,
|
||||
],
|
||||
styleUrl: './home.page.scss',
|
||||
template: `
|
||||
@@ -123,7 +128,11 @@ export const routeMeta: RouteMeta = {
|
||||
>
|
||||
<div class="landing-grid">
|
||||
@for (project of data().projects; track project.id) {
|
||||
<app-project-card [project]="project" [copy]="copy()" />
|
||||
<app-project-card
|
||||
[project]="project"
|
||||
[copy]="copy()"
|
||||
[locale]="data().locale"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</app-section>
|
||||
@@ -176,11 +185,10 @@ export const routeMeta: RouteMeta = {
|
||||
<ul class="landing-card__chips">
|
||||
@for (technology of group.technologies; track technology.id) {
|
||||
<li>
|
||||
<pi-chip
|
||||
size="small"
|
||||
appearance="outline"
|
||||
[label]="technology.name"
|
||||
/>
|
||||
<app-technology-chip
|
||||
[technology]="technology"
|
||||
[locale]="data().locale"
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@@ -262,6 +270,25 @@ export const routeMeta: RouteMeta = {
|
||||
</app-section>
|
||||
}
|
||||
|
||||
<!-- The latest posts, so the blog is found from the landing rather than
|
||||
only from a link in the footer. -->
|
||||
@if (latestPosts().length > 0) {
|
||||
<app-section
|
||||
anchor="novedades"
|
||||
[title]="copy().blog.title"
|
||||
[lead]="copy().blog.lead"
|
||||
>
|
||||
<div class="landing-grid">
|
||||
@for (post of latestPosts(); track post.id) {
|
||||
<app-post-card [post]="post" [copy]="copy()" [locale]="data().locale" />
|
||||
}
|
||||
</div>
|
||||
<p class="landing-section__more">
|
||||
<a [href]="blogHref()">{{ copy().blog.seeAll }} →</a>
|
||||
</p>
|
||||
</app-section>
|
||||
}
|
||||
|
||||
<app-section
|
||||
anchor="contacto"
|
||||
[title]="copy().sections.contact.title"
|
||||
@@ -297,6 +324,13 @@ export default class HomePage {
|
||||
|
||||
protected readonly radarHref = computed(() => listingPath('radar', this.data().locale));
|
||||
|
||||
protected readonly blogHref = computed(() => listingPath('blog', this.data().locale));
|
||||
|
||||
/** The three most recent posts; the rest are on the blog. */
|
||||
protected readonly latestPosts = computed(() =>
|
||||
byPublication(this.data().pages.filter((page) => isBlogPost(page, this.data().locale))).slice(0, 3),
|
||||
);
|
||||
|
||||
protected readonly contributeHref = computed(() =>
|
||||
listingPath('contribute', this.data().locale),
|
||||
);
|
||||
|
||||
@@ -32,6 +32,10 @@
|
||||
// ending in a dead end.
|
||||
.content-page__layout {
|
||||
display: grid;
|
||||
// One column below the desktop layout, and one that may shrink: an
|
||||
// implicit `auto` track grows to its widest line, and a code block in a
|
||||
// post pushed the whole text past the edge of a phone.
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 3rem;
|
||||
padding-bottom: 5rem;
|
||||
|
||||
@@ -90,18 +94,28 @@
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
// The bullet outside the text, where a list's bullet goes. `@pi/ui`'s
|
||||
// reset puts it inside, and the editor wraps each item's text in a `<p>`:
|
||||
// together the bullet sat alone on one line and the text, a block, fell to
|
||||
// the next — with a paragraph's margins on top.
|
||||
ul,
|
||||
ol {
|
||||
margin: 0 0 1.5rem;
|
||||
padding-left: 1.25rem;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0 0 0.5rem;
|
||||
padding-left: 0.25rem;
|
||||
list-style-position: outside;
|
||||
|
||||
&::marker {
|
||||
color: var(--pi-color-primary);
|
||||
}
|
||||
|
||||
> p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
strong {
|
||||
@@ -112,6 +126,67 @@
|
||||
color: var(--pi-color-primary);
|
||||
}
|
||||
|
||||
code {
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 0.375rem;
|
||||
background: color-mix(in srgb, var(--pi-color-primary) 10%, transparent);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0 0 1.5rem;
|
||||
padding: 1rem 1.25rem;
|
||||
overflow-x: auto;
|
||||
border-radius: var(--pi-border-radius-medium, 0.75rem);
|
||||
background: color-mix(in srgb, var(--pi-color-primary) 7%, transparent);
|
||||
line-height: 1.55;
|
||||
|
||||
code {
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
// `@pi/ui` already frames a quote with its own quotation marks; only the
|
||||
// spacing is this page's business.
|
||||
blockquote {
|
||||
margin: 0 0 1.5rem;
|
||||
|
||||
p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 2.5rem 0;
|
||||
border: 0;
|
||||
border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent);
|
||||
}
|
||||
|
||||
// Images in the text — attached in the panel's editor, or by URL through
|
||||
// the API. Never wider than the column, and set apart from the prose.
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin: 2rem 0;
|
||||
border-radius: var(--pi-border-radius-medium, 0.75rem);
|
||||
}
|
||||
|
||||
figure {
|
||||
margin: 2rem 0;
|
||||
|
||||
img {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
figcaption {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: color-mix(in srgb, currentColor 68%, transparent);
|
||||
}
|
||||
|
||||
> :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@@ -31,65 +31,41 @@
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
.blog-tagged {
|
||||
margin: 1rem 0 0;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.blog-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin: 0 0 2rem;
|
||||
}
|
||||
|
||||
.blog-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.875rem;
|
||||
border: 1px solid color-mix(in oklab, currentColor 18%, transparent);
|
||||
border-radius: 999px;
|
||||
color: inherit;
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--pi-color-primary);
|
||||
}
|
||||
|
||||
&[aria-current='page'] {
|
||||
border-color: var(--pi-color-primary);
|
||||
background: var(--pi-color-primary);
|
||||
color: var(--pi-color-primary-contrast, #fff);
|
||||
}
|
||||
}
|
||||
|
||||
.blog-tag__count {
|
||||
opacity: 0.7;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { map } from 'rxjs';
|
||||
import {
|
||||
PiNoteComponent,
|
||||
PiTextColorDirective,
|
||||
@@ -8,18 +17,23 @@ import {
|
||||
|
||||
import type { Locale } from '../core/api';
|
||||
import type { PublicPage } from '../core/api.types';
|
||||
import { tagHref, taggedWith, tagsOf } from '../core/blog';
|
||||
import type { Copy } from '../core/copy';
|
||||
import { ApiImage } from './api-image';
|
||||
import { listingPath } from '../core/routes';
|
||||
import { PostCard } from './post-card';
|
||||
|
||||
/**
|
||||
* The blog: the pages in the panel's "Blog" category, newest first, each
|
||||
* leading to its own address like any page.
|
||||
*
|
||||
* `?tag=` narrows it to one subject. Plain links rather than a client-side
|
||||
* filter, so a tag's page can be shared and works before any script runs.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-blog-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
ApiImage,
|
||||
PostCard,
|
||||
PiNoteComponent,
|
||||
PiTextColorDirective,
|
||||
PiTextLargeDirective,
|
||||
@@ -34,6 +48,11 @@ import { ApiImage } from './api-image';
|
||||
<p l pi-color color="grey" class="content-hero__lead">
|
||||
{{ copy().blog.lead }}
|
||||
</p>
|
||||
@if (activeTag(); as tag) {
|
||||
<p class="blog-tagged">
|
||||
{{ copy().blog.tagged.replace('{tag}', tag.name) }}
|
||||
</p>
|
||||
}
|
||||
<p class="blog-feed">
|
||||
<a [href]="'/blog/rss.xml?lang=' + locale()">{{
|
||||
copy().blog.feed
|
||||
@@ -43,47 +62,36 @@ import { ApiImage } from './api-image';
|
||||
</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 (tags().length > 0) {
|
||||
<nav class="blog-tags" [attr.aria-label]="copy().blog.tags">
|
||||
<a
|
||||
class="blog-tag"
|
||||
[href]="blogHref()"
|
||||
[attr.aria-current]="activeSlug() === null ? 'page' : null"
|
||||
>{{ copy().blog.allTags }}</a
|
||||
>
|
||||
@for (tag of tags(); track tag.slug) {
|
||||
<a
|
||||
class="blog-tag"
|
||||
[href]="tagHref(tag.slug)"
|
||||
[attr.aria-current]="activeSlug() === tag.slug ? 'page' : null"
|
||||
>{{ tag.name }}
|
||||
<span class="blog-tag__count">{{ tag.count }}</span></a
|
||||
>
|
||||
@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>
|
||||
}
|
||||
</nav>
|
||||
}
|
||||
|
||||
@if (shown().length > 0) {
|
||||
<ul class="blog-list">
|
||||
@for (post of shown(); track post.id) {
|
||||
<li>
|
||||
<app-post-card
|
||||
[post]="post"
|
||||
[copy]="copy()"
|
||||
[locale]="locale()"
|
||||
layout="row"
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@@ -92,7 +100,7 @@ import { ApiImage } from './api-image';
|
||||
status="info"
|
||||
appearance="fill"
|
||||
gap="xs"
|
||||
[title]="copy().blog.empty"
|
||||
[title]="activeSlug() ? copy().blog.noneTagged : copy().blog.empty"
|
||||
[text]="''"
|
||||
/>
|
||||
}
|
||||
@@ -104,11 +112,28 @@ export class BlogPage {
|
||||
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));
|
||||
protected readonly activeSlug = toSignal(
|
||||
inject(ActivatedRoute).queryParamMap.pipe(
|
||||
map((params) => params.get('tag')),
|
||||
),
|
||||
{ initialValue: null },
|
||||
);
|
||||
|
||||
protected readonly tags = computed(() => tagsOf(this.posts()));
|
||||
|
||||
protected readonly activeTag = computed(
|
||||
() => this.tags().find((tag) => tag.slug === this.activeSlug()) ?? null,
|
||||
);
|
||||
|
||||
protected readonly shown = computed(() =>
|
||||
taggedWith(this.posts(), this.activeSlug()),
|
||||
);
|
||||
|
||||
protected readonly blogHref = computed(() =>
|
||||
listingPath('blog', this.locale()),
|
||||
);
|
||||
|
||||
protected tagHref(slug: string): string {
|
||||
return tagHref(this.locale(), slug);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
import type { Copy } from '../core/copy';
|
||||
import type { Locale } from '../core/api';
|
||||
import type { PublicCall } from '../core/api.types';
|
||||
import { TechnologyChip } from './technology-chip';
|
||||
import { LinkButton } from './link-button';
|
||||
|
||||
@Component({
|
||||
selector: 'app-call-card',
|
||||
@@ -30,6 +32,8 @@ import type { PublicCall } from '../core/api.types';
|
||||
PiTextXSmallDirective,
|
||||
PiTextColorDirective,
|
||||
PiTextWeightBoldDirective,
|
||||
TechnologyChip,
|
||||
LinkButton,
|
||||
],
|
||||
template: `
|
||||
<pi-card
|
||||
@@ -74,7 +78,10 @@ import type { PublicCall } from '../core/api.types';
|
||||
<ul class="landing-card__chips">
|
||||
@for (technology of call().technologies; track technology.id) {
|
||||
<li>
|
||||
<pi-chip size="small" appearance="outline" [label]="technology.name" />
|
||||
<app-technology-chip
|
||||
[technology]="technology"
|
||||
[locale]="locale()"
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@@ -91,15 +98,13 @@ import type { PublicCall } from '../core/api.types';
|
||||
<!-- 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>
|
||||
<app-link-button
|
||||
class="landing-card__button-link"
|
||||
size="small"
|
||||
appearance="outline"
|
||||
[href]="path"
|
||||
[text]="copy().labels.apply"
|
||||
/>
|
||||
} @else {
|
||||
<pi-action-button
|
||||
size="small"
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { PublicCall } from '../core/api.types';
|
||||
import type { Copy } from '../core/copy';
|
||||
import { homeHref } from '../core/routes';
|
||||
import { ContactForm } from './contact-form';
|
||||
import { TechnologyChip } from './technology-chip';
|
||||
|
||||
/**
|
||||
* A call for help on its own address, with the form to answer it.
|
||||
@@ -36,6 +37,7 @@ import { ContactForm } from './contact-form';
|
||||
PiTextColorDirective,
|
||||
PiTextLargeDirective,
|
||||
PiTextWeightBoldDirective,
|
||||
TechnologyChip,
|
||||
],
|
||||
styleUrl: './call-detail.scss',
|
||||
template: `
|
||||
@@ -82,10 +84,9 @@ import { ContactForm } from './contact-form';
|
||||
}
|
||||
@for (technology of call().technologies ?? []; track technology.id) {
|
||||
<li>
|
||||
<pi-chip
|
||||
size="small"
|
||||
appearance="outline"
|
||||
[label]="technology.name"
|
||||
<app-technology-chip
|
||||
[technology]="technology"
|
||||
[locale]="locale()"
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@ import {
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { PiActionButtonComponent, PiTextColorDirective } from '@pi/ui';
|
||||
import { PiTextColorDirective } from '@pi/ui';
|
||||
|
||||
import type { Locale } from '../core/api';
|
||||
import type { Copy } from '../core/copy';
|
||||
import { homeHref } from '../core/routes';
|
||||
import { LinkButton } from './link-button';
|
||||
|
||||
/**
|
||||
* "Shall we talk?" at the end of a page's side column, so no page is a dead
|
||||
@@ -17,20 +18,18 @@ import { homeHref } from '../core/routes';
|
||||
@Component({
|
||||
selector: 'app-contact-cta',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PiActionButtonComponent, PiTextColorDirective],
|
||||
imports: [PiTextColorDirective, LinkButton],
|
||||
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>
|
||||
<app-link-button
|
||||
class="contact-cta__link"
|
||||
[href]="href()"
|
||||
[text]="copy().nav.contact"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
|
||||
@@ -130,3 +130,26 @@
|
||||
font-size: var(--pi-font-size-x-small, 0.75rem);
|
||||
}
|
||||
}
|
||||
|
||||
// TEMPORARY. Remove when @pi/ui ships the fix.
|
||||
//
|
||||
// `pi-number-field` declares `<ng-container matPrefix>` unconditionally, so
|
||||
// Material marks the field as having an icon prefix even though nothing is
|
||||
// projected into it. The empty prefix is 4px wide and takes the place of the
|
||||
// 16px inset every other field gets: the value ended up 12px to the left of
|
||||
// its own label, which is what makes it look wrong.
|
||||
//
|
||||
// Only two of the library's twenty fields declare a prefix, both numeric, and
|
||||
// only `incremental-number-field` actually uses one — so this is an oversight
|
||||
// rather than a pattern. The fix belongs there and is written:
|
||||
// `pireon-ui`, `number-field.component.html`, guarded by a `contentChild`.
|
||||
// This restores the inset here until that version is published.
|
||||
:host ::ng-deep .pi-form-field__number {
|
||||
.mat-mdc-form-field-icon-prefix {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mat-mdc-form-field-infix {
|
||||
padding-left: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,3 +17,28 @@
|
||||
margin: 1rem 0 0;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.content-page__byline {
|
||||
color: inherit;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.content-page__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 2.5rem;
|
||||
}
|
||||
|
||||
.content-page__tag {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--pi-color-primary) 12%, transparent);
|
||||
color: inherit;
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in oklab, var(--pi-color-primary) 22%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
|
||||
import type { Locale } from '../core/api';
|
||||
import type { PublicPage } from '../core/api.types';
|
||||
import { isBlogPost } from '../core/blog';
|
||||
import { isBlogPost, tagHref } from '../core/blog';
|
||||
import type { Copy } from '../core/copy';
|
||||
import { listingPath } from '../core/routes';
|
||||
import { ApiImage } from './api-image';
|
||||
@@ -55,6 +55,13 @@ import { ContactCta } from './contact-cta';
|
||||
|
||||
@if (isPost() && page().published_at; as published) {
|
||||
<p class="content-page__muted content-page__date">
|
||||
@if (page().byline; as byline) {
|
||||
{{ copy().blog.by }}
|
||||
<a class="content-page__byline" [href]="byline.path">{{
|
||||
byline.name
|
||||
}}</a>
|
||||
·
|
||||
}
|
||||
{{ copy().blog.published }}
|
||||
<time [attr.datetime]="published">{{ publishedOn() }}</time>
|
||||
</p>
|
||||
@@ -82,6 +89,16 @@ import { ContactCta } from './contact-cta';
|
||||
}
|
||||
|
||||
<div class="content-page__body" [innerHTML]="page().body ?? ''"></div>
|
||||
|
||||
@if (isPost() && (page().tags ?? []).length > 0) {
|
||||
<nav class="content-page__tags" [attr.aria-label]="copy().blog.tags">
|
||||
@for (tag of page().tags; track tag.slug) {
|
||||
<a class="content-page__tag" [href]="tagHref(tag.slug)"
|
||||
>#{{ tag.name }}</a
|
||||
>
|
||||
}
|
||||
</nav>
|
||||
}
|
||||
</article>
|
||||
|
||||
<aside class="content-page__aside">
|
||||
@@ -126,6 +143,10 @@ export class ContentPage {
|
||||
listingPath('blog', this.locale()),
|
||||
);
|
||||
|
||||
protected tagHref(slug: string): string {
|
||||
return tagHref(this.locale(), slug);
|
||||
}
|
||||
|
||||
protected readonly publishedOn = computed(() => {
|
||||
const published = this.page().published_at;
|
||||
|
||||
|
||||
79
landing/src/app/ui/link-button.scss
Normal file
79
landing/src/app/ui/link-button.scss
Normal file
@@ -0,0 +1,79 @@
|
||||
:host {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
// `@pi/ui` scopes its button rules to the element (`html button:not(…)` in
|
||||
// `_pi-button.scss`), so an anchor cannot inherit them. Every value below is
|
||||
// still the library's: these are the same `--pi-theme-button-*` custom
|
||||
// properties its own rules read, so switching shell or theme moves this button
|
||||
// too, and dark mode needs nothing of its own.
|
||||
.link-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 5.5rem;
|
||||
border: none;
|
||||
border-radius: var(--pi-theme-button-radius);
|
||||
line-height: 100%;
|
||||
text-decoration: none;
|
||||
letter-spacing: var(--pi-theme-button-letter-spacing);
|
||||
text-transform: var(--pi-theme-button-text-transform);
|
||||
font-weight: var(--pi-theme-button-font-weight);
|
||||
transition: var(--pi-theme-button-transition);
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
flex: none;
|
||||
width: 0.375rem;
|
||||
height: 0.5625rem;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--pi-color-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.link-button--medium,
|
||||
.link-button--large {
|
||||
min-height: var(--pi-theme-button-height);
|
||||
padding: var(--pi-spacing-xx-small) var(--pi-spacing-x-large);
|
||||
}
|
||||
|
||||
.link-button--small {
|
||||
min-width: 4.5rem;
|
||||
padding: var(--pi-spacing-xxxx-small) var(--pi-spacing-medium);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
// Filled. The theme may inject a gradient over the flat colour, exactly as it
|
||||
// does for a real button.
|
||||
.link-button--fill {
|
||||
color: var(--pi-color-white);
|
||||
box-shadow: var(--pi-theme-button-shadow);
|
||||
|
||||
&.link-button--primary {
|
||||
background-color: var(--pi-color-primary);
|
||||
background-image: var(--pi-theme-button-fill-primary, none);
|
||||
}
|
||||
|
||||
&.link-button--grey {
|
||||
background-color: var(--pi-color-grey);
|
||||
}
|
||||
}
|
||||
|
||||
// Outline. The colour is the border and the text; the surface stays clear.
|
||||
.link-button--outline {
|
||||
background: none;
|
||||
|
||||
&.link-button--primary {
|
||||
border: 1px solid var(--pi-color-primary);
|
||||
color: var(--pi-color-primary);
|
||||
}
|
||||
|
||||
&.link-button--grey {
|
||||
border: 1px solid var(--pi-color-grey-200);
|
||||
color: var(--pi-color-grey);
|
||||
}
|
||||
}
|
||||
73
landing/src/app/ui/link-button.ts
Normal file
73
landing/src/app/ui/link-button.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
||||
|
||||
/**
|
||||
* A link that looks like the library's action button.
|
||||
*
|
||||
* `pi-action-button` renders a `<button>`, so wrapping one in an `<a>` puts a
|
||||
* button inside a link: invalid HTML, and the button swallows the click, so
|
||||
* nothing navigates. That is what stopped "Web oficial" working, and the same
|
||||
* pattern sat in five other places.
|
||||
*
|
||||
* Making it an anchor is not only about the click. A real link can be opened
|
||||
* in a new tab, middle-clicked, copied, reached by keyboard and followed by a
|
||||
* crawler; a button can do none of that.
|
||||
*
|
||||
* The library's own button classes are deliberately NOT reused. Its rules are
|
||||
* scoped to the element — `html button:not(…)` — so on an anchor they
|
||||
* contribute nothing except `pi-color__primary`, which is a *text* colour and
|
||||
* actively fights the white a filled button needs. The stylesheet beside this
|
||||
* file reads the same `--pi-theme-button-*` tokens the library reads instead,
|
||||
* so a change of shell or theme still reaches this button.
|
||||
*/
|
||||
type Size = 'small' | 'medium' | 'large';
|
||||
type Tone = 'primary' | 'grey';
|
||||
|
||||
@Component({
|
||||
selector: 'app-link-button',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styleUrl: './link-button.scss',
|
||||
template: `
|
||||
<a
|
||||
[class]="classes()"
|
||||
[href]="href()"
|
||||
[attr.target]="external() ? '_blank' : null"
|
||||
[attr.rel]="external() ? 'noopener noreferrer' : null"
|
||||
>
|
||||
<span>{{ text() }}</span>
|
||||
|
||||
<svg
|
||||
viewBox="0 0 6 9"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
d="M1 1C4.5 4 5 4.38484 5 4.5C5 4.61516 4.5 5 1 8"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
/>
|
||||
</g>
|
||||
<g>
|
||||
<path d="M4.5 4.5H0" stroke="currentColor" stroke-width="1" />
|
||||
</g>
|
||||
</svg>
|
||||
</a>
|
||||
`,
|
||||
})
|
||||
export class LinkButton {
|
||||
readonly href = input.required<string>();
|
||||
readonly text = input.required<string>();
|
||||
readonly size = input<Size>('medium');
|
||||
readonly color = input<Tone>('primary');
|
||||
readonly appearance = input<'fill' | 'outline'>('fill');
|
||||
/** Opens in a new tab, for addresses that are not ours. */
|
||||
readonly external = input(false);
|
||||
|
||||
protected readonly classes = computed(() => [
|
||||
'link-button',
|
||||
`link-button--${this.size()}`,
|
||||
`link-button--${this.color()}`,
|
||||
`link-button--${this.appearance()}`,
|
||||
]);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import type { Copy } from '../core/copy';
|
||||
import { ContactCta } from './contact-cta';
|
||||
import { memberLinks } from './member-card';
|
||||
import { ProjectCard } from './project-card';
|
||||
import { TechnologyChip } from './technology-chip';
|
||||
|
||||
/**
|
||||
* Someone in the collective, on their own address: who they are, what they
|
||||
@@ -37,6 +38,7 @@ import { ProjectCard } from './project-card';
|
||||
PiTextLargeDirective,
|
||||
PiTextWeightBoldDirective,
|
||||
ProjectCard,
|
||||
TechnologyChip,
|
||||
],
|
||||
styleUrl: './member-detail.scss',
|
||||
template: `
|
||||
@@ -100,7 +102,11 @@ import { ProjectCard } from './project-card';
|
||||
<h2>{{ copy().member.projects }}</h2>
|
||||
<div class="member-projects">
|
||||
@for (project of member().projects; track project.id) {
|
||||
<app-project-card [project]="project" [copy]="copy()" />
|
||||
<app-project-card
|
||||
[project]="project"
|
||||
[copy]="copy()"
|
||||
[locale]="locale()"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@@ -146,11 +152,10 @@ import { ProjectCard } from './project-card';
|
||||
<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"
|
||||
/>
|
||||
<app-technology-chip
|
||||
[technology]="technology"
|
||||
[locale]="locale()"
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { LinkButton } from './link-button';
|
||||
import {
|
||||
PiActionButtonComponent,
|
||||
PiIconComponent,
|
||||
PiTextColorDirective,
|
||||
PiTextMediumDirective,
|
||||
@@ -26,12 +26,12 @@ import {
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
PiIconComponent,
|
||||
PiActionButtonComponent,
|
||||
PiTextXLargeDirective,
|
||||
PiTextXLargeDirective,
|
||||
PiTextMediumDirective,
|
||||
PiTextSmallDirective,
|
||||
PiTextColorDirective,
|
||||
PiTextWeightBoldDirective,
|
||||
LinkButton,
|
||||
],
|
||||
styleUrl: './not-found.scss',
|
||||
template: `
|
||||
@@ -47,14 +47,12 @@ import {
|
||||
La dirección puede haber cambiado. Vuelve al inicio y sigue desde ahí.
|
||||
</p>
|
||||
|
||||
<a class="not-found__home" href="/">
|
||||
<pi-action-button
|
||||
size="large"
|
||||
color="primary"
|
||||
text="Ir al inicio"
|
||||
[hasIcon]="true"
|
||||
/>
|
||||
</a>
|
||||
<app-link-button
|
||||
class="not-found__home"
|
||||
size="large"
|
||||
href="/"
|
||||
text="Ir al inicio"
|
||||
/>
|
||||
|
||||
<p s pi-color color="grey" class="not-found__note">
|
||||
Si has llegado desde un enlace nuestro, cuéntanoslo.
|
||||
|
||||
77
landing/src/app/ui/post-card.scss
Normal file
77
landing/src/app/ui/post-card.scss
Normal file
@@ -0,0 +1,77 @@
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.post-card {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: var(--pi-theme-card-radius, 1.5rem);
|
||||
box-shadow: var(--pi-theme-card-shadow, none);
|
||||
}
|
||||
|
||||
// Beside the text rather than above it, and only when there is an image:
|
||||
// without one the text would sit in the image's column with the rest empty.
|
||||
:host(.post-card--row) .post-card--media {
|
||||
@media (min-width: 768px) {
|
||||
grid-template-rows: none;
|
||||
grid-template-columns: minmax(0, 20rem) minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
// The `<img>` belongs to `app-api-image`, so a scoped rule cannot reach it.
|
||||
.post-card__media ::ng-deep img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.post-card__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1.5rem 1.75rem;
|
||||
}
|
||||
|
||||
.post-card__date {
|
||||
font-size: 0.8125rem;
|
||||
color: color-mix(in srgb, currentColor 68%, transparent);
|
||||
}
|
||||
|
||||
.post-card__title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
|
||||
:host(.post-card--row) & {
|
||||
font-size: 1.375rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.post-card__excerpt {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
color: color-mix(in srgb, currentColor 68%, transparent);
|
||||
}
|
||||
|
||||
.post-card__more {
|
||||
margin-top: auto;
|
||||
padding-top: 0.5rem;
|
||||
color: var(--pi-color-primary);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
83
landing/src/app/ui/post-card.ts
Normal file
83
landing/src/app/ui/post-card.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
|
||||
import type { Locale } from '../core/api';
|
||||
import type { PublicPage } from '../core/api.types';
|
||||
import type { Copy } from '../core/copy';
|
||||
import { ApiImage } from './api-image';
|
||||
|
||||
/**
|
||||
* One blog post as a card: image, date, title, excerpt, and the way in.
|
||||
*
|
||||
* `row` on the blog itself, image beside the text, one post a line; `stack`
|
||||
* in a grid — the landing's latest posts — image above.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-post-card',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [ApiImage],
|
||||
styleUrl: './post-card.scss',
|
||||
host: { '[class.post-card--row]': "layout() === 'row'" },
|
||||
template: `
|
||||
<article
|
||||
class="landing-card post-card"
|
||||
[class.post-card--media]="!!post().featured_image"
|
||||
>
|
||||
@if (post().featured_image; as image) {
|
||||
<a
|
||||
class="post-card__media"
|
||||
[href]="post().path"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<app-api-image
|
||||
[image]="image"
|
||||
[sizes]="
|
||||
layout() === 'row'
|
||||
? '(min-width: 1024px) 20rem, 100vw'
|
||||
: '(min-width: 1024px) 24rem, 100vw'
|
||||
"
|
||||
/>
|
||||
</a>
|
||||
}
|
||||
<div class="post-card__body">
|
||||
@if (post().published_at; as published) {
|
||||
<time class="post-card__date" [attr.datetime]="published">{{
|
||||
date()
|
||||
}}</time>
|
||||
}
|
||||
<h3 class="post-card__title">
|
||||
<a [href]="post().path">{{ post().title }}</a>
|
||||
</h3>
|
||||
@if (post().excerpt; as excerpt) {
|
||||
<p class="post-card__excerpt">{{ excerpt }}</p>
|
||||
}
|
||||
<a class="post-card__more" [href]="post().path"
|
||||
>{{ copy().blog.readMore }} →</a
|
||||
>
|
||||
</div>
|
||||
</article>
|
||||
`,
|
||||
})
|
||||
export class PostCard {
|
||||
readonly post = input.required<PublicPage>();
|
||||
readonly copy = input.required<Copy>();
|
||||
readonly locale = input.required<Locale>();
|
||||
readonly layout = input<'row' | 'stack'>('stack');
|
||||
|
||||
protected readonly date = computed(() => {
|
||||
const published = this.post().published_at;
|
||||
|
||||
return published
|
||||
? new Intl.DateTimeFormat(this.locale(), {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}).format(new Date(published))
|
||||
: '';
|
||||
});
|
||||
}
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
PiTextWeightBoldDirective,
|
||||
} from '@pi/ui';
|
||||
|
||||
import type { Locale } from '../core/api';
|
||||
import type { Copy } from '../core/copy';
|
||||
import type { PublicProject } from '../core/api.types';
|
||||
import { TechnologyChip } from './technology-chip';
|
||||
import { ApiImage } from './api-image';
|
||||
|
||||
/** The API's five project states onto the status chip's five tones. */
|
||||
@@ -43,6 +45,7 @@ export const PROJECT_STATUS_TONES: Record<
|
||||
PiTextSmallDirective,
|
||||
PiTextColorDirective,
|
||||
PiTextWeightBoldDirective,
|
||||
TechnologyChip,
|
||||
],
|
||||
template: `
|
||||
<pi-card
|
||||
@@ -96,7 +99,10 @@ export const PROJECT_STATUS_TONES: Record<
|
||||
<ul class="landing-card__chips">
|
||||
@for (technology of technologies(); track technology.id) {
|
||||
<li>
|
||||
<pi-chip size="small" appearance="outline" [label]="technology.name" />
|
||||
<app-technology-chip
|
||||
[technology]="technology"
|
||||
[locale]="locale()"
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@@ -131,6 +137,8 @@ export const PROJECT_STATUS_TONES: Record<
|
||||
export class ProjectCard {
|
||||
readonly project = input.required<PublicProject>();
|
||||
readonly copy = input.required<Copy>();
|
||||
/** Only to address the technology chips, which link to their own pages. */
|
||||
readonly locale = input.required<Locale>();
|
||||
|
||||
/** Enough to read the stack at a glance; the rest is noise on a card. */
|
||||
protected readonly technologies = computed(() =>
|
||||
|
||||
@@ -87,3 +87,69 @@
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.project-roadmap,
|
||||
.project-releases,
|
||||
.project-first,
|
||||
.project-proposals {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.project-roadmap__item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.25rem 1rem;
|
||||
align-items: baseline;
|
||||
|
||||
&.is-done a {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
|
||||
.project-roadmap__bar {
|
||||
grid-column: 1 / -1;
|
||||
height: 0.375rem;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, currentColor 12%, transparent);
|
||||
|
||||
span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--pi-color-primary, currentColor);
|
||||
}
|
||||
}
|
||||
|
||||
.project-releases li {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.project-releases__notes {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.project-proposals li {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem 1rem;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
// Clear of the lead above and of the link below.
|
||||
.project-roadmap,
|
||||
.project-proposals {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.project-proposals__propose {
|
||||
margin: 1.25rem 0 0;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
PiActionButtonComponent,
|
||||
PiAvatarComponent,
|
||||
PiChipComponent,
|
||||
PiChipStatusComponent,
|
||||
@@ -17,9 +16,12 @@ import {
|
||||
import type { Locale } from '../core/api';
|
||||
import type { PublicProject } from '../core/api.types';
|
||||
import type { Copy } from '../core/copy';
|
||||
import { boardLink, type ProjectBoard } from '../core/taskstream';
|
||||
import { ApiImage } from './api-image';
|
||||
import { ContactCta } from './contact-cta';
|
||||
import { PROJECT_STATUS_TONES } from './project-card';
|
||||
import { TechnologyChip } from './technology-chip';
|
||||
import { LinkButton } from './link-button';
|
||||
|
||||
/**
|
||||
* A project on its own address: what it is, why it was built the way it was,
|
||||
@@ -34,13 +36,14 @@ import { PROJECT_STATUS_TONES } from './project-card';
|
||||
imports: [
|
||||
ApiImage,
|
||||
ContactCta,
|
||||
PiActionButtonComponent,
|
||||
PiAvatarComponent,
|
||||
PiAvatarComponent,
|
||||
PiChipComponent,
|
||||
PiChipStatusComponent,
|
||||
PiTextColorDirective,
|
||||
PiTextLargeDirective,
|
||||
PiTextWeightBoldDirective,
|
||||
TechnologyChip,
|
||||
LinkButton,
|
||||
],
|
||||
styleUrl: './project-detail.scss',
|
||||
template: `
|
||||
@@ -79,10 +82,9 @@ import { PROJECT_STATUS_TONES } from './project-card';
|
||||
track technology.id
|
||||
) {
|
||||
<li>
|
||||
<pi-chip
|
||||
size="small"
|
||||
appearance="outline"
|
||||
[label]="technology.name"
|
||||
<app-technology-chip
|
||||
[technology]="technology"
|
||||
[locale]="locale()"
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
@@ -91,23 +93,20 @@ import { PROJECT_STATUS_TONES } from './project-card';
|
||||
@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>
|
||||
<app-link-button
|
||||
[href]="demo"
|
||||
[text]="copy().labels.demo"
|
||||
[external]="true"
|
||||
/>
|
||||
}
|
||||
@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>
|
||||
<app-link-button
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
[href]="repository"
|
||||
[text]="copy().labels.repository"
|
||||
[external]="true"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -145,6 +144,91 @@ import { PROJECT_STATUS_TONES } from './project-card';
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (board(); as b) {
|
||||
@if (b.showcase.roadmap.length > 0) {
|
||||
<section class="content-page__section">
|
||||
<h2>{{ copy().project.roadmap }}</h2>
|
||||
<p class="content-page__muted">{{ copy().project.roadmapLead }}</p>
|
||||
<ol class="project-roadmap">
|
||||
@for (epic of b.showcase.roadmap; track epic.key) {
|
||||
<li class="project-roadmap__item" [class.is-done]="epic.done">
|
||||
<a [href]="link(epic.path)" target="_blank" rel="noopener">{{
|
||||
epic.title
|
||||
}}</a>
|
||||
<span class="content-page__muted project-roadmap__when"
|
||||
>{{ dates(epic.startDate, epic.dueDate) }}
|
||||
@if (epic.votes && !epic.done) {
|
||||
· {{ votes(epic.votes) }}
|
||||
}
|
||||
</span>
|
||||
<span
|
||||
class="project-roadmap__bar"
|
||||
role="img"
|
||||
[attr.aria-label]="epic.percent + '%'"
|
||||
><span [style.width.%]="epic.percent"></span
|
||||
></span>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</section>
|
||||
}
|
||||
@if (b.showcase.proposals; as proposals) {
|
||||
@if (proposals.open || proposals.top.length > 0) {
|
||||
<section class="content-page__section">
|
||||
<h2>{{ copy().project.proposals }}</h2>
|
||||
@if (proposals.top.length > 0) {
|
||||
<p class="content-page__muted">{{ copy().project.proposalsLead }}</p>
|
||||
<ol class="project-proposals">
|
||||
@for (proposal of proposals.top; track proposal.key) {
|
||||
<li>
|
||||
<a [href]="link(proposal.path)" target="_blank" rel="noopener">{{
|
||||
proposal.title
|
||||
}}</a>
|
||||
<span class="content-page__muted">{{ votes(proposal.votes) }}</span>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
}
|
||||
@if (proposals.open && proposals.path) {
|
||||
<p class="project-proposals__propose">
|
||||
<a [href]="link(proposals.path)" target="_blank" rel="noopener"
|
||||
>{{ copy().project.propose }} →</a
|
||||
>
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
}
|
||||
@if (b.showcase.releases.length > 0) {
|
||||
<section class="content-page__section">
|
||||
<h2>{{ copy().project.releases }}</h2>
|
||||
<ul class="project-releases">
|
||||
@for (release of b.showcase.releases; track release.name) {
|
||||
<li>
|
||||
<strong>{{ release.name }}</strong>
|
||||
<span class="content-page__muted">
|
||||
{{
|
||||
release.status === 'RELEASED'
|
||||
? copy().project.released
|
||||
: copy().project.planned
|
||||
}}
|
||||
@if (release.releasedAt ?? release.targetDate; as day) {
|
||||
· {{ dates(day, null) }}
|
||||
}
|
||||
@if (release.status !== 'RELEASED') {
|
||||
· {{ release.percent }}%
|
||||
}
|
||||
</span>
|
||||
@if (release.notes) {
|
||||
<pre class="project-releases__notes">{{ release.notes }}</pre>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@if ((project().gallery ?? []).length > 0) {
|
||||
<section class="content-page__section">
|
||||
<h2>{{ copy().project.gallery }}</h2>
|
||||
@@ -204,6 +288,36 @@ import { PROJECT_STATUS_TONES } from './project-card';
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
@if (board(); as b) {
|
||||
<div class="landing-card content-page__panel">
|
||||
@if (b.showcase.activeSprint; as sprint) {
|
||||
<p class="landing-eyebrow">{{ copy().project.sprint }}</p>
|
||||
<p>
|
||||
<strong>{{ sprint.name }}</strong> · {{ sprint.percent }}%
|
||||
@if (sprint.goal) {
|
||||
<br /><span class="content-page__muted">{{ sprint.goal }}</span>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
@if (b.showcase.goodFirst.length > 0) {
|
||||
<h2>{{ copy().project.goodFirst }}</h2>
|
||||
<p class="content-page__muted">{{ copy().project.goodFirstLead }}</p>
|
||||
<ul class="project-first">
|
||||
@for (issue of b.showcase.goodFirst; track issue.key) {
|
||||
<li>
|
||||
<a [href]="link(issue.path)" target="_blank" rel="noopener"
|
||||
>{{ issue.key }} · {{ issue.title }}</a
|
||||
>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
<a [href]="b.url" target="_blank" rel="noopener">{{
|
||||
copy().project.board
|
||||
}} →</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if ((project().calls ?? []).length > 0) {
|
||||
<nav [attr.aria-label]="copy().project.openCalls">
|
||||
<p class="landing-eyebrow">{{ copy().project.openCalls }}</p>
|
||||
@@ -306,6 +420,32 @@ export class ProjectDetail {
|
||||
readonly project = input.required<PublicProject>();
|
||||
readonly copy = input.required<Copy>();
|
||||
readonly locale = input.required<Locale>();
|
||||
/** Its TaskStream board, when it has one with something to show. */
|
||||
readonly board = input<ProjectBoard | null>(null);
|
||||
|
||||
protected link(path: string): string {
|
||||
return boardLink(this.board()?.webUrl ?? '', path);
|
||||
}
|
||||
|
||||
/** "1 voto", "3 votos". */
|
||||
protected votes(count: number): string {
|
||||
const copy = this.copy().project;
|
||||
return count === 1 ? copy.voteOne : copy.voteMany.replace('{n}', String(count));
|
||||
}
|
||||
|
||||
/** "3 nov – 20 dic 2026", or one end when only one is known. */
|
||||
protected dates(start: string | null, end: string | null): string {
|
||||
const format = (date: string | null) =>
|
||||
date
|
||||
? new Intl.DateTimeFormat(this.locale(), {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
}).format(new Date(`${date.slice(0, 10)}T00:00:00Z`))
|
||||
: null;
|
||||
return [format(start), format(end)].filter(Boolean).join(' – ');
|
||||
}
|
||||
|
||||
protected readonly statusTone = computed(
|
||||
() => PROJECT_STATUS_TONES[this.project().status],
|
||||
|
||||
@@ -144,3 +144,19 @@
|
||||
color: var(--pi-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
// The card's logo and name lead to the technology's own page.
|
||||
.radar-card__top {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
|
||||
&:hover .radar-card__name {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--pi-color-primary);
|
||||
outline-offset: 4px;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
} from '@pi/ui';
|
||||
|
||||
import type { PublicTechnology } from '../core/api.types';
|
||||
import type { Locale } from '../core/api';
|
||||
import type { Copy } from '../core/copy';
|
||||
import { technologyPath } from '../core/routes';
|
||||
import { ApiImage } from './api-image';
|
||||
|
||||
type Ring = NonNullable<PublicTechnology['ring']>;
|
||||
@@ -81,7 +83,7 @@ const RINGS: Ring[] = ['adopt', 'trial', 'assess', 'hold'];
|
||||
<ul class="radar-grid">
|
||||
@for (technology of group.technologies; track technology.id) {
|
||||
<li class="landing-card radar-card">
|
||||
<div class="radar-card__top">
|
||||
<a class="radar-card__top" [href]="href(technology)">
|
||||
<span
|
||||
class="radar-logo"
|
||||
[style.--brand]="technology.color || null"
|
||||
@@ -104,7 +106,7 @@ const RINGS: Ring[] = ['adopt', 'trial', 'assess', 'hold'];
|
||||
{{ copy().radar.categories[technology.category] }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@if (technology.description; as description) {
|
||||
<p class="radar-card__text">{{ description }}</p>
|
||||
@@ -148,6 +150,13 @@ const RINGS: Ring[] = ['adopt', 'trial', 'assess', 'hold'];
|
||||
export class RadarPage {
|
||||
readonly technologies = input.required<PublicTechnology[]>();
|
||||
readonly copy = input.required<Copy>();
|
||||
/** Only to address each technology's own page. */
|
||||
readonly locale = input.required<Locale>();
|
||||
|
||||
/** Each card leads to the technology it names. */
|
||||
protected href(technology: PublicTechnology): string {
|
||||
return technologyPath(technology.slug, this.locale());
|
||||
}
|
||||
|
||||
/** The rings that have anything in them, in the radar's own order. */
|
||||
protected readonly rings = computed(() =>
|
||||
|
||||
@@ -6,12 +6,18 @@ import {
|
||||
} from '@angular/core';
|
||||
|
||||
import { copyFor, type Copy } from '../core/copy';
|
||||
import { listingAlternates, listingPath } from '../core/routes';
|
||||
import {
|
||||
listingAlternates,
|
||||
listingPath,
|
||||
technologyAlternates,
|
||||
technologyPath,
|
||||
} 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 { TechnologyDetail } from './technology-detail';
|
||||
import { BlogPage } from './blog-page';
|
||||
import { CallDetail } from './call-detail';
|
||||
import type { RoutedPageData, RoutedRecord } from '../core/routed-page';
|
||||
@@ -38,6 +44,7 @@ import { ProjectDetail } from './project-detail';
|
||||
ProjectDetail,
|
||||
RadarPage,
|
||||
ServiceDetail,
|
||||
TechnologyDetail,
|
||||
],
|
||||
template: `
|
||||
<app-page-shell [data]="data()" [seo]="seo()">
|
||||
@@ -54,6 +61,7 @@ import { ProjectDetail } from './project-detail';
|
||||
@case ('project') {
|
||||
<app-project-detail
|
||||
[project]="found.record"
|
||||
[board]="found.board"
|
||||
[copy]="copy()"
|
||||
[locale]="data().locale"
|
||||
/>
|
||||
@@ -88,7 +96,18 @@ import { ProjectDetail } from './project-detail';
|
||||
/>
|
||||
}
|
||||
@case ('radar') {
|
||||
<app-radar-page [technologies]="found.record" [copy]="copy()" />
|
||||
<app-radar-page
|
||||
[technologies]="found.record"
|
||||
[copy]="copy()"
|
||||
[locale]="data().locale"
|
||||
/>
|
||||
}
|
||||
@case ('technology') {
|
||||
<app-technology-detail
|
||||
[technology]="found.record"
|
||||
[copy]="copy()"
|
||||
[locale]="data().locale"
|
||||
/>
|
||||
}
|
||||
@case ('member') {
|
||||
<app-member-detail
|
||||
@@ -223,6 +242,26 @@ export function seoFor(
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'technology': {
|
||||
const technology = found.record;
|
||||
|
||||
// The only record whose address this site composes rather than reads
|
||||
// off the payload: the API gives technologies no `path`.
|
||||
return {
|
||||
title: `Pireon — ${technology.name}`,
|
||||
description: technology.description || copy.radar.lead,
|
||||
path: technologyPath(technology.slug, locale),
|
||||
alternates: technologyAlternates(technology.slug),
|
||||
type: 'article',
|
||||
jsonLd: {
|
||||
'@type': 'SoftwareApplication',
|
||||
name: technology.name,
|
||||
description: technology.description,
|
||||
applicationCategory: copy.radar.categories[technology.category],
|
||||
url: technology.website,
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'blog':
|
||||
return {
|
||||
title: `Pireon — ${copy.blog.title}`,
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
PiChipComponent,
|
||||
PiIconComponent,
|
||||
PiTextColorDirective,
|
||||
PiTextLargeDirective,
|
||||
@@ -19,6 +18,7 @@ import { resolveIcon } from '../core/icons';
|
||||
import { ContactCta } from './contact-cta';
|
||||
import { ProjectCard } from './project-card';
|
||||
import { TestimonialCard } from './testimonial-card';
|
||||
import { TechnologyChip } from './technology-chip';
|
||||
|
||||
/**
|
||||
* A service on its own address, and the published work that backs it: the
|
||||
@@ -32,13 +32,13 @@ import { TestimonialCard } from './testimonial-card';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
ContactCta,
|
||||
PiChipComponent,
|
||||
PiIconComponent,
|
||||
PiIconComponent,
|
||||
PiTextColorDirective,
|
||||
PiTextLargeDirective,
|
||||
PiTextWeightBoldDirective,
|
||||
ProjectCard,
|
||||
TestimonialCard,
|
||||
TechnologyChip,
|
||||
],
|
||||
styleUrl: './service-detail.scss',
|
||||
template: `
|
||||
@@ -72,7 +72,11 @@ import { TestimonialCard } from './testimonial-card';
|
||||
<h2>{{ copy().service.projects }}</h2>
|
||||
<div class="service-grid">
|
||||
@for (project of service().projects; track project.id) {
|
||||
<app-project-card [project]="project" [copy]="copy()" />
|
||||
<app-project-card
|
||||
[project]="project"
|
||||
[copy]="copy()"
|
||||
[locale]="locale()"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@@ -100,11 +104,10 @@ import { TestimonialCard } from './testimonial-card';
|
||||
<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"
|
||||
/>
|
||||
<app-technology-chip
|
||||
[technology]="technology"
|
||||
[locale]="locale()"
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
22
landing/src/app/ui/technology-chip.scss
Normal file
22
landing/src/app/ui/technology-chip.scss
Normal file
@@ -0,0 +1,22 @@
|
||||
:host {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.technology-chip {
|
||||
display: inline-flex;
|
||||
text-decoration: none;
|
||||
border-radius: var(--pi-theme-chip-radius, 0.25rem);
|
||||
|
||||
// The chip carries the visible border, so the focus ring goes on the anchor
|
||||
// around it rather than being lost inside the component.
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--pi-color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:hover ::ng-deep .pi-chip,
|
||||
&:hover ::ng-deep span {
|
||||
border-color: var(--pi-color-primary);
|
||||
color: var(--pi-color-primary);
|
||||
}
|
||||
}
|
||||
33
landing/src/app/ui/technology-chip.ts
Normal file
33
landing/src/app/ui/technology-chip.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
||||
import { PiChipComponent } from '@pi/ui';
|
||||
|
||||
import type { Locale } from '../core/api';
|
||||
import type { PublicTechnology } from '../core/api.types';
|
||||
import { technologyPath } from '../core/routes';
|
||||
|
||||
/**
|
||||
* A technology's name, leading to its page.
|
||||
*
|
||||
* The same chip was written out in nine places — every card and every detail
|
||||
* page that names a technology — so the link lives here rather than nine
|
||||
* times. `pi-chip` renders a `<span>`, so the anchor goes around it.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-technology-chip',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PiChipComponent],
|
||||
styleUrl: './technology-chip.scss',
|
||||
template: `
|
||||
<a class="technology-chip" [href]="href()">
|
||||
<pi-chip size="small" appearance="outline" [label]="technology().name" />
|
||||
</a>
|
||||
`,
|
||||
})
|
||||
export class TechnologyChip {
|
||||
readonly technology = input.required<PublicTechnology>();
|
||||
readonly locale = input.required<Locale>();
|
||||
|
||||
protected readonly href = computed(() =>
|
||||
technologyPath(this.technology().slug, this.locale()),
|
||||
);
|
||||
}
|
||||
53
landing/src/app/ui/technology-detail.scss
Normal file
53
landing/src/app/ui/technology-detail.scss
Normal file
@@ -0,0 +1,53 @@
|
||||
@use './detail';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.technology-hero {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
// The same light tile the radar uses, for the same reason: a brand's mark is
|
||||
// not ours to recolour, and a black one would disappear in dark mode.
|
||||
.technology-logo {
|
||||
display: grid;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
width: 4.5rem;
|
||||
height: 4.5rem;
|
||||
border-radius: 1.125rem;
|
||||
background: #fff;
|
||||
box-shadow:
|
||||
0 0 0 1px
|
||||
color-mix(in srgb, var(--brand, var(--pi-color-primary)) 22%, transparent),
|
||||
0 8px 20px -10px
|
||||
color-mix(in srgb, var(--brand, var(--pi-color-primary)) 55%, transparent);
|
||||
}
|
||||
|
||||
.technology-logo ::ng-deep img {
|
||||
display: block;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.technology-logo__initial {
|
||||
font-weight: 700;
|
||||
font-size: 1.5rem;
|
||||
color: var(--brand, var(--pi-color-primary));
|
||||
}
|
||||
|
||||
.technology-text {
|
||||
margin: 0;
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.technology-note {
|
||||
margin-top: 0.75rem;
|
||||
color: var(--pi-color-grey);
|
||||
}
|
||||
163
landing/src/app/ui/technology-detail.ts
Normal file
163
landing/src/app/ui/technology-detail.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
PiTextColorDirective,
|
||||
PiTextLargeDirective,
|
||||
PiTextWeightBoldDirective,
|
||||
} from '@pi/ui';
|
||||
|
||||
import type { Locale } from '../core/api';
|
||||
import type { PublicTechnology } from '../core/api.types';
|
||||
import type { Copy } from '../core/copy';
|
||||
import { listingPath } from '../core/routes';
|
||||
import { LinkButton } from './link-button';
|
||||
import { ApiImage } from './api-image';
|
||||
import { ContactCta } from './contact-cta';
|
||||
|
||||
/**
|
||||
* One technology on its own address: what it is, where the collective places
|
||||
* it on the radar, and where its own people document it.
|
||||
*
|
||||
* The logo sits on a light tile in both themes, as it does on the radar. A
|
||||
* brand's own mark is not ours to recolour, and some — Rust's, black — would
|
||||
* otherwise vanish against a dark page.
|
||||
*
|
||||
* `website` is the only link the API carries. It is the project's own home
|
||||
* page, which is where its documentation lives; there is no separate field for
|
||||
* the docs, so the label says what the link actually is.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-technology-detail',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
ApiImage,
|
||||
ContactCta,
|
||||
PiTextColorDirective,
|
||||
PiTextLargeDirective,
|
||||
PiTextWeightBoldDirective,
|
||||
LinkButton,
|
||||
],
|
||||
styleUrl: './technology-detail.scss',
|
||||
template: `
|
||||
<header class="content-hero">
|
||||
<div class="landing-shell technology-hero">
|
||||
<span
|
||||
class="technology-logo"
|
||||
[style.--brand]="technology().color || null"
|
||||
>
|
||||
@if (technology().logo; as logo) {
|
||||
<app-api-image
|
||||
[image]="logo"
|
||||
sizes="72px"
|
||||
loading="eager"
|
||||
imgClass="technology-logo__image"
|
||||
/>
|
||||
} @else {
|
||||
<span class="technology-logo__initial" aria-hidden="true">{{
|
||||
technology().name[0]
|
||||
}}</span>
|
||||
}
|
||||
</span>
|
||||
|
||||
<div>
|
||||
<p class="landing-eyebrow">{{ category() }}</p>
|
||||
<h1 bold class="content-hero__title">{{ technology().name }}</h1>
|
||||
|
||||
@if (technology().description; as description) {
|
||||
<p l pi-color color="grey" class="content-hero__lead">
|
||||
{{ description }}
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (technology().website; as website) {
|
||||
<p class="content-hero__actions">
|
||||
<app-link-button
|
||||
size="large"
|
||||
[href]="website"
|
||||
[text]="copy().technology.website"
|
||||
[external]="true"
|
||||
/>
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="landing-shell content-page__layout">
|
||||
<div>
|
||||
@if (ring(); as ring) {
|
||||
<section class="content-page__section">
|
||||
<h2>{{ copy().technology.ringTitle }}: {{ ring.label }}</h2>
|
||||
<p class="technology-text">{{ ring.lead }}</p>
|
||||
|
||||
@if (technology().radar_note; as note) {
|
||||
<p class="technology-text technology-note">{{ note }}</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
<p>
|
||||
<a [href]="radarHref()">{{ copy().technology.back }} →</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<aside class="content-page__aside">
|
||||
@if (facts().length > 0) {
|
||||
<div class="landing-card content-page__panel">
|
||||
<ul class="content-page__facts">
|
||||
@for (fact of facts(); track fact) {
|
||||
<li>{{ fact }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-contact-cta [copy]="copy()" [locale]="locale()" />
|
||||
</aside>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class TechnologyDetail {
|
||||
readonly technology = input.required<PublicTechnology>();
|
||||
readonly copy = input.required<Copy>();
|
||||
readonly locale = input.required<Locale>();
|
||||
|
||||
protected readonly category = computed(
|
||||
() => this.copy().radar.categories[this.technology().category],
|
||||
);
|
||||
|
||||
protected readonly radarHref = computed(() =>
|
||||
listingPath('radar', this.locale()),
|
||||
);
|
||||
|
||||
/** Null for a technology the collective uses but has not placed. */
|
||||
protected readonly ring = computed(() => {
|
||||
const ring = this.technology().ring;
|
||||
|
||||
return ring
|
||||
? { label: this.copy().rings[ring], lead: this.copy().radar.ringLead[ring] }
|
||||
: null;
|
||||
});
|
||||
|
||||
/** Counts the API reports as zero say nothing, so they are not shown. */
|
||||
protected readonly facts = computed(() => {
|
||||
const { projects_count: projects, members_count: members } =
|
||||
this.technology();
|
||||
const copy = this.copy().technology;
|
||||
const facts: string[] = [];
|
||||
|
||||
if (projects > 0) {
|
||||
facts.push(`${projects} ${copy.usedIn}`);
|
||||
}
|
||||
|
||||
if (members > 0) {
|
||||
facts.push(`${members} ${copy.membersUsing}`);
|
||||
}
|
||||
|
||||
return facts;
|
||||
});
|
||||
}
|
||||
@@ -13,13 +13,18 @@ import {
|
||||
setResponseStatus,
|
||||
} from 'h3';
|
||||
|
||||
import { pireonApi } from '../../app/core/api';
|
||||
import { DEFAULT_LOCALE, 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();
|
||||
// The technologies are fetched alongside, not instead: their addresses are
|
||||
// this site's own and `/slugs` cannot carry them.
|
||||
const [{ data }, technologies] = await Promise.all([
|
||||
pireonApi.slugs(),
|
||||
pireonApi.technologies(DEFAULT_LOCALE, { in_use: false }),
|
||||
]);
|
||||
|
||||
if (!data) {
|
||||
setResponseStatus(event, 503);
|
||||
@@ -30,5 +35,9 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
setResponseHeader(event, 'Content-Type', 'application/xml; charset=utf-8');
|
||||
|
||||
return buildSitemap(origin, data);
|
||||
// A failed `/slugs` is a 503 above, because a sitemap missing most of the
|
||||
// site is worse than none. A failed technologies call is not: they are an
|
||||
// extra this site adds, and leaving them out costs a handful of URLs rather
|
||||
// than misrepresenting the whole site.
|
||||
return buildSitemap(origin, data, technologies.data ?? []);
|
||||
});
|
||||
|
||||
9
package-lock.json
generated
9
package-lock.json
generated
@@ -22,7 +22,7 @@
|
||||
"@angular/platform-server": "~22.0.4",
|
||||
"@angular/router": "~22.0.4",
|
||||
"@ngx-translate/core": "^18.0.0",
|
||||
"@pi/ui": "^0.0.2",
|
||||
"@pi/ui": "^0.0.7",
|
||||
"front-matter": "^4.0.2",
|
||||
"marked": "^15.0.7",
|
||||
"marked-gfm-heading-id": "^4.1.1",
|
||||
@@ -9587,12 +9587,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@pi/ui": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://nexus.pireon.dev/repository/npm-releases/@pi/ui/-/ui-0.0.2.tgz",
|
||||
"integrity": "sha1-kCo1VJKozqkA7ag+oOYIRoGESAg=",
|
||||
"version": "0.0.7",
|
||||
"resolved": "https://nexus.pireon.dev/repository/npm-releases/@pi/ui/-/ui-0.0.7.tgz",
|
||||
"integrity": "sha1-PkfcIIYypFMmuypt5C3UbxDNJbc=",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.0",
|
||||
"luxon": "^3.0.0",
|
||||
"ngx-infinite-scroll": "^22.0.0",
|
||||
"tslib": "^2.3.0"
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"@angular/platform-server": "~22.0.4",
|
||||
"@angular/router": "~22.0.4",
|
||||
"@ngx-translate/core": "^18.0.0",
|
||||
"@pi/ui": "^0.0.2",
|
||||
"@pi/ui": "^0.0.7",
|
||||
"front-matter": "^4.0.2",
|
||||
"marked": "^15.0.7",
|
||||
"marked-gfm-heading-id": "^4.1.1",
|
||||
|
||||
@@ -17,6 +17,15 @@ import { createServer } from 'node:http';
|
||||
|
||||
const tech = (id, name, slug, category, color, ring) => ({
|
||||
id, name, slug, category, color, ring, projects_count: 3, members_count: 2,
|
||||
description: `${name} es una de las herramientas con las que trabajamos a diario. Esta descripción viene de la API, como en producción.`,
|
||||
website: `https://example.com/${slug}`,
|
||||
// An inline SVG so the mock needs no file server: the real API serves these
|
||||
// from its own storage.
|
||||
logo: {
|
||||
url: `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="${color}"/><text x="32" y="42" font-family="sans-serif" font-size="30" font-weight="700" fill="#fff" text-anchor="middle">${name[0]}</text></svg>`)}`,
|
||||
alt: name, width: 64, height: 64, aspect_ratio: 1,
|
||||
mime: 'image/svg+xml', placeholder: null, srcset: null,
|
||||
},
|
||||
});
|
||||
|
||||
const technologies = [
|
||||
@@ -55,9 +64,23 @@ const page = (id, title, slug, featured) => ({
|
||||
alternates: { es: `/${slug}`, en: `/en/${slug}-en`, ca: `/ca/${slug}-ca` }, path: `/${slug}`,
|
||||
});
|
||||
|
||||
const post = (id, title, slug, published_at) => ({
|
||||
// A body with everything the editor can produce, images included, so the
|
||||
// post's typography can be reviewed without writing one in the panel.
|
||||
const richBody =
|
||||
'<p>Volvimos a renderizar en el servidor por tres motivos.</p>' +
|
||||
'<ul><li><p><strong>Rendimiento</strong> en móviles modestos.</p></li><li><p><strong>SEO</strong> sin trucos.</p></li></ul>' +
|
||||
'<img src="/logo.png" alt="El logo de Pireon">' +
|
||||
'<h2>Cómo lo hicimos</h2><p>Con <code>injectLoad</code> y un loader por página:</p>' +
|
||||
'<pre><code>export const load = async () => fetchPage();</code></pre>' +
|
||||
'<figure><img src="/logo.png" alt="Otra vez el logo"><figcaption>Una imagen con pie de foto.</figcaption></figure>' +
|
||||
'<blockquote><p>Lo que no se mide en un móvil de gama baja, no está medido.</p></blockquote>';
|
||||
|
||||
const post = (id, title, slug, published_at, tags = []) => ({
|
||||
...page(id, title, slug, false),
|
||||
body: richBody,
|
||||
published_at,
|
||||
tags,
|
||||
byline: { name: 'Ada Lovelace', slug: 'ada', headline: 'Backend', avatar: null, path: '/equipo/ada' },
|
||||
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 },
|
||||
});
|
||||
@@ -65,8 +88,8 @@ const post = (id, title, slug, published_at) => ({
|
||||
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'),
|
||||
post(3, 'Por qué volvimos a renderizar en el servidor', 'volvimos-al-servidor', '2026-09-20T09:00:00Z', [{ name: 'Angular', slug: 'angular' }, { name: 'Rendimiento', slug: 'rendimiento' }]),
|
||||
post(4, 'Nuestro primer año como colectivo', 'primer-ano', '2026-06-01T09:00:00Z', [{ name: 'Colectivo', slug: 'colectivo' }]),
|
||||
];
|
||||
|
||||
const data = {
|
||||
@@ -100,6 +123,7 @@ const data = {
|
||||
{ id: 4, quote: 'Trabajar con el repositorio abierto desde el primer día nos quitó el miedo a depender de nadie.', original_locale: 'es', is_original: true, author: { name: 'Pau Ferrer', role: 'Dirección' }, client: { id: 3, name: 'Fundació Oberta', slug: 'oberta', projects_count: 3 } },
|
||||
],
|
||||
'/public/v1/radar': technologies,
|
||||
'/public/v1/technologies': technologies,
|
||||
'/public/v1/members': [member(1, 'Ada Lovelace', 'ada', true), member(2, 'Linus T.', 'linus', false), member(3, 'Grace Hopper', 'grace', true), member(4, 'Alan Turing', 'alan', false)],
|
||||
'/public/v1/calls': [
|
||||
{ id: 1, locale: 'es', title: 'Frontend Angular', slug: 'frontend-angular', description: 'Ayúdanos con el catálogo público: componentes, accesibilidad y tests.', status: 'open', hours_per_week: 4, duration_weeks: 8, seniority: 'any', closes_at: '2026-10-01T00:00:00Z', project: project(1, 'Catálogo abierto', 'catalogo', 'active', true), technologies: technologies.slice(0, 2), alternates: {} },
|
||||
@@ -145,6 +169,38 @@ for (const listed of data['/public/v1/projects']) {
|
||||
};
|
||||
}
|
||||
|
||||
// The catalogue has a board in TaskStream: its showcase is served here too,
|
||||
// so the roadmap, releases, proposals and first issues show without one.
|
||||
const showcase = {
|
||||
project: { id: 'ts-1', key: 'CAT', name: 'Catálogo abierto', description: null },
|
||||
activeSprint: { name: 'Sprint 4', goal: 'Buscar por etiqueta', startDate: '2026-10-05', endDate: '2026-10-19', percent: 40 },
|
||||
roadmap: [
|
||||
{ key: 'CAT-1', title: 'Fichas accesibles', startDate: '2026-07-01', dueDate: '2026-09-15', percent: 100, done: true, votes: 2, path: '/p/CAT/issues/CAT-1' },
|
||||
{ key: 'CAT-7', title: 'Búsqueda por etiquetas', startDate: '2026-09-20', dueDate: '2026-11-30', percent: 45, done: false, votes: 9, path: '/p/CAT/issues/CAT-7' },
|
||||
{ key: 'CAT-12', title: 'Exportar a PDF', startDate: null, dueDate: '2027-02-01', percent: 0, done: false, votes: 1, path: '/p/CAT/issues/CAT-12' },
|
||||
],
|
||||
releases: [
|
||||
{ name: '1.2', status: 'PLANNED', targetDate: '2026-11-30', releasedAt: null, percent: 60, notes: null },
|
||||
{ name: '1.1', status: 'RELEASED', targetDate: null, releasedAt: '2026-09-16', percent: 100, notes: '- Fichas accesibles\n- Arreglos en móvil' },
|
||||
],
|
||||
goodFirst: [{ key: 'CAT-21', title: 'Traducir los avisos al catalán', type: 't-task', path: '/p/CAT/issues/CAT-21' }],
|
||||
proposals: {
|
||||
open: true,
|
||||
path: '/p/CAT/proponer',
|
||||
top: [
|
||||
{ key: 'CAT-30', title: 'Modo oscuro', type: 't-feature', path: '/p/CAT/issues/CAT-30', votes: 14 },
|
||||
{ key: 'CAT-28', title: 'El buscador no encuentra palabras con tilde', type: 't-bug', path: '/p/CAT/issues/CAT-28', votes: 6 },
|
||||
{ key: 'CAT-33', title: 'Compartir una ficha por enlace corto', type: 't-feature', path: '/p/CAT/issues/CAT-33', votes: 1 },
|
||||
],
|
||||
},
|
||||
};
|
||||
data['/public/v1/projects/catalogo'].taskstream = {
|
||||
key: 'CAT',
|
||||
url: 'https://taskstream.pireon.dev/p/CAT',
|
||||
web_url: 'https://taskstream.pireon.dev',
|
||||
showcase_url: 'http://localhost:4599/taskstream/CAT/showcase',
|
||||
};
|
||||
|
||||
// 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']) {
|
||||
@@ -201,12 +257,24 @@ createServer((req, res) => {
|
||||
let payload = data[path];
|
||||
|
||||
// The one filter the frontend relies on: pages by category slug.
|
||||
if (path === '/public/v1/technologies') {
|
||||
const inUse = url.searchParams.get('in_use');
|
||||
payload =
|
||||
inUse === 'false' ? payload : payload.filter((t) => t.projects_count > 0);
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
// TaskStream answers its showcase bare, not inside `data`.
|
||||
if (path === '/taskstream/CAT/showcase') {
|
||||
res.end(JSON.stringify(showcase));
|
||||
return;
|
||||
}
|
||||
|
||||
// The two write endpoints: accept whatever arrives, as the real API does
|
||||
// for a valid submission.
|
||||
if (req.method === 'POST') {
|
||||
|
||||
Reference in New Issue
Block a user