Skip to content

Commit 86c3254

Browse files
asithadeclaude
andauthored
feat: add complete meeting attachments functionality with file upload (#29)
* refactor: rename projectId to projectUid Signed-off-by: Asitha de Silva <asithade@gmail.com> * feat: add complete meeting attachments functionality with file upload - Add file upload functionality to meeting creation form - Implement immediate file uploads with progress indicators - Create reusable file upload component wrapper for PrimeNG - Add attachment display in meeting cards with file type icons - Support for PDF, Word, Excel, PowerPoint, and image files - REST API integration with Supabase storage (no SDK required) - Created pipes for file type icons and file size formatting - Add attachment CRUD operations in backend API - Store attachment metadata in meeting_attachments table - Display attachments in meeting cards for all scenarios - Combine attachments and important links in unified resource section 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Asitha de Silva <asithade@gmail.com> * refactor: extract file upload constants to shared package - Extract duplicated allowed file types array to ALLOWED_FILE_TYPES constant - Extract file size limits to MAX_FILE_SIZE_BYTES constant - Move filename sanitization to reusable sanitizeFilename utility in utils folder - Remove code duplication between upload endpoints in meetings.ts Generated with [Claude Code](https://claude.ai/code) Signed-off-by: Asitha de Silva <asithade@gmail.com> * refactor: remove unused messages module Signed-off-by: Asitha de Silva <asithade@gmail.com> * feat: add Excel and PowerPoint file support to uploads - Add application/vnd.ms-excel and application/vnd.openxmlformats-officedocument.spreadsheetml.sheet for Excel files - Add application/vnd.ms-powerpoint and application/vnd.openxmlformats-officedocument.presentationml.presentation for PowerPoint files - File type icon pipe already supports these file types Generated with [Claude Code](https://claude.ai/code) Signed-off-by: Asitha de Silva <asithade@gmail.com> --------- Signed-off-by: Asitha de Silva <asithade@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 04a0e32 commit 86c3254

File tree

25 files changed

+1179
-56
lines changed

25 files changed

+1179
-56
lines changed

apps/lfx-pcc/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ QUERY_SERVICE_TOKEN=your-jwt-token-here
1818
# Get these from your Supabase project settings
1919
SUPABASE_URL=https://your-project.supabase.co
2020
POSTGRES_API_KEY=your-supabase-anon-key
21+
SUPABASE_STORAGE_BUCKET=meeting-attachments
2122

2223
# E2E Test Configuration (Optional)
2324
# Test user credentials for automated testing

apps/lfx-pcc/src/app/app.component.scss

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,18 @@
5858
.pill {
5959
@apply inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-full transition-colors text-gray-600 border border-gray-200 hover:bg-gray-50;
6060
}
61+
62+
.p-fileupload {
63+
.p-fileupload-content {
64+
@apply mx-3 mb-3 border border-dashed border-blue-300 rounded-md text-gray-500;
65+
66+
.p-fileupload-file-list {
67+
@apply hidden;
68+
}
69+
70+
p-progressbar {
71+
@apply hidden;
72+
}
73+
}
74+
}
6175
}

apps/lfx-pcc/src/app/modules/project/committees/components/committee-form/committee-form.component.html

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22
<!-- SPDX-License-Identifier: MIT -->
33

44
<form [formGroup]="form()" (ngSubmit)="onSubmit()" class="space-y-6">
5+
@if (!isEditing()) {
6+
<div class="bg-blue-50 shadow-md p-3 rounded-md text-sm">
7+
<p class="text-sm">
8+
Committees are a great way to connect community members who have shared interests. There are common committees like governing boards and technical
9+
oversight committees, but you can also create committees for special interest groups (SIGs) or working groups to tackle specific concerns for your
10+
project community.
11+
</p>
12+
</div>
13+
}
514
<!-- Basic Information Section -->
615
<div class="flex flex-col gap-3">
716
<h3 class="text-lg font-medium text-gray-900">Basic Information</h3>
@@ -45,19 +54,49 @@ <h3 class="text-lg font-medium text-gray-900">Basic Information</h3>
4554
<h3 class="text-lg font-medium text-gray-900">Settings</h3>
4655

4756
<div class="flex flex-col gap-2">
48-
<lfx-toggle size="small" [form]="form()" control="business_email_required" label="Business Email Required" id="business-email-toggle"></lfx-toggle>
57+
<lfx-toggle
58+
size="small"
59+
[form]="form()"
60+
control="business_email_required"
61+
label="Business Email Required"
62+
id="business-email-toggle"
63+
tooltip="If enabled, public emails like Gmail or Yahoo will require confirmation before being saved to limit their presence in the committee"
64+
tooltipPosition="right"></lfx-toggle>
4965

50-
<lfx-toggle size="small" [form]="form()" control="enable_voting" label="Enable Voting" id="voting-toggle"></lfx-toggle>
66+
<lfx-toggle
67+
size="small"
68+
[form]="form()"
69+
control="enable_voting"
70+
label="Enable Voting"
71+
id="voting-toggle"
72+
tooltip="Voting status for members can be enabled for the committee"
73+
tooltipPosition="right"></lfx-toggle>
5174

5275
<lfx-toggle size="small" [form]="form()" control="is_audit_enabled" label="Enable Audit" id="audit-toggle"></lfx-toggle>
76+
77+
<lfx-toggle
78+
size="small"
79+
[form]="form()"
80+
control="joinable"
81+
label="Joinable"
82+
id="joinable-toggle"
83+
tooltip="When enabled, users are able to join the committee through LFX Projects"
84+
tooltipPosition="right"></lfx-toggle>
5385
</div>
5486
</div>
5587

5688
<!-- Public Settings Section -->
5789
<div class="flex flex-col gap-3">
5890
<h3 class="text-lg font-medium text-gray-900">Public Settings</h3>
5991

60-
<lfx-toggle size="small" [form]="form()" control="public_enabled" label="Make Committee Public" id="public-toggle"></lfx-toggle>
92+
<lfx-toggle
93+
size="small"
94+
[form]="form()"
95+
control="public_enabled"
96+
label="Make Committee Public"
97+
id="public-toggle"
98+
tooltip="When enabled, the committee will be visible to the public on LFX tools like the public meeting calendar"
99+
tooltipPosition="right"></lfx-toggle>
61100

62101
<!-- Conditional Public Name Field -->
63102
<div *ngIf="form().get('public_enabled')?.value === true">

apps/lfx-pcc/src/app/modules/project/committees/components/committee-form/committee-form.component.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ export class CommitteeFormComponent {
184184
sso_group_name: new FormControl(committee?.sso_group_name || ''),
185185
committee_website: new FormControl(committee?.committee_website || '', [Validators.pattern(/^https?:\/\/.+\..+/)]),
186186
project_uid: new FormControl(committee?.project_uid || ''),
187+
joinable: new FormControl(committee?.joinable || false),
187188
});
188189
}
189190
}

apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-card/meeting-card.component.html

Lines changed: 48 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -103,30 +103,56 @@ <h3 class="text-xl font-display font-semibold text-gray-900 mb-3">
103103
}
104104
</div>
105105

106-
@if (meeting().agenda) {
107-
<div class="pb-3">
106+
<!-- Meeting Attachments Template -->
107+
@if (attachments().length > 0 || importantLinks().length > 0) {
108+
<div class="bg-gray-50 p-4 border border-gray-100 mb-4 rounded-md shadow-sm">
109+
<h4 class="text-sm font-semibold text-gray-700 mb-2 flex items-center gap-1">
110+
<i class="fa-light fa-link text-blue-500"></i>
111+
@if (attachments().length > 0) {
112+
<span>Attachments</span>
113+
@if (importantLinks().length > 0) {
114+
<span>&</span>
115+
}
116+
}
117+
@if (importantLinks().length > 0) {
118+
<span>Important Links</span>
119+
}
120+
</h4>
121+
108122
<!-- Important Links -->
109-
@if (importantLinks().length > 0) {
110-
<div class="mb-4">
111-
<h4 class="text-sm font-semibold text-gray-700 mb-2 flex items-center gap-2">
112-
<i class="fa-light fa-link text-blue-500"></i>
113-
Important Links
114-
</h4>
115-
<div class="flex flex-wrap gap-2">
116-
@for (link of importantLinks(); track link.url) {
117-
<a
118-
[href]="link.url"
119-
target="_blank"
120-
rel="noopener noreferrer"
121-
class="cursor-pointer inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-md transition-colors"
122-
[title]="link.url">
123-
<i class="fa-light fa-external-link"></i>
124-
{{ link.domain }}
125-
</a>
126-
}
127-
</div>
123+
<div>
124+
<div class="flex flex-wrap gap-2">
125+
@for (attachment of attachments(); track attachment.id) {
126+
<a
127+
[href]="attachment.file_url"
128+
target="_blank"
129+
rel="noopener noreferrer"
130+
class="cursor-pointer inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-600 bg-blue-50 hover:bg-blue-100 rounded-md transition-colors"
131+
[title]="attachment.file_name + ' (' + (attachment.file_size || 0 | fileSize) + ')'">
132+
<i [class]="attachment.mime_type || '' | fileTypeIcon"></i>
133+
<span class="truncate max-w-[120px]">{{ attachment.file_name }}</span>
134+
<span class="text-gray-400 ml-1">({{ attachment.file_size || 0 | fileSize }})</span>
135+
</a>
136+
}
137+
138+
@for (link of importantLinks(); track link.url) {
139+
<a
140+
[href]="link.url"
141+
target="_blank"
142+
rel="noopener noreferrer"
143+
class="cursor-pointer inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-md transition-colors"
144+
[title]="link.url">
145+
<i class="fa-light fa-link"></i>
146+
{{ link.domain }}
147+
</a>
148+
}
128149
</div>
129-
}
150+
</div>
151+
</div>
152+
}
153+
154+
@if (meeting().agenda) {
155+
<div class="pb-3">
130156
<lfx-expandable-text [maxHeight]="100">
131157
<div class="flex flex-col gap-4">
132158
<div [innerHTML]="meeting().agenda | linkify"></div>

apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-card/meeting-card.component.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,18 @@
22
// SPDX-License-Identifier: MIT
33

44
import { CommonModule } from '@angular/common';
5-
import { Component, computed, effect, inject, Injector, input, output, runInInjectionContext, signal, Signal, WritableSignal } from '@angular/core';
5+
import { Component, computed, effect, inject, Injector, input, OnInit, output, runInInjectionContext, signal, Signal, WritableSignal } from '@angular/core';
66
import { toSignal } from '@angular/core/rxjs-interop';
77
import { RouterLink } from '@angular/router';
8+
import { FileSizePipe } from '@app/shared/pipes/file-size.pipe';
9+
import { FileTypeIconPipe } from '@app/shared/pipes/file-type-icon.pipe';
810
import { LinkifyPipe } from '@app/shared/pipes/linkify.pipe';
911
import { AvatarComponent } from '@components/avatar/avatar.component';
1012
import { BadgeComponent } from '@components/badge/badge.component';
1113
import { ButtonComponent } from '@components/button/button.component';
1214
import { ExpandableTextComponent } from '@components/expandable-text/expandable-text.component';
1315
import { MenuComponent } from '@components/menu/menu.component';
14-
import { extractUrlsWithDomains, Meeting, MeetingParticipant } from '@lfx-pcc/shared';
16+
import { extractUrlsWithDomains, Meeting, MeetingAttachment, MeetingParticipant } from '@lfx-pcc/shared';
1517
import { MeetingTimePipe } from '@pipes/meeting-time.pipe';
1618
import { CommitteeService } from '@services/committee.service';
1719
import { MeetingService } from '@services/meeting.service';
@@ -45,12 +47,14 @@ import { RecurringEditOption, RecurringMeetingEditOptionsComponent } from '../re
4547
ConfirmDialogModule,
4648
ExpandableTextComponent,
4749
LinkifyPipe,
50+
FileTypeIconPipe,
51+
FileSizePipe,
4852
],
4953
providers: [ConfirmationService],
5054
templateUrl: './meeting-card.component.html',
5155
styleUrl: './meeting-card.component.scss',
5256
})
53-
export class MeetingCardComponent {
57+
export class MeetingCardComponent implements OnInit {
5458
private readonly projectService = inject(ProjectService);
5559
private readonly meetingService = inject(MeetingService);
5660
private readonly committeeService = inject(CommitteeService);
@@ -70,19 +74,26 @@ export class MeetingCardComponent {
7074
public participantsLabel: Signal<string> = this.initParticipantsLabel();
7175
public additionalParticipantsCount: WritableSignal<number> = signal(0);
7276
public actionMenuItems: Signal<MenuItem[]> = this.initializeActionMenuItems();
77+
public attachments: Signal<MeetingAttachment[]> = signal([]);
7378

7479
public readonly meetingDeleted = output<void>();
7580
public readonly project = this.projectService.project;
7681

7782
// Extract important links from agenda
7883
public readonly importantLinks = this.initImportantLinks();
7984

85+
// Meeting attachments
86+
8087
public constructor() {
8188
effect(() => {
8289
this.meeting.set(this.meetingInput());
8390
});
8491
}
8592

93+
public ngOnInit(): void {
94+
this.attachments = this.initAttachments();
95+
}
96+
8697
public onParticipantsToggle(event: Event): void {
8798
event.stopPropagation();
8899

@@ -95,8 +106,6 @@ export class MeetingCardComponent {
95106
// Show/hide inline participants display
96107
this.participantsLoading.set(true);
97108

98-
// Show/hide inline participants display
99-
this.participantsLoading.set(true);
100109
if (!this.showParticipants()) {
101110
this.initParticipantsList();
102111
}
@@ -406,4 +415,10 @@ export class MeetingCardComponent {
406415
return extractUrlsWithDomains(agenda);
407416
});
408417
}
418+
419+
private initAttachments(): Signal<MeetingAttachment[]> {
420+
return runInInjectionContext(this.injector, () => {
421+
return toSignal(this.meetingService.getMeetingAttachments(this.meetingInput().id).pipe(catchError(() => of([]))), { initialValue: [] });
422+
});
423+
}
409424
}

apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-form/meeting-form.component.html

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,71 @@ <h3 class="text-lg font-medium text-gray-900">Meeting Settings</h3>
302302
</div>
303303
}
304304

305+
@if (!isEditing()) {
306+
<div class="flex flex-col gap-2">
307+
<div class="flex items-center gap-2">
308+
<label for="demo[]" class="block text-sm font-medium text-gray-700">Upload Meeting Attachments</label>
309+
<i
310+
class="fa-light fa-info-circle text-gray-400 cursor-help"
311+
pTooltip="Upload any meeting materials that will be shared with participants"
312+
tooltipPosition="right"
313+
tooltipStyleClass="text-xs max-w-xs"></i>
314+
</div>
315+
<lfx-file-upload
316+
name="attachments[]"
317+
[customUpload]="true"
318+
(onSelect)="onFileSelect($event)"
319+
[multiple]="true"
320+
accept="image/*, application/pdf, application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document"
321+
[maxFileSize]="10000000"
322+
[showUploadButton]="false"
323+
[showCancelButton]="true"
324+
chooseStyleClass="p-button-sm"
325+
cancelStyleClass="p-button-sm"
326+
mode="advanced"
327+
chooseLabel="Choose Files"
328+
cancelLabel="Clear">
329+
<ng-template #content>
330+
<div class="text-sm flex items-center justify-center">Drag and drop files to here to upload.</div>
331+
</ng-template>
332+
</lfx-file-upload>
333+
334+
<!-- Show pending attachments -->
335+
@if (pendingAttachments().length > 0) {
336+
<div class="mt-4 space-y-2">
337+
<h4 class="text-sm font-medium text-gray-900">Selected Files:</h4>
338+
@for (attachment of pendingAttachments(); track attachment.id) {
339+
<div class="flex items-center justify-between p-2 bg-gray-50 rounded-lg">
340+
<div class="flex items-center space-x-2">
341+
@if (attachment.uploading) {
342+
<i class="pi pi-spin pi-spinner text-blue-500"></i>
343+
} @else if (attachment.uploadError) {
344+
<i class="pi pi-exclamation-triangle text-red-500"></i>
345+
} @else {
346+
<i class="pi pi-check-circle text-green-500"></i>
347+
}
348+
<span class="text-sm text-gray-700">{{ attachment.fileName }}</span>
349+
<span class="text-xs text-gray-500">({{ attachment.fileSize / 1024 / 1024 | number: '1.1-1' }}MB)</span>
350+
</div>
351+
<div class="flex items-center space-x-2">
352+
@if (attachment.uploading) {
353+
<span class="text-xs text-blue-600">Uploading...</span>
354+
} @else if (attachment.uploadError) {
355+
<span class="text-xs text-red-600">{{ attachment.uploadError }}</span>
356+
} @else {
357+
<span class="text-xs text-green-600">Ready</span>
358+
}
359+
<button type="button" (click)="removePendingAttachment(attachment.id)" class="text-red-500 hover:text-red-700">
360+
<i class="pi pi-times text-xs"></i>
361+
</button>
362+
</div>
363+
</div>
364+
}
365+
</div>
366+
}
367+
</div>
368+
}
369+
305370
<!-- Form Actions -->
306371
<div class="flex justify-end gap-3 pt-6 border-t">
307372
<lfx-button label="Cancel" severity="secondary" [outlined]="true" (click)="onCancel()" size="small" type="button"></lfx-button>

0 commit comments

Comments
 (0)