feat: formatted text, a list view, ticket details, public proposals and votes

Formatted text
- Descriptions, comments, replies and release notes render Markdown,
  sanitised with DOMPurify (tested against scripts, handlers and
  javascript: links). A pasted image is uploaded and written as
  ![name](attachment:<id>); the web fetches it with the reader's session,
  so it works on private projects. Every text box has a preview. The
  attachment upload now answers its attachmentId.

List view
- /p/:key/list: every ticket as rows, sortable by any column, filtered by
  text, open/closed, type, priority and assignee; status, priority and
  assignee change in place; bulk edit (the backlog's bar, now shared).

Ticket details
- Reactions on comments (GitHub's eight), for members and viewers.
- Duplicate a ticket: what it says, not where it stands.
- blockedBy: open tickets it waits for (BLOCKS / DEPENDS_ON), worked out
  when reading with one query per board or page; shown on cards, list
  rows and the ticket.

Public proposals
- A public project can take bug reports and ideas from anyone
  (acceptsProposals): POST /api/explore/projects/{project}/proposals and
  the form at /p/:key/proponer. They become tickets labelled «propuesta»,
  opened by TaskStream; a honeypot field and 5 per address every 15
  minutes keep spam out. No e-mail is asked: it would be public.

Votes
- Anyone signed in votes on a public project's tickets, members on a
  private one (voteIssue, PUT/DELETE /api/issues/{issue}/vote): votes and
  voted on each ticket, buttons on the ticket and the roadmap, a column
  in the list. The showcase carries votes and the open proposals.

MCP: vote_issue, duplicate_issue, react_to_comment; listings show
blockers and votes. Docs, Word and PDF updated; chat notifications
(Discord, Slack, Telegram) noted for later.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 21:36:11 +02:00
parent 298766ad1a
commit f2001aca88
116 changed files with 17997 additions and 650 deletions

View File

@@ -12,7 +12,7 @@ import {
viewChild,
} from '@angular/core';
import { form, FormField, required } from '@angular/forms/signals';
import { RouterLink } from '@angular/router';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import {
PiActionButtonComponent,
PiCheckboxComponent,
@@ -47,9 +47,9 @@ import {
isTyping,
matches,
mentionQuery,
mentionSegments,
POINT_SCALE,
ReleaseStatus,
REACTIONS,
} from '@task-stream/web/tracker/domain';
import {
clock,
@@ -63,6 +63,13 @@ import {
typeLook,
} from '../ui/copy';
import { AttachmentItem } from '../ui/attachments';
import {
AttachmentLoader,
ImageUploader,
MarkdownComponent,
MarkdownHintComponent,
PasteImagesDirective,
} from '../ui/markdown';
const NONE = '';
/** Same ceiling as the API's ATTACHMENTS_MAX_MB default. */
@@ -72,6 +79,9 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
selector: 'ts-issue-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
MarkdownComponent,
MarkdownHintComponent,
PasteImagesDirective,
NgTemplateOutlet,
RouterLink,
FormField,
@@ -133,10 +143,46 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
<span class="ts-parent__title">{{ parent.title }}</span>
</a>
}
@if (issue.blockedBy.length) {
<span
class="ts-blocked"
role="status"
[title]="blockedTitle(issue.blockedBy)"
>
<pi-icon icon="lock" size="x-small" aria-hidden="true" />
Bloqueado por
@for (blocker of issue.blockedBy; track blocker.id) {
<a class="ts-key" [routerLink]="['..', blocker.key]">{{
blocker.key
}}</a>
}
</span>
}
</div>
<div class="ts-issue__title-row">
<h1>{{ issue.title }}</h1>
<div class="ts-issue__actions">
@if (facade.me()) {
<pi-icon-button
icon="arrow-up"
color="grey"
appearance="outline"
[text]="
(issue.voted ? 'Votado · ' : 'Votar · ') + issue.votes
"
[attr.aria-pressed]="issue.voted"
[attr.title]="
issue.voted
? 'Quitar mi voto'
: 'Votar: dice al equipo que lo quieres'
"
(execute)="facade.vote(!issue.voted)"
/>
} @else if (issue.votes) {
<span class="ts-votes ts-mono" title="Votos">
▲ {{ issue.votes }}
</span>
}
@if (project.myRole()) {
<pi-icon-button
[icon]="facade.watching() ? 'eye-off' : 'eye'"
@@ -165,6 +211,14 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
title="Editar (tecla E)"
(execute)="openEdit()"
/>
<pi-icon-button
icon="copy"
color="grey"
appearance="outline"
text="Duplicar"
title="Crear otro ticket con lo mismo (sin estado, persona ni comentarios)"
(execute)="duplicate()"
/>
}
</div>
</div>
@@ -190,7 +244,11 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
<article class="ts-block">
<h2 class="ts-section">Descripción</h2>
@if (issue.description) {
<p class="ts-prewrap">{{ issue.description }}</p>
<ts-markdown
[text]="issue.description"
[me]="facade.me()"
[loader]="loadImage"
/>
} @else {
<p class="ts-muted">Sin descripción.</p>
}
@@ -539,7 +597,9 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
}}{{ comment.editedAt ? ' · editado' : '' }}</span
>
<span class="ts-spacer"></span>
@if (!comment.deleted && comment.author.id === facade.me()) {
@if (
!comment.deleted && comment.author.id === facade.me()
) {
<pi-icon-button
icon="edit"
color="grey"
@@ -564,19 +624,60 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
@if (comment.deleted) {
<p class="ts-muted ts-comment__gone">Comentario borrado</p>
} @else {
<p class="ts-prewrap">
@for (part of segments(comment.body); track $index) {
@if ('mention' in part) {
<span
class="ts-mention"
[class.is-me]="part.userId === facade.me()"
>@{{ part.mention }}</span
<ts-markdown
[text]="comment.body"
[me]="facade.me()"
[loader]="loadImage"
/>
}
@if (
!comment.deleted &&
(comment.reactions.length || project.myRole())
) {
<div class="ts-reactions">
@for (
reaction of comment.reactions;
track reaction.emoji
) {
<button
type="button"
class="ts-reaction"
[class.is-mine]="reacted(reaction)"
[attr.aria-pressed]="reacted(reaction)"
[title]="reactionTitle(reaction)"
[disabled]="!project.myRole()"
(click)="
facade.react(
comment.id,
reaction.emoji,
!reacted(reaction)
)
"
>
} @else {
{{ part.text }}
{{ reaction.emoji }}
<span class="ts-mono">{{
reaction.users.length
}}</span>
</button>
}
}
</p>
@if (project.myRole()) {
<details class="ts-reaction-picker">
<summary title="Reaccionar" aria-label="Reaccionar">
<pi-icon icon="add" size="x-small" />
</summary>
<div class="ts-reaction-picker__menu">
@for (emoji of reactions; track emoji) {
<button
type="button"
(click)="pickReaction(comment, emoji, $event)"
>
{{ emoji }}
</button>
}
</div>
</details>
}
</div>
}
@if (!comment.deleted && filesOf(comment).length) {
<div class="ts-attachments">
@@ -593,12 +694,18 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
</ng-template>
@for (thread of threads(); track thread.comment.id) {
<ng-container
*ngTemplateOutlet="commentView; context: { $implicit: thread.comment }"
*ngTemplateOutlet="
commentView;
context: { $implicit: thread.comment }
"
/>
<div class="ts-replies">
@for (reply of thread.replies; track reply.id) {
<ng-container
*ngTemplateOutlet="commentView; context: { $implicit: reply }"
*ngTemplateOutlet="
commentView;
context: { $implicit: reply }
"
/>
}
@if (replyingTo() === thread.comment.id) {
@@ -614,6 +721,13 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
'… Escribe @ para mencionar a alguien.'
"
[(value)]="replyBody"
[tsPasteImages]="pasteImage"
/>
<ts-markdown-hint
[text]="replyBody()"
[me]="facade.me()"
[loader]="loadImage"
[images]="true"
/>
@if (replyMentionOptions().length) {
<div
@@ -621,15 +735,20 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
role="group"
aria-label="Mencionar a"
>
@for (member of replyMentionOptions(); track member.id) {
@for (
member of replyMentionOptions();
track member.id
) {
<button
type="button"
class="ts-mentions__option"
(click)="mentionInReply(member)"
>
<span class="ts-comment__avatar" aria-hidden="true">{{
initialsOf(member.name)
}}</span>
<span
class="ts-comment__avatar"
aria-hidden="true"
>{{ initialsOf(member.name) }}</span
>
{{ member.name }}
</button>
}
@@ -651,7 +770,9 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
</button>
</div>
</form>
} @else if (project.canContribute() && !thread.comment.deleted) {
} @else if (
project.canContribute() && !thread.comment.deleted
) {
<button
type="button"
class="ts-plain-link ts-reply__open"
@@ -675,6 +796,13 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
<pi-textarea
placeholder="Deja una decisión, una explicación, un paso para reproducir… Escribe @ para mencionar a alguien."
[(value)]="commentBody"
[tsPasteImages]="pasteImage"
/>
<ts-markdown-hint
[text]="commentBody()"
[me]="facade.me()"
[loader]="loadImage"
[images]="true"
/>
@if (mentionOptions().length) {
<div
@@ -881,9 +1009,18 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
<pi-text-field [formField]="edit.title">
<pi-label [isRequired]="true">Título</pi-label>
</pi-text-field>
<pi-textarea [formField]="edit.description">
<pi-textarea
[formField]="edit.description"
[tsPasteImages]="pasteImage"
>
<pi-label>Descripción</pi-label>
</pi-textarea>
<ts-markdown-hint
[text]="editModel().description"
[me]="facade.me()"
[loader]="loadImage"
[images]="true"
/>
<pi-text-field [formField]="edit.labels" placeholder="safari, checkout">
<pi-label>Etiquetas (separadas por comas)</pi-label>
</pi-text-field>
@@ -909,7 +1046,16 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
(submit)="$event.preventDefault(); saveComment()"
novalidate
>
<pi-textarea [formField]="commentEdit.body" />
<pi-textarea
[formField]="commentEdit.body"
[tsPasteImages]="pasteImage"
/>
<ts-markdown-hint
[text]="commentEditModel().body"
[me]="facade.me()"
[loader]="loadImage"
[images]="true"
/>
<pi-action-button
size="w100"
color="primary"
@@ -929,6 +1075,8 @@ export class IssuePage {
protected readonly project = inject(ProjectFacade);
private readonly drawer = inject(DrawerService);
private readonly toast = inject(ToastService);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly reasonForm =
viewChild.required<TemplateRef<unknown>>('reasonForm');
private readonly editForm =
@@ -1046,7 +1194,48 @@ export class IssuePage {
() => this.facade.comments().filter((c) => !c.deleted).length,
);
protected readonly commentFiles = signal<File[]>([]);
protected readonly segments = mentionSegments;
protected readonly reactions = REACTIONS;
protected reacted(reaction: { users: { id: string }[] }): boolean {
return reaction.users.some((u) => u.id === this.facade.me());
}
protected reactionTitle(reaction: {
emoji: string;
users: { name: string }[];
}): string {
return `${reaction.users.map((u) => u.name).join(', ')} · ${reaction.emoji}`;
}
/** From the picker: adds it, or takes it back if it was already there. */
protected pickReaction(comment: Comment, emoji: string, event: Event): void {
const mine = comment.reactions.find((r) => r.emoji === emoji);
this.facade.react(comment.id, emoji, !(mine && this.reacted(mine)));
(event.target as HTMLElement).closest('details')?.removeAttribute('open');
}
protected blockedTitle(blockers: { key: string; title: string }[]): string {
return blockers.map((b) => `${b.key}: ${b.title}`).join('\n');
}
protected duplicate(): void {
this.facade.duplicate().subscribe({
next: (key) => {
this.toast.success(
key ? `Creado ${key}, copia de este` : 'Copia creada',
);
if (key)
void this.router.navigate(['..', key], { relativeTo: this.route });
},
error: () => this.toast.error('No se pudo duplicar'),
});
}
/** Images inside descriptions and comments: fetched, and uploaded on paste. */
protected readonly loadImage: AttachmentLoader = (id) =>
this.facade.fileById(id);
protected readonly pasteImage: ImageUploader = (file) =>
this.facade.uploadImage(file);
/** Members matching what is typed after an `@` at the end of the comment. */
protected readonly mentionOptions = computed(() =>
this.membersMatching(this.commentBody()),
@@ -1111,7 +1300,7 @@ export class IssuePage {
private readonly reasonModel = signal({ text: '' });
protected readonly reason = form(this.reasonModel, (p) => required(p.text));
private readonly editModel = signal({
protected readonly editModel = signal({
title: '',
description: '',
labels: '',
@@ -1120,7 +1309,7 @@ export class IssuePage {
protected readonly edit = form(this.editModel, (p) => required(p.title));
private editingComment: string | null = null;
private readonly commentEditModel = signal({ body: '' });
protected readonly commentEditModel = signal({ body: '' });
protected readonly commentEdit = form(this.commentEditModel, (p) =>
required(p.body),
);