Compare commits
10 Commits
e2e90d5512
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
289af6c3a1 | ||
|
|
1ee4f98a9e | ||
|
|
3366f57e14 | ||
|
|
334640d938 | ||
|
|
63d4410296 | ||
|
|
a3783222c8 | ||
|
|
bca3e4bcc6 | ||
|
|
918465a8ab | ||
|
|
e9082b90ee | ||
|
|
aea8f5675d |
14
README.md
14
README.md
@@ -131,6 +131,15 @@ pass on the server:
|
||||
| Charlas | `/talks?limit=6` |
|
||||
| Contact form | `POST /contact` |
|
||||
|
||||
A project page also asks TaskStream, when the project has a board there
|
||||
(`taskstream.showcase_url` in its payload): roadmap (with how many want each
|
||||
epic), releases, the sprint under way, tickets labelled `good-first-issue`, and
|
||||
the most voted bug reports and ideas from the public, with a link to send one
|
||||
while the board takes them. It is public, answers within four seconds or the
|
||||
page goes without those sections, and its links open the board (voting needs
|
||||
signing in there). `npm run mock` serves a showcase for the «Catálogo abierto»
|
||||
project, so every section can be seen without TaskStream.
|
||||
|
||||
`landing/openapi.yaml` is the specification the app was written against, and
|
||||
`landing/src/app/core/api.types.ts` is the API's own generated type file. Both
|
||||
are copies — refresh them from the API when the contract moves:
|
||||
@@ -265,6 +274,11 @@ any page. That category has to exist with the slug `blog` in every language
|
||||
- `/blog` (`/en/blog`, `/ca/blog`) lists them newest first.
|
||||
- A post shows its date and leads back to the blog; its structured data is a
|
||||
`BlogPosting`.
|
||||
- A post shows who signs it (`byline`, linked to their profile) and its tags,
|
||||
each linking to `/blog?tag=…`. The blog offers the tags in use as a row of
|
||||
links and narrows to one with `?tag=` — plain links, so a tag's listing can
|
||||
be shared and works before any script runs. Tags and bylines are set in the
|
||||
panel.
|
||||
- `/blog/rss.xml?lang=xx` is the feed, one per language, announced in `<head>`.
|
||||
- Once there is a post, the blog is in the header menu (desktop and mobile)
|
||||
and the landing shows the three latest under "Novedades"; the footer links
|
||||
|
||||
4369
landing/openapi.yaml
4369
landing/openapi.yaml
File diff suppressed because it is too large
Load Diff
@@ -165,6 +165,14 @@ export interface PublicPage {
|
||||
unpublish_at?: string;
|
||||
updated_at: string;
|
||||
category: PublicCategory;
|
||||
/**
|
||||
* The ones named in this language, to link to the blog filtered by each.
|
||||
*/
|
||||
tags: ({ name: string; slug: string })[];
|
||||
/**
|
||||
* The member who signs it, when they have a public profile.
|
||||
*/
|
||||
byline?: { name: string; slug: string; headline?: string; avatar?: Image; path: string };
|
||||
alternates: Alternates;
|
||||
path?: string;
|
||||
}
|
||||
@@ -233,6 +241,10 @@ export interface PublicProject {
|
||||
lead: PublicMember;
|
||||
calls: PublicCall[];
|
||||
repositories: PublicRepository[];
|
||||
/**
|
||||
* Its board in TaskStream, if it has one. `showcase_url` answers without a token: roadmap, releases, the sprint under way and tickets labelled good-first-issue.
|
||||
*/
|
||||
taskstream?: { key: string; url: string; web_url: string; showcase_url: string };
|
||||
alternates: Alternates;
|
||||
path?: string;
|
||||
}
|
||||
@@ -268,6 +280,21 @@ export interface PublicRepository {
|
||||
good_first_issues: PublicIssue[];
|
||||
}
|
||||
|
||||
export interface PublicSearchResult {
|
||||
type: 'page' | 'project' | 'service' | 'call' | 'member' | 'technology';
|
||||
id: number;
|
||||
title: string;
|
||||
/**
|
||||
* Plain text around the first match.
|
||||
*/
|
||||
excerpt: string;
|
||||
path?: string;
|
||||
/**
|
||||
* Higher is a better match. Only comparable within one response.
|
||||
*/
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface PublicService {
|
||||
id: number;
|
||||
locale: string;
|
||||
@@ -382,6 +409,8 @@ export interface PublicApiPayloads {
|
||||
'/public/v1/site': { locales: ({ code: string; name: string; flag: string; is_default: boolean })[]; default_locale: string; content_version: number; submissions_enabled: boolean; stats: { projects: number; active_projects: number; members: number; open_calls: number; good_first_issues: number; stars: number } };
|
||||
'/public/v1/slugs': Record<string, ({ id: number; updated_at: string; alternates: Record<string, string> })[]>;
|
||||
'/public/v1/redirects': ({ from: string; to: string; status: 301 | 302; locale?: string })[];
|
||||
'/public/v1/search': PublicSearchResult[];
|
||||
'/public/v1/tags': ({ name: string; slug: string; count: number })[];
|
||||
'/public/v1/projects': PublicProject[];
|
||||
'/public/v1/projects/{slug}': PublicProject;
|
||||
'/public/v1/members': PublicMember[];
|
||||
|
||||
35
landing/src/app/core/blog.spec.ts
Normal file
35
landing/src/app/core/blog.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { PublicPage } from './api.types';
|
||||
import { tagHref, taggedWith, tagsOf } from './blog';
|
||||
|
||||
const post = (id: number, tags: { name: string; slug: string }[]) =>
|
||||
({ id, tags }) as unknown as PublicPage;
|
||||
|
||||
describe('blog tags', () => {
|
||||
const posts = [
|
||||
post(1, [{ name: 'Angular', slug: 'angular' }]),
|
||||
post(2, [
|
||||
{ name: 'Angular', slug: 'angular' },
|
||||
{ name: 'Accesibilidad', slug: 'accesibilidad' },
|
||||
]),
|
||||
post(3, []),
|
||||
];
|
||||
|
||||
it('lists each tag once, most used first', () => {
|
||||
expect(tagsOf(posts)).toEqual([
|
||||
{ name: 'Angular', slug: 'angular', count: 2 },
|
||||
{ name: 'Accesibilidad', slug: 'accesibilidad', count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('narrows the posts to a tag, or keeps them all', () => {
|
||||
expect(taggedWith(posts, 'accesibilidad').map((p) => p.id)).toEqual([2]);
|
||||
expect(taggedWith(posts, null)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('links to the blog in each language', () => {
|
||||
expect(tagHref('es', 'angular')).toBe('/blog?tag=angular');
|
||||
expect(tagHref('en', 'a b')).toBe('/en/blog?tag=a%20b');
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
import type { Locale } from './api';
|
||||
import type { PublicPage } from './api.types';
|
||||
import { listingPath } from './routes';
|
||||
|
||||
export const BLOG_CATEGORY: Record<Locale, string> = {
|
||||
es: 'blog',
|
||||
@@ -25,3 +26,44 @@ export function byPublication(pages: PublicPage[]): PublicPage[] {
|
||||
(b.published_at ?? '').localeCompare(a.published_at ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
export interface BlogTag {
|
||||
name: string;
|
||||
slug: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** The blog, showing only the posts with this tag. */
|
||||
export function tagHref(locale: Locale, slug: string): string {
|
||||
return `${listingPath('blog', locale)}?tag=${encodeURIComponent(slug)}`;
|
||||
}
|
||||
|
||||
/** Every tag on these posts, most used first, then by name. */
|
||||
export function tagsOf(posts: PublicPage[]): BlogTag[] {
|
||||
const tags = new Map<string, BlogTag>();
|
||||
|
||||
for (const post of posts) {
|
||||
for (const tag of post.tags ?? []) {
|
||||
const known = tags.get(tag.slug);
|
||||
tags.set(tag.slug, {
|
||||
name: tag.name,
|
||||
slug: tag.slug,
|
||||
count: (known?.count ?? 0) + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...tags.values()].sort(
|
||||
(a, b) => b.count - a.count || a.name.localeCompare(b.name),
|
||||
);
|
||||
}
|
||||
|
||||
/** The posts carrying a tag, or all of them when there is none. */
|
||||
export function taggedWith(
|
||||
posts: PublicPage[],
|
||||
tag: string | null,
|
||||
): PublicPage[] {
|
||||
return tag
|
||||
? posts.filter((post) => (post.tags ?? []).some((t) => t.slug === tag))
|
||||
: posts;
|
||||
}
|
||||
|
||||
@@ -107,6 +107,21 @@ export interface Copy {
|
||||
archived: string;
|
||||
openCalls: string;
|
||||
technologies: string;
|
||||
roadmap: string;
|
||||
roadmapLead: string;
|
||||
releases: string;
|
||||
released: string;
|
||||
planned: string;
|
||||
sprint: string;
|
||||
goodFirst: string;
|
||||
goodFirstLead: string;
|
||||
board: string;
|
||||
done: string;
|
||||
proposals: string;
|
||||
proposalsLead: string;
|
||||
propose: string;
|
||||
voteOne: string;
|
||||
voteMany: string;
|
||||
};
|
||||
member: {
|
||||
projects: string;
|
||||
@@ -137,6 +152,11 @@ export interface Copy {
|
||||
published: string;
|
||||
feed: string;
|
||||
seeAll: string;
|
||||
by: string;
|
||||
tags: string;
|
||||
allTags: string;
|
||||
tagged: string;
|
||||
noneTagged: string;
|
||||
};
|
||||
service: {
|
||||
eyebrow: string;
|
||||
@@ -292,6 +312,21 @@ const es: Copy = {
|
||||
archived: 'Archivado',
|
||||
openCalls: 'Buscamos ayuda',
|
||||
technologies: 'Tecnologías',
|
||||
roadmap: 'Hoja de ruta',
|
||||
roadmapLead: 'Lo que viene, según nuestro tablero.',
|
||||
releases: 'Versiones',
|
||||
released: 'Publicada',
|
||||
planned: 'Prevista',
|
||||
sprint: 'Ahora mismo',
|
||||
goodFirst: 'Para empezar a colaborar',
|
||||
goodFirstLead: 'Tareas pensadas para quien llega nuevo.',
|
||||
board: 'Ver el tablero',
|
||||
done: 'Hecho',
|
||||
proposals: 'Ideas y errores',
|
||||
proposalsLead: 'Lo que más se pide. Para votar, entra en el tablero.',
|
||||
propose: 'Proponer una idea o avisar de un error',
|
||||
voteOne: '1 voto',
|
||||
voteMany: '{n} votos',
|
||||
},
|
||||
member: {
|
||||
projects: 'Proyectos',
|
||||
@@ -328,6 +363,11 @@ const es: Copy = {
|
||||
published: 'Publicado el',
|
||||
feed: 'Suscríbete por RSS',
|
||||
seeAll: 'Ver todas las novedades',
|
||||
by: 'Por',
|
||||
tags: 'Temas',
|
||||
allTags: 'Todo',
|
||||
tagged: 'Entradas sobre «{tag}»',
|
||||
noneTagged: 'No hay entradas con esta etiqueta.',
|
||||
},
|
||||
service: {
|
||||
eyebrow: 'Servicio',
|
||||
@@ -496,6 +536,21 @@ const en: Copy = {
|
||||
archived: 'Archived',
|
||||
openCalls: 'Looking for help',
|
||||
technologies: 'Technologies',
|
||||
roadmap: 'Roadmap',
|
||||
roadmapLead: 'What is coming, from our board.',
|
||||
releases: 'Releases',
|
||||
released: 'Released',
|
||||
planned: 'Planned',
|
||||
sprint: 'Right now',
|
||||
goodFirst: 'Good first issues',
|
||||
goodFirstLead: 'Tasks meant for newcomers.',
|
||||
board: 'See the board',
|
||||
done: 'Done',
|
||||
proposals: 'Ideas and bugs',
|
||||
proposalsLead: 'What people ask for most. To vote, sign in on the board.',
|
||||
propose: 'Suggest an idea or report a bug',
|
||||
voteOne: '1 vote',
|
||||
voteMany: '{n} votes',
|
||||
},
|
||||
member: {
|
||||
projects: 'Projects',
|
||||
@@ -532,6 +587,11 @@ const en: Copy = {
|
||||
published: 'Published on',
|
||||
feed: 'Subscribe via RSS',
|
||||
seeAll: 'See all the news',
|
||||
by: 'By',
|
||||
tags: 'Topics',
|
||||
allTags: 'Everything',
|
||||
tagged: 'Posts about “{tag}”',
|
||||
noneTagged: 'There are no posts with this tag.',
|
||||
},
|
||||
service: {
|
||||
eyebrow: 'Service',
|
||||
@@ -700,6 +760,21 @@ const ca: Copy = {
|
||||
archived: 'Arxivat',
|
||||
openCalls: 'Busquem ajuda',
|
||||
technologies: 'Tecnologies',
|
||||
roadmap: 'Full de ruta',
|
||||
roadmapLead: 'El que ve, segons el nostre tauler.',
|
||||
releases: 'Versions',
|
||||
released: 'Publicada',
|
||||
planned: 'Prevista',
|
||||
sprint: 'Ara mateix',
|
||||
goodFirst: 'Per començar a col·laborar',
|
||||
goodFirstLead: 'Tasques pensades per a qui arriba de nou.',
|
||||
board: 'Veure el tauler',
|
||||
done: 'Fet',
|
||||
proposals: 'Idees i errors',
|
||||
proposalsLead: 'El que més es demana. Per votar, entra al tauler.',
|
||||
propose: "Proposar una idea o avisar d'un error",
|
||||
voteOne: '1 vot',
|
||||
voteMany: '{n} vots',
|
||||
},
|
||||
member: {
|
||||
projects: 'Projectes',
|
||||
@@ -736,6 +811,11 @@ const ca: Copy = {
|
||||
published: 'Publicat el',
|
||||
feed: "Subscriu-t'hi per RSS",
|
||||
seeAll: 'Veure totes les novetats',
|
||||
by: 'Per',
|
||||
tags: 'Temes',
|
||||
allTags: 'Tot',
|
||||
tagged: 'Entrades sobre «{tag}»',
|
||||
noneTagged: 'No hi ha entrades amb aquesta etiqueta.',
|
||||
},
|
||||
service: {
|
||||
eyebrow: 'Servei',
|
||||
|
||||
@@ -25,11 +25,16 @@ import type {
|
||||
import { BLOG_CATEGORY, byPublication, isBlogPost } from './blog';
|
||||
import { copyFor } from './copy';
|
||||
import { listingPath, resolveRoute, type RouteMatch } from './routes';
|
||||
import {
|
||||
fetchShowcase,
|
||||
hasContent,
|
||||
type ProjectBoard,
|
||||
} from './taskstream';
|
||||
|
||||
/** The record, tagged with its type so the template can switch on it. */
|
||||
export type RoutedRecord =
|
||||
| { type: 'page'; record: PublicPage }
|
||||
| { type: 'project'; record: PublicProject }
|
||||
| { type: 'project'; record: PublicProject; board: ProjectBoard | null }
|
||||
| { type: 'member'; record: PublicMember }
|
||||
| { type: 'call'; record: PublicCall }
|
||||
| { type: 'service'; record: PublicService }
|
||||
@@ -82,7 +87,9 @@ async function fetchRecord(route: RouteMatch): Promise<RoutedRecord | null> {
|
||||
}
|
||||
case 'project': {
|
||||
const { data } = await pireonApi.project(locale, slug);
|
||||
return data ? { type: 'project', record: data } : null;
|
||||
return data
|
||||
? { type: 'project', record: data, board: await boardOf(data) }
|
||||
: null;
|
||||
}
|
||||
case 'member': {
|
||||
const { data } = await pireonApi.member(locale, slug);
|
||||
@@ -166,3 +173,13 @@ export function pageNavLinks(
|
||||
]
|
||||
: 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;
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
@@ -32,6 +32,10 @@
|
||||
// ending in a dead end.
|
||||
.content-page__layout {
|
||||
display: grid;
|
||||
// One column below the desktop layout, and one that may shrink: an
|
||||
// implicit `auto` track grows to its widest line, and a code block in a
|
||||
// post pushed the whole text past the edge of a phone.
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 3rem;
|
||||
padding-bottom: 5rem;
|
||||
|
||||
|
||||
@@ -30,3 +30,42 @@
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.blog-tagged {
|
||||
margin: 1rem 0 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.blog-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin: 0 0 2rem;
|
||||
}
|
||||
|
||||
.blog-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.875rem;
|
||||
border: 1px solid color-mix(in oklab, currentColor 18%, transparent);
|
||||
border-radius: 999px;
|
||||
color: inherit;
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--pi-color-primary);
|
||||
}
|
||||
|
||||
&[aria-current='page'] {
|
||||
border-color: var(--pi-color-primary);
|
||||
background: var(--pi-color-primary);
|
||||
color: var(--pi-color-primary-contrast, #fff);
|
||||
}
|
||||
}
|
||||
|
||||
.blog-tag__count {
|
||||
opacity: 0.7;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { map } from 'rxjs';
|
||||
import {
|
||||
PiNoteComponent,
|
||||
PiTextColorDirective,
|
||||
@@ -8,12 +17,17 @@ import {
|
||||
|
||||
import type { Locale } from '../core/api';
|
||||
import type { PublicPage } from '../core/api.types';
|
||||
import { tagHref, taggedWith, tagsOf } from '../core/blog';
|
||||
import type { Copy } from '../core/copy';
|
||||
import { listingPath } from '../core/routes';
|
||||
import { PostCard } from './post-card';
|
||||
|
||||
/**
|
||||
* The blog: the pages in the panel's "Blog" category, newest first, each
|
||||
* leading to its own address like any page.
|
||||
*
|
||||
* `?tag=` narrows it to one subject. Plain links rather than a client-side
|
||||
* filter, so a tag's page can be shared and works before any script runs.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-blog-page',
|
||||
@@ -34,6 +48,11 @@ import { PostCard } from './post-card';
|
||||
<p l pi-color color="grey" class="content-hero__lead">
|
||||
{{ copy().blog.lead }}
|
||||
</p>
|
||||
@if (activeTag(); as tag) {
|
||||
<p class="blog-tagged">
|
||||
{{ copy().blog.tagged.replace('{tag}', tag.name) }}
|
||||
</p>
|
||||
}
|
||||
<p class="blog-feed">
|
||||
<a [href]="'/blog/rss.xml?lang=' + locale()">{{
|
||||
copy().blog.feed
|
||||
@@ -43,9 +62,29 @@ import { PostCard } from './post-card';
|
||||
</header>
|
||||
|
||||
<div class="landing-shell blog-body">
|
||||
@if (posts().length > 0) {
|
||||
@if (tags().length > 0) {
|
||||
<nav class="blog-tags" [attr.aria-label]="copy().blog.tags">
|
||||
<a
|
||||
class="blog-tag"
|
||||
[href]="blogHref()"
|
||||
[attr.aria-current]="activeSlug() === null ? 'page' : null"
|
||||
>{{ copy().blog.allTags }}</a
|
||||
>
|
||||
@for (tag of tags(); track tag.slug) {
|
||||
<a
|
||||
class="blog-tag"
|
||||
[href]="tagHref(tag.slug)"
|
||||
[attr.aria-current]="activeSlug() === tag.slug ? 'page' : null"
|
||||
>{{ tag.name }}
|
||||
<span class="blog-tag__count">{{ tag.count }}</span></a
|
||||
>
|
||||
}
|
||||
</nav>
|
||||
}
|
||||
|
||||
@if (shown().length > 0) {
|
||||
<ul class="blog-list">
|
||||
@for (post of posts(); track post.id) {
|
||||
@for (post of shown(); track post.id) {
|
||||
<li>
|
||||
<app-post-card
|
||||
[post]="post"
|
||||
@@ -61,7 +100,7 @@ import { PostCard } from './post-card';
|
||||
status="info"
|
||||
appearance="fill"
|
||||
gap="xs"
|
||||
[title]="copy().blog.empty"
|
||||
[title]="activeSlug() ? copy().blog.noneTagged : copy().blog.empty"
|
||||
[text]="''"
|
||||
/>
|
||||
}
|
||||
@@ -72,4 +111,29 @@ export class BlogPage {
|
||||
readonly posts = input.required<PublicPage[]>();
|
||||
readonly copy = input.required<Copy>();
|
||||
readonly locale = input.required<Locale>();
|
||||
|
||||
protected readonly activeSlug = toSignal(
|
||||
inject(ActivatedRoute).queryParamMap.pipe(
|
||||
map((params) => params.get('tag')),
|
||||
),
|
||||
{ initialValue: null },
|
||||
);
|
||||
|
||||
protected readonly tags = computed(() => tagsOf(this.posts()));
|
||||
|
||||
protected readonly activeTag = computed(
|
||||
() => this.tags().find((tag) => tag.slug === this.activeSlug()) ?? null,
|
||||
);
|
||||
|
||||
protected readonly shown = computed(() =>
|
||||
taggedWith(this.posts(), this.activeSlug()),
|
||||
);
|
||||
|
||||
protected readonly blogHref = computed(() =>
|
||||
listingPath('blog', this.locale()),
|
||||
);
|
||||
|
||||
protected tagHref(slug: string): string {
|
||||
return tagHref(this.locale(), slug);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { Copy } from '../core/copy';
|
||||
import type { Locale } from '../core/api';
|
||||
import type { PublicCall } from '../core/api.types';
|
||||
import { TechnologyChip } from './technology-chip';
|
||||
import { LinkButton } from './link-button';
|
||||
|
||||
@Component({
|
||||
selector: 'app-call-card',
|
||||
@@ -32,6 +33,7 @@ import { TechnologyChip } from './technology-chip';
|
||||
PiTextColorDirective,
|
||||
PiTextWeightBoldDirective,
|
||||
TechnologyChip,
|
||||
LinkButton,
|
||||
],
|
||||
template: `
|
||||
<pi-card
|
||||
@@ -96,15 +98,13 @@ import { TechnologyChip } from './technology-chip';
|
||||
<!-- To the call's own page and its form when it has one; to the
|
||||
landing's general form otherwise. -->
|
||||
@if (call().path; as path) {
|
||||
<a class="landing-card__button-link" [href]="path">
|
||||
<pi-action-button
|
||||
<app-link-button
|
||||
class="landing-card__button-link"
|
||||
size="small"
|
||||
color="primary"
|
||||
appearance="outline"
|
||||
[hasIcon]="true"
|
||||
[href]="path"
|
||||
[text]="copy().labels.apply"
|
||||
/>
|
||||
</a>
|
||||
} @else {
|
||||
<pi-action-button
|
||||
size="small"
|
||||
|
||||
@@ -4,11 +4,12 @@ import {
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { PiActionButtonComponent, PiTextColorDirective } from '@pi/ui';
|
||||
import { PiTextColorDirective } from '@pi/ui';
|
||||
|
||||
import type { Locale } from '../core/api';
|
||||
import type { Copy } from '../core/copy';
|
||||
import { homeHref } from '../core/routes';
|
||||
import { LinkButton } from './link-button';
|
||||
|
||||
/**
|
||||
* "Shall we talk?" at the end of a page's side column, so no page is a dead
|
||||
@@ -17,20 +18,18 @@ import { homeHref } from '../core/routes';
|
||||
@Component({
|
||||
selector: 'app-contact-cta',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PiActionButtonComponent, PiTextColorDirective],
|
||||
imports: [PiTextColorDirective, LinkButton],
|
||||
template: `
|
||||
<div class="landing-card contact-cta">
|
||||
<h2 class="contact-cta__title">{{ copy().page.ctaTitle }}</h2>
|
||||
<p pi-color color="grey">{{ copy().page.ctaLead }}</p>
|
||||
<!-- An anchor around the library's button, which renders a <button>:
|
||||
a real link, so it works before hydration and opens in a new tab. -->
|
||||
<a class="contact-cta__link" [href]="href()">
|
||||
<pi-action-button
|
||||
color="primary"
|
||||
<app-link-button
|
||||
class="contact-cta__link"
|
||||
[href]="href()"
|
||||
[text]="copy().nav.contact"
|
||||
[hasIcon]="true"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
|
||||
@@ -130,3 +130,26 @@
|
||||
font-size: var(--pi-font-size-x-small, 0.75rem);
|
||||
}
|
||||
}
|
||||
|
||||
// TEMPORARY. Remove when @pi/ui ships the fix.
|
||||
//
|
||||
// `pi-number-field` declares `<ng-container matPrefix>` unconditionally, so
|
||||
// Material marks the field as having an icon prefix even though nothing is
|
||||
// projected into it. The empty prefix is 4px wide and takes the place of the
|
||||
// 16px inset every other field gets: the value ended up 12px to the left of
|
||||
// its own label, which is what makes it look wrong.
|
||||
//
|
||||
// Only two of the library's twenty fields declare a prefix, both numeric, and
|
||||
// only `incremental-number-field` actually uses one — so this is an oversight
|
||||
// rather than a pattern. The fix belongs there and is written:
|
||||
// `pireon-ui`, `number-field.component.html`, guarded by a `contentChild`.
|
||||
// This restores the inset here until that version is published.
|
||||
:host ::ng-deep .pi-form-field__number {
|
||||
.mat-mdc-form-field-icon-prefix {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mat-mdc-form-field-infix {
|
||||
padding-left: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,3 +17,28 @@
|
||||
margin: 1rem 0 0;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.content-page__byline {
|
||||
color: inherit;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.content-page__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 2.5rem;
|
||||
}
|
||||
|
||||
.content-page__tag {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--pi-color-primary) 12%, transparent);
|
||||
color: inherit;
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in oklab, var(--pi-color-primary) 22%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
|
||||
import type { Locale } from '../core/api';
|
||||
import type { PublicPage } from '../core/api.types';
|
||||
import { isBlogPost } from '../core/blog';
|
||||
import { isBlogPost, tagHref } from '../core/blog';
|
||||
import type { Copy } from '../core/copy';
|
||||
import { listingPath } from '../core/routes';
|
||||
import { ApiImage } from './api-image';
|
||||
@@ -55,6 +55,13 @@ import { ContactCta } from './contact-cta';
|
||||
|
||||
@if (isPost() && page().published_at; as published) {
|
||||
<p class="content-page__muted content-page__date">
|
||||
@if (page().byline; as byline) {
|
||||
{{ copy().blog.by }}
|
||||
<a class="content-page__byline" [href]="byline.path">{{
|
||||
byline.name
|
||||
}}</a>
|
||||
·
|
||||
}
|
||||
{{ copy().blog.published }}
|
||||
<time [attr.datetime]="published">{{ publishedOn() }}</time>
|
||||
</p>
|
||||
@@ -82,6 +89,16 @@ import { ContactCta } from './contact-cta';
|
||||
}
|
||||
|
||||
<div class="content-page__body" [innerHTML]="page().body ?? ''"></div>
|
||||
|
||||
@if (isPost() && (page().tags ?? []).length > 0) {
|
||||
<nav class="content-page__tags" [attr.aria-label]="copy().blog.tags">
|
||||
@for (tag of page().tags; track tag.slug) {
|
||||
<a class="content-page__tag" [href]="tagHref(tag.slug)"
|
||||
>#{{ tag.name }}</a
|
||||
>
|
||||
}
|
||||
</nav>
|
||||
}
|
||||
</article>
|
||||
|
||||
<aside class="content-page__aside">
|
||||
@@ -126,6 +143,10 @@ export class ContentPage {
|
||||
listingPath('blog', this.locale()),
|
||||
);
|
||||
|
||||
protected tagHref(slug: string): string {
|
||||
return tagHref(this.locale(), slug);
|
||||
}
|
||||
|
||||
protected readonly publishedOn = computed(() => {
|
||||
const published = this.page().published_at;
|
||||
|
||||
|
||||
79
landing/src/app/ui/link-button.scss
Normal file
79
landing/src/app/ui/link-button.scss
Normal file
@@ -0,0 +1,79 @@
|
||||
:host {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
// `@pi/ui` scopes its button rules to the element (`html button:not(…)` in
|
||||
// `_pi-button.scss`), so an anchor cannot inherit them. Every value below is
|
||||
// still the library's: these are the same `--pi-theme-button-*` custom
|
||||
// properties its own rules read, so switching shell or theme moves this button
|
||||
// too, and dark mode needs nothing of its own.
|
||||
.link-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 5.5rem;
|
||||
border: none;
|
||||
border-radius: var(--pi-theme-button-radius);
|
||||
line-height: 100%;
|
||||
text-decoration: none;
|
||||
letter-spacing: var(--pi-theme-button-letter-spacing);
|
||||
text-transform: var(--pi-theme-button-text-transform);
|
||||
font-weight: var(--pi-theme-button-font-weight);
|
||||
transition: var(--pi-theme-button-transition);
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
flex: none;
|
||||
width: 0.375rem;
|
||||
height: 0.5625rem;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--pi-color-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.link-button--medium,
|
||||
.link-button--large {
|
||||
min-height: var(--pi-theme-button-height);
|
||||
padding: var(--pi-spacing-xx-small) var(--pi-spacing-x-large);
|
||||
}
|
||||
|
||||
.link-button--small {
|
||||
min-width: 4.5rem;
|
||||
padding: var(--pi-spacing-xxxx-small) var(--pi-spacing-medium);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
// Filled. The theme may inject a gradient over the flat colour, exactly as it
|
||||
// does for a real button.
|
||||
.link-button--fill {
|
||||
color: var(--pi-color-white);
|
||||
box-shadow: var(--pi-theme-button-shadow);
|
||||
|
||||
&.link-button--primary {
|
||||
background-color: var(--pi-color-primary);
|
||||
background-image: var(--pi-theme-button-fill-primary, none);
|
||||
}
|
||||
|
||||
&.link-button--grey {
|
||||
background-color: var(--pi-color-grey);
|
||||
}
|
||||
}
|
||||
|
||||
// Outline. The colour is the border and the text; the surface stays clear.
|
||||
.link-button--outline {
|
||||
background: none;
|
||||
|
||||
&.link-button--primary {
|
||||
border: 1px solid var(--pi-color-primary);
|
||||
color: var(--pi-color-primary);
|
||||
}
|
||||
|
||||
&.link-button--grey {
|
||||
border: 1px solid var(--pi-color-grey-200);
|
||||
color: var(--pi-color-grey);
|
||||
}
|
||||
}
|
||||
73
landing/src/app/ui/link-button.ts
Normal file
73
landing/src/app/ui/link-button.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
||||
|
||||
/**
|
||||
* A link that looks like the library's action button.
|
||||
*
|
||||
* `pi-action-button` renders a `<button>`, so wrapping one in an `<a>` puts a
|
||||
* button inside a link: invalid HTML, and the button swallows the click, so
|
||||
* nothing navigates. That is what stopped "Web oficial" working, and the same
|
||||
* pattern sat in five other places.
|
||||
*
|
||||
* Making it an anchor is not only about the click. A real link can be opened
|
||||
* in a new tab, middle-clicked, copied, reached by keyboard and followed by a
|
||||
* crawler; a button can do none of that.
|
||||
*
|
||||
* The library's own button classes are deliberately NOT reused. Its rules are
|
||||
* scoped to the element — `html button:not(…)` — so on an anchor they
|
||||
* contribute nothing except `pi-color__primary`, which is a *text* colour and
|
||||
* actively fights the white a filled button needs. The stylesheet beside this
|
||||
* file reads the same `--pi-theme-button-*` tokens the library reads instead,
|
||||
* so a change of shell or theme still reaches this button.
|
||||
*/
|
||||
type Size = 'small' | 'medium' | 'large';
|
||||
type Tone = 'primary' | 'grey';
|
||||
|
||||
@Component({
|
||||
selector: 'app-link-button',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styleUrl: './link-button.scss',
|
||||
template: `
|
||||
<a
|
||||
[class]="classes()"
|
||||
[href]="href()"
|
||||
[attr.target]="external() ? '_blank' : null"
|
||||
[attr.rel]="external() ? 'noopener noreferrer' : null"
|
||||
>
|
||||
<span>{{ text() }}</span>
|
||||
|
||||
<svg
|
||||
viewBox="0 0 6 9"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
d="M1 1C4.5 4 5 4.38484 5 4.5C5 4.61516 4.5 5 1 8"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
/>
|
||||
</g>
|
||||
<g>
|
||||
<path d="M4.5 4.5H0" stroke="currentColor" stroke-width="1" />
|
||||
</g>
|
||||
</svg>
|
||||
</a>
|
||||
`,
|
||||
})
|
||||
export class LinkButton {
|
||||
readonly href = input.required<string>();
|
||||
readonly text = input.required<string>();
|
||||
readonly size = input<Size>('medium');
|
||||
readonly color = input<Tone>('primary');
|
||||
readonly appearance = input<'fill' | 'outline'>('fill');
|
||||
/** Opens in a new tab, for addresses that are not ours. */
|
||||
readonly external = input(false);
|
||||
|
||||
protected readonly classes = computed(() => [
|
||||
'link-button',
|
||||
`link-button--${this.size()}`,
|
||||
`link-button--${this.color()}`,
|
||||
`link-button--${this.appearance()}`,
|
||||
]);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { LinkButton } from './link-button';
|
||||
import {
|
||||
PiActionButtonComponent,
|
||||
PiIconComponent,
|
||||
PiTextColorDirective,
|
||||
PiTextMediumDirective,
|
||||
@@ -26,12 +26,12 @@ import {
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
PiIconComponent,
|
||||
PiActionButtonComponent,
|
||||
PiTextXLargeDirective,
|
||||
PiTextMediumDirective,
|
||||
PiTextSmallDirective,
|
||||
PiTextColorDirective,
|
||||
PiTextWeightBoldDirective,
|
||||
LinkButton,
|
||||
],
|
||||
styleUrl: './not-found.scss',
|
||||
template: `
|
||||
@@ -47,14 +47,12 @@ import {
|
||||
La dirección puede haber cambiado. Vuelve al inicio y sigue desde ahí.
|
||||
</p>
|
||||
|
||||
<a class="not-found__home" href="/">
|
||||
<pi-action-button
|
||||
<app-link-button
|
||||
class="not-found__home"
|
||||
size="large"
|
||||
color="primary"
|
||||
href="/"
|
||||
text="Ir al inicio"
|
||||
[hasIcon]="true"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<p s pi-color color="grey" class="not-found__note">
|
||||
Si has llegado desde un enlace nuestro, cuéntanoslo.
|
||||
|
||||
@@ -87,3 +87,69 @@
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.project-roadmap,
|
||||
.project-releases,
|
||||
.project-first,
|
||||
.project-proposals {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.project-roadmap__item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.25rem 1rem;
|
||||
align-items: baseline;
|
||||
|
||||
&.is-done a {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
|
||||
.project-roadmap__bar {
|
||||
grid-column: 1 / -1;
|
||||
height: 0.375rem;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, currentColor 12%, transparent);
|
||||
|
||||
span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--pi-color-primary, currentColor);
|
||||
}
|
||||
}
|
||||
|
||||
.project-releases li {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.project-releases__notes {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.project-proposals li {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem 1rem;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
// Clear of the lead above and of the link below.
|
||||
.project-roadmap,
|
||||
.project-proposals {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.project-proposals__propose {
|
||||
margin: 1.25rem 0 0;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
PiActionButtonComponent,
|
||||
PiAvatarComponent,
|
||||
PiChipComponent,
|
||||
PiChipStatusComponent,
|
||||
@@ -17,10 +16,12 @@ import {
|
||||
import type { Locale } from '../core/api';
|
||||
import type { PublicProject } from '../core/api.types';
|
||||
import type { Copy } from '../core/copy';
|
||||
import { boardLink, type ProjectBoard } from '../core/taskstream';
|
||||
import { ApiImage } from './api-image';
|
||||
import { ContactCta } from './contact-cta';
|
||||
import { PROJECT_STATUS_TONES } from './project-card';
|
||||
import { TechnologyChip } from './technology-chip';
|
||||
import { LinkButton } from './link-button';
|
||||
|
||||
/**
|
||||
* A project on its own address: what it is, why it was built the way it was,
|
||||
@@ -35,7 +36,6 @@ import { TechnologyChip } from './technology-chip';
|
||||
imports: [
|
||||
ApiImage,
|
||||
ContactCta,
|
||||
PiActionButtonComponent,
|
||||
PiAvatarComponent,
|
||||
PiChipComponent,
|
||||
PiChipStatusComponent,
|
||||
@@ -43,6 +43,7 @@ import { TechnologyChip } from './technology-chip';
|
||||
PiTextLargeDirective,
|
||||
PiTextWeightBoldDirective,
|
||||
TechnologyChip,
|
||||
LinkButton,
|
||||
],
|
||||
styleUrl: './project-detail.scss',
|
||||
template: `
|
||||
@@ -92,23 +93,20 @@ import { TechnologyChip } from './technology-chip';
|
||||
@if (project().demo_url || repositoryUrl()) {
|
||||
<div class="content-hero__actions">
|
||||
@if (project().demo_url; as demo) {
|
||||
<a [href]="demo" target="_blank" rel="noopener">
|
||||
<pi-action-button
|
||||
color="primary"
|
||||
<app-link-button
|
||||
[href]="demo"
|
||||
[text]="copy().labels.demo"
|
||||
[hasIcon]="true"
|
||||
[external]="true"
|
||||
/>
|
||||
</a>
|
||||
}
|
||||
@if (repositoryUrl(); as repository) {
|
||||
<a [href]="repository" target="_blank" rel="noopener">
|
||||
<pi-action-button
|
||||
appearance="outline"
|
||||
<app-link-button
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
[href]="repository"
|
||||
[text]="copy().labels.repository"
|
||||
[hasIcon]="true"
|
||||
[external]="true"
|
||||
/>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -146,6 +144,91 @@ import { TechnologyChip } from './technology-chip';
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (board(); as b) {
|
||||
@if (b.showcase.roadmap.length > 0) {
|
||||
<section class="content-page__section">
|
||||
<h2>{{ copy().project.roadmap }}</h2>
|
||||
<p class="content-page__muted">{{ copy().project.roadmapLead }}</p>
|
||||
<ol class="project-roadmap">
|
||||
@for (epic of b.showcase.roadmap; track epic.key) {
|
||||
<li class="project-roadmap__item" [class.is-done]="epic.done">
|
||||
<a [href]="link(epic.path)" target="_blank" rel="noopener">{{
|
||||
epic.title
|
||||
}}</a>
|
||||
<span class="content-page__muted project-roadmap__when"
|
||||
>{{ dates(epic.startDate, epic.dueDate) }}
|
||||
@if (epic.votes && !epic.done) {
|
||||
· {{ votes(epic.votes) }}
|
||||
}
|
||||
</span>
|
||||
<span
|
||||
class="project-roadmap__bar"
|
||||
role="img"
|
||||
[attr.aria-label]="epic.percent + '%'"
|
||||
><span [style.width.%]="epic.percent"></span
|
||||
></span>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</section>
|
||||
}
|
||||
@if (b.showcase.proposals; as proposals) {
|
||||
@if (proposals.open || proposals.top.length > 0) {
|
||||
<section class="content-page__section">
|
||||
<h2>{{ copy().project.proposals }}</h2>
|
||||
@if (proposals.top.length > 0) {
|
||||
<p class="content-page__muted">{{ copy().project.proposalsLead }}</p>
|
||||
<ol class="project-proposals">
|
||||
@for (proposal of proposals.top; track proposal.key) {
|
||||
<li>
|
||||
<a [href]="link(proposal.path)" target="_blank" rel="noopener">{{
|
||||
proposal.title
|
||||
}}</a>
|
||||
<span class="content-page__muted">{{ votes(proposal.votes) }}</span>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
}
|
||||
@if (proposals.open && proposals.path) {
|
||||
<p class="project-proposals__propose">
|
||||
<a [href]="link(proposals.path)" target="_blank" rel="noopener"
|
||||
>{{ copy().project.propose }} →</a
|
||||
>
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
}
|
||||
@if (b.showcase.releases.length > 0) {
|
||||
<section class="content-page__section">
|
||||
<h2>{{ copy().project.releases }}</h2>
|
||||
<ul class="project-releases">
|
||||
@for (release of b.showcase.releases; track release.name) {
|
||||
<li>
|
||||
<strong>{{ release.name }}</strong>
|
||||
<span class="content-page__muted">
|
||||
{{
|
||||
release.status === 'RELEASED'
|
||||
? copy().project.released
|
||||
: copy().project.planned
|
||||
}}
|
||||
@if (release.releasedAt ?? release.targetDate; as day) {
|
||||
· {{ dates(day, null) }}
|
||||
}
|
||||
@if (release.status !== 'RELEASED') {
|
||||
· {{ release.percent }}%
|
||||
}
|
||||
</span>
|
||||
@if (release.notes) {
|
||||
<pre class="project-releases__notes">{{ release.notes }}</pre>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@if ((project().gallery ?? []).length > 0) {
|
||||
<section class="content-page__section">
|
||||
<h2>{{ copy().project.gallery }}</h2>
|
||||
@@ -205,6 +288,36 @@ import { TechnologyChip } from './technology-chip';
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
@if (board(); as b) {
|
||||
<div class="landing-card content-page__panel">
|
||||
@if (b.showcase.activeSprint; as sprint) {
|
||||
<p class="landing-eyebrow">{{ copy().project.sprint }}</p>
|
||||
<p>
|
||||
<strong>{{ sprint.name }}</strong> · {{ sprint.percent }}%
|
||||
@if (sprint.goal) {
|
||||
<br /><span class="content-page__muted">{{ sprint.goal }}</span>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
@if (b.showcase.goodFirst.length > 0) {
|
||||
<h2>{{ copy().project.goodFirst }}</h2>
|
||||
<p class="content-page__muted">{{ copy().project.goodFirstLead }}</p>
|
||||
<ul class="project-first">
|
||||
@for (issue of b.showcase.goodFirst; track issue.key) {
|
||||
<li>
|
||||
<a [href]="link(issue.path)" target="_blank" rel="noopener"
|
||||
>{{ issue.key }} · {{ issue.title }}</a
|
||||
>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
<a [href]="b.url" target="_blank" rel="noopener">{{
|
||||
copy().project.board
|
||||
}} →</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if ((project().calls ?? []).length > 0) {
|
||||
<nav [attr.aria-label]="copy().project.openCalls">
|
||||
<p class="landing-eyebrow">{{ copy().project.openCalls }}</p>
|
||||
@@ -307,6 +420,32 @@ export class ProjectDetail {
|
||||
readonly project = input.required<PublicProject>();
|
||||
readonly copy = input.required<Copy>();
|
||||
readonly locale = input.required<Locale>();
|
||||
/** Its TaskStream board, when it has one with something to show. */
|
||||
readonly board = input<ProjectBoard | null>(null);
|
||||
|
||||
protected link(path: string): string {
|
||||
return boardLink(this.board()?.webUrl ?? '', path);
|
||||
}
|
||||
|
||||
/** "1 voto", "3 votos". */
|
||||
protected votes(count: number): string {
|
||||
const copy = this.copy().project;
|
||||
return count === 1 ? copy.voteOne : copy.voteMany.replace('{n}', String(count));
|
||||
}
|
||||
|
||||
/** "3 nov – 20 dic 2026", or one end when only one is known. */
|
||||
protected dates(start: string | null, end: string | null): string {
|
||||
const format = (date: string | null) =>
|
||||
date
|
||||
? new Intl.DateTimeFormat(this.locale(), {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
}).format(new Date(`${date.slice(0, 10)}T00:00:00Z`))
|
||||
: null;
|
||||
return [format(start), format(end)].filter(Boolean).join(' – ');
|
||||
}
|
||||
|
||||
protected readonly statusTone = computed(
|
||||
() => PROJECT_STATUS_TONES[this.project().status],
|
||||
|
||||
@@ -61,6 +61,7 @@ import { ProjectDetail } from './project-detail';
|
||||
@case ('project') {
|
||||
<app-project-detail
|
||||
[project]="found.record"
|
||||
[board]="found.board"
|
||||
[copy]="copy()"
|
||||
[locale]="data().locale"
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
PiActionButtonComponent,
|
||||
PiTextColorDirective,
|
||||
PiTextLargeDirective,
|
||||
PiTextWeightBoldDirective,
|
||||
@@ -15,6 +14,7 @@ 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';
|
||||
|
||||
@@ -36,10 +36,10 @@ import { ContactCta } from './contact-cta';
|
||||
imports: [
|
||||
ApiImage,
|
||||
ContactCta,
|
||||
PiActionButtonComponent,
|
||||
PiTextColorDirective,
|
||||
PiTextLargeDirective,
|
||||
PiTextWeightBoldDirective,
|
||||
LinkButton,
|
||||
],
|
||||
styleUrl: './technology-detail.scss',
|
||||
template: `
|
||||
@@ -75,14 +75,12 @@ import { ContactCta } from './contact-cta';
|
||||
|
||||
@if (technology().website; as website) {
|
||||
<p class="content-hero__actions">
|
||||
<a [href]="website" target="_blank" rel="noopener noreferrer">
|
||||
<pi-action-button
|
||||
<app-link-button
|
||||
size="large"
|
||||
color="primary"
|
||||
[href]="website"
|
||||
[text]="copy().technology.website"
|
||||
[hasIcon]="true"
|
||||
[external]="true"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
9
package-lock.json
generated
9
package-lock.json
generated
@@ -22,7 +22,7 @@
|
||||
"@angular/platform-server": "~22.0.4",
|
||||
"@angular/router": "~22.0.4",
|
||||
"@ngx-translate/core": "^18.0.0",
|
||||
"@pi/ui": "^0.0.2",
|
||||
"@pi/ui": "^0.0.7",
|
||||
"front-matter": "^4.0.2",
|
||||
"marked": "^15.0.7",
|
||||
"marked-gfm-heading-id": "^4.1.1",
|
||||
@@ -9587,12 +9587,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@pi/ui": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://nexus.pireon.dev/repository/npm-releases/@pi/ui/-/ui-0.0.2.tgz",
|
||||
"integrity": "sha1-kCo1VJKozqkA7ag+oOYIRoGESAg=",
|
||||
"version": "0.0.7",
|
||||
"resolved": "https://nexus.pireon.dev/repository/npm-releases/@pi/ui/-/ui-0.0.7.tgz",
|
||||
"integrity": "sha1-PkfcIIYypFMmuypt5C3UbxDNJbc=",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.0",
|
||||
"luxon": "^3.0.0",
|
||||
"ngx-infinite-scroll": "^22.0.0",
|
||||
"tslib": "^2.3.0"
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"@angular/platform-server": "~22.0.4",
|
||||
"@angular/router": "~22.0.4",
|
||||
"@ngx-translate/core": "^18.0.0",
|
||||
"@pi/ui": "^0.0.2",
|
||||
"@pi/ui": "^0.0.7",
|
||||
"front-matter": "^4.0.2",
|
||||
"marked": "^15.0.7",
|
||||
"marked-gfm-heading-id": "^4.1.1",
|
||||
|
||||
@@ -75,10 +75,12 @@ const richBody =
|
||||
'<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) => ({
|
||||
const post = (id, title, slug, published_at, tags = []) => ({
|
||||
...page(id, title, slug, false),
|
||||
body: richBody,
|
||||
published_at,
|
||||
tags,
|
||||
byline: { name: 'Ada Lovelace', slug: 'ada', headline: 'Backend', avatar: null, path: '/equipo/ada' },
|
||||
excerpt: 'Lo que hemos aprendido este mes, contado sin prisa y con ejemplos de código.',
|
||||
category: { id: 9, name: 'Blog', slug: 'blog', position: 3 },
|
||||
});
|
||||
@@ -86,8 +88,8 @@ const post = (id, title, slug, published_at) => ({
|
||||
const pages = [
|
||||
page(1, 'Sobre nosotros', 'sobre-nosotros', true),
|
||||
page(2, 'Contacto', 'contacto', false),
|
||||
post(3, 'Por qué volvimos a renderizar en el servidor', 'volvimos-al-servidor', '2026-09-20T09:00:00Z'),
|
||||
post(4, 'Nuestro primer año como colectivo', 'primer-ano', '2026-06-01T09:00:00Z'),
|
||||
post(3, 'Por qué volvimos a renderizar en el servidor', 'volvimos-al-servidor', '2026-09-20T09:00:00Z', [{ name: 'Angular', slug: 'angular' }, { name: 'Rendimiento', slug: 'rendimiento' }]),
|
||||
post(4, 'Nuestro primer año como colectivo', 'primer-ano', '2026-06-01T09:00:00Z', [{ name: 'Colectivo', slug: 'colectivo' }]),
|
||||
];
|
||||
|
||||
const data = {
|
||||
@@ -167,6 +169,38 @@ for (const listed of data['/public/v1/projects']) {
|
||||
};
|
||||
}
|
||||
|
||||
// The catalogue has a board in TaskStream: its showcase is served here too,
|
||||
// so the roadmap, releases, proposals and first issues show without one.
|
||||
const showcase = {
|
||||
project: { id: 'ts-1', key: 'CAT', name: 'Catálogo abierto', description: null },
|
||||
activeSprint: { name: 'Sprint 4', goal: 'Buscar por etiqueta', startDate: '2026-10-05', endDate: '2026-10-19', percent: 40 },
|
||||
roadmap: [
|
||||
{ key: 'CAT-1', title: 'Fichas accesibles', startDate: '2026-07-01', dueDate: '2026-09-15', percent: 100, done: true, votes: 2, path: '/p/CAT/issues/CAT-1' },
|
||||
{ key: 'CAT-7', title: 'Búsqueda por etiquetas', startDate: '2026-09-20', dueDate: '2026-11-30', percent: 45, done: false, votes: 9, path: '/p/CAT/issues/CAT-7' },
|
||||
{ key: 'CAT-12', title: 'Exportar a PDF', startDate: null, dueDate: '2027-02-01', percent: 0, done: false, votes: 1, path: '/p/CAT/issues/CAT-12' },
|
||||
],
|
||||
releases: [
|
||||
{ name: '1.2', status: 'PLANNED', targetDate: '2026-11-30', releasedAt: null, percent: 60, notes: null },
|
||||
{ name: '1.1', status: 'RELEASED', targetDate: null, releasedAt: '2026-09-16', percent: 100, notes: '- Fichas accesibles\n- Arreglos en móvil' },
|
||||
],
|
||||
goodFirst: [{ key: 'CAT-21', title: 'Traducir los avisos al catalán', type: 't-task', path: '/p/CAT/issues/CAT-21' }],
|
||||
proposals: {
|
||||
open: true,
|
||||
path: '/p/CAT/proponer',
|
||||
top: [
|
||||
{ key: 'CAT-30', title: 'Modo oscuro', type: 't-feature', path: '/p/CAT/issues/CAT-30', votes: 14 },
|
||||
{ key: 'CAT-28', title: 'El buscador no encuentra palabras con tilde', type: 't-bug', path: '/p/CAT/issues/CAT-28', votes: 6 },
|
||||
{ key: 'CAT-33', title: 'Compartir una ficha por enlace corto', type: 't-feature', path: '/p/CAT/issues/CAT-33', votes: 1 },
|
||||
],
|
||||
},
|
||||
};
|
||||
data['/public/v1/projects/catalogo'].taskstream = {
|
||||
key: 'CAT',
|
||||
url: 'https://taskstream.pireon.dev/p/CAT',
|
||||
web_url: 'https://taskstream.pireon.dev',
|
||||
showcase_url: 'http://localhost:4599/taskstream/CAT/showcase',
|
||||
};
|
||||
|
||||
// Calls link to their own page, and one of them is closed so the page can
|
||||
// show what a visitor sees when a link to a filled call is still out there.
|
||||
for (const call of data['/public/v1/calls']) {
|
||||
@@ -235,6 +269,12 @@ createServer((req, res) => {
|
||||
|
||||
res.setHeader('content-type', 'application/json');
|
||||
|
||||
// TaskStream answers its showcase bare, not inside `data`.
|
||||
if (path === '/taskstream/CAT/showcase') {
|
||||
res.end(JSON.stringify(showcase));
|
||||
return;
|
||||
}
|
||||
|
||||
// The two write endpoints: accept whatever arrives, as the real API does
|
||||
// for a valid submission.
|
||||
if (req.method === 'POST') {
|
||||
|
||||
Reference in New Issue
Block a user