The API has always served a project's and a member's detail, with `path` and `alternates` in three languages, and the site had nowhere to show it. A project now has its page — tagline, status, technologies, description, technical decisions, what was learned, gallery, client, dates, lead, team, repositories and open calls — and so does a member: headline, bio, links, technologies and projects. Cards on the landing link to both. Addresses follow the API's rule read backwards (core/routes.ts): pages at the root, records under a section named in their language, the default language unprefixed. One catch-all route serves them all. Not a route per shape: Analog registers each loader as an internal Nitro route, and Nitro's router cannot hold `/:prefix/:slug` beside `/:prefix/:section/:slug` — the first became unreachable. The frame around a record (header, footer, lights, 404/503, title, description, canonical, hreflang) is one component now, PageShell, and each type only brings its body, all sharing one layout partial. Also fixes chips that stepped down one row after the first, on the landing's cards too: @pi/ui's reset gives every `li + li` a top margin, right for prose and wrong for a row spaced by `gap`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
123 lines
3.5 KiB
TypeScript
123 lines
3.5 KiB
TypeScript
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
|
import {
|
|
PiAvatarComponent,
|
|
PiCardCenterContentDirective,
|
|
PiCardComponent,
|
|
PiCardTopContentDirective,
|
|
PiChipComponent,
|
|
PiTextColorDirective,
|
|
PiTextSmallDirective,
|
|
PiTextViewerLinkComponent,
|
|
PiTextWeightBoldDirective,
|
|
} from '@pi/ui';
|
|
|
|
import type { Copy } from '../core/copy';
|
|
import type { PublicMember } from '../core/api.types';
|
|
|
|
@Component({
|
|
selector: 'app-member-card',
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
imports: [
|
|
PiCardComponent,
|
|
PiCardTopContentDirective,
|
|
PiCardCenterContentDirective,
|
|
PiAvatarComponent,
|
|
PiChipComponent,
|
|
PiTextViewerLinkComponent,
|
|
PiTextSmallDirective,
|
|
PiTextColorDirective,
|
|
PiTextWeightBoldDirective,
|
|
],
|
|
template: `
|
|
<pi-card
|
|
class="landing-card"
|
|
[hasShadow]="true"
|
|
[isStroked]="true"
|
|
gap="m"
|
|
padding="l"
|
|
innerPadding="none"
|
|
alignment="center"
|
|
context="public"
|
|
>
|
|
<pi-card-top-content>
|
|
<!-- The avatar falls back to initials on its own, and again to an
|
|
icon, so a missing photo never leaves a hole. -->
|
|
<pi-avatar
|
|
size="x-large"
|
|
shape="circle"
|
|
color="primary"
|
|
icon="user"
|
|
[image]="member().avatar?.url"
|
|
[alt]="member().avatar?.alt"
|
|
[name]="member().name"
|
|
/>
|
|
</pi-card-top-content>
|
|
|
|
<pi-card-center-content>
|
|
<h3 bold class="landing-card__title">
|
|
@if (member().path; as path) {
|
|
<a class="landing-card__title-link" [href]="path">{{ member().name }}</a>
|
|
} @else {
|
|
{{ member().name }}
|
|
}
|
|
</h3>
|
|
@if (member().role || member().headline; as subtitle) {
|
|
<p s pi-color color="grey">{{ subtitle }}</p>
|
|
}
|
|
|
|
<div class="landing-card__chips landing-card__chips--center">
|
|
@if (member().is_founder) {
|
|
<pi-chip size="small" color="primary" [label]="copy().labels.founder" />
|
|
}
|
|
@if (!member().is_current) {
|
|
<pi-chip
|
|
size="small"
|
|
appearance="outline"
|
|
[label]="copy().labels.alumni"
|
|
/>
|
|
}
|
|
</div>
|
|
|
|
@if (links().length > 0) {
|
|
<div class="landing-card__links landing-card__links--center">
|
|
@for (link of links(); track link.label) {
|
|
<pi-text-viewer-link
|
|
icon="link"
|
|
iconSize="x-small"
|
|
[value]="link.label"
|
|
[link]="link.url"
|
|
target="_blank"
|
|
/>
|
|
}
|
|
</div>
|
|
}
|
|
</pi-card-center-content>
|
|
</pi-card>
|
|
`,
|
|
})
|
|
export class MemberCard {
|
|
readonly member = input.required<PublicMember>();
|
|
readonly copy = input.required<Copy>();
|
|
|
|
protected readonly links = computed(() => memberLinks(this.member()).slice(0, 3));
|
|
}
|
|
|
|
/** A member's links, GitHub first when there is a username for it. */
|
|
export function memberLinks(member: PublicMember): { label: string; url: string }[] {
|
|
const entries = Object.entries(member.links ?? {}).map(([label, url]) => ({
|
|
label,
|
|
url,
|
|
}));
|
|
|
|
// The username is handed over separately, so build the URL rather than
|
|
// hope somebody also filled the links map in.
|
|
if (member.github_username && !entries.some((link) => link.label === 'github')) {
|
|
entries.unshift({
|
|
label: 'github',
|
|
url: `https://github.com/${member.github_username}`,
|
|
});
|
|
}
|
|
|
|
return entries;
|
|
}
|