Give the tech radar its own page
/radar (/en/radar, /ca/radar) shows every technology the collective has placed, by ring: logo, category, what it is, why it sits there, its website and how many projects use it. The landing's radar summary links to it. It is the first listing — a page this site composes rather than one record's — so resolveRoute learns about those (LISTINGS), and builds their addresses and hreflang alternates from the same table. Logos sit on a light tile in both themes. A brand's mark is not ours to recolour, and Rust's, which is black, would vanish in dark mode. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -114,6 +114,19 @@ export interface Copy {
|
||||
links: string;
|
||||
since: string;
|
||||
};
|
||||
radar: {
|
||||
eyebrow: string;
|
||||
lead: string;
|
||||
ringLead: Record<'adopt' | 'trial' | 'assess' | 'hold', string>;
|
||||
categories: Record<
|
||||
'language' | 'framework' | 'datastore' | 'infrastructure' | 'tool' | 'practice',
|
||||
string
|
||||
>;
|
||||
website: string;
|
||||
projects: string;
|
||||
empty: string;
|
||||
seeAll: string;
|
||||
};
|
||||
call: {
|
||||
eyebrow: string;
|
||||
seniority: Record<'any' | 'junior' | 'mid' | 'senior', string>;
|
||||
@@ -248,6 +261,28 @@ const es: Copy = {
|
||||
links: 'Enlaces',
|
||||
since: 'En el colectivo desde',
|
||||
},
|
||||
radar: {
|
||||
eyebrow: 'Radar tecnológico',
|
||||
lead: 'Las herramientas con las que trabajamos y lo que opinamos de cada una: qué usamos sin dudar, qué estamos probando y qué preferimos evitar.',
|
||||
ringLead: {
|
||||
adopt: 'Lo usamos en proyectos reales y lo recomendamos sin reservas.',
|
||||
trial: 'Lo estamos probando en proyectos donde el riesgo es asumible.',
|
||||
assess: 'Nos interesa y lo estamos estudiando, sin usarlo aún en serio.',
|
||||
hold: 'Por ahora preferimos no empezar nada nuevo con esto.',
|
||||
},
|
||||
categories: {
|
||||
language: 'Lenguaje',
|
||||
framework: 'Framework',
|
||||
datastore: 'Datos',
|
||||
infrastructure: 'Infraestructura',
|
||||
tool: 'Herramienta',
|
||||
practice: 'Práctica',
|
||||
},
|
||||
website: 'Web oficial',
|
||||
projects: 'proyectos',
|
||||
empty: 'Todavía no hemos colocado ninguna tecnología en el radar.',
|
||||
seeAll: 'Ver el radar completo',
|
||||
},
|
||||
call: {
|
||||
eyebrow: 'Convocatoria',
|
||||
seniority: { any: 'Cualquier nivel', junior: 'Junior', mid: 'Intermedio', senior: 'Senior' },
|
||||
@@ -386,6 +421,28 @@ const en: Copy = {
|
||||
links: 'Links',
|
||||
since: 'In the collective since',
|
||||
},
|
||||
radar: {
|
||||
eyebrow: 'Tech radar',
|
||||
lead: 'The tools we work with and what we think of each: what we use without a second thought, what we are trying out and what we would rather avoid.',
|
||||
ringLead: {
|
||||
adopt: 'We use it on real projects and recommend it without reservation.',
|
||||
trial: 'We are trying it out on projects where the risk is acceptable.',
|
||||
assess: 'It interests us and we are looking into it, without using it seriously yet.',
|
||||
hold: 'For now we would rather not start anything new with it.',
|
||||
},
|
||||
categories: {
|
||||
language: 'Language',
|
||||
framework: 'Framework',
|
||||
datastore: 'Data',
|
||||
infrastructure: 'Infrastructure',
|
||||
tool: 'Tool',
|
||||
practice: 'Practice',
|
||||
},
|
||||
website: 'Official website',
|
||||
projects: 'projects',
|
||||
empty: 'We have not placed any technology on the radar yet.',
|
||||
seeAll: 'See the full radar',
|
||||
},
|
||||
call: {
|
||||
eyebrow: 'Open call',
|
||||
seniority: { any: 'Any level', junior: 'Junior', mid: 'Mid-level', senior: 'Senior' },
|
||||
@@ -524,6 +581,28 @@ const ca: Copy = {
|
||||
links: 'Enllaços',
|
||||
since: 'Al col·lectiu des de',
|
||||
},
|
||||
radar: {
|
||||
eyebrow: 'Radar tecnològic',
|
||||
lead: "Les eines amb què treballem i què en pensem de cadascuna: què fem servir sense dubtar, què estem provant i què preferim evitar.",
|
||||
ringLead: {
|
||||
adopt: 'El fem servir en projectes reals i el recomanem sense reserves.',
|
||||
trial: "L'estem provant en projectes on el risc és assumible.",
|
||||
assess: "Ens interessa i l'estem estudiant, sense fer-lo servir encara seriosament.",
|
||||
hold: 'Ara per ara preferim no començar res de nou amb això.',
|
||||
},
|
||||
categories: {
|
||||
language: 'Llenguatge',
|
||||
framework: 'Framework',
|
||||
datastore: 'Dades',
|
||||
infrastructure: 'Infraestructura',
|
||||
tool: 'Eina',
|
||||
practice: 'Pràctica',
|
||||
},
|
||||
website: 'Web oficial',
|
||||
projects: 'projectes',
|
||||
empty: 'Encara no hem col·locat cap tecnologia al radar.',
|
||||
seeAll: 'Veure el radar sencer',
|
||||
},
|
||||
call: {
|
||||
eyebrow: 'Convocatòria',
|
||||
seniority: { any: 'Qualsevol nivell', junior: 'Júnior', mid: 'Intermedi', senior: 'Sènior' },
|
||||
|
||||
@@ -13,7 +13,13 @@ import {
|
||||
type MaintenanceNotice,
|
||||
type SitePayload,
|
||||
} from './api';
|
||||
import type { PublicCall, PublicMember, PublicPage, PublicProject } from './api.types';
|
||||
import type {
|
||||
PublicCall,
|
||||
PublicMember,
|
||||
PublicPage,
|
||||
PublicProject,
|
||||
PublicTechnology,
|
||||
} from './api.types';
|
||||
import { resolveRoute, type RouteMatch } from './routes';
|
||||
|
||||
/** The record, tagged with its type so the template can switch on it. */
|
||||
@@ -21,7 +27,8 @@ export type RoutedRecord =
|
||||
| { type: 'page'; record: PublicPage }
|
||||
| { type: 'project'; record: PublicProject }
|
||||
| { type: 'member'; record: PublicMember }
|
||||
| { type: 'call'; record: PublicCall };
|
||||
| { type: 'call'; record: PublicCall }
|
||||
| { type: 'radar'; record: PublicTechnology[] };
|
||||
|
||||
export interface RoutedPageData {
|
||||
locale: Locale;
|
||||
@@ -74,6 +81,11 @@ async function fetchRecord(route: RouteMatch): Promise<RoutedRecord | null> {
|
||||
const { data } = await pireonApi.call(locale, slug);
|
||||
return data ? { type: 'call', record: data } : null;
|
||||
}
|
||||
case 'radar': {
|
||||
// An empty radar is still the radar: the page says so rather than 404.
|
||||
const { data, error } = await pireonApi.radar(locale);
|
||||
return error ? null : { type: 'radar', record: data ?? [] };
|
||||
}
|
||||
default:
|
||||
// A type the site has no page for yet: an address the API hands out
|
||||
// but nothing here renders, which is a 404 until it does.
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { DEFAULT_LOCALE, SUPPORTED_LOCALES } from './api';
|
||||
import { homeHref, resolveRoute, SECTIONS } from './routes';
|
||||
import {
|
||||
homeHref,
|
||||
listingAlternates,
|
||||
listingPath,
|
||||
resolveRoute,
|
||||
SECTIONS,
|
||||
} from './routes';
|
||||
|
||||
describe('resolveRoute', () => {
|
||||
it('reads a page at the root as the default language', () => {
|
||||
@@ -77,6 +83,33 @@ describe('resolveRoute', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('listings', () => {
|
||||
it('reads the radar in every language, before any page of that slug', () => {
|
||||
expect(resolveRoute('/radar')).toEqual({
|
||||
type: 'radar',
|
||||
locale: 'es',
|
||||
slug: '',
|
||||
});
|
||||
expect(resolveRoute('/ca/radar')).toEqual({
|
||||
type: 'radar',
|
||||
locale: 'ca',
|
||||
slug: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds the address resolveRoute reads back', () => {
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
expect(resolveRoute(listingPath('radar', locale))?.type).toBe('radar');
|
||||
}
|
||||
|
||||
expect(listingAlternates('radar')).toEqual({
|
||||
es: '/radar',
|
||||
en: '/en/radar',
|
||||
ca: '/ca/radar',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('homeHref', () => {
|
||||
it('answers on ?lang= for every language but the default', () => {
|
||||
expect(homeHref(DEFAULT_LOCALE)).toBe('/');
|
||||
|
||||
@@ -15,6 +15,9 @@ import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type Locale } from './api';
|
||||
|
||||
export type RecordType = 'page' | 'project' | 'member' | 'call' | 'service';
|
||||
|
||||
/** Pages this site composes itself rather than one record's: no slug. */
|
||||
export type ListingType = 'radar';
|
||||
|
||||
/**
|
||||
* The section each type lives under, per language. The API's own
|
||||
* `config/site.php` (`paths`) is the source; a change there has to be made here
|
||||
@@ -30,9 +33,18 @@ export const SECTIONS: Record<
|
||||
service: { es: 'servicios', en: 'services', ca: 'serveis' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Where each listing lives, per language. A panel page with the same slug
|
||||
* would be shadowed by it, so these are few and deliberately named.
|
||||
*/
|
||||
export const LISTINGS: Record<ListingType, Record<Locale, string>> = {
|
||||
radar: { es: 'radar', en: 'radar', ca: 'radar' },
|
||||
};
|
||||
|
||||
export interface RouteMatch {
|
||||
type: RecordType;
|
||||
type: RecordType | ListingType;
|
||||
locale: Locale;
|
||||
/** Empty for a listing. */
|
||||
slug: string;
|
||||
}
|
||||
|
||||
@@ -51,7 +63,13 @@ export function resolveRoute(pathname: string): RouteMatch | null {
|
||||
const rest = prefixed ? segments.slice(1) : segments;
|
||||
|
||||
if (rest.length === 1) {
|
||||
return { type: 'page', locale, slug: rest[0] };
|
||||
const listing = (Object.keys(LISTINGS) as ListingType[]).find(
|
||||
(candidate) => LISTINGS[candidate][locale] === rest[0],
|
||||
);
|
||||
|
||||
return listing
|
||||
? { type: listing, locale, slug: '' }
|
||||
: { type: 'page', locale, slug: rest[0] };
|
||||
}
|
||||
|
||||
if (rest.length === 2) {
|
||||
@@ -65,6 +83,20 @@ export function resolveRoute(pathname: string): RouteMatch | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A listing's address in one language, as `resolveRoute` reads it back. */
|
||||
export function listingPath(listing: ListingType, locale: Locale): string {
|
||||
return `${locale === DEFAULT_LOCALE ? '' : `/${locale}`}/${LISTINGS[listing][locale]}`;
|
||||
}
|
||||
|
||||
/** The same listing in every language, for `hreflang`. */
|
||||
export function listingAlternates(
|
||||
listing: ListingType,
|
||||
): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
SUPPORTED_LOCALES.map((locale) => [locale, listingPath(listing, locale)]),
|
||||
);
|
||||
}
|
||||
|
||||
/** The landing itself in a given language. It answers on `?lang=`, not a prefix. */
|
||||
export function homeHref(locale: Locale): string {
|
||||
return locale === DEFAULT_LOCALE ? '/' : `/?lang=${locale}`;
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '@pi/ui';
|
||||
|
||||
import { pageNavLinks } from '../core/routed-page';
|
||||
import { listingPath } from '../core/routes';
|
||||
import { copyFor } from '../core/copy';
|
||||
import { CallCard } from '../ui/call-card';
|
||||
import { ClientLogo } from '../ui/client-logo';
|
||||
@@ -185,6 +186,9 @@ export const routeMeta: RouteMeta = {
|
||||
</pi-card>
|
||||
}
|
||||
</div>
|
||||
<p class="landing-section__more">
|
||||
<a [href]="radarHref()">{{ copy().radar.seeAll }} →</a>
|
||||
</p>
|
||||
</app-section>
|
||||
}
|
||||
|
||||
@@ -283,6 +287,8 @@ export default class HomePage {
|
||||
|
||||
protected readonly copy = computed(() => copyFor(this.data().locale));
|
||||
|
||||
protected readonly radarHref = computed(() => listingPath('radar', this.data().locale));
|
||||
|
||||
/** The switcher needs somewhere to go even if `/site` was unreachable. */
|
||||
protected readonly locales = computed(
|
||||
() =>
|
||||
|
||||
@@ -73,3 +73,18 @@
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
// "See the full radar" under the section's summary of it.
|
||||
.landing-section__more {
|
||||
margin: 2rem 0 0;
|
||||
|
||||
a {
|
||||
color: var(--pi-color-primary);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
146
landing/src/app/ui/radar-page.scss
Normal file
146
landing/src/app/ui/radar-page.scss
Normal file
@@ -0,0 +1,146 @@
|
||||
@use './detail';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.radar-jump {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.75rem;
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.radar-body {
|
||||
padding-bottom: 6rem;
|
||||
}
|
||||
|
||||
.radar-ring {
|
||||
// Clear of the sticky header when reached through an anchor.
|
||||
scroll-margin-top: 5.5rem;
|
||||
|
||||
& + & {
|
||||
margin-top: 4.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.radar-ring__head {
|
||||
max-width: 42rem;
|
||||
margin-bottom: 1.75rem;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.75rem, 3.5vw, 2.25rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.radar-grid {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
@media (min-width: 640px) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
// `@pi/ui`'s reset spaces every `li + li`; this grid is spaced by `gap`.
|
||||
> li {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.radar-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
padding: 1.5rem;
|
||||
border-radius: var(--pi-theme-card-radius, 1.5rem);
|
||||
box-shadow: var(--pi-theme-card-shadow, none);
|
||||
}
|
||||
|
||||
.radar-card__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
// Light in both themes, on purpose: see the component's comment.
|
||||
.radar-logo {
|
||||
display: grid;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: 0.875rem;
|
||||
background: #fff;
|
||||
box-shadow:
|
||||
0 0 0 1px
|
||||
color-mix(in srgb, var(--brand, var(--pi-color-primary)) 22%, transparent),
|
||||
0 6px 16px -8px
|
||||
color-mix(in srgb, var(--brand, var(--pi-color-primary)) 55%, transparent);
|
||||
}
|
||||
|
||||
.radar-logo ::ng-deep img {
|
||||
display: block;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.radar-logo__initial {
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem;
|
||||
color: var(--brand, var(--pi-color-primary));
|
||||
}
|
||||
|
||||
.radar-card__name {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.radar-card__category {
|
||||
margin: 0.125rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.radar-card__text,
|
||||
.radar-card__note {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.radar-card__note {
|
||||
padding-left: 0.75rem;
|
||||
border-left: 2px solid var(--pi-color-accent);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.radar-card__footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin: auto 0 0;
|
||||
font-size: 0.875rem;
|
||||
|
||||
a {
|
||||
color: var(--pi-color-primary);
|
||||
}
|
||||
}
|
||||
161
landing/src/app/ui/radar-page.ts
Normal file
161
landing/src/app/ui/radar-page.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
PiChipComponent,
|
||||
PiNoteComponent,
|
||||
PiTextColorDirective,
|
||||
PiTextLargeDirective,
|
||||
PiTextWeightBoldDirective,
|
||||
} from '@pi/ui';
|
||||
|
||||
import type { PublicTechnology } from '../core/api.types';
|
||||
import type { Copy } from '../core/copy';
|
||||
import { ApiImage } from './api-image';
|
||||
|
||||
type Ring = NonNullable<PublicTechnology['ring']>;
|
||||
|
||||
const RINGS: Ring[] = ['adopt', 'trial', 'assess', 'hold'];
|
||||
|
||||
/**
|
||||
* The tech radar on its own address: every technology the collective has
|
||||
* placed, by ring, with its logo, what it is and why it sits where it does.
|
||||
*
|
||||
* Logos sit on a light tile in both themes. A brand's own mark is not ours to
|
||||
* recolour, and some — Rust's, black — would otherwise vanish in dark mode.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-radar-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
ApiImage,
|
||||
PiChipComponent,
|
||||
PiNoteComponent,
|
||||
PiTextColorDirective,
|
||||
PiTextLargeDirective,
|
||||
PiTextWeightBoldDirective,
|
||||
],
|
||||
styleUrl: './radar-page.scss',
|
||||
template: `
|
||||
<header class="content-hero">
|
||||
<div class="landing-shell">
|
||||
<p class="landing-eyebrow">{{ copy().radar.eyebrow }}</p>
|
||||
<h1 bold class="content-hero__title">
|
||||
{{ copy().sections.radar.title }}
|
||||
</h1>
|
||||
<p l pi-color color="grey" class="content-hero__lead">
|
||||
{{ copy().radar.lead }}
|
||||
</p>
|
||||
|
||||
@if (rings().length > 0) {
|
||||
<nav class="radar-jump" [attr.aria-label]="copy().radar.eyebrow">
|
||||
@for (group of rings(); track group.ring) {
|
||||
<a [href]="'#' + group.ring">
|
||||
<pi-chip
|
||||
size="small"
|
||||
appearance="outline"
|
||||
[label]="
|
||||
copy().rings[group.ring] + ' · ' + group.technologies.length
|
||||
"
|
||||
/>
|
||||
</a>
|
||||
}
|
||||
</nav>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="landing-shell radar-body">
|
||||
@for (group of rings(); track group.ring) {
|
||||
<section class="radar-ring" [id]="group.ring">
|
||||
<header class="radar-ring__head">
|
||||
<h2>{{ copy().rings[group.ring] }}</h2>
|
||||
<p class="content-page__muted">
|
||||
{{ copy().radar.ringLead[group.ring] }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ul class="radar-grid">
|
||||
@for (technology of group.technologies; track technology.id) {
|
||||
<li class="landing-card radar-card">
|
||||
<div class="radar-card__top">
|
||||
<span
|
||||
class="radar-logo"
|
||||
[style.--brand]="technology.color || null"
|
||||
>
|
||||
@if (technology.logo; as logo) {
|
||||
<app-api-image
|
||||
[image]="logo"
|
||||
sizes="48px"
|
||||
imgClass="radar-logo__image"
|
||||
/>
|
||||
} @else {
|
||||
<span class="radar-logo__initial" aria-hidden="true">{{
|
||||
technology.name[0]
|
||||
}}</span>
|
||||
}
|
||||
</span>
|
||||
<div>
|
||||
<h3 class="radar-card__name">{{ technology.name }}</h3>
|
||||
<p class="content-page__muted radar-card__category">
|
||||
{{ copy().radar.categories[technology.category] }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (technology.description; as description) {
|
||||
<p class="radar-card__text">{{ description }}</p>
|
||||
}
|
||||
|
||||
@if (technology.radar_note; as note) {
|
||||
<p class="radar-card__note">{{ note }}</p>
|
||||
}
|
||||
|
||||
@if (technology.website || technology.projects_count > 0) {
|
||||
<p class="radar-card__footer content-page__muted">
|
||||
@if (technology.website; as website) {
|
||||
<a [href]="website" target="_blank" rel="noopener">{{
|
||||
copy().radar.website
|
||||
}}</a>
|
||||
}
|
||||
@if (technology.projects_count > 0) {
|
||||
<span
|
||||
>{{ technology.projects_count }}
|
||||
{{ copy().radar.projects }}</span
|
||||
>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
} @empty {
|
||||
<pi-note
|
||||
status="info"
|
||||
appearance="fill"
|
||||
gap="xs"
|
||||
[title]="copy().radar.empty"
|
||||
[text]="''"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class RadarPage {
|
||||
readonly technologies = input.required<PublicTechnology[]>();
|
||||
readonly copy = input.required<Copy>();
|
||||
|
||||
/** The rings that have anything in them, in the radar's own order. */
|
||||
protected readonly rings = computed(() =>
|
||||
RINGS.map((ring) => ({
|
||||
ring,
|
||||
technologies: this.technologies().filter(
|
||||
(technology) => technology.ring === ring,
|
||||
),
|
||||
})).filter((group) => group.technologies.length > 0),
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,10 @@ import {
|
||||
input,
|
||||
} from '@angular/core';
|
||||
|
||||
import { copyFor } from '../core/copy';
|
||||
import { copyFor, type Copy } from '../core/copy';
|
||||
import { listingAlternates, listingPath } from '../core/routes';
|
||||
import type { Locale } from '../core/api';
|
||||
import { RadarPage } from './radar-page';
|
||||
import { CallDetail } from './call-detail';
|
||||
import type { RoutedPageData, RoutedRecord } from '../core/routed-page';
|
||||
import { ContentPage } from './content-page';
|
||||
@@ -20,7 +23,14 @@ import { ProjectDetail } from './project-detail';
|
||||
@Component({
|
||||
selector: 'app-routed-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CallDetail, ContentPage, MemberDetail, PageShell, ProjectDetail],
|
||||
imports: [
|
||||
CallDetail,
|
||||
ContentPage,
|
||||
MemberDetail,
|
||||
PageShell,
|
||||
ProjectDetail,
|
||||
RadarPage,
|
||||
],
|
||||
template: `
|
||||
<app-page-shell [data]="data()" [seo]="seo()">
|
||||
@if (data().found; as found) {
|
||||
@@ -47,6 +57,9 @@ import { ProjectDetail } from './project-detail';
|
||||
[locale]="data().locale"
|
||||
/>
|
||||
}
|
||||
@case ('radar') {
|
||||
<app-radar-page [technologies]="found.record" [copy]="copy()" />
|
||||
}
|
||||
@case ('member') {
|
||||
<app-member-detail
|
||||
[member]="found.record"
|
||||
@@ -64,11 +77,17 @@ export class RoutedPage {
|
||||
|
||||
protected readonly copy = computed(() => copyFor(this.data().locale));
|
||||
|
||||
protected readonly seo = computed(() => seoFor(this.data().found));
|
||||
protected readonly seo = computed(() =>
|
||||
seoFor(this.data().found, this.copy(), this.data().locale),
|
||||
);
|
||||
}
|
||||
|
||||
/** What each type puts in the document head. Null when nothing was found. */
|
||||
export function seoFor(found: RoutedRecord | null): PageSeo | null {
|
||||
export function seoFor(
|
||||
found: RoutedRecord | null,
|
||||
copy: Copy,
|
||||
locale: Locale,
|
||||
): PageSeo | null {
|
||||
if (!found) {
|
||||
return null;
|
||||
}
|
||||
@@ -107,6 +126,14 @@ export function seoFor(found: RoutedRecord | null): PageSeo | null {
|
||||
type: 'profile',
|
||||
};
|
||||
}
|
||||
case 'radar':
|
||||
return {
|
||||
title: `Pireon — ${copy.sections.radar.title}`,
|
||||
description: copy.radar.lead,
|
||||
path: listingPath('radar', locale),
|
||||
alternates: listingAlternates('radar'),
|
||||
type: 'website',
|
||||
};
|
||||
case 'call': {
|
||||
const call = found.record;
|
||||
const project = call.project?.title;
|
||||
|
||||
Reference in New Issue
Block a user