feat: attachments on tickets and comments

Files are uploaded over REST, stored on disk (a volume in Docker) and
recorded on the ticket as events; the bytes are written first, so no
attachment ever points at a missing file. Only images, video, audio, PDF
and plain text are shown inline, everything with nosniff and a sandbox CSP.
The web shows thumbnails, uploads by drag and drop or by pasting a
screenshot, and attaches files to new comments.

Also adds `nx run api:contracts`, which writes schema.gql and
openapi.json without starting the API or any database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 08:35:33 +02:00
parent 80d9bee6e0
commit 0b65c1c912
72 changed files with 6766 additions and 4297 deletions

View File

@@ -0,0 +1,178 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
computed,
inject,
input,
OnInit,
signal,
} from '@angular/core';
import { PiIconButtonComponent, PiIconComponent } from '@pi/ui';
import { IssueFacade } from '@task-stream/web/tracker/application';
import { Attachment } from '@task-stream/web/tracker/domain';
import { timeAgo } from './copy';
/** Types the browser shows in a tab of its own; anything else is saved. */
const OPENS_IN_TAB =
/^(image\/(png|jpe?g|gif|webp|avif)|video\/(mp4|webm)|audio\/(mpeg|ogg|wav)|application\/pdf|text\/plain)$/;
const PREVIEWED = /^image\/(png|jpe?g|gif|webp|avif)$/;
export function readableSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
/**
* One file: a thumbnail for images, an icon otherwise. The bytes are fetched
* with the session's token (a plain link could not send it) and shown from a
* local object URL, released when the item goes.
*/
@Component({
selector: 'ts-attachment',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiIconComponent, PiIconButtonComponent],
template: `
<button
type="button"
class="ts-attachment__open"
[attr.aria-label]="'Abrir ' + attachment().fileName"
(click)="open()"
>
@if (thumbnail(); as src) {
<img class="ts-attachment__thumb" [src]="src" alt="" />
} @else {
<span class="ts-attachment__icon" aria-hidden="true">
<pi-icon [icon]="isImage() ? 'image' : 'document'" size="small" />
</span>
}
<span class="ts-attachment__text">
<span class="ts-attachment__name">{{ attachment().fileName }}</span>
<span class="ts-attachment__meta"
>{{ size() }} · {{ attachment().uploadedBy.name }} ·
{{ ago(attachment().uploadedAt) }}</span
>
</span>
</button>
@if (removable()) {
<pi-icon-button
icon="trash"
color="grey"
appearance="ghost"
[attr.aria-label]="'Quitar ' + attachment().fileName"
(execute)="remove()"
/>
}
`,
styles: `
:host {
display: flex;
align-items: center;
gap: 0.25rem;
min-width: 0;
}
.ts-attachment__open {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
flex: 1;
padding: 0.375rem;
border: 1px solid var(--ts-line, rgba(127, 127, 127, 0.25));
border-radius: 0.5rem;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.ts-attachment__open:hover,
.ts-attachment__open:focus-visible {
border-color: currentColor;
}
.ts-attachment__thumb,
.ts-attachment__icon {
flex: none;
width: 3rem;
height: 3rem;
border-radius: 0.375rem;
object-fit: cover;
display: grid;
place-items: center;
background: rgba(127, 127, 127, 0.12);
}
.ts-attachment__text {
display: grid;
min-width: 0;
}
.ts-attachment__name {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ts-attachment__meta {
font-size: 0.8125rem;
opacity: 0.7;
}
`,
})
export class AttachmentItem implements OnInit {
readonly attachment = input.required<Attachment>();
readonly removable = input(false);
private readonly facade = inject(IssueFacade);
private readonly destroyRef = inject(DestroyRef);
private objectUrl: string | null = null;
protected readonly thumbnail = signal<string | null>(null);
protected readonly isImage = computed(() =>
PREVIEWED.test(this.attachment().mimeType),
);
protected readonly size = computed(() =>
readableSize(this.attachment().size),
);
protected readonly ago = timeAgo;
ngOnInit(): void {
this.destroyRef.onDestroy(() => {
if (this.objectUrl) URL.revokeObjectURL(this.objectUrl);
});
// Small images only: a 20 MB screenshot is not worth a thumbnail.
if (this.isImage() && this.attachment().size <= 5 * 1024 * 1024) {
this.withUrl((url) => this.thumbnail.set(url));
}
}
protected open(): void {
this.withUrl((url) => {
const { fileName, mimeType } = this.attachment();
if (OPENS_IN_TAB.test(mimeType)) {
window.open(url, '_blank', 'noopener');
return;
}
const link = document.createElement('a');
link.href = url;
link.download = fileName;
link.click();
});
}
protected remove(): void {
if (confirm(`¿Quitar ${this.attachment().fileName}?`)) {
this.facade.removeAttachment(this.attachment().id);
}
}
private withUrl(use: (url: string) => void): void {
if (this.objectUrl) {
use(this.objectUrl);
return;
}
this.facade.fileOf(this.attachment()).subscribe((blob) => {
this.objectUrl = URL.createObjectURL(blob);
use(this.objectUrl);
});
}
}