Give projects and members their own pages
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>
This commit is contained in:
182
landing/src/app/ui/member-detail.ts
Normal file
182
landing/src/app/ui/member-detail.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
PiAvatarComponent,
|
||||
PiChipComponent,
|
||||
PiTextColorDirective,
|
||||
PiTextLargeDirective,
|
||||
PiTextWeightBoldDirective,
|
||||
} from '@pi/ui';
|
||||
|
||||
import type { Locale } from '../core/api';
|
||||
import type { PublicMember } from '../core/api.types';
|
||||
import type { Copy } from '../core/copy';
|
||||
import { ContactCta } from './contact-cta';
|
||||
import { memberLinks } from './member-card';
|
||||
import { ProjectCard } from './project-card';
|
||||
|
||||
/**
|
||||
* Someone in the collective, on their own address: who they are, what they
|
||||
* work with, and what they have worked on.
|
||||
*
|
||||
* The bio is plain text from a textarea, not HTML, so it is printed as text
|
||||
* with its line breaks kept rather than handed to `innerHTML`.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-member-detail',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
ContactCta,
|
||||
PiAvatarComponent,
|
||||
PiChipComponent,
|
||||
PiTextColorDirective,
|
||||
PiTextLargeDirective,
|
||||
PiTextWeightBoldDirective,
|
||||
ProjectCard,
|
||||
],
|
||||
styleUrl: './member-detail.scss',
|
||||
template: `
|
||||
<header class="content-hero">
|
||||
<div class="landing-shell member-hero">
|
||||
<pi-avatar
|
||||
class="member-hero__avatar"
|
||||
size="x-large"
|
||||
shape="circle"
|
||||
color="primary"
|
||||
icon="user"
|
||||
[image]="member().avatar?.url"
|
||||
[alt]="member().avatar?.alt"
|
||||
[name]="member().name"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<p class="landing-eyebrow">{{ copy().nav.team }}</p>
|
||||
<h1 bold class="content-hero__title">{{ member().name }}</h1>
|
||||
|
||||
@if (member().headline; as headline) {
|
||||
<p l pi-color color="grey" class="content-hero__lead">
|
||||
{{ headline }}
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (member().is_founder || !member().is_current) {
|
||||
<ul class="content-hero__chips">
|
||||
@if (member().is_founder) {
|
||||
<li>
|
||||
<pi-chip
|
||||
size="small"
|
||||
color="primary"
|
||||
[label]="copy().labels.founder"
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
@if (!member().is_current) {
|
||||
<li>
|
||||
<pi-chip
|
||||
size="small"
|
||||
appearance="outline"
|
||||
[label]="copy().labels.alumni"
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="landing-shell content-page__layout">
|
||||
<article class="content-page__main">
|
||||
@if (member().bio; as bio) {
|
||||
<p class="member-bio">{{ bio }}</p>
|
||||
}
|
||||
|
||||
@if ((member().projects ?? []).length > 0) {
|
||||
<section class="content-page__section">
|
||||
<h2>{{ copy().member.projects }}</h2>
|
||||
<div class="member-projects">
|
||||
@for (project of member().projects; track project.id) {
|
||||
<app-project-card [project]="project" [copy]="copy()" />
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</article>
|
||||
|
||||
<aside class="content-page__aside">
|
||||
@if (since() || links().length > 0) {
|
||||
<div class="landing-card content-page__panel">
|
||||
<dl class="content-page__facts">
|
||||
@if (since(); as since) {
|
||||
<div>
|
||||
<dt>{{ copy().member.since }}</dt>
|
||||
<dd>{{ since }}</dd>
|
||||
</div>
|
||||
}
|
||||
@if (links().length > 0) {
|
||||
<div>
|
||||
<dt>{{ copy().member.links }}</dt>
|
||||
<dd>
|
||||
<ul class="member-links">
|
||||
@for (link of links(); track link.url) {
|
||||
<li>
|
||||
<a
|
||||
[href]="link.url"
|
||||
target="_blank"
|
||||
rel="noopener me"
|
||||
>{{ link.label }}</a
|
||||
>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if ((member().technologies ?? []).length > 0) {
|
||||
<div class="landing-card content-page__panel">
|
||||
<h2>{{ copy().member.technologies }}</h2>
|
||||
<ul class="content-hero__chips member-technologies">
|
||||
@for (technology of member().technologies; track technology.id) {
|
||||
<li>
|
||||
<pi-chip
|
||||
size="small"
|
||||
appearance="outline"
|
||||
[label]="technology.name"
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-contact-cta [copy]="copy()" [locale]="locale()" />
|
||||
</aside>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class MemberDetail {
|
||||
readonly member = input.required<PublicMember>();
|
||||
readonly copy = input.required<Copy>();
|
||||
readonly locale = input.required<Locale>();
|
||||
|
||||
protected readonly links = computed(() => memberLinks(this.member()));
|
||||
|
||||
protected readonly since = computed(() => {
|
||||
const joined = this.member().joined_at;
|
||||
|
||||
return joined
|
||||
? new Intl.DateTimeFormat(this.locale(), {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}).format(new Date(joined))
|
||||
: null;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user