Files
landing-app/landing/src/app/ui/project-card.ts
2026-08-30 21:11:59 +02:00

149 lines
4.1 KiB
TypeScript

import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import {
PiCardBottomContentDirective,
PiCardCenterContentDirective,
PiCardComponent,
PiCardTopContentDirective,
PiChipComponent,
PiChipStatusComponent,
PiTextColorDirective,
PiTextSmallDirective,
PiTextViewerLinkComponent,
PiTextWeightBoldDirective,
} from '@pi/ui';
import type { Copy } from '../core/copy';
import type { PublicProject } from '../core/api.types';
import { ApiImage } from './api-image';
@Component({
selector: 'app-project-card',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ApiImage,
PiCardComponent,
PiCardTopContentDirective,
PiCardCenterContentDirective,
PiCardBottomContentDirective,
PiChipComponent,
PiChipStatusComponent,
PiTextViewerLinkComponent,
PiTextSmallDirective,
PiTextColorDirective,
PiTextWeightBoldDirective,
],
template: `
<pi-card
class="landing-card"
[hasShadow]="true"
[isStroked]="true"
gap="m"
padding="m"
innerPadding="none"
alignment="stretch"
context="public"
>
<pi-card-top-content>
@if (project().cover_image; as cover) {
<div class="landing-card__media">
<app-api-image [image]="cover" />
</div>
}
<div class="landing-card__chips">
<pi-chip-status
size="small"
[status]="statusTone()"
[label]="copy().projectStatus[project().status]"
/>
@if (project().needs_help) {
<pi-chip
size="small"
appearance="outline"
color="accent"
[label]="copy().labels.needsHelp"
/>
}
@if (stars() > 0) {
<pi-chip size="small" icon="star-fill" [label]="stars().toString()" />
}
</div>
</pi-card-top-content>
<pi-card-center-content>
<h3 bold class="landing-card__title">{{ project().title }}</h3>
@if (project().tagline; as tagline) {
<p s pi-color color="grey">{{ tagline }}</p>
}
@if (technologies().length > 0) {
<ul class="landing-card__chips">
@for (technology of technologies(); track technology.id) {
<li>
<pi-chip size="small" appearance="outline" [label]="technology.name" />
</li>
}
</ul>
}
</pi-card-center-content>
<pi-card-bottom-content>
<div class="landing-card__links">
@if (project().demo_url; as demo) {
<pi-text-viewer-link
icon="play-arrow"
iconSize="small"
[value]="copy().labels.demo"
[link]="demo"
target="_blank"
/>
}
@if (repositoryUrl(); as repository) {
<pi-text-viewer-link
icon="link"
iconSize="small"
[value]="copy().labels.repository"
[link]="repository"
target="_blank"
/>
}
</div>
</pi-card-bottom-content>
</pi-card>
`,
})
export class ProjectCard {
readonly project = input.required<PublicProject>();
readonly copy = input.required<Copy>();
/** Enough to read the stack at a glance; the rest is noise on a card. */
protected readonly technologies = computed(() =>
(this.project().technologies ?? []).slice(0, 5)
);
protected readonly stars = computed(() =>
(this.project().repositories ?? []).reduce(
(total, repository) => total + (repository.stars ?? 0),
0
)
);
protected readonly repositoryUrl = computed(
() => (this.project().repositories ?? []).find((repo) => repo.url)?.url ?? null
);
/** The API's five project states onto the chip's five tones. */
protected readonly statusTone = computed(() => {
const tones: Record<
PublicProject['status'],
'success' | 'error' | 'pending' | 'info' | 'disabled'
> = {
idea: 'info',
active: 'success',
paused: 'pending',
completed: 'info',
archived: 'disabled',
};
return tones[this.project().status];
});
}