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:
@@ -3,6 +3,7 @@ import {
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
HostListener,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
@@ -13,6 +14,7 @@ import { form, FormField, required } from '@angular/forms/signals';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import {
|
||||
PiActionButtonComponent,
|
||||
PiFileUploadComponent,
|
||||
PiIconButtonComponent,
|
||||
PiIconComponent,
|
||||
PiLabelDirective,
|
||||
@@ -21,15 +23,17 @@ import {
|
||||
PiSkeletonComponent,
|
||||
PiTextareaComponent,
|
||||
PiTextFieldComponent,
|
||||
type PiFileRejection,
|
||||
type PiSelectFieldOption,
|
||||
} from '@pi/ui';
|
||||
import { DrawerService } from '@task-stream/web/shared/ui';
|
||||
import { DrawerService, ToastService } from '@task-stream/web/shared/ui';
|
||||
import {
|
||||
IssueFacade,
|
||||
ProjectFacade,
|
||||
provideIssueFacade,
|
||||
} from '@task-stream/web/tracker/application';
|
||||
import {
|
||||
Attachment,
|
||||
Comment,
|
||||
IssueLink,
|
||||
IssueLinkType,
|
||||
@@ -46,8 +50,11 @@ import {
|
||||
timeAgo,
|
||||
typeLook,
|
||||
} from '../ui/copy';
|
||||
import { AttachmentItem } from '../ui/attachments';
|
||||
|
||||
const NONE = '';
|
||||
/** Same ceiling as the API's ATTACHMENTS_MAX_MB default. */
|
||||
const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
@Component({
|
||||
selector: 'ts-issue-page',
|
||||
@@ -55,7 +62,9 @@ const NONE = '';
|
||||
imports: [
|
||||
RouterLink,
|
||||
FormField,
|
||||
AttachmentItem,
|
||||
PiActionButtonComponent,
|
||||
PiFileUploadComponent,
|
||||
PiIconComponent,
|
||||
PiIconButtonComponent,
|
||||
PiLabelDirective,
|
||||
@@ -134,6 +143,47 @@ const NONE = '';
|
||||
}
|
||||
</article>
|
||||
|
||||
<article class="ts-block">
|
||||
<h2 class="ts-section">
|
||||
Adjuntos
|
||||
<span class="ts-mono">{{ ticketFiles().length }}</span>
|
||||
</h2>
|
||||
@if (ticketFiles().length) {
|
||||
<div class="ts-attachments">
|
||||
@for (file of ticketFiles(); track file.id) {
|
||||
<ts-attachment
|
||||
[attachment]="file"
|
||||
[removable]="canRemove(file)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
} @else if (!project.canContribute()) {
|
||||
<p class="ts-muted">Sin adjuntos.</p>
|
||||
}
|
||||
@if (project.canContribute()) {
|
||||
<div class="ts-form">
|
||||
<pi-file-upload
|
||||
label="Capturas, logs, vídeos…"
|
||||
hint="Arrastra aquí, elige, o pega una captura con Ctrl+V. Hasta 25 MB por archivo."
|
||||
[multiple]="true"
|
||||
[maxFiles]="10"
|
||||
[maxSizeBytes]="maxFileBytes"
|
||||
[(value)]="pendingFiles"
|
||||
(rejected)="rejected($event)"
|
||||
/>
|
||||
@if (pendingFiles().length) {
|
||||
<div>
|
||||
<pi-action-button
|
||||
color="primary"
|
||||
[text]="'Adjuntar ' + pendingFiles().length"
|
||||
(execute)="attach()"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
|
||||
<article class="ts-block">
|
||||
<h2 class="ts-section">Relaciones</h2>
|
||||
@for (
|
||||
@@ -238,6 +288,16 @@ const NONE = '';
|
||||
}
|
||||
</div>
|
||||
<p class="ts-prewrap">{{ comment.body }}</p>
|
||||
@if (filesOf(comment).length) {
|
||||
<div class="ts-attachments">
|
||||
@for (file of filesOf(comment); track file.id) {
|
||||
<ts-attachment
|
||||
[attachment]="file"
|
||||
[removable]="canRemove(file)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -251,6 +311,14 @@ const NONE = '';
|
||||
placeholder="Deja una decisión, una explicación, un paso para reproducir…"
|
||||
[(value)]="commentBody"
|
||||
/>
|
||||
<pi-file-upload
|
||||
label="Adjuntar al comentario"
|
||||
[multiple]="true"
|
||||
[maxFiles]="10"
|
||||
[maxSizeBytes]="maxFileBytes"
|
||||
[(value)]="commentFiles"
|
||||
(rejected)="rejected($event)"
|
||||
/>
|
||||
<div>
|
||||
<pi-action-button
|
||||
color="primary"
|
||||
@@ -436,6 +504,7 @@ export class IssuePage {
|
||||
protected readonly facade = inject(IssueFacade);
|
||||
protected readonly project = inject(ProjectFacade);
|
||||
private readonly drawer = inject(DrawerService);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly reasonForm =
|
||||
viewChild.required<TemplateRef<unknown>>('reasonForm');
|
||||
private readonly editForm =
|
||||
@@ -506,6 +575,21 @@ export class IssuePage {
|
||||
protected readonly linkType = signal<string | null>(IssueLinkType.RELATES_TO);
|
||||
protected readonly linkTarget = signal('');
|
||||
protected readonly commentBody = signal('');
|
||||
protected readonly commentFiles = signal<File[]>([]);
|
||||
protected readonly pendingFiles = signal<File[]>([]);
|
||||
protected readonly maxFileBytes = MAX_FILE_BYTES;
|
||||
|
||||
/** Files on the ticket itself; those under a comment are shown there. */
|
||||
protected readonly ticketFiles = computed(
|
||||
() => this.facade.issue()?.attachments.filter((a) => !a.commentId) ?? [],
|
||||
);
|
||||
protected filesOf = (comment: Comment): Attachment[] =>
|
||||
this.facade
|
||||
.issue()
|
||||
?.attachments.filter((a) => a.commentId === comment.id) ?? [];
|
||||
protected canRemove = (file: Attachment): boolean =>
|
||||
this.project.canContribute() &&
|
||||
(file.uploadedBy.id === this.facade.me() || this.project.canManage());
|
||||
|
||||
private pendingStatus: string | null = null;
|
||||
private readonly reasonModel = signal({ text: '' });
|
||||
@@ -590,8 +674,53 @@ export class IssuePage {
|
||||
protected addComment(): void {
|
||||
const body = this.commentBody().trim();
|
||||
if (!body) return;
|
||||
this.facade.addComment(body);
|
||||
this.facade.addComment(body, this.commentFiles());
|
||||
this.commentBody.set('');
|
||||
this.commentFiles.set([]);
|
||||
}
|
||||
|
||||
protected attach(): void {
|
||||
this.facade.attach(this.pendingFiles());
|
||||
this.pendingFiles.set([]);
|
||||
}
|
||||
|
||||
protected rejected(rejections: PiFileRejection[]): void {
|
||||
for (const { file, reason } of rejections) {
|
||||
this.toast.error(
|
||||
reason === 'size'
|
||||
? `${file.name} pasa de 25 MB`
|
||||
: reason === 'max-files'
|
||||
? 'Como mucho 10 archivos a la vez'
|
||||
: `${file.name} no es un tipo admitido`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** A screenshot pasted anywhere on the ticket is attached to it. */
|
||||
@HostListener('document:paste', ['$event'])
|
||||
protected pasted(event: ClipboardEvent): void {
|
||||
const target = event.target as HTMLElement | null;
|
||||
// Pasting text into a field is just typing.
|
||||
if (target?.closest('input, textarea, [contenteditable]')) return;
|
||||
if (!this.project.canContribute() || !this.facade.issue()) return;
|
||||
const files = [...(event.clipboardData?.files ?? [])].filter(
|
||||
(file) => file.size <= MAX_FILE_BYTES,
|
||||
);
|
||||
if (!files.length) return;
|
||||
event.preventDefault();
|
||||
this.facade.attach(
|
||||
files.map((file) =>
|
||||
file.name && file.name !== 'image.png'
|
||||
? file
|
||||
: new File(
|
||||
[file],
|
||||
`captura-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.png`,
|
||||
{
|
||||
type: file.type,
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
protected openEditComment(comment: Comment): void {
|
||||
|
||||
Reference in New Issue
Block a user