From 5b9fa3149c5abc8463edb6f2ece3e327b1c569e3 Mon Sep 17 00:00:00 2001 From: CSantosM <4a.santos@gmail.com> Date: Mon, 2 Mar 2026 18:18:24 +0100 Subject: [PATCH] frontend: add room members list component with filtering and selection features - Implemented RoomMembersListComponent for displaying room members in a Material Design table. - Added SCSS styles for the room members list and its associated elements. - Created AddRoomMemberComponent for adding new members to a room with role and permission configuration. - Integrated user search functionality with autocomplete in the AddRoomMemberComponent. - Updated RoomDetailComponent to utilize the new RoomMembersListComponent for displaying members. - Defined routing for adding room members. - Enhanced overall user experience with loading states and error handling. --- .../domains/console/routes/console.routes.ts | 2 + .../domains/room-members/components/index.ts | 1 + .../room-members-list.component.html | 302 ++++++++++++++++++ .../room-members-list.component.scss | 209 ++++++++++++ .../room-members-list.component.ts | 286 +++++++++++++++++ .../src/lib/domains/room-members/index.ts | 2 + .../add-room-member.component.html | 172 ++++++++++ .../add-room-member.component.scss | 263 +++++++++++++++ .../add-room-member.component.ts | 189 +++++++++++ .../routes/room-members.routes.ts | 19 ++ .../room-detail/room-detail.component.html | 77 +---- 11 files changed, 1457 insertions(+), 65 deletions(-) create mode 100644 meet-ce/frontend/projects/shared-meet-components/src/lib/domains/room-members/components/index.ts create mode 100644 meet-ce/frontend/projects/shared-meet-components/src/lib/domains/room-members/components/room-members-list/room-members-list.component.html create mode 100644 meet-ce/frontend/projects/shared-meet-components/src/lib/domains/room-members/components/room-members-list/room-members-list.component.scss create mode 100644 meet-ce/frontend/projects/shared-meet-components/src/lib/domains/room-members/components/room-members-list/room-members-list.component.ts create mode 100644 meet-ce/frontend/projects/shared-meet-components/src/lib/domains/room-members/pages/add-room-member/add-room-member.component.html create mode 100644 meet-ce/frontend/projects/shared-meet-components/src/lib/domains/room-members/pages/add-room-member/add-room-member.component.scss create mode 100644 meet-ce/frontend/projects/shared-meet-components/src/lib/domains/room-members/pages/add-room-member/add-room-member.component.ts create mode 100644 meet-ce/frontend/projects/shared-meet-components/src/lib/domains/room-members/routes/room-members.routes.ts diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/console/routes/console.routes.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/console/routes/console.routes.ts index 328e5462..39d869d5 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/console/routes/console.routes.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/console/routes/console.routes.ts @@ -2,6 +2,7 @@ import { Route } from '@angular/router'; import { DomainRouteConfig } from '../../../shared/models/domain-routes.model'; import { checkUserAuthenticatedGuard } from '../../auth/guards/auth.guard'; import { recordingsConsoleRoutes } from '../../recordings/routes/recordings.routes'; +import { roomMembersConsoleRoutes } from '../../room-members/routes/room-members.routes'; import { roomsConsoleRoutes } from '../../rooms/routes/rooms.routes'; import { usersConsoleRoutes } from '../../users/routes/users.routes'; import { clearRoomSessionGuard } from '../guards/clear-room-session.guard'; @@ -24,6 +25,7 @@ export const consoleChildRoutes: DomainRouteConfig[] = [ } }, ...roomsConsoleRoutes, + ...roomMembersConsoleRoutes, ...recordingsConsoleRoutes, ...usersConsoleRoutes, { diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/room-members/components/index.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/room-members/components/index.ts new file mode 100644 index 00000000..1f970ac7 --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/room-members/components/index.ts @@ -0,0 +1 @@ +export * from './room-members-list/room-members-list.component'; diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/room-members/components/room-members-list/room-members-list.component.html b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/room-members/components/room-members-list/room-members-list.component.html new file mode 100644 index 00000000..5a5a853c --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/room-members/components/room-members-list/room-members-list.component.html @@ -0,0 +1,302 @@ +@if (!loading() && members().length === 0 && !showEmptyFilterMessage) { + +
+
+ group_off +

No members yet

+

This room doesn't have any members assigned.

+
+ +
+
+
+} @else { + + + +
+ @if (showSearchBox()) { + + Search members + + + + } +
+ + + @if (showSelection() && selectedMembers().size > 0) { +
+
+ +
+
+ } + + +
+ @if (hasActiveFilters()) { + + } + + +
+
+ + + @if (loading()) { +
+ + Loading members... +
+ } @else if (members().length === 0 && showEmptyFilterMessage) { + +
+
+

No members match your search

+

Try adjusting or clearing your filters to see more members.

+
+ +
+
+
+ } @else { + +
+ + + @if (showSelection()) { + + + + + } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + Member + +
+
+ {{ getMemberInitials(member) }} +
+
+ {{ member.name }} + {{ member.memberId }} +
+
+
Role + + {{ member.baseRole }} + + Type +
+ {{ getMemberTypeIcon(member) }} + {{ getMemberTypeLabel(member) }} +
+
+ Added + + @if (member.membershipDate) { +
+ {{ member.membershipDate | date: 'mediumDate' }} + {{ member.membershipDate | date: 'shortTime' }} +
+ } @else { + - + } +
+ Actions + +
+ + + + + + + + + +
+
+
+ + + @if (showLoadMore()) { +
+ +
+ } + } +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/room-members/components/room-members-list/room-members-list.component.scss b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/room-members/components/room-members-list/room-members-list.component.scss new file mode 100644 index 00000000..167ff335 --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/room-members/components/room-members-list/room-members-list.component.scss @@ -0,0 +1,209 @@ +@use '../../../../../../../../src/assets/styles/design-tokens'; + +.members-toolbar { + @extend .ov-data-toolbar; + + .toolbar-left { + ::ng-deep .search-btn { + padding: var(--ov-meet-spacing-sm); + } + } + + .toolbar-right { + gap: var(--ov-meet-spacing-sm); + + ::ng-deep .refresh-btn { + padding: var(--ov-meet-spacing-sm); + } + + ::ng-deep .clear-btn { + padding: var(--ov-meet-spacing-sm); + } + } +} + +.search-field { + @extend .ov-search-field; +} + +.batch-actions { + @extend .ov-batch-actions; + ::ng-deep button { + padding: var(--ov-meet-spacing-sm); + } +} + +.loading-container { + @extend .ov-loading-container; +} + +.table-container { + @extend .ov-table-container; + margin-top: 0; +} + +.members-toolbar + .table-container { + border-top-left-radius: 0; + border-top-right-radius: 0; + border-top: none; + box-shadow: var(--ov-meet-shadow-sm); +} + +:host:not(:has(.members-toolbar)) .table-container { + margin-top: var(--ov-meet-spacing-md); +} + +.members-table { + @extend .ov-data-table; + + .mat-mdc-header-cell { + &.member-name-header { + @extend .primary-header; + } + + &.actions-header { + @extend .actions-header; + } + } + + .mat-mdc-cell { + &.member-cell { + @extend .primary-cell; + } + + &.actions-cell { + @extend .actions-cell; + } + } +} + +.member-info { + display: flex; + align-items: center; + gap: var(--ov-meet-spacing-sm); + + .member-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + background-color: var(--ov-meet-color-primary); + color: var(--ov-meet-color-on-primary, white); + display: flex; + align-items: center; + justify-content: center; + font-size: var(--ov-meet-font-size-sm); + font-weight: var(--ov-meet-font-weight-semibold); + flex-shrink: 0; + } + + .member-details { + display: flex; + flex-direction: column; + min-width: 0; + } + + .member-name { + @extend .primary-text; + } + + .member-id { + @extend .secondary-text, .monospace-text; + } +} + +.role-badge { + display: inline-flex; + align-items: center; + padding: var(--ov-meet-spacing-xs) var(--ov-meet-spacing-sm); + border-radius: var(--ov-meet-radius-sm); + font-size: var(--ov-meet-font-size-xs); + font-weight: var(--ov-meet-font-weight-medium); + text-transform: uppercase; + background-color: var(--ov-meet-surface-variant); + color: var(--ov-meet-text-secondary); + + &.moderator { + background-color: color-mix(in srgb, var(--ov-meet-color-primary), transparent 85%); + color: var(--ov-meet-color-primary); + } + + &.speaker { + background-color: color-mix(in srgb, var(--ov-meet-color-success), transparent 85%); + color: var(--ov-meet-color-success); + } +} + +.member-type { + display: flex; + align-items: center; + gap: var(--ov-meet-spacing-xs); + font-size: var(--ov-meet-font-size-sm); + color: var(--ov-meet-text-secondary); + + .type-icon { + @include design-tokens.ov-icon(sm); + color: var(--ov-meet-text-hint); + } +} + +.date-info { + @extend .ov-date-info; +} + +.no-data { + @extend .ov-no-data; +} + +.action-buttons { + @extend .ov-action-buttons; + + ::ng-deep button { + padding: var(--ov-meet-spacing-sm); + } + + .mat-mdc-icon-button { + &:hover { + background-color: transparent; + } + + &.copy-link-btn { + color: var(--ov-meet-text-secondary); + } + } +} + +.delete-action { + @extend .ov-delete-action; +} + +.no-members-state { + @extend .ov-empty-state; + + .empty-icon { + @include design-tokens.ov-icon(lg); + color: var(--ov-meet-text-hint); + } + box-shadow: none; + &:hover { + color: var(--ov-meet-text-hint); + background-color: transparent; + box-shadow: none; + } +} + +.load-more-container { + display: flex; + justify-content: center; + padding: var(--ov-meet-spacing-md); + border-top: 1px solid var(--ov-meet-border-color); + + .load-more-btn { + color: var(--ov-meet-color-primary); + } +} + +.mat-mdc-checkbox, +.mat-mdc-icon-button, +.mat-mdc-button { + @extend .ov-focus-visible; +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/room-members/components/room-members-list/room-members-list.component.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/room-members/components/room-members-list/room-members-list.component.ts new file mode 100644 index 00000000..aa3a46ed --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/room-members/components/room-members-list/room-members-list.component.ts @@ -0,0 +1,286 @@ +import { CommonModule, DatePipe } from '@angular/common'; +import { Component, effect, EventEmitter, HostBinding, input, OnInit, Output, signal, untracked } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { MatBadgeModule } from '@angular/material/badge'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatSortModule, Sort } from '@angular/material/sort'; +import { MatTableModule } from '@angular/material/table'; +import { MatToolbarModule } from '@angular/material/toolbar'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MeetRoomMember, MeetRoomMemberRole, MeetRoomMemberSortField, SortOrder } from '@openvidu-meet/typings'; +import { setsAreEqual } from '../../../../shared/utils/array.utils'; + +export interface MemberTableAction { + members: MeetRoomMember[]; + action: 'copyLink' | 'delete' | 'bulkDelete'; +} + +export interface MemberTableFilter { + nameFilter: string; + sortField: MeetRoomMemberSortField; + sortOrder: SortOrder; +} + +/** + * Reusable component for displaying a list of room members with filtering, selection, and bulk operations. + * + * Features: + * - Display room members in a Material Design table + * - Filter by member name + * - Multi-selection for bulk operations + * - Individual member actions (copy link, delete) + * - Responsive design with mobile optimization + * + * @example + * ```html + * + * + * ``` + */ +@Component({ + selector: 'ov-room-members-list', + imports: [ + CommonModule, + ReactiveFormsModule, + MatTableModule, + MatCheckboxModule, + MatButtonModule, + MatIconModule, + MatFormFieldModule, + MatInputModule, + MatMenuModule, + MatTooltipModule, + MatProgressSpinnerModule, + MatToolbarModule, + MatBadgeModule, + MatDividerModule, + MatSortModule, + DatePipe + ], + templateUrl: './room-members-list.component.html', + styleUrl: './room-members-list.component.scss' +}) +export class RoomMembersListsComponent implements OnInit { + members = input([]); + showSearchBox = input(true); + showFilters = input(false); + showSelection = input(true); + showLoadMore = input(false); + loading = input(false); + initialFilters = input({ + nameFilter: '', + sortField: 'membershipDate', + sortOrder: SortOrder.DESC + }); + + // Host binding for styling when members are selected + @HostBinding('class.has-selections') + get hasSelections(): boolean { + return this.selectedMembers().size > 0; + } + + // Output events + @Output() memberAction = new EventEmitter(); + @Output() filterChange = new EventEmitter(); + @Output() loadMore = new EventEmitter(); + @Output() refresh = new EventEmitter(); + @Output() addMember = new EventEmitter(); + + // Filter controls + nameFilterControl = new FormControl(''); + + // Sort state + currentSortField: MeetRoomMemberSortField = 'membershipDate'; + currentSortOrder: SortOrder = SortOrder.DESC; + + showEmptyFilterMessage = false; + + // Selection state + selectedMembers = signal>(new Set()); + allSelected = signal(false); + someSelected = signal(false); + + // Table configuration + displayedColumns: string[] = ['select', 'name', 'role', 'memberType', 'membershipDate', 'actions']; + + // Expose enum to template + protected readonly MeetRoomMemberRole = MeetRoomMemberRole; + + constructor() { + effect(() => { + const members = this.members(); + const validMemberIds = new Set(members.map((m) => m.memberId)); + + const currentSelection = untracked(() => this.selectedMembers()); + const filteredSelection = new Set([...currentSelection].filter((id) => validMemberIds.has(id))); + + if (!setsAreEqual(filteredSelection, currentSelection)) { + this.selectedMembers.set(filteredSelection); + this.updateSelectionState(); + } + + this.showEmptyFilterMessage = members.length === 0 && this.hasActiveFilters(); + }); + } + + ngOnInit() { + this.setupFilters(); + this.updateDisplayedColumns(); + } + + // ===== INITIALIZATION METHODS ===== + + private setupFilters() { + this.nameFilterControl.setValue(this.initialFilters().nameFilter); + this.currentSortField = this.initialFilters().sortField; + this.currentSortOrder = this.initialFilters().sortOrder; + + this.nameFilterControl.valueChanges.subscribe((value) => { + if (!value) { + this.emitFilterChange(); + } + }); + } + + private updateDisplayedColumns() { + this.displayedColumns = []; + + if (this.showSelection()) { + this.displayedColumns.push('select'); + } + + this.displayedColumns.push('name', 'role', 'memberType', 'membershipDate', 'actions'); + } + + // ===== SELECTION METHODS ===== + + toggleAllSelection() { + const selected = this.selectedMembers(); + if (this.allSelected()) { + selected.clear(); + } else { + this.members().forEach((member) => selected.add(member.memberId)); + } + this.selectedMembers.set(new Set(selected)); + this.updateSelectionState(); + } + + toggleMemberSelection(member: MeetRoomMember) { + const selected = this.selectedMembers(); + if (selected.has(member.memberId)) { + selected.delete(member.memberId); + } else { + selected.add(member.memberId); + } + this.selectedMembers.set(new Set(selected)); + this.updateSelectionState(); + } + + private updateSelectionState() { + const memberCount = this.members().length; + const selectedCount = this.selectedMembers().size; + + this.allSelected.set(selectedCount > 0 && selectedCount === memberCount); + this.someSelected.set(selectedCount > 0 && selectedCount < memberCount); + } + + isMemberSelected(member: MeetRoomMember): boolean { + return this.selectedMembers().has(member.memberId); + } + + getSelectedMembers(): MeetRoomMember[] { + const selected = this.selectedMembers(); + return this.members().filter((m) => selected.has(m.memberId)); + } + + // ===== ACTION METHODS ===== + + copyMemberLink(member: MeetRoomMember) { + this.memberAction.emit({ members: [member], action: 'copyLink' }); + } + + deleteMember(member: MeetRoomMember) { + this.memberAction.emit({ members: [member], action: 'delete' }); + } + + bulkDeleteSelected() { + const selectedMembers = this.getSelectedMembers(); + if (selectedMembers.length > 0) { + this.memberAction.emit({ members: selectedMembers, action: 'bulkDelete' }); + } + } + + refreshMembers() { + const nameFilter = this.nameFilterControl.value || ''; + this.refresh.emit({ + nameFilter, + sortField: this.currentSortField, + sortOrder: this.currentSortOrder + }); + } + + loadMoreMembers() { + const nameFilter = this.nameFilterControl.value || ''; + this.loadMore.emit({ + nameFilter, + sortField: this.currentSortField, + sortOrder: this.currentSortOrder + }); + } + + onSortChange(sortState: Sort) { + this.currentSortField = sortState.active as MeetRoomMemberSortField; + this.currentSortOrder = sortState.direction as SortOrder; + this.emitFilterChange(); + } + + // ===== FILTER METHODS ===== + + triggerSearch() { + this.emitFilterChange(); + } + + private emitFilterChange() { + this.filterChange.emit({ + nameFilter: this.nameFilterControl.value || '', + sortField: this.currentSortField, + sortOrder: this.currentSortOrder + }); + } + + hasActiveFilters(): boolean { + return !!this.nameFilterControl.value; + } + + clearFilters() { + this.nameFilterControl.setValue(''); + } + + // ===== UTILS ===== + + getMemberTypeLabel(member: MeetRoomMember): string { + return member.memberId.startsWith('ext-') ? 'External' : 'Registered'; + } + + getMemberTypeIcon(member: MeetRoomMember): string { + return member.memberId.startsWith('ext-') ? 'person_outline' : 'verified_user'; + } + + getMemberInitials(member: MeetRoomMember): string { + return member.name.substring(0, 2).toUpperCase(); + } +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/room-members/index.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/room-members/index.ts index 45732831..33fb96b2 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/room-members/index.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/room-members/index.ts @@ -1,2 +1,4 @@ +export * from './components'; export * from './interceptor-handlers'; export * from './services'; + diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/room-members/pages/add-room-member/add-room-member.component.html b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/room-members/pages/add-room-member/add-room-member.component.html new file mode 100644 index 00000000..a6dc696c --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/room-members/pages/add-room-member/add-room-member.component.html @@ -0,0 +1,172 @@ +
+ + + + +
+ + +
+ group_add +
+ Member Configuration + Select a user and configure their access role +
+ + +
+ + + + User ID + badge + + @if (isLoadingUsers()) { + + } @else { + search + } + + @for (user of filteredUsers(); track user.userId) { + +
+ person +
+ {{ user.userId }} + @if (user.name) { + {{ user.name }} + } +
+
+
+ } + @if (!isLoadingUsers() && filteredUsers().length === 0 && form.get('userId')?.value) { + + No users found + + } +
+ Start typing to search registered users + @if (getFieldError('userId')) { + {{ getFieldError('userId') }} + } +
+ + + + Role + manage_accounts + + @for (role of availableRoles; track role) { + +
+ {{ getRoleIcon(role) }} + {{ getRoleLabel(role) }} +
+
+ } +
+ The base role determines default permissions + @if (getFieldError('role')) { + {{ getFieldError('role') }} + } +
+ + +
+ + + + + admin_panel_settings + Custom Permissions + + + Override the default role permissions + + + +
+

+ These settings override the defaults for the selected role. Leave toggles in + their default position to inherit role permissions. +

+ + @for (group of permissionGroups; track group.label) { +
+
+ {{ group.icon }} + {{ group.label }} +
+ + @for (permission of group.permissions; track permission.key) { +
+
+ {{ + permission.icon + }} +
+ {{ permission.label }} + {{ + permission.description + }} +
+
+ + +
+ } +
+ } +
+
+
+
+
+
+ + + + + + +
+
+
diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/room-members/pages/add-room-member/add-room-member.component.scss b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/room-members/pages/add-room-member/add-room-member.component.scss new file mode 100644 index 00000000..d0a9a8ff --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/room-members/pages/add-room-member/add-room-member.component.scss @@ -0,0 +1,263 @@ +@use '../../../../../../../../src/assets/styles/design-tokens'; + +// ─── Page Layout ────────────────────────────────────────────────────────────── + +.form-card-wrapper { + max-width: 680px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: var(--ov-meet-spacing-lg); +} + +// ─── Card ───────────────────────────────────────────────────────────────────── + +.add-member-card { + @include design-tokens.ov-section-card; +} + +// ─── Form ───────────────────────────────────────────────────────────────────── + +.create-form { + display: flex; + flex-direction: column; + gap: var(--ov-meet-spacing-lg); + padding-top: var(--ov-meet-spacing-sm); + + mat-form-field { + width: 100%; + } +} + +.field-prefix-icon { + @include design-tokens.ov-icon(md); + color: var(--ov-meet-text-secondary); + margin-right: var(--ov-meet-spacing-xs); +} + +.field-suffix-icon { + @include design-tokens.ov-icon(md); + color: var(--ov-meet-text-secondary); +} + +.search-spinner { + margin-right: var(--ov-meet-spacing-xs); +} + +// ─── Autocomplete Options ───────────────────────────────────────────────────── + +.user-option { + display: flex; + align-items: center; + gap: var(--ov-meet-spacing-sm); + + .user-option-icon { + @include design-tokens.ov-icon(md); + color: var(--ov-meet-icon-primary); + flex-shrink: 0; + } + + .user-option-info { + display: flex; + flex-direction: column; + gap: 2px; + + .user-option-id { + font-size: var(--ov-meet-font-size-md); + font-weight: var(--ov-meet-font-weight-medium); + color: var(--ov-meet-text-primary); + } + + .user-option-name { + font-size: var(--ov-meet-font-size-sm); + color: var(--ov-meet-text-secondary); + } + } +} + +.no-results { + font-size: var(--ov-meet-font-size-sm); + color: var(--ov-meet-text-secondary); + font-style: italic; +} + +// ─── Role Select Options ────────────────────────────────────────────────────── + +.role-option { + display: flex; + align-items: center; + gap: var(--ov-meet-spacing-sm); + + .role-option-icon { + @include design-tokens.ov-icon(md); + color: var(--ov-meet-icon-primary); + } +} + +// ─── Permissions Section ────────────────────────────────────────────────────── + +.permissions-section { + padding-top: var(--ov-meet-spacing-sm); +} + +.permissions-accordion { + border: 1px solid var(--ov-border-color-light); + border-radius: var(--ov-meet-radius-md) !important; + overflow: hidden; +} + +.permissions-panel { + box-shadow: none !important; + + ::ng-deep .mat-expansion-panel-header { + padding: var(--ov-meet-spacing-md) var(--ov-meet-spacing-lg); + background: var(--ov-meet-surface-secondary); + + &:hover { + background: var(--ov-meet-surface-hover) !important; + } + } + + ::ng-deep .mat-expansion-panel-body { + padding: 0; + } +} + +.permissions-panel-title { + display: flex; + align-items: center; + gap: var(--ov-meet-spacing-sm); + font-weight: var(--ov-meet-font-weight-medium); + color: var(--ov-meet-text-primary); + + .permissions-panel-icon { + @include design-tokens.ov-icon(md); + color: var(--ov-meet-icon-settings); + } +} + +.permissions-panel-description { + font-size: var(--ov-meet-font-size-sm); + color: var(--ov-meet-text-secondary); +} + +.permissions-content { + padding: var(--ov-meet-spacing-md) var(--ov-meet-spacing-lg) var(--ov-meet-spacing-lg); + display: flex; + flex-direction: column; + gap: var(--ov-meet-spacing-lg); +} + +.permissions-hint { + margin: 0 0 var(--ov-meet-spacing-sm) 0; + font-size: var(--ov-meet-font-size-sm); + color: var(--ov-meet-text-secondary); + line-height: var(--ov-meet-line-height-normal); + padding: var(--ov-meet-spacing-sm) var(--ov-meet-spacing-md); + background: var(--ov-meet-surface-secondary); + border-radius: var(--ov-meet-radius-sm); + border-left: 3px solid var(--ov-meet-color-primary); +} + +.permission-group { + display: flex; + flex-direction: column; + gap: 0; +} + +.permission-group-header { + display: flex; + align-items: center; + gap: var(--ov-meet-spacing-sm); + padding: var(--ov-meet-spacing-xs) var(--ov-meet-spacing-md); + margin-bottom: var(--ov-meet-spacing-xs); + + .permission-group-icon { + @include design-tokens.ov-icon(sm); + color: var(--ov-meet-icon-primary); + } + + .permission-group-label { + font-size: var(--ov-meet-font-size-xs); + font-weight: var(--ov-meet-font-weight-semibold); + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--ov-meet-text-secondary); + } +} + +.permission-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--ov-meet-spacing-sm); + padding: var(--ov-meet-spacing-sm) var(--ov-meet-spacing-md); + border-radius: var(--ov-meet-radius-sm); + transition: background-color var(--ov-meet-transition-fast); + + &:hover { + background: var(--ov-meet-surface-hover); + } + + .permission-info { + display: flex; + align-items: center; + gap: var(--ov-meet-spacing-sm); + flex: 1; + min-width: 0; + + .permission-icon { + @include design-tokens.ov-icon(md); + color: var(--ov-meet-icon-primary); + flex-shrink: 0; + } + + .permission-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + + .permission-label { + font-size: var(--ov-meet-font-size-md); + font-weight: var(--ov-meet-font-weight-medium); + color: var(--ov-meet-text-primary); + line-height: var(--ov-meet-line-height-tight); + } + + .permission-description { + font-size: var(--ov-meet-font-size-sm); + color: var(--ov-meet-text-secondary); + line-height: var(--ov-meet-line-height-normal); + } + } + } + + .permission-toggle { + flex-shrink: 0; + } +} + +// ─── Card Footer Actions ────────────────────────────────────────────────────── + +.card-actions { + display: flex; + justify-content: flex-end; + align-items: center; + gap: var(--ov-meet-spacing-sm); + padding: var(--ov-meet-spacing-md) var(--ov-meet-spacing-lg) !important; + border-top: 1px solid var(--ov-border-color-light); + margin: 0 !important; + + .cancel-button { + color: var(--ov-meet-text-secondary); + } + + .primary-button { + min-width: 140px; + + mat-spinner { + margin-right: var(--ov-meet-spacing-xs); + } + } +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/room-members/pages/add-room-member/add-room-member.component.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/room-members/pages/add-room-member/add-room-member.component.ts new file mode 100644 index 00000000..50e80c09 --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/room-members/pages/add-room-member/add-room-member.component.ts @@ -0,0 +1,189 @@ +import { Component, inject, OnDestroy, OnInit, signal } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatSelectModule } from '@angular/material/select'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { ActivatedRoute } from '@angular/router'; +import { MeetRoomMemberOptions, MeetRoomMemberPermissions, MeetRoomMemberRole, MeetUserDTO } from '@openvidu-meet/typings'; +import { Subject, debounceTime, distinctUntilChanged, takeUntil } from 'rxjs'; +import { NavigationService } from '../../../../shared/services/navigation.service'; +import { NotificationService } from '../../../../shared/services/notification.service'; +import { UserService } from '../../../users/services/user.service'; +import { PERMISSION_GROUPS } from '../../../rooms/pages/room-wizard/steps/role-permissions/role-permissions.component'; +import { RoomMemberService } from '../../services/room-member.service'; + +@Component({ + selector: 'ov-add-room-member', + imports: [ + ReactiveFormsModule, + MatButtonModule, + MatCardModule, + MatIconModule, + MatInputModule, + MatFormFieldModule, + MatSelectModule, + MatAutocompleteModule, + MatExpansionModule, + MatSlideToggleModule, + MatTooltipModule, + MatProgressSpinnerModule + ], + templateUrl: './add-room-member.component.html', + styleUrl: './add-room-member.component.scss' +}) +export class AddRoomMemberComponent implements OnInit, OnDestroy { + private route = inject(ActivatedRoute); + private roomMemberService = inject(RoomMemberService); + private userService = inject(UserService); + private navigationService = inject(NavigationService); + private notificationService = inject(NotificationService); + + private destroy$ = new Subject(); + + roomId = signal(''); + isSaving = signal(false); + isLoadingUsers = signal(false); + filteredUsers = signal([]); + + readonly availableRoles: MeetRoomMemberRole[] = [MeetRoomMemberRole.MODERATOR, MeetRoomMemberRole.SPEAKER]; + readonly permissionGroups = PERMISSION_GROUPS; + + form = new FormGroup({ + userId: new FormControl('', [Validators.required]), + role: new FormControl(MeetRoomMemberRole.SPEAKER, [Validators.required]), + permissions: new FormGroup( + Object.fromEntries( + PERMISSION_GROUPS.flatMap((g) => g.permissions.map((p) => [p.key, new FormControl(false)])) + ) as Record> + ) + }); + + get permissionsForm(): FormGroup { + return this.form.get('permissions') as FormGroup; + } + + ngOnInit(): void { + const roomId = this.route.snapshot.paramMap.get('roomId'); + if (!roomId) { + this.notificationService.showSnackbar('Room ID is required'); + this.navigationService.navigateTo('/rooms'); + return; + } + this.roomId.set(roomId); + + // Set up autocomplete search reactive to userId input changes + this.form.get('userId')!.valueChanges + .pipe(debounceTime(300), distinctUntilChanged(), takeUntil(this.destroy$)) + .subscribe((value) => { + if (value && value.length >= 1) { + this.searchUsers(value); + } else { + this.filteredUsers.set([]); + } + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + private async searchUsers(query: string): Promise { + this.isLoadingUsers.set(true); + try { + const response = await this.userService.listUsers({ userId: query, maxItems: 20 }); + this.filteredUsers.set(response.users); + } catch { + this.filteredUsers.set([]); + } finally { + this.isLoadingUsers.set(false); + } + } + + getRoleLabel(role: MeetRoomMemberRole): string { + switch (role) { + case MeetRoomMemberRole.MODERATOR: + return 'Moderator'; + case MeetRoomMemberRole.SPEAKER: + return 'Speaker'; + default: + return role; + } + } + + getRoleIcon(role: MeetRoomMemberRole): string { + switch (role) { + case MeetRoomMemberRole.MODERATOR: + return 'manage_accounts'; + case MeetRoomMemberRole.SPEAKER: + return 'record_voice_over'; + default: + return 'person'; + } + } + + displayUserFn(user: MeetUserDTO | string | null): string { + if (!user) return ''; + if (typeof user === 'string') return user; + return user.userId; + } + + onUserSelected(userId: string): void { + this.form.get('userId')!.setValue(userId); + } + + async onSubmit(): Promise { + if (this.form.invalid) { + this.form.markAllAsTouched(); + return; + } + + const { userId, role, permissions } = this.form.getRawValue(); + + // Only include customPermissions if the user explicitly modified them + let customPermissions: Partial | undefined; + if (this.permissionsForm.dirty && permissions) { + customPermissions = {}; + for (const [key, val] of Object.entries(permissions)) { + (customPermissions as any)[key] = val as boolean; + } + } + + const options: MeetRoomMemberOptions = { + userId: userId!, + baseRole: role!, + customPermissions + }; + + this.isSaving.set(true); + try { + await this.roomMemberService.createRoomMember(this.roomId(), options); + this.notificationService.showSnackbar('Member added successfully'); + await this.navigationService.navigateTo(`/rooms/${this.roomId()}`); + } catch (error: any) { + const msg = error?.error?.message ?? 'Failed to add member'; + this.notificationService.showSnackbar(msg); + } finally { + this.isSaving.set(false); + } + } + + async onCancel(): Promise { + await this.navigationService.navigateTo(`/rooms/${this.roomId()}`); + } + + getFieldError(field: string): string | null { + const control = this.form.get(field); + if (!control?.errors || !control.touched) return null; + if (control.errors['required']) return 'This field is required'; + return null; + } +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/room-members/routes/room-members.routes.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/room-members/routes/room-members.routes.ts new file mode 100644 index 00000000..dea36760 --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/room-members/routes/room-members.routes.ts @@ -0,0 +1,19 @@ +import { DomainRouteConfig } from '../../../shared/models/domain-routes.model'; + +/** + * Room Members domain route configurations + */ +export const roomMembersDomainRoutes: DomainRouteConfig[] = []; + +/** + * Console child routes for room members domain + */ +export const roomMembersConsoleRoutes: DomainRouteConfig[] = [ + { + route: { + path: 'rooms/:roomId/members/new', + loadComponent: () => + import('../pages/add-room-member/add-room-member.component').then((m) => m.AddRoomMemberComponent) + } + } +]; diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/pages/room-detail/room-detail.component.html b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/pages/room-detail/room-detail.component.html index d1aaef8e..1f1045a4 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/pages/room-detail/room-detail.component.html +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/pages/room-detail/room-detail.component.html @@ -138,71 +138,18 @@
- @if (loadingMembers()) { -
- -

Loading members...

-
- } @else if (roomMembers().length === 0) { -
- person_off -

No members yet

-

This room doesn't have any members assigned.

-
- } @else { - -
-
-

Members ({{ roomMembers().length }})

-
- @for (member of roomMembers(); track member.memberId) { -
-
-
- {{ member.name.substring(0, 2).toUpperCase() }} -
-
-

{{ member.name }}

-

{{ member.memberId }}

-
-
-
-
- - {{ - member.memberId.startsWith('ext-') - ? 'person_outline' - : 'verified_user' - }} - - {{ - member.memberId.startsWith('ext-') ? 'External' : 'Registered' - }} -
-
- - {{ member.baseRole }} - -
-
-
- - - -
-
- } -
- } +