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` |
|
| Charlas | `/talks?limit=6` |
|
||||||
| Contact form | `POST /contact` |
|
| 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/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
|
`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:
|
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.
|
- `/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
|
- A post shows its date and leads back to the blog; its structured data is a
|
||||||
`BlogPosting`.
|
`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>`.
|
- `/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
|
- Once there is a post, the blog is in the header menu (desktop and mobile)
|
||||||
links the blog once there is any.
|
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
|
## 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 ({
|
test('refuses an incomplete contact submission without calling the API', async ({
|
||||||
page,
|
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']) =>
|
technologies: (lang: Locale, query?: GetOptions['query']) =>
|
||||||
list<PublicTechnology>('/technologies', { lang, 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']) =>
|
goodFirstIssues: (query?: GetOptions['query']) =>
|
||||||
list<PublicIssue>('/good-first-issues', { query }),
|
list<PublicIssue>('/good-first-issues', { query }),
|
||||||
|
|
||||||
|
|||||||
@@ -165,6 +165,14 @@ export interface PublicPage {
|
|||||||
unpublish_at?: string;
|
unpublish_at?: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
category: PublicCategory;
|
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;
|
alternates: Alternates;
|
||||||
path?: string;
|
path?: string;
|
||||||
}
|
}
|
||||||
@@ -233,6 +241,10 @@ export interface PublicProject {
|
|||||||
lead: PublicMember;
|
lead: PublicMember;
|
||||||
calls: PublicCall[];
|
calls: PublicCall[];
|
||||||
repositories: PublicRepository[];
|
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;
|
alternates: Alternates;
|
||||||
path?: string;
|
path?: string;
|
||||||
}
|
}
|
||||||
@@ -268,6 +280,21 @@ export interface PublicRepository {
|
|||||||
good_first_issues: PublicIssue[];
|
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 {
|
export interface PublicService {
|
||||||
id: number;
|
id: number;
|
||||||
locale: string;
|
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/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/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/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': PublicProject[];
|
||||||
'/public/v1/projects/{slug}': PublicProject;
|
'/public/v1/projects/{slug}': PublicProject;
|
||||||
'/public/v1/members': PublicMember[];
|
'/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 { Locale } from './api';
|
||||||
import type { PublicPage } from './api.types';
|
import type { PublicPage } from './api.types';
|
||||||
|
import { listingPath } from './routes';
|
||||||
|
|
||||||
export const BLOG_CATEGORY: Record<Locale, string> = {
|
export const BLOG_CATEGORY: Record<Locale, string> = {
|
||||||
es: 'blog',
|
es: 'blog',
|
||||||
@@ -25,3 +26,44 @@ export function byPublication(pages: PublicPage[]): PublicPage[] {
|
|||||||
(b.published_at ?? '').localeCompare(a.published_at ?? ''),
|
(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;
|
archived: string;
|
||||||
openCalls: string;
|
openCalls: string;
|
||||||
technologies: 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: {
|
member: {
|
||||||
projects: string;
|
projects: string;
|
||||||
@@ -136,6 +151,12 @@ export interface Copy {
|
|||||||
readMore: string;
|
readMore: string;
|
||||||
published: string;
|
published: string;
|
||||||
feed: string;
|
feed: string;
|
||||||
|
seeAll: string;
|
||||||
|
by: string;
|
||||||
|
tags: string;
|
||||||
|
allTags: string;
|
||||||
|
tagged: string;
|
||||||
|
noneTagged: string;
|
||||||
};
|
};
|
||||||
service: {
|
service: {
|
||||||
eyebrow: string;
|
eyebrow: string;
|
||||||
@@ -143,6 +164,14 @@ export interface Copy {
|
|||||||
testimonials: string;
|
testimonials: string;
|
||||||
technologies: string;
|
technologies: string;
|
||||||
};
|
};
|
||||||
|
technology: {
|
||||||
|
eyebrow: string;
|
||||||
|
ringTitle: string;
|
||||||
|
website: string;
|
||||||
|
usedIn: string;
|
||||||
|
membersUsing: string;
|
||||||
|
back: string;
|
||||||
|
};
|
||||||
radar: {
|
radar: {
|
||||||
eyebrow: string;
|
eyebrow: string;
|
||||||
lead: string;
|
lead: string;
|
||||||
@@ -283,6 +312,21 @@ const es: Copy = {
|
|||||||
archived: 'Archivado',
|
archived: 'Archivado',
|
||||||
openCalls: 'Buscamos ayuda',
|
openCalls: 'Buscamos ayuda',
|
||||||
technologies: 'Tecnologías',
|
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: {
|
member: {
|
||||||
projects: 'Proyectos',
|
projects: 'Proyectos',
|
||||||
@@ -318,6 +362,12 @@ const es: Copy = {
|
|||||||
readMore: 'Leer',
|
readMore: 'Leer',
|
||||||
published: 'Publicado el',
|
published: 'Publicado el',
|
||||||
feed: 'Suscríbete por RSS',
|
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: {
|
service: {
|
||||||
eyebrow: 'Servicio',
|
eyebrow: 'Servicio',
|
||||||
@@ -325,6 +375,14 @@ const es: Copy = {
|
|||||||
testimonials: 'Qué dicen',
|
testimonials: 'Qué dicen',
|
||||||
technologies: 'Con qué lo hacemos',
|
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: {
|
radar: {
|
||||||
eyebrow: 'Radar tecnológico',
|
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.',
|
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',
|
archived: 'Archived',
|
||||||
openCalls: 'Looking for help',
|
openCalls: 'Looking for help',
|
||||||
technologies: 'Technologies',
|
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: {
|
member: {
|
||||||
projects: 'Projects',
|
projects: 'Projects',
|
||||||
@@ -513,6 +586,12 @@ const en: Copy = {
|
|||||||
readMore: 'Read',
|
readMore: 'Read',
|
||||||
published: 'Published on',
|
published: 'Published on',
|
||||||
feed: 'Subscribe via RSS',
|
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: {
|
service: {
|
||||||
eyebrow: 'Service',
|
eyebrow: 'Service',
|
||||||
@@ -520,6 +599,14 @@ const en: Copy = {
|
|||||||
testimonials: 'What people say',
|
testimonials: 'What people say',
|
||||||
technologies: 'What we use',
|
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: {
|
radar: {
|
||||||
eyebrow: 'Tech 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.',
|
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',
|
archived: 'Arxivat',
|
||||||
openCalls: 'Busquem ajuda',
|
openCalls: 'Busquem ajuda',
|
||||||
technologies: 'Tecnologies',
|
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: {
|
member: {
|
||||||
projects: 'Projectes',
|
projects: 'Projectes',
|
||||||
@@ -708,6 +810,12 @@ const ca: Copy = {
|
|||||||
readMore: 'Llegir',
|
readMore: 'Llegir',
|
||||||
published: 'Publicat el',
|
published: 'Publicat el',
|
||||||
feed: "Subscriu-t'hi per RSS",
|
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: {
|
service: {
|
||||||
eyebrow: 'Servei',
|
eyebrow: 'Servei',
|
||||||
@@ -715,6 +823,14 @@ const ca: Copy = {
|
|||||||
testimonials: 'Què en diuen',
|
testimonials: 'Què en diuen',
|
||||||
technologies: 'Amb què ho fem',
|
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: {
|
radar: {
|
||||||
eyebrow: 'Radar tecnològic',
|
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.",
|
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,
|
PublicTechnology,
|
||||||
} from './api.types';
|
} from './api.types';
|
||||||
import { BLOG_CATEGORY, byPublication, isBlogPost } from './blog';
|
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. */
|
/** The record, tagged with its type so the template can switch on it. */
|
||||||
export type RoutedRecord =
|
export type RoutedRecord =
|
||||||
| { type: 'page'; record: PublicPage }
|
| { type: 'page'; record: PublicPage }
|
||||||
| { type: 'project'; record: PublicProject }
|
| { type: 'project'; record: PublicProject; board: ProjectBoard | null }
|
||||||
| { type: 'member'; record: PublicMember }
|
| { type: 'member'; record: PublicMember }
|
||||||
| { type: 'call'; record: PublicCall }
|
| { type: 'call'; record: PublicCall }
|
||||||
| { type: 'service'; record: PublicService }
|
| { type: 'service'; record: PublicService }
|
||||||
|
| { type: 'technology'; record: PublicTechnology }
|
||||||
| { type: 'radar'; record: PublicTechnology[] }
|
| { type: 'radar'; record: PublicTechnology[] }
|
||||||
| { type: 'blog'; record: PublicPage[] }
|
| { type: 'blog'; record: PublicPage[] }
|
||||||
| {
|
| {
|
||||||
@@ -80,7 +87,9 @@ async function fetchRecord(route: RouteMatch): Promise<RoutedRecord | null> {
|
|||||||
}
|
}
|
||||||
case 'project': {
|
case 'project': {
|
||||||
const { data } = await pireonApi.project(locale, slug);
|
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': {
|
case 'member': {
|
||||||
const { data } = await pireonApi.member(locale, slug);
|
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);
|
const { data } = await pireonApi.service(locale, slug);
|
||||||
return data ? { type: 'service', record: data } : null;
|
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': {
|
case 'blog': {
|
||||||
const { data, error } = await pireonApi.pages(locale, {
|
const { data, error } = await pireonApi.pages(locale, {
|
||||||
category: BLOG_CATEGORY[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.
|
* 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(
|
export function pageNavLinks(
|
||||||
pages: PublicPage[],
|
pages: PublicPage[],
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
): { href: string; label: string }[] {
|
): { href: string; label: string }[] {
|
||||||
return pages
|
const featured = pages
|
||||||
.filter(
|
.filter(
|
||||||
(page) =>
|
(page) =>
|
||||||
page.is_featured &&
|
page.is_featured &&
|
||||||
@@ -145,4 +160,26 @@ export function pageNavLinks(
|
|||||||
href: page.path as string,
|
href: page.path as string,
|
||||||
label: page.title 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,
|
listingPath,
|
||||||
resolveRoute,
|
resolveRoute,
|
||||||
SECTIONS,
|
SECTIONS,
|
||||||
|
technologyAlternates,
|
||||||
|
technologyPath,
|
||||||
} from './routes';
|
} from './routes';
|
||||||
|
|
||||||
describe('resolveRoute', () => {
|
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', () => {
|
describe('homeHref', () => {
|
||||||
it('answers on ?lang= for every language but the default', () => {
|
it('answers on ?lang= for every language but the default', () => {
|
||||||
expect(homeHref(DEFAULT_LOCALE)).toBe('/');
|
expect(homeHref(DEFAULT_LOCALE)).toBe('/');
|
||||||
|
|||||||
@@ -13,7 +13,13 @@
|
|||||||
*/
|
*/
|
||||||
import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type Locale } from './api';
|
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. */
|
/** Pages this site composes itself rather than one record's: no slug. */
|
||||||
export type ListingType = 'radar' | 'contribute' | 'blog';
|
export type ListingType = 'radar' | 'contribute' | 'blog';
|
||||||
@@ -31,6 +37,11 @@ export const SECTIONS: Record<
|
|||||||
member: { es: 'equipo', en: 'team', ca: 'equip' },
|
member: { es: 'equipo', en: 'team', ca: 'equip' },
|
||||||
call: { es: 'colabora', en: 'contribute', ca: 'col-labora' },
|
call: { es: 'colabora', en: 'contribute', ca: 'col-labora' },
|
||||||
service: { es: 'servicios', en: 'services', ca: 'serveis' },
|
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. */
|
/** The landing itself in a given language. It answers on `?lang=`, not a prefix. */
|
||||||
export function homeHref(locale: Locale): string {
|
export function homeHref(locale: Locale): string {
|
||||||
return locale === DEFAULT_LOCALE ? '/' : `/?lang=${locale}`;
|
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', () => {
|
describe('buildSitemap', () => {
|
||||||
it('lists every language of a record as its own url', () => {
|
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>');
|
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', () => {
|
it('is a well-formed document', () => {
|
||||||
const document = new DOMParser().parseFromString(xml, 'application/xml');
|
const document = new DOMParser().parseFromString(xml, 'application/xml');
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,12 @@
|
|||||||
* is not fetched first.
|
* is not fetched first.
|
||||||
*/
|
*/
|
||||||
import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type SlugEntry } from './api';
|
import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type SlugEntry } from './api';
|
||||||
|
import type { PublicTechnology } from './api.types';
|
||||||
import {
|
import {
|
||||||
homeHref,
|
homeHref,
|
||||||
listingAlternates,
|
listingAlternates,
|
||||||
LISTINGS,
|
LISTINGS,
|
||||||
|
technologyAlternates,
|
||||||
type ListingType,
|
type ListingType,
|
||||||
} from './routes';
|
} from './routes';
|
||||||
|
|
||||||
@@ -22,6 +24,7 @@ interface SitemapEntry {
|
|||||||
export function buildSitemap(
|
export function buildSitemap(
|
||||||
origin: string,
|
origin: string,
|
||||||
slugs: Record<string, SlugEntry[]>,
|
slugs: Record<string, SlugEntry[]>,
|
||||||
|
technologies: PublicTechnology[] = [],
|
||||||
): string {
|
): string {
|
||||||
const entries: SitemapEntry[] = [
|
const entries: SitemapEntry[] = [
|
||||||
// The landing answers on `?lang=`, not a prefix.
|
// The landing answers on `?lang=`, not a prefix.
|
||||||
@@ -34,6 +37,12 @@ export function buildSitemap(
|
|||||||
alternates: listingAlternates(listing),
|
alternates: listingAlternates(listing),
|
||||||
})),
|
})),
|
||||||
...Object.values(slugs).flat(),
|
...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) =>
|
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,
|
PiChipComponent,
|
||||||
} from '@pi/ui';
|
} from '@pi/ui';
|
||||||
|
|
||||||
|
import { byPublication, isBlogPost } from '../core/blog';
|
||||||
import { pageNavLinks } from '../core/routed-page';
|
import { pageNavLinks } from '../core/routed-page';
|
||||||
import { listingPath } from '../core/routes';
|
import { listingPath } from '../core/routes';
|
||||||
import { copyFor } from '../core/copy';
|
import { copyFor } from '../core/copy';
|
||||||
import { absoluteUrl, setShareImage, setStructuredData } from '../core/head';
|
import { absoluteUrl, setShareImage, setStructuredData } from '../core/head';
|
||||||
import { originFromHeaders } from '../core/origin';
|
import { originFromHeaders } from '../core/origin';
|
||||||
|
import { TechnologyChip } from '../ui/technology-chip';
|
||||||
import { CallCard } from '../ui/call-card';
|
import { CallCard } from '../ui/call-card';
|
||||||
import { ClientLogo } from '../ui/client-logo';
|
import { ClientLogo } from '../ui/client-logo';
|
||||||
import { ContactForm } from '../ui/contact-form';
|
import { ContactForm } from '../ui/contact-form';
|
||||||
@@ -29,6 +31,7 @@ import { Hero } from '../ui/hero';
|
|||||||
import { IssueRow } from '../ui/issue-row';
|
import { IssueRow } from '../ui/issue-row';
|
||||||
import { MaintenanceScreen } from '../ui/maintenance-screen';
|
import { MaintenanceScreen } from '../ui/maintenance-screen';
|
||||||
import { MemberCard } from '../ui/member-card';
|
import { MemberCard } from '../ui/member-card';
|
||||||
|
import { PostCard } from '../ui/post-card';
|
||||||
import { ProjectCard } from '../ui/project-card';
|
import { ProjectCard } from '../ui/project-card';
|
||||||
import { Section } from '../ui/section';
|
import { Section } from '../ui/section';
|
||||||
import { ServiceCard } from '../ui/service-card';
|
import { ServiceCard } from '../ui/service-card';
|
||||||
@@ -65,6 +68,7 @@ export const routeMeta: RouteMeta = {
|
|||||||
IssueRow,
|
IssueRow,
|
||||||
MaintenanceScreen,
|
MaintenanceScreen,
|
||||||
MemberCard,
|
MemberCard,
|
||||||
|
PostCard,
|
||||||
ProjectCard,
|
ProjectCard,
|
||||||
Section,
|
Section,
|
||||||
ServiceCard,
|
ServiceCard,
|
||||||
@@ -72,6 +76,7 @@ export const routeMeta: RouteMeta = {
|
|||||||
SiteHeader,
|
SiteHeader,
|
||||||
TalkCard,
|
TalkCard,
|
||||||
TestimonialCarousel,
|
TestimonialCarousel,
|
||||||
|
TechnologyChip,
|
||||||
],
|
],
|
||||||
styleUrl: './home.page.scss',
|
styleUrl: './home.page.scss',
|
||||||
template: `
|
template: `
|
||||||
@@ -123,7 +128,11 @@ export const routeMeta: RouteMeta = {
|
|||||||
>
|
>
|
||||||
<div class="landing-grid">
|
<div class="landing-grid">
|
||||||
@for (project of data().projects; track project.id) {
|
@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>
|
</div>
|
||||||
</app-section>
|
</app-section>
|
||||||
@@ -176,11 +185,10 @@ export const routeMeta: RouteMeta = {
|
|||||||
<ul class="landing-card__chips">
|
<ul class="landing-card__chips">
|
||||||
@for (technology of group.technologies; track technology.id) {
|
@for (technology of group.technologies; track technology.id) {
|
||||||
<li>
|
<li>
|
||||||
<pi-chip
|
<app-technology-chip
|
||||||
size="small"
|
[technology]="technology"
|
||||||
appearance="outline"
|
[locale]="data().locale"
|
||||||
[label]="technology.name"
|
/>
|
||||||
/>
|
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -262,6 +270,25 @@ export const routeMeta: RouteMeta = {
|
|||||||
</app-section>
|
</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
|
<app-section
|
||||||
anchor="contacto"
|
anchor="contacto"
|
||||||
[title]="copy().sections.contact.title"
|
[title]="copy().sections.contact.title"
|
||||||
@@ -297,6 +324,13 @@ export default class HomePage {
|
|||||||
|
|
||||||
protected readonly radarHref = computed(() => listingPath('radar', this.data().locale));
|
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(() =>
|
protected readonly contributeHref = computed(() =>
|
||||||
listingPath('contribute', this.data().locale),
|
listingPath('contribute', this.data().locale),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,6 +32,10 @@
|
|||||||
// ending in a dead end.
|
// ending in a dead end.
|
||||||
.content-page__layout {
|
.content-page__layout {
|
||||||
display: grid;
|
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;
|
gap: 3rem;
|
||||||
padding-bottom: 5rem;
|
padding-bottom: 5rem;
|
||||||
|
|
||||||
@@ -90,18 +94,28 @@
|
|||||||
margin: 0 0 1rem;
|
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,
|
ul,
|
||||||
ol {
|
ol {
|
||||||
margin: 0 0 1.5rem;
|
margin: 0 0 1.5rem;
|
||||||
padding-left: 1.25rem;
|
padding-left: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
li {
|
li {
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
list-style-position: outside;
|
||||||
|
|
||||||
&::marker {
|
&::marker {
|
||||||
color: var(--pi-color-primary);
|
color: var(--pi-color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
> p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
strong {
|
strong {
|
||||||
@@ -112,6 +126,67 @@
|
|||||||
color: var(--pi-color-primary);
|
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 {
|
> :first-child {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,65 +31,41 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-card {
|
.blog-tagged {
|
||||||
display: grid;
|
margin: 1rem 0 0;
|
||||||
overflow: hidden;
|
|
||||||
border-radius: var(--pi-theme-card-radius, 1.5rem);
|
|
||||||
box-shadow: var(--pi-theme-card-shadow, none);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The image column only when there is an image: without one the text would
|
|
||||||
// sit in the image's column with the rest of the card empty.
|
|
||||||
.blog-card--media {
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
grid-template-columns: minmax(0, 20rem) minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.blog-card__media ::ng-deep img {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
aspect-ratio: 16 / 10;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blog-card__body {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 1.5rem 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blog-card__date {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blog-card__title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.375rem;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.25;
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.blog-card__excerpt {
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blog-card__more {
|
|
||||||
margin-top: auto;
|
|
||||||
padding-top: 0.5rem;
|
|
||||||
color: var(--pi-color-primary);
|
|
||||||
font-weight: 600;
|
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 {
|
import {
|
||||||
PiNoteComponent,
|
PiNoteComponent,
|
||||||
PiTextColorDirective,
|
PiTextColorDirective,
|
||||||
@@ -8,18 +17,23 @@ import {
|
|||||||
|
|
||||||
import type { Locale } from '../core/api';
|
import type { Locale } from '../core/api';
|
||||||
import type { PublicPage } from '../core/api.types';
|
import type { PublicPage } from '../core/api.types';
|
||||||
|
import { tagHref, taggedWith, tagsOf } from '../core/blog';
|
||||||
import type { Copy } from '../core/copy';
|
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
|
* The blog: the pages in the panel's "Blog" category, newest first, each
|
||||||
* leading to its own address like any page.
|
* 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({
|
@Component({
|
||||||
selector: 'app-blog-page',
|
selector: 'app-blog-page',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [
|
imports: [
|
||||||
ApiImage,
|
PostCard,
|
||||||
PiNoteComponent,
|
PiNoteComponent,
|
||||||
PiTextColorDirective,
|
PiTextColorDirective,
|
||||||
PiTextLargeDirective,
|
PiTextLargeDirective,
|
||||||
@@ -34,6 +48,11 @@ import { ApiImage } from './api-image';
|
|||||||
<p l pi-color color="grey" class="content-hero__lead">
|
<p l pi-color color="grey" class="content-hero__lead">
|
||||||
{{ copy().blog.lead }}
|
{{ copy().blog.lead }}
|
||||||
</p>
|
</p>
|
||||||
|
@if (activeTag(); as tag) {
|
||||||
|
<p class="blog-tagged">
|
||||||
|
{{ copy().blog.tagged.replace('{tag}', tag.name) }}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
<p class="blog-feed">
|
<p class="blog-feed">
|
||||||
<a [href]="'/blog/rss.xml?lang=' + locale()">{{
|
<a [href]="'/blog/rss.xml?lang=' + locale()">{{
|
||||||
copy().blog.feed
|
copy().blog.feed
|
||||||
@@ -43,47 +62,36 @@ import { ApiImage } from './api-image';
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="landing-shell blog-body">
|
<div class="landing-shell blog-body">
|
||||||
@if (posts().length > 0) {
|
@if (tags().length > 0) {
|
||||||
<ul class="blog-list">
|
<nav class="blog-tags" [attr.aria-label]="copy().blog.tags">
|
||||||
@for (post of posts(); track post.id) {
|
<a
|
||||||
<li
|
class="blog-tag"
|
||||||
class="landing-card blog-card"
|
[href]="blogHref()"
|
||||||
[class.blog-card--media]="!!post.featured_image"
|
[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
|
</nav>
|
||||||
class="blog-card__media"
|
}
|
||||||
[href]="post.path"
|
|
||||||
tabindex="-1"
|
@if (shown().length > 0) {
|
||||||
aria-hidden="true"
|
<ul class="blog-list">
|
||||||
>
|
@for (post of shown(); track post.id) {
|
||||||
<app-api-image
|
<li>
|
||||||
[image]="image"
|
<app-post-card
|
||||||
sizes="(min-width: 1024px) 24rem, 100vw"
|
[post]="post"
|
||||||
/>
|
[copy]="copy()"
|
||||||
</a>
|
[locale]="locale()"
|
||||||
}
|
layout="row"
|
||||||
<div class="blog-card__body">
|
/>
|
||||||
@if (post.published_at) {
|
|
||||||
<time
|
|
||||||
class="content-page__muted blog-card__date"
|
|
||||||
[attr.datetime]="post.published_at"
|
|
||||||
>
|
|
||||||
{{ date(post.published_at) }}
|
|
||||||
</time>
|
|
||||||
}
|
|
||||||
<h2 class="blog-card__title">
|
|
||||||
<a [href]="post.path">{{ post.title }}</a>
|
|
||||||
</h2>
|
|
||||||
@if (post.excerpt; as excerpt) {
|
|
||||||
<p class="content-page__muted blog-card__excerpt">
|
|
||||||
{{ excerpt }}
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
<a class="blog-card__more" [href]="post.path"
|
|
||||||
>{{ copy().blog.readMore }} →</a
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -92,7 +100,7 @@ import { ApiImage } from './api-image';
|
|||||||
status="info"
|
status="info"
|
||||||
appearance="fill"
|
appearance="fill"
|
||||||
gap="xs"
|
gap="xs"
|
||||||
[title]="copy().blog.empty"
|
[title]="activeSlug() ? copy().blog.noneTagged : copy().blog.empty"
|
||||||
[text]="''"
|
[text]="''"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -104,11 +112,28 @@ export class BlogPage {
|
|||||||
readonly copy = input.required<Copy>();
|
readonly copy = input.required<Copy>();
|
||||||
readonly locale = input.required<Locale>();
|
readonly locale = input.required<Locale>();
|
||||||
|
|
||||||
protected date(value: string): string {
|
protected readonly activeSlug = toSignal(
|
||||||
return new Intl.DateTimeFormat(this.locale(), {
|
inject(ActivatedRoute).queryParamMap.pipe(
|
||||||
day: 'numeric',
|
map((params) => params.get('tag')),
|
||||||
month: 'long',
|
),
|
||||||
year: 'numeric',
|
{ initialValue: null },
|
||||||
}).format(new Date(value));
|
);
|
||||||
|
|
||||||
|
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 { Copy } from '../core/copy';
|
||||||
import type { Locale } from '../core/api';
|
import type { Locale } from '../core/api';
|
||||||
import type { PublicCall } from '../core/api.types';
|
import type { PublicCall } from '../core/api.types';
|
||||||
|
import { TechnologyChip } from './technology-chip';
|
||||||
|
import { LinkButton } from './link-button';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-call-card',
|
selector: 'app-call-card',
|
||||||
@@ -30,6 +32,8 @@ import type { PublicCall } from '../core/api.types';
|
|||||||
PiTextXSmallDirective,
|
PiTextXSmallDirective,
|
||||||
PiTextColorDirective,
|
PiTextColorDirective,
|
||||||
PiTextWeightBoldDirective,
|
PiTextWeightBoldDirective,
|
||||||
|
TechnologyChip,
|
||||||
|
LinkButton,
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<pi-card
|
<pi-card
|
||||||
@@ -74,7 +78,10 @@ import type { PublicCall } from '../core/api.types';
|
|||||||
<ul class="landing-card__chips">
|
<ul class="landing-card__chips">
|
||||||
@for (technology of call().technologies; track technology.id) {
|
@for (technology of call().technologies; track technology.id) {
|
||||||
<li>
|
<li>
|
||||||
<pi-chip size="small" appearance="outline" [label]="technology.name" />
|
<app-technology-chip
|
||||||
|
[technology]="technology"
|
||||||
|
[locale]="locale()"
|
||||||
|
/>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</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
|
<!-- To the call's own page and its form when it has one; to the
|
||||||
landing's general form otherwise. -->
|
landing's general form otherwise. -->
|
||||||
@if (call().path; as path) {
|
@if (call().path; as path) {
|
||||||
<a class="landing-card__button-link" [href]="path">
|
<app-link-button
|
||||||
<pi-action-button
|
class="landing-card__button-link"
|
||||||
size="small"
|
size="small"
|
||||||
color="primary"
|
appearance="outline"
|
||||||
appearance="outline"
|
[href]="path"
|
||||||
[hasIcon]="true"
|
[text]="copy().labels.apply"
|
||||||
[text]="copy().labels.apply"
|
/>
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
} @else {
|
} @else {
|
||||||
<pi-action-button
|
<pi-action-button
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type { PublicCall } from '../core/api.types';
|
|||||||
import type { Copy } from '../core/copy';
|
import type { Copy } from '../core/copy';
|
||||||
import { homeHref } from '../core/routes';
|
import { homeHref } from '../core/routes';
|
||||||
import { ContactForm } from './contact-form';
|
import { ContactForm } from './contact-form';
|
||||||
|
import { TechnologyChip } from './technology-chip';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A call for help on its own address, with the form to answer it.
|
* A call for help on its own address, with the form to answer it.
|
||||||
@@ -36,6 +37,7 @@ import { ContactForm } from './contact-form';
|
|||||||
PiTextColorDirective,
|
PiTextColorDirective,
|
||||||
PiTextLargeDirective,
|
PiTextLargeDirective,
|
||||||
PiTextWeightBoldDirective,
|
PiTextWeightBoldDirective,
|
||||||
|
TechnologyChip,
|
||||||
],
|
],
|
||||||
styleUrl: './call-detail.scss',
|
styleUrl: './call-detail.scss',
|
||||||
template: `
|
template: `
|
||||||
@@ -82,10 +84,9 @@ import { ContactForm } from './contact-form';
|
|||||||
}
|
}
|
||||||
@for (technology of call().technologies ?? []; track technology.id) {
|
@for (technology of call().technologies ?? []; track technology.id) {
|
||||||
<li>
|
<li>
|
||||||
<pi-chip
|
<app-technology-chip
|
||||||
size="small"
|
[technology]="technology"
|
||||||
appearance="outline"
|
[locale]="locale()"
|
||||||
[label]="technology.name"
|
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import {
|
|||||||
computed,
|
computed,
|
||||||
input,
|
input,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { PiActionButtonComponent, PiTextColorDirective } from '@pi/ui';
|
import { PiTextColorDirective } from '@pi/ui';
|
||||||
|
|
||||||
import type { Locale } from '../core/api';
|
import type { Locale } from '../core/api';
|
||||||
import type { Copy } from '../core/copy';
|
import type { Copy } from '../core/copy';
|
||||||
import { homeHref } from '../core/routes';
|
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
|
* "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({
|
@Component({
|
||||||
selector: 'app-contact-cta',
|
selector: 'app-contact-cta',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [PiActionButtonComponent, PiTextColorDirective],
|
imports: [PiTextColorDirective, LinkButton],
|
||||||
template: `
|
template: `
|
||||||
<div class="landing-card contact-cta">
|
<div class="landing-card contact-cta">
|
||||||
<h2 class="contact-cta__title">{{ copy().page.ctaTitle }}</h2>
|
<h2 class="contact-cta__title">{{ copy().page.ctaTitle }}</h2>
|
||||||
<p pi-color color="grey">{{ copy().page.ctaLead }}</p>
|
<p pi-color color="grey">{{ copy().page.ctaLead }}</p>
|
||||||
<!-- An anchor around the library's button, which renders a <button>:
|
<!-- 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 real link, so it works before hydration and opens in a new tab. -->
|
||||||
<a class="contact-cta__link" [href]="href()">
|
<app-link-button
|
||||||
<pi-action-button
|
class="contact-cta__link"
|
||||||
color="primary"
|
[href]="href()"
|
||||||
[text]="copy().nav.contact"
|
[text]="copy().nav.contact"
|
||||||
[hasIcon]="true"
|
/>
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
styles: `
|
styles: `
|
||||||
|
|||||||
@@ -130,3 +130,26 @@
|
|||||||
font-size: var(--pi-font-size-x-small, 0.75rem);
|
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;
|
margin: 1rem 0 0;
|
||||||
font-size: 0.9375rem;
|
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 { Locale } from '../core/api';
|
||||||
import type { PublicPage } from '../core/api.types';
|
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 type { Copy } from '../core/copy';
|
||||||
import { listingPath } from '../core/routes';
|
import { listingPath } from '../core/routes';
|
||||||
import { ApiImage } from './api-image';
|
import { ApiImage } from './api-image';
|
||||||
@@ -55,6 +55,13 @@ import { ContactCta } from './contact-cta';
|
|||||||
|
|
||||||
@if (isPost() && page().published_at; as published) {
|
@if (isPost() && page().published_at; as published) {
|
||||||
<p class="content-page__muted content-page__date">
|
<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 }}
|
{{ copy().blog.published }}
|
||||||
<time [attr.datetime]="published">{{ publishedOn() }}</time>
|
<time [attr.datetime]="published">{{ publishedOn() }}</time>
|
||||||
</p>
|
</p>
|
||||||
@@ -82,6 +89,16 @@ import { ContactCta } from './contact-cta';
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div class="content-page__body" [innerHTML]="page().body ?? ''"></div>
|
<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>
|
</article>
|
||||||
|
|
||||||
<aside class="content-page__aside">
|
<aside class="content-page__aside">
|
||||||
@@ -126,6 +143,10 @@ export class ContentPage {
|
|||||||
listingPath('blog', this.locale()),
|
listingPath('blog', this.locale()),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
protected tagHref(slug: string): string {
|
||||||
|
return tagHref(this.locale(), slug);
|
||||||
|
}
|
||||||
|
|
||||||
protected readonly publishedOn = computed(() => {
|
protected readonly publishedOn = computed(() => {
|
||||||
const published = this.page().published_at;
|
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 { ContactCta } from './contact-cta';
|
||||||
import { memberLinks } from './member-card';
|
import { memberLinks } from './member-card';
|
||||||
import { ProjectCard } from './project-card';
|
import { ProjectCard } from './project-card';
|
||||||
|
import { TechnologyChip } from './technology-chip';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Someone in the collective, on their own address: who they are, what they
|
* Someone in the collective, on their own address: who they are, what they
|
||||||
@@ -37,6 +38,7 @@ import { ProjectCard } from './project-card';
|
|||||||
PiTextLargeDirective,
|
PiTextLargeDirective,
|
||||||
PiTextWeightBoldDirective,
|
PiTextWeightBoldDirective,
|
||||||
ProjectCard,
|
ProjectCard,
|
||||||
|
TechnologyChip,
|
||||||
],
|
],
|
||||||
styleUrl: './member-detail.scss',
|
styleUrl: './member-detail.scss',
|
||||||
template: `
|
template: `
|
||||||
@@ -100,7 +102,11 @@ import { ProjectCard } from './project-card';
|
|||||||
<h2>{{ copy().member.projects }}</h2>
|
<h2>{{ copy().member.projects }}</h2>
|
||||||
<div class="member-projects">
|
<div class="member-projects">
|
||||||
@for (project of member().projects; track project.id) {
|
@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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -146,11 +152,10 @@ import { ProjectCard } from './project-card';
|
|||||||
<ul class="content-hero__chips member-technologies">
|
<ul class="content-hero__chips member-technologies">
|
||||||
@for (technology of member().technologies; track technology.id) {
|
@for (technology of member().technologies; track technology.id) {
|
||||||
<li>
|
<li>
|
||||||
<pi-chip
|
<app-technology-chip
|
||||||
size="small"
|
[technology]="technology"
|
||||||
appearance="outline"
|
[locale]="locale()"
|
||||||
[label]="technology.name"
|
/>
|
||||||
/>
|
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||||
|
import { LinkButton } from './link-button';
|
||||||
import {
|
import {
|
||||||
PiActionButtonComponent,
|
|
||||||
PiIconComponent,
|
PiIconComponent,
|
||||||
PiTextColorDirective,
|
PiTextColorDirective,
|
||||||
PiTextMediumDirective,
|
PiTextMediumDirective,
|
||||||
@@ -26,12 +26,12 @@ import {
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [
|
imports: [
|
||||||
PiIconComponent,
|
PiIconComponent,
|
||||||
PiActionButtonComponent,
|
PiTextXLargeDirective,
|
||||||
PiTextXLargeDirective,
|
|
||||||
PiTextMediumDirective,
|
PiTextMediumDirective,
|
||||||
PiTextSmallDirective,
|
PiTextSmallDirective,
|
||||||
PiTextColorDirective,
|
PiTextColorDirective,
|
||||||
PiTextWeightBoldDirective,
|
PiTextWeightBoldDirective,
|
||||||
|
LinkButton,
|
||||||
],
|
],
|
||||||
styleUrl: './not-found.scss',
|
styleUrl: './not-found.scss',
|
||||||
template: `
|
template: `
|
||||||
@@ -47,14 +47,12 @@ import {
|
|||||||
La dirección puede haber cambiado. Vuelve al inicio y sigue desde ahí.
|
La dirección puede haber cambiado. Vuelve al inicio y sigue desde ahí.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<a class="not-found__home" href="/">
|
<app-link-button
|
||||||
<pi-action-button
|
class="not-found__home"
|
||||||
size="large"
|
size="large"
|
||||||
color="primary"
|
href="/"
|
||||||
text="Ir al inicio"
|
text="Ir al inicio"
|
||||||
[hasIcon]="true"
|
/>
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<p s pi-color color="grey" class="not-found__note">
|
<p s pi-color color="grey" class="not-found__note">
|
||||||
Si has llegado desde un enlace nuestro, cuéntanoslo.
|
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,
|
PiTextWeightBoldDirective,
|
||||||
} from '@pi/ui';
|
} from '@pi/ui';
|
||||||
|
|
||||||
|
import type { Locale } from '../core/api';
|
||||||
import type { Copy } from '../core/copy';
|
import type { Copy } from '../core/copy';
|
||||||
import type { PublicProject } from '../core/api.types';
|
import type { PublicProject } from '../core/api.types';
|
||||||
|
import { TechnologyChip } from './technology-chip';
|
||||||
import { ApiImage } from './api-image';
|
import { ApiImage } from './api-image';
|
||||||
|
|
||||||
/** The API's five project states onto the status chip's five tones. */
|
/** The API's five project states onto the status chip's five tones. */
|
||||||
@@ -43,6 +45,7 @@ export const PROJECT_STATUS_TONES: Record<
|
|||||||
PiTextSmallDirective,
|
PiTextSmallDirective,
|
||||||
PiTextColorDirective,
|
PiTextColorDirective,
|
||||||
PiTextWeightBoldDirective,
|
PiTextWeightBoldDirective,
|
||||||
|
TechnologyChip,
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<pi-card
|
<pi-card
|
||||||
@@ -96,7 +99,10 @@ export const PROJECT_STATUS_TONES: Record<
|
|||||||
<ul class="landing-card__chips">
|
<ul class="landing-card__chips">
|
||||||
@for (technology of technologies(); track technology.id) {
|
@for (technology of technologies(); track technology.id) {
|
||||||
<li>
|
<li>
|
||||||
<pi-chip size="small" appearance="outline" [label]="technology.name" />
|
<app-technology-chip
|
||||||
|
[technology]="technology"
|
||||||
|
[locale]="locale()"
|
||||||
|
/>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -131,6 +137,8 @@ export const PROJECT_STATUS_TONES: Record<
|
|||||||
export class ProjectCard {
|
export class ProjectCard {
|
||||||
readonly project = input.required<PublicProject>();
|
readonly project = input.required<PublicProject>();
|
||||||
readonly copy = input.required<Copy>();
|
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. */
|
/** Enough to read the stack at a glance; the rest is noise on a card. */
|
||||||
protected readonly technologies = computed(() =>
|
protected readonly technologies = computed(() =>
|
||||||
|
|||||||
@@ -87,3 +87,69 @@
|
|||||||
margin: 0;
|
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,
|
input,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import {
|
import {
|
||||||
PiActionButtonComponent,
|
|
||||||
PiAvatarComponent,
|
PiAvatarComponent,
|
||||||
PiChipComponent,
|
PiChipComponent,
|
||||||
PiChipStatusComponent,
|
PiChipStatusComponent,
|
||||||
@@ -17,9 +16,12 @@ import {
|
|||||||
import type { Locale } from '../core/api';
|
import type { Locale } from '../core/api';
|
||||||
import type { PublicProject } from '../core/api.types';
|
import type { PublicProject } from '../core/api.types';
|
||||||
import type { Copy } from '../core/copy';
|
import type { Copy } from '../core/copy';
|
||||||
|
import { boardLink, type ProjectBoard } from '../core/taskstream';
|
||||||
import { ApiImage } from './api-image';
|
import { ApiImage } from './api-image';
|
||||||
import { ContactCta } from './contact-cta';
|
import { ContactCta } from './contact-cta';
|
||||||
import { PROJECT_STATUS_TONES } from './project-card';
|
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,
|
* 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: [
|
imports: [
|
||||||
ApiImage,
|
ApiImage,
|
||||||
ContactCta,
|
ContactCta,
|
||||||
PiActionButtonComponent,
|
PiAvatarComponent,
|
||||||
PiAvatarComponent,
|
|
||||||
PiChipComponent,
|
PiChipComponent,
|
||||||
PiChipStatusComponent,
|
PiChipStatusComponent,
|
||||||
PiTextColorDirective,
|
PiTextColorDirective,
|
||||||
PiTextLargeDirective,
|
PiTextLargeDirective,
|
||||||
PiTextWeightBoldDirective,
|
PiTextWeightBoldDirective,
|
||||||
|
TechnologyChip,
|
||||||
|
LinkButton,
|
||||||
],
|
],
|
||||||
styleUrl: './project-detail.scss',
|
styleUrl: './project-detail.scss',
|
||||||
template: `
|
template: `
|
||||||
@@ -79,10 +82,9 @@ import { PROJECT_STATUS_TONES } from './project-card';
|
|||||||
track technology.id
|
track technology.id
|
||||||
) {
|
) {
|
||||||
<li>
|
<li>
|
||||||
<pi-chip
|
<app-technology-chip
|
||||||
size="small"
|
[technology]="technology"
|
||||||
appearance="outline"
|
[locale]="locale()"
|
||||||
[label]="technology.name"
|
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
@@ -91,23 +93,20 @@ import { PROJECT_STATUS_TONES } from './project-card';
|
|||||||
@if (project().demo_url || repositoryUrl()) {
|
@if (project().demo_url || repositoryUrl()) {
|
||||||
<div class="content-hero__actions">
|
<div class="content-hero__actions">
|
||||||
@if (project().demo_url; as demo) {
|
@if (project().demo_url; as demo) {
|
||||||
<a [href]="demo" target="_blank" rel="noopener">
|
<app-link-button
|
||||||
<pi-action-button
|
[href]="demo"
|
||||||
color="primary"
|
[text]="copy().labels.demo"
|
||||||
[text]="copy().labels.demo"
|
[external]="true"
|
||||||
[hasIcon]="true"
|
/>
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
}
|
}
|
||||||
@if (repositoryUrl(); as repository) {
|
@if (repositoryUrl(); as repository) {
|
||||||
<a [href]="repository" target="_blank" rel="noopener">
|
<app-link-button
|
||||||
<pi-action-button
|
color="grey"
|
||||||
appearance="outline"
|
appearance="outline"
|
||||||
color="grey"
|
[href]="repository"
|
||||||
[text]="copy().labels.repository"
|
[text]="copy().labels.repository"
|
||||||
[hasIcon]="true"
|
[external]="true"
|
||||||
/>
|
/>
|
||||||
</a>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -145,6 +144,91 @@ import { PROJECT_STATUS_TONES } from './project-card';
|
|||||||
</section>
|
</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) {
|
@if ((project().gallery ?? []).length > 0) {
|
||||||
<section class="content-page__section">
|
<section class="content-page__section">
|
||||||
<h2>{{ copy().project.gallery }}</h2>
|
<h2>{{ copy().project.gallery }}</h2>
|
||||||
@@ -204,6 +288,36 @@ import { PROJECT_STATUS_TONES } from './project-card';
|
|||||||
</dl>
|
</dl>
|
||||||
</div>
|
</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) {
|
@if ((project().calls ?? []).length > 0) {
|
||||||
<nav [attr.aria-label]="copy().project.openCalls">
|
<nav [attr.aria-label]="copy().project.openCalls">
|
||||||
<p class="landing-eyebrow">{{ copy().project.openCalls }}</p>
|
<p class="landing-eyebrow">{{ copy().project.openCalls }}</p>
|
||||||
@@ -306,6 +420,32 @@ export class ProjectDetail {
|
|||||||
readonly project = input.required<PublicProject>();
|
readonly project = input.required<PublicProject>();
|
||||||
readonly copy = input.required<Copy>();
|
readonly copy = input.required<Copy>();
|
||||||
readonly locale = input.required<Locale>();
|
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(
|
protected readonly statusTone = computed(
|
||||||
() => PROJECT_STATUS_TONES[this.project().status],
|
() => PROJECT_STATUS_TONES[this.project().status],
|
||||||
|
|||||||
@@ -144,3 +144,19 @@
|
|||||||
color: var(--pi-color-primary);
|
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';
|
} from '@pi/ui';
|
||||||
|
|
||||||
import type { PublicTechnology } from '../core/api.types';
|
import type { PublicTechnology } from '../core/api.types';
|
||||||
|
import type { Locale } from '../core/api';
|
||||||
import type { Copy } from '../core/copy';
|
import type { Copy } from '../core/copy';
|
||||||
|
import { technologyPath } from '../core/routes';
|
||||||
import { ApiImage } from './api-image';
|
import { ApiImage } from './api-image';
|
||||||
|
|
||||||
type Ring = NonNullable<PublicTechnology['ring']>;
|
type Ring = NonNullable<PublicTechnology['ring']>;
|
||||||
@@ -81,7 +83,7 @@ const RINGS: Ring[] = ['adopt', 'trial', 'assess', 'hold'];
|
|||||||
<ul class="radar-grid">
|
<ul class="radar-grid">
|
||||||
@for (technology of group.technologies; track technology.id) {
|
@for (technology of group.technologies; track technology.id) {
|
||||||
<li class="landing-card radar-card">
|
<li class="landing-card radar-card">
|
||||||
<div class="radar-card__top">
|
<a class="radar-card__top" [href]="href(technology)">
|
||||||
<span
|
<span
|
||||||
class="radar-logo"
|
class="radar-logo"
|
||||||
[style.--brand]="technology.color || null"
|
[style.--brand]="technology.color || null"
|
||||||
@@ -104,7 +106,7 @@ const RINGS: Ring[] = ['adopt', 'trial', 'assess', 'hold'];
|
|||||||
{{ copy().radar.categories[technology.category] }}
|
{{ copy().radar.categories[technology.category] }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
|
|
||||||
@if (technology.description; as description) {
|
@if (technology.description; as description) {
|
||||||
<p class="radar-card__text">{{ description }}</p>
|
<p class="radar-card__text">{{ description }}</p>
|
||||||
@@ -148,6 +150,13 @@ const RINGS: Ring[] = ['adopt', 'trial', 'assess', 'hold'];
|
|||||||
export class RadarPage {
|
export class RadarPage {
|
||||||
readonly technologies = input.required<PublicTechnology[]>();
|
readonly technologies = input.required<PublicTechnology[]>();
|
||||||
readonly copy = input.required<Copy>();
|
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. */
|
/** The rings that have anything in them, in the radar's own order. */
|
||||||
protected readonly rings = computed(() =>
|
protected readonly rings = computed(() =>
|
||||||
|
|||||||
@@ -6,12 +6,18 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
|
||||||
import { copyFor, type Copy } from '../core/copy';
|
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 { memberLinks } from './member-card';
|
||||||
import { isBlogPost } from '../core/blog';
|
import { isBlogPost } from '../core/blog';
|
||||||
import type { Locale } from '../core/api';
|
import type { Locale } from '../core/api';
|
||||||
import { RadarPage } from './radar-page';
|
import { RadarPage } from './radar-page';
|
||||||
import { ServiceDetail } from './service-detail';
|
import { ServiceDetail } from './service-detail';
|
||||||
|
import { TechnologyDetail } from './technology-detail';
|
||||||
import { BlogPage } from './blog-page';
|
import { BlogPage } from './blog-page';
|
||||||
import { CallDetail } from './call-detail';
|
import { CallDetail } from './call-detail';
|
||||||
import type { RoutedPageData, RoutedRecord } from '../core/routed-page';
|
import type { RoutedPageData, RoutedRecord } from '../core/routed-page';
|
||||||
@@ -38,6 +44,7 @@ import { ProjectDetail } from './project-detail';
|
|||||||
ProjectDetail,
|
ProjectDetail,
|
||||||
RadarPage,
|
RadarPage,
|
||||||
ServiceDetail,
|
ServiceDetail,
|
||||||
|
TechnologyDetail,
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<app-page-shell [data]="data()" [seo]="seo()">
|
<app-page-shell [data]="data()" [seo]="seo()">
|
||||||
@@ -54,6 +61,7 @@ import { ProjectDetail } from './project-detail';
|
|||||||
@case ('project') {
|
@case ('project') {
|
||||||
<app-project-detail
|
<app-project-detail
|
||||||
[project]="found.record"
|
[project]="found.record"
|
||||||
|
[board]="found.board"
|
||||||
[copy]="copy()"
|
[copy]="copy()"
|
||||||
[locale]="data().locale"
|
[locale]="data().locale"
|
||||||
/>
|
/>
|
||||||
@@ -88,7 +96,18 @@ import { ProjectDetail } from './project-detail';
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@case ('radar') {
|
@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') {
|
@case ('member') {
|
||||||
<app-member-detail
|
<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':
|
case 'blog':
|
||||||
return {
|
return {
|
||||||
title: `Pireon — ${copy.blog.title}`,
|
title: `Pireon — ${copy.blog.title}`,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
input,
|
input,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import {
|
import {
|
||||||
PiChipComponent,
|
|
||||||
PiIconComponent,
|
PiIconComponent,
|
||||||
PiTextColorDirective,
|
PiTextColorDirective,
|
||||||
PiTextLargeDirective,
|
PiTextLargeDirective,
|
||||||
@@ -19,6 +18,7 @@ import { resolveIcon } from '../core/icons';
|
|||||||
import { ContactCta } from './contact-cta';
|
import { ContactCta } from './contact-cta';
|
||||||
import { ProjectCard } from './project-card';
|
import { ProjectCard } from './project-card';
|
||||||
import { TestimonialCard } from './testimonial-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
|
* 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,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [
|
imports: [
|
||||||
ContactCta,
|
ContactCta,
|
||||||
PiChipComponent,
|
PiIconComponent,
|
||||||
PiIconComponent,
|
|
||||||
PiTextColorDirective,
|
PiTextColorDirective,
|
||||||
PiTextLargeDirective,
|
PiTextLargeDirective,
|
||||||
PiTextWeightBoldDirective,
|
PiTextWeightBoldDirective,
|
||||||
ProjectCard,
|
ProjectCard,
|
||||||
TestimonialCard,
|
TestimonialCard,
|
||||||
|
TechnologyChip,
|
||||||
],
|
],
|
||||||
styleUrl: './service-detail.scss',
|
styleUrl: './service-detail.scss',
|
||||||
template: `
|
template: `
|
||||||
@@ -72,7 +72,11 @@ import { TestimonialCard } from './testimonial-card';
|
|||||||
<h2>{{ copy().service.projects }}</h2>
|
<h2>{{ copy().service.projects }}</h2>
|
||||||
<div class="service-grid">
|
<div class="service-grid">
|
||||||
@for (project of service().projects; track project.id) {
|
@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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -100,11 +104,10 @@ import { TestimonialCard } from './testimonial-card';
|
|||||||
<ul class="content-hero__chips service-technologies">
|
<ul class="content-hero__chips service-technologies">
|
||||||
@for (technology of service().technologies; track technology.id) {
|
@for (technology of service().technologies; track technology.id) {
|
||||||
<li>
|
<li>
|
||||||
<pi-chip
|
<app-technology-chip
|
||||||
size="small"
|
[technology]="technology"
|
||||||
appearance="outline"
|
[locale]="locale()"
|
||||||
[label]="technology.name"
|
/>
|
||||||
/>
|
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</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,
|
setResponseStatus,
|
||||||
} from 'h3';
|
} from 'h3';
|
||||||
|
|
||||||
import { pireonApi } from '../../app/core/api';
|
import { DEFAULT_LOCALE, pireonApi } from '../../app/core/api';
|
||||||
import { originFromHeaders } from '../../app/core/origin';
|
import { originFromHeaders } from '../../app/core/origin';
|
||||||
import { buildSitemap } from '../../app/core/sitemap';
|
import { buildSitemap } from '../../app/core/sitemap';
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const origin = originFromHeaders(getRequestHeaders(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) {
|
if (!data) {
|
||||||
setResponseStatus(event, 503);
|
setResponseStatus(event, 503);
|
||||||
@@ -30,5 +35,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
setResponseHeader(event, 'Content-Type', 'application/xml; charset=utf-8');
|
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/platform-server": "~22.0.4",
|
||||||
"@angular/router": "~22.0.4",
|
"@angular/router": "~22.0.4",
|
||||||
"@ngx-translate/core": "^18.0.0",
|
"@ngx-translate/core": "^18.0.0",
|
||||||
"@pi/ui": "^0.0.2",
|
"@pi/ui": "^0.0.7",
|
||||||
"front-matter": "^4.0.2",
|
"front-matter": "^4.0.2",
|
||||||
"marked": "^15.0.7",
|
"marked": "^15.0.7",
|
||||||
"marked-gfm-heading-id": "^4.1.1",
|
"marked-gfm-heading-id": "^4.1.1",
|
||||||
@@ -9587,12 +9587,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@pi/ui": {
|
"node_modules/@pi/ui": {
|
||||||
"version": "0.0.2",
|
"version": "0.0.7",
|
||||||
"resolved": "https://nexus.pireon.dev/repository/npm-releases/@pi/ui/-/ui-0.0.2.tgz",
|
"resolved": "https://nexus.pireon.dev/repository/npm-releases/@pi/ui/-/ui-0.0.7.tgz",
|
||||||
"integrity": "sha1-kCo1VJKozqkA7ag+oOYIRoGESAg=",
|
"integrity": "sha1-PkfcIIYypFMmuypt5C3UbxDNJbc=",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lodash": "^4.17.0",
|
|
||||||
"luxon": "^3.0.0",
|
"luxon": "^3.0.0",
|
||||||
"ngx-infinite-scroll": "^22.0.0",
|
"ngx-infinite-scroll": "^22.0.0",
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
"@angular/platform-server": "~22.0.4",
|
"@angular/platform-server": "~22.0.4",
|
||||||
"@angular/router": "~22.0.4",
|
"@angular/router": "~22.0.4",
|
||||||
"@ngx-translate/core": "^18.0.0",
|
"@ngx-translate/core": "^18.0.0",
|
||||||
"@pi/ui": "^0.0.2",
|
"@pi/ui": "^0.0.7",
|
||||||
"front-matter": "^4.0.2",
|
"front-matter": "^4.0.2",
|
||||||
"marked": "^15.0.7",
|
"marked": "^15.0.7",
|
||||||
"marked-gfm-heading-id": "^4.1.1",
|
"marked-gfm-heading-id": "^4.1.1",
|
||||||
|
|||||||
@@ -17,6 +17,15 @@ import { createServer } from 'node:http';
|
|||||||
|
|
||||||
const tech = (id, name, slug, category, color, ring) => ({
|
const tech = (id, name, slug, category, color, ring) => ({
|
||||||
id, name, slug, category, color, ring, projects_count: 3, members_count: 2,
|
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 = [
|
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}`,
|
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),
|
...page(id, title, slug, false),
|
||||||
|
body: richBody,
|
||||||
published_at,
|
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.',
|
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 },
|
category: { id: 9, name: 'Blog', slug: 'blog', position: 3 },
|
||||||
});
|
});
|
||||||
@@ -65,8 +88,8 @@ const post = (id, title, slug, published_at) => ({
|
|||||||
const pages = [
|
const pages = [
|
||||||
page(1, 'Sobre nosotros', 'sobre-nosotros', true),
|
page(1, 'Sobre nosotros', 'sobre-nosotros', true),
|
||||||
page(2, 'Contacto', 'contacto', false),
|
page(2, 'Contacto', 'contacto', false),
|
||||||
post(3, 'Por qué volvimos a renderizar en el servidor', 'volvimos-al-servidor', '2026-09-20T09: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'),
|
post(4, 'Nuestro primer año como colectivo', 'primer-ano', '2026-06-01T09:00:00Z', [{ name: 'Colectivo', slug: 'colectivo' }]),
|
||||||
];
|
];
|
||||||
|
|
||||||
const data = {
|
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 } },
|
{ 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/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/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': [
|
'/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: {} },
|
{ 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
|
// 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.
|
// show what a visitor sees when a link to a filled call is still out there.
|
||||||
for (const call of data['/public/v1/calls']) {
|
for (const call of data['/public/v1/calls']) {
|
||||||
@@ -201,12 +257,24 @@ createServer((req, res) => {
|
|||||||
let payload = data[path];
|
let payload = data[path];
|
||||||
|
|
||||||
// The one filter the frontend relies on: pages by category slug.
|
// 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')) {
|
if (path === '/public/v1/pages' && url.searchParams.get('category')) {
|
||||||
payload = payload.filter((p) => p.category?.slug === url.searchParams.get('category'));
|
payload = payload.filter((p) => p.category?.slug === url.searchParams.get('category'));
|
||||||
}
|
}
|
||||||
|
|
||||||
res.setHeader('content-type', 'application/json');
|
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
|
// The two write endpoints: accept whatever arrives, as the real API does
|
||||||
// for a valid submission.
|
// for a valid submission.
|
||||||
if (req.method === 'POST') {
|
if (req.method === 'POST') {
|
||||||
|
|||||||
Reference in New Issue
Block a user