import { Component, computed, inject, input } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { RouterLink } from '@angular/router'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { marked } from 'marked'; import { handleImagePaste, insertAtSelection } from '../paste-image.util'; import { IssueComment, IssueEntity, IssuesStore } from '../issues.store'; import { StatusEntity, StatusesStore } from '../../settings/statuses/statuses.store'; import { MilestonesStore } from '../../milestones/milestones.store'; @Component({ selector: 'app-issue-comments', imports: [FormsModule, RouterLink], templateUrl: './issue-comments.html', styleUrl: './issue-comments.css', }) export class IssueComments { private readonly issuesStore = inject(IssuesStore); private readonly milestonesStore = inject(MilestonesStore); private readonly sanitizer = inject(DomSanitizer); private readonly statusesStore = inject(StatusesStore); readonly issueId = input.required(); protected readonly comments = computed( () => this.issuesStore.issues().find((i) => i.id === this.issueId())?.comments ?? [], ); protected readonly allOtherIssues = computed(() => this.issuesStore.issues().filter((i) => i.id !== this.issueId()), ); protected newCommentText = ''; protected editingCommentId: number | null = null; protected editingCommentText = ''; protected creatingTaskForCommentId: number | null = null; protected newTaskName = ''; protected linkingIssueForCommentId: number | null = null; protected issueSearchText = ''; protected parseMarkdown(text: string): SafeHtml { const html = marked.parse(text) as string; return this.sanitizer.bypassSecurityTrustHtml(html); } protected formatDate(iso: string): string { return new Date(iso).toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', }); } protected async addComment(): Promise { const text = this.newCommentText.trim(); if (!text) return; const issue = this.issuesStore.getById(this.issueId()); if (!issue) return; const nextId = Math.max(0, ...issue.comments.map((c) => c.id)) + 1; const comment: IssueComment = { id: nextId, text, createdAt: new Date().toISOString(), updatedAt: null, linkedIssueIds: [], }; await this.issuesStore.upsert({ ...issue, comments: [...issue.comments, comment] }); this.newCommentText = ''; } protected startEditComment(comment: IssueComment): void { this.editingCommentId = comment.id; this.editingCommentText = comment.text; this.creatingTaskForCommentId = null; this.linkingIssueForCommentId = null; } protected async saveEditComment(): Promise { const text = this.editingCommentText.trim(); if (!text || this.editingCommentId === null) return; const issue = this.issuesStore.getById(this.issueId()); if (!issue) return; const updatedComments = issue.comments.map((c) => c.id === this.editingCommentId ? { ...c, text, updatedAt: new Date().toISOString() } : c, ); await this.issuesStore.upsert({ ...issue, comments: updatedComments }); this.editingCommentId = null; this.editingCommentText = ''; } protected onPaste(event: ClipboardEvent, field: 'new' | 'edit'): void { const ta = event.target as HTMLTextAreaElement; const start = ta.selectionStart; const end = ta.selectionEnd; handleImagePaste(event, (md) => { if (field === 'new') { this.newCommentText = insertAtSelection(ta, this.newCommentText, start, end, md); } else { this.editingCommentText = insertAtSelection(ta, this.editingCommentText, start, end, md); } }); } protected cancelEditComment(): void { this.editingCommentId = null; this.editingCommentText = ''; } protected async deleteComment(id: number): Promise { const issue = this.issuesStore.getById(this.issueId()); if (!issue) return; await this.issuesStore.upsert({ ...issue, comments: issue.comments.filter((c) => c.id !== id) }); } protected getLinkedIssues(comment: IssueComment): IssueEntity[] { if (!comment.linkedIssueIds.length) return []; return comment.linkedIssueIds .map((id) => this.issuesStore.getById(id)) .filter((i): i is IssueEntity => i !== undefined); } protected filteredIssuesForLink(commentId: number): IssueEntity[] { const comment = this.comments().find((c) => c.id === commentId); const linkedIds = new Set(comment?.linkedIssueIds ?? []); const search = this.issueSearchText.toLowerCase(); return this.allOtherIssues().filter( (i) => !linkedIds.has(i.id) && (!search || i.name.toLowerCase().includes(search)), ); } protected typeIcon(type: IssueEntity['type']): { letter: string; bg: string } { const map: Record = { Epic: { letter: 'E', bg: '#7c3aed' }, Bug: { letter: 'B', bg: '#dc2626' }, Story: { letter: 'S', bg: '#16a34a' }, Task: { letter: 'T', bg: '#2563eb' }, Study: { letter: 'St', bg: '#6b7280' }, 'Technical Story': { letter: 'TS', bg: '#d97706' }, }; return map[type] ?? { letter: '?', bg: '#6b7280' }; } protected statusLabel(status: IssueEntity['status']): StatusEntity { return this.statusesStore.getById(status) ?? { id: status, label: status, bg: '#e2e8f0', color: '#475569', order: 99 }; } protected startCreateTask(commentId: number): void { this.creatingTaskForCommentId = commentId; this.newTaskName = ''; this.linkingIssueForCommentId = null; this.issueSearchText = ''; } protected cancelCreateTask(): void { this.creatingTaskForCommentId = null; this.newTaskName = ''; } protected async createTaskForComment(commentId: number): Promise { const name = this.newTaskName.trim(); if (!name) return; const issue = this.issuesStore.getById(this.issueId()); if (!issue) return; const newTask: IssueEntity = { id: 0, type: 'Task', name, assignee: '', epic: issue.epic, startDate: '', startDateMode: 'forced', endDate: '', dueDate: '', description: '', estimatedTime: null, dependsOnIds: [], comments: [], priority: 'MOYENNE', status: 'draft', progress: 0, }; const created = await this.issuesStore.upsert(newTask); const updatedComments = issue.comments.map((c) => { if (c.id !== commentId) return c; return { ...c, linkedIssueIds: [...c.linkedIssueIds, created.id] }; }); await this.issuesStore.upsert({ ...issue, comments: updatedComments }); const milestone = this.milestonesStore.milestones().find((m) => m.issueIds.includes(issue.id)); if (milestone) { await this.milestonesStore.upsert({ ...milestone, issueIds: [...milestone.issueIds, created.id] }); } this.creatingTaskForCommentId = null; this.newTaskName = ''; } protected startLinkIssue(commentId: number): void { this.linkingIssueForCommentId = commentId; this.issueSearchText = ''; this.creatingTaskForCommentId = null; this.newTaskName = ''; } protected cancelLinkIssue(): void { this.linkingIssueForCommentId = null; this.issueSearchText = ''; } protected async linkIssueToComment(commentId: number, issueId: number): Promise { const issue = this.issuesStore.getById(this.issueId()); if (!issue) return; const updatedComments = issue.comments.map((c) => { if (c.id !== commentId) return c; if (c.linkedIssueIds.includes(issueId)) return c; return { ...c, linkedIssueIds: [...c.linkedIssueIds, issueId] }; }); await this.issuesStore.upsert({ ...issue, comments: updatedComments }); this.linkingIssueForCommentId = null; this.issueSearchText = ''; } protected async unlinkIssueFromComment(commentId: number, issueId: number): Promise { const issue = this.issuesStore.getById(this.issueId()); if (!issue) return; const updatedComments = issue.comments.map((c) => { if (c.id !== commentId) return c; return { ...c, linkedIssueIds: c.linkedIssueIds.filter((id) => id !== issueId) }; }); await this.issuesStore.upsert({ ...issue, comments: updatedComments }); } }