Compare commits
6 Commits
bca3e4bcc6
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
289af6c3a1 | ||
|
|
1ee4f98a9e | ||
|
|
3366f57e14 | ||
|
|
334640d938 | ||
|
|
63d4410296 | ||
|
|
a3783222c8 |
@@ -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:
|
||||||
|
|||||||
4369
landing/openapi.yaml
4369
landing/openapi.yaml
File diff suppressed because it is too large
Load Diff
@@ -241,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -297,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',
|
||||||
@@ -506,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',
|
||||||
@@ -715,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',
|
||||||
|
|||||||
@@ -25,11 +25,16 @@ import type {
|
|||||||
import { BLOG_CATEGORY, byPublication, isBlogPost } from './blog';
|
import { BLOG_CATEGORY, byPublication, isBlogPost } from './blog';
|
||||||
import { copyFor } from './copy';
|
import { copyFor } from './copy';
|
||||||
import { listingPath, resolveRoute, type RouteMatch } from './routes';
|
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 }
|
||||||
@@ -82,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);
|
||||||
@@ -166,3 +173,13 @@ export function pageNavLinks(
|
|||||||
]
|
]
|
||||||
: featured;
|
: 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}`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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';
|
||||||
@@ -143,6 +144,91 @@ import { LinkButton } from './link-button';
|
|||||||
</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>
|
||||||
@@ -202,6 +288,36 @@ import { LinkButton } from './link-button';
|
|||||||
</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>
|
||||||
@@ -304,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],
|
||||||
|
|||||||
@@ -61,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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
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",
|
||||||
|
|||||||
@@ -169,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']) {
|
||||||
@@ -237,6 +269,12 @@ createServer((req, res) => {
|
|||||||
|
|
||||||
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