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>
336 lines
11 KiB
TypeScript
336 lines
11 KiB
TypeScript
import {
|
||
ChangeDetectionStrategy,
|
||
Component,
|
||
computed,
|
||
input,
|
||
} from '@angular/core';
|
||
import {
|
||
PiActionButtonComponent,
|
||
PiAvatarComponent,
|
||
PiChipComponent,
|
||
PiChipStatusComponent,
|
||
PiTextColorDirective,
|
||
PiTextLargeDirective,
|
||
PiTextWeightBoldDirective,
|
||
} from '@pi/ui';
|
||
|
||
import type { Locale } from '../core/api';
|
||
import type { PublicProject } from '../core/api.types';
|
||
import type { Copy } from '../core/copy';
|
||
import { ApiImage } from './api-image';
|
||
import { ContactCta } from './contact-cta';
|
||
import { PROJECT_STATUS_TONES } from './project-card';
|
||
|
||
/**
|
||
* A project on its own address: what it is, why it was built the way it was,
|
||
* what went wrong, who made it, and where the code is.
|
||
*
|
||
* `description`, `decisions` and `postmortem` are HTML from the panel's rich
|
||
* editor and go through Angular's sanitiser, as a page's body does.
|
||
*/
|
||
@Component({
|
||
selector: 'app-project-detail',
|
||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||
imports: [
|
||
ApiImage,
|
||
ContactCta,
|
||
PiActionButtonComponent,
|
||
PiAvatarComponent,
|
||
PiChipComponent,
|
||
PiChipStatusComponent,
|
||
PiTextColorDirective,
|
||
PiTextLargeDirective,
|
||
PiTextWeightBoldDirective,
|
||
],
|
||
styleUrl: './project-detail.scss',
|
||
template: `
|
||
<header class="content-hero">
|
||
<div class="landing-shell">
|
||
<p class="landing-eyebrow">{{ copy().project.details }}</p>
|
||
|
||
<h1 bold class="content-hero__title">{{ project().title }}</h1>
|
||
|
||
@if (project().tagline; as tagline) {
|
||
<p l pi-color color="grey" class="content-hero__lead">
|
||
{{ tagline }}
|
||
</p>
|
||
}
|
||
|
||
<ul class="content-hero__chips">
|
||
<li>
|
||
<pi-chip-status
|
||
size="small"
|
||
[status]="statusTone()"
|
||
[label]="copy().projectStatus[project().status]"
|
||
/>
|
||
</li>
|
||
@if (project().needs_help) {
|
||
<li>
|
||
<pi-chip
|
||
size="small"
|
||
appearance="outline"
|
||
color="accent"
|
||
[label]="copy().labels.needsHelp"
|
||
/>
|
||
</li>
|
||
}
|
||
@for (
|
||
technology of project().technologies ?? [];
|
||
track technology.id
|
||
) {
|
||
<li>
|
||
<pi-chip
|
||
size="small"
|
||
appearance="outline"
|
||
[label]="technology.name"
|
||
/>
|
||
</li>
|
||
}
|
||
</ul>
|
||
|
||
@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"
|
||
[text]="copy().labels.demo"
|
||
[hasIcon]="true"
|
||
/>
|
||
</a>
|
||
}
|
||
@if (repositoryUrl(); as repository) {
|
||
<a [href]="repository" target="_blank" rel="noopener">
|
||
<pi-action-button
|
||
appearance="outline"
|
||
color="grey"
|
||
[text]="copy().labels.repository"
|
||
[hasIcon]="true"
|
||
/>
|
||
</a>
|
||
}
|
||
</div>
|
||
}
|
||
</div>
|
||
</header>
|
||
|
||
<div class="landing-shell content-page__layout">
|
||
<article class="content-page__main">
|
||
@if (project().cover_image; as cover) {
|
||
<figure class="content-page__media">
|
||
<app-api-image
|
||
[image]="cover"
|
||
sizes="(min-width: 1024px) 46rem, 100vw"
|
||
loading="eager"
|
||
imgClass="content-page__image"
|
||
/>
|
||
</figure>
|
||
}
|
||
|
||
@if (project().description; as description) {
|
||
<div class="content-page__body" [innerHTML]="description"></div>
|
||
}
|
||
|
||
@if (project().decisions; as decisions) {
|
||
<section class="content-page__section">
|
||
<h2>{{ copy().project.decisions }}</h2>
|
||
<div class="content-page__body" [innerHTML]="decisions"></div>
|
||
</section>
|
||
}
|
||
|
||
@if (project().postmortem; as postmortem) {
|
||
<section class="content-page__section">
|
||
<h2>{{ copy().project.postmortem }}</h2>
|
||
<div class="content-page__body" [innerHTML]="postmortem"></div>
|
||
</section>
|
||
}
|
||
|
||
@if ((project().gallery ?? []).length > 0) {
|
||
<section class="content-page__section">
|
||
<h2>{{ copy().project.gallery }}</h2>
|
||
<div class="project-gallery">
|
||
@for (image of project().gallery; track image.url) {
|
||
<figure class="project-gallery__item">
|
||
<app-api-image
|
||
[image]="image"
|
||
sizes="(min-width: 1024px) 23rem, 100vw"
|
||
imgClass="project-gallery__image"
|
||
/>
|
||
@if (image.caption) {
|
||
<figcaption>{{ image.caption }}</figcaption>
|
||
}
|
||
</figure>
|
||
}
|
||
</div>
|
||
</section>
|
||
}
|
||
</article>
|
||
|
||
<aside class="content-page__aside">
|
||
<div class="landing-card content-page__panel">
|
||
<dl class="content-page__facts">
|
||
<div>
|
||
<dt>{{ copy().project.status }}</dt>
|
||
<dd>{{ copy().projectStatus[project().status] }}</dd>
|
||
</div>
|
||
@if (project().client; as client) {
|
||
<div>
|
||
<dt>{{ copy().project.client }}</dt>
|
||
<dd>
|
||
@if (client.url) {
|
||
<a [href]="client.url" target="_blank" rel="noopener">{{
|
||
client.name
|
||
}}</a>
|
||
} @else {
|
||
{{ client.name }}
|
||
}
|
||
</dd>
|
||
</div>
|
||
}
|
||
@if (period(); as period) {
|
||
<div>
|
||
<dt>{{ copy().project.period }}</dt>
|
||
<dd>{{ period }}</dd>
|
||
</div>
|
||
}
|
||
@if (project().lead; as lead) {
|
||
<div>
|
||
<dt>{{ copy().project.lead }}</dt>
|
||
<dd>
|
||
<a [href]="lead.path">{{ lead.name }}</a>
|
||
</dd>
|
||
</div>
|
||
}
|
||
</dl>
|
||
</div>
|
||
|
||
@if ((project().calls ?? []).length > 0) {
|
||
<nav [attr.aria-label]="copy().project.openCalls">
|
||
<p class="landing-eyebrow">{{ copy().project.openCalls }}</p>
|
||
<ul class="content-page__related">
|
||
@for (call of project().calls; track call.id) {
|
||
<li>
|
||
<a class="landing-card content-page__card" [href]="call.path">
|
||
<span class="content-page__card-title">{{
|
||
call.title
|
||
}}</span>
|
||
@if (call.hours_per_week) {
|
||
<span class="content-page__card-text">
|
||
{{ call.hours_per_week }}
|
||
{{ copy().labels.hoursPerWeek }}
|
||
</span>
|
||
}
|
||
</a>
|
||
</li>
|
||
}
|
||
</ul>
|
||
</nav>
|
||
}
|
||
|
||
@if ((project().members ?? []).length > 0) {
|
||
<div class="landing-card content-page__panel">
|
||
<h2>{{ copy().project.team }}</h2>
|
||
<ul class="project-team">
|
||
@for (member of project().members; track member.id) {
|
||
<li>
|
||
<a class="project-team__member" [href]="member.path">
|
||
<pi-avatar
|
||
size="small"
|
||
shape="circle"
|
||
color="primary"
|
||
icon="user"
|
||
[image]="member.avatar?.url"
|
||
[alt]="member.avatar?.alt"
|
||
[name]="member.name"
|
||
/>
|
||
<span>
|
||
<span class="project-team__name">{{ member.name }}</span>
|
||
@if (member.role) {
|
||
<span class="content-page__muted">{{
|
||
member.role
|
||
}}</span>
|
||
}
|
||
</span>
|
||
</a>
|
||
</li>
|
||
}
|
||
</ul>
|
||
</div>
|
||
}
|
||
|
||
@if ((project().repositories ?? []).length > 0) {
|
||
<div class="landing-card content-page__panel">
|
||
<h2>{{ copy().project.repositories }}</h2>
|
||
<ul class="project-repos">
|
||
@for (repository of project().repositories; track repository.id) {
|
||
<li>
|
||
@if (repository.url) {
|
||
<a [href]="repository.url" target="_blank" rel="noopener">{{
|
||
repository.full_name
|
||
}}</a>
|
||
} @else {
|
||
<span>{{ repository.full_name }}</span>
|
||
}
|
||
<span class="content-page__muted project-repos__stats">
|
||
★ {{ repository.stars }} · {{ repository.forks }}
|
||
{{ copy().project.forks }}
|
||
@if (repository.primary_language) {
|
||
· {{ repository.primary_language }}
|
||
}
|
||
</span>
|
||
@if (repository.is_archived) {
|
||
<pi-chip
|
||
size="small"
|
||
appearance="outline"
|
||
[label]="copy().project.archived"
|
||
/>
|
||
} @else if (repository.is_dormant) {
|
||
<pi-chip
|
||
size="small"
|
||
appearance="outline"
|
||
[label]="copy().project.dormant"
|
||
/>
|
||
}
|
||
</li>
|
||
}
|
||
</ul>
|
||
</div>
|
||
}
|
||
|
||
<app-contact-cta [copy]="copy()" [locale]="locale()" />
|
||
</aside>
|
||
</div>
|
||
`,
|
||
})
|
||
export class ProjectDetail {
|
||
readonly project = input.required<PublicProject>();
|
||
readonly copy = input.required<Copy>();
|
||
readonly locale = input.required<Locale>();
|
||
|
||
protected readonly statusTone = computed(
|
||
() => PROJECT_STATUS_TONES[this.project().status],
|
||
);
|
||
|
||
protected readonly repositoryUrl = computed(
|
||
() =>
|
||
(this.project().repositories ?? []).find((repository) => repository.url)
|
||
?.url ?? null,
|
||
);
|
||
|
||
/** "marzo de 2025 – junio de 2026", or just the start while it runs. */
|
||
protected readonly period = computed(() => {
|
||
const { started_at, ended_at } = this.project();
|
||
const format = (date?: string) =>
|
||
date
|
||
? new Intl.DateTimeFormat(this.locale(), {
|
||
month: 'long',
|
||
year: 'numeric',
|
||
}).format(new Date(date))
|
||
: null;
|
||
|
||
const [start, end] = [format(started_at), format(ended_at)];
|
||
|
||
return start && end ? `${start} – ${end}` : (start ?? end);
|
||
});
|
||
}
|