Files
task-stream/libs/web/tracker/presenters/src/lib/ui/home/pinned.ts
Josep Piguillem dfa42a8a1a feat: pinned tickets, at hand on the home page and in search
Each person pins up to 20 tickets from the ticket's page («Fijar»). They
show as a strip under the projects on the home page and in the search
palette before typing. Private and without history, like the views of
«my work» (Mongo `pinned_issues`); tickets in the trash or out of reach
are left out, and when the list is full the unreadable ones are dropped
first.

API: GET /api/me/pins, PUT/DELETE /api/issues/{issue}/pin; GraphQL
myPins, pinIssue, unpinIssue and Issue.pinned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 10:15:28 +02:00

173 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
ChangeDetectionStrategy,
Component,
DestroyRef,
inject,
signal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { RouterLink } from '@angular/router';
import { PiIconComponent } from '@pi/ui';
import { ToastService } from '@task-stream/web/shared/ui';
import {
IssuesGateway,
IssueSummary,
StatusCategory,
} from '@task-stream/web/tracker/domain';
/**
* The tickets I pinned, at hand on the home page: one strip, only when
* there are some (they are pinned from the ticket's page).
*/
@Component({
selector: 'ts-pinned',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink, PiIconComponent],
template: `
@if (pins().length) {
<section class="ts-pinned" aria-labelledby="pinned-title">
<h2 id="pinned-title">
<pi-icon icon="pin" size="small" />
Fijados
</h2>
<ul role="list">
@for (pin of pins(); track pin.id) {
<li [class.is-done]="pin.statusCategory === done">
<a [routerLink]="['/p', projectOf(pin.key), 'issues', pin.key]">
<span class="ts-pinned__key">{{ pin.key }}</span>
<span class="ts-pinned__title">{{ pin.title }}</span>
</a>
<button
type="button"
class="ts-pinned__unpin"
[attr.aria-label]="'Quitar ' + pin.key + ' de fijados'"
title="Quitar de fijados"
(click)="unpin(pin)"
>
×
</button>
</li>
}
</ul>
</section>
}
`,
host: { '[style.display]': "pins().length ? 'block' : 'none'" },
styles: `
.ts-pinned {
display: flex;
align-items: center;
gap: 0.75rem 1rem;
flex-wrap: wrap;
}
h2 {
display: inline-flex;
align-items: center;
gap: 0.375rem;
margin: 0;
color: var(--ts-muted);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
ul {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
}
li {
display: inline-flex;
align-items: center;
max-width: min(100%, 22rem);
border: 1px solid var(--ts-line);
border-radius: 999px;
background: var(--ts-surface);
&:hover,
&:focus-within {
border-color: color-mix(in srgb, var(--ts-brand) 45%, var(--ts-line));
}
&.is-done .ts-pinned__title {
color: var(--ts-muted);
text-decoration: line-through;
}
}
a {
display: inline-flex;
align-items: baseline;
gap: 0.5rem;
min-width: 0;
padding: 0.375rem 0.25rem 0.375rem 0.875rem;
color: inherit;
font-size: 0.875rem;
text-decoration: none;
}
.ts-pinned__key {
flex: none;
color: var(--ts-muted);
font-family: var(--ts-font-mono);
font-size: 0.75rem;
}
.ts-pinned__title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* A plain glyph, not @pi/ui's sized button. */
.ts-pinned__unpin {
flex: none;
width: 1.75rem !important;
min-width: 0 !important;
height: 1.75rem !important;
margin-right: 0.25rem;
padding: 0 !important;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--ts-muted);
font-size: 1.125rem;
line-height: 1;
cursor: pointer;
&:hover,
&:focus-visible {
background: var(--ts-sunken);
color: var(--ts-ink);
}
}
`,
})
export class PinnedComponent {
private readonly gateway = inject(IssuesGateway);
private readonly toast = inject(ToastService);
private readonly destroyRef = inject(DestroyRef);
protected readonly pins = signal<IssueSummary[]>([]);
protected readonly done = StatusCategory.DONE;
constructor() {
this.gateway
.pins()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({ next: (pins) => this.pins.set(pins), error: () => null });
}
protected projectOf(key: string): string {
return key.slice(0, key.lastIndexOf('-'));
}
protected unpin(pin: IssueSummary): void {
this.pins.update((list) => list.filter((p) => p.id !== pin.id));
this.gateway
.pin(pin.id, false)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
error: () => {
this.toast.error(`No se pudo quitar ${pin.key} de fijados`);
this.pins.update((list) => [pin, ...list]);
},
});
}
}