frontend: add room members and recordings tabs with loading states and actions

This commit is contained in:
CSantosM 2026-02-09 18:09:23 +01:00
parent 6c4adfeaaa
commit 5bb9a2f3e1
3 changed files with 455 additions and 20 deletions

View File

@ -84,8 +84,9 @@
</div>
</div>
<!-- Room Information Section -->
<!-- Room Information and Tabs Section -->
<div class="page-content">
<!-- Room Basic Information Card -->
<mat-card class="info-card">
<mat-card-content>
<div class="info-grid">
@ -120,6 +121,115 @@
</div>
</mat-card-content>
</mat-card>
<!-- Tabs for Room Members and Recordings -->
<mat-card class="tabs-card">
<mat-tab-group
[selectedIndex]="selectedTabIndex()"
(selectedIndexChange)="selectedTabIndex.set($event)"
animationDuration="0ms"
>
<!-- Room Members Tab -->
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">people</mat-icon>
<span>Room Members</span>
<span class="tab-count">{{ roomMembers().length }}</span>
</ng-template>
<div class="tab-content">
@if (loadingMembers()) {
<div class="loading-state">
<mat-spinner diameter="40"></mat-spinner>
<p>Loading members...</p>
</div>
} @else if (roomMembers().length === 0) {
<div class="empty-state">
<mat-icon>person_off</mat-icon>
<h3>No members yet</h3>
<p>This room doesn't have any members assigned.</p>
</div>
} @else {
<!-- Members List - Simple table for now -->
<div class="members-list">
<div class="members-header">
<h3>Members ({{ roomMembers().length }})</h3>
</div>
@for (member of roomMembers(); track member.memberId) {
<div class="member-item">
<div class="member-info">
<div class="member-avatar">
<span>{{ member.name.substring(0, 2).toUpperCase() }}</span>
</div>
<div class="member-details">
<p class="member-name">{{ member.name }}</p>
<p class="member-id">{{ member.memberId }}</p>
</div>
</div>
<div class="member-meta">
<div class="member-type">
<mat-icon class="meta-icon">
{{
member.memberId.startsWith('ext-')
? 'person_outline'
: 'verified_user'
}}
</mat-icon>
<span>{{
member.memberId.startsWith('ext-') ? 'External' : 'Registered'
}}</span>
</div>
<div class="member-role">
<span
class="role-badge"
[class.moderator]="member.baseRole === 'moderator'"
>
{{ member.baseRole }}
</span>
</div>
</div>
<div class="member-actions">
<button mat-icon-button matTooltip="Copy access URL">
<mat-icon>link</mat-icon>
</button>
<button mat-icon-button matTooltip="Edit permissions">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button matTooltip="Remove member" color="warn">
<mat-icon>person_remove</mat-icon>
</button>
</div>
</div>
}
</div>
}
</div>
</mat-tab>
<!-- Recordings Tab -->
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon ov-recording-icon">video_library</mat-icon>
<span>Recordings</span>
<span class="tab-count">{{ recordings().length }}</span>
</ng-template>
<div class="tab-content recordings-tab">
<ov-recording-lists
[recordings]="recordings()"
[loading]="loadingRecordings()"
[showLoadMore]="hasMoreRecordings"
[showSearchBox]="false"
[showFilters]="false"
(recordingAction)="onRecordingAction($event)"
(loadMore)="loadMoreRecordings($event)"
(refresh)="refreshRecordings($event)"
>
</ov-recording-lists>
</div>
</mat-tab>
</mat-tab-group>
</mat-card>
</div>
</div>
}

View File

@ -1,5 +1,8 @@
@use '../../../../../../../../src/assets/styles/design-tokens' as *;
.ov-page-container {
overflow: hidden;
}
.page-header {
@include ov-page-header;
@ -41,18 +44,6 @@
text-transform: uppercase;
width: fit-content;
&.active {
background-color: rgba(76, 175, 80, 0.1);
color: var(--ov-meet-color-success);
border: 1px solid rgba(76, 175, 80, 0.3);
}
&.closed {
background-color: rgba(158, 158, 158, 0.1);
color: var(--ov-meet-text-secondary);
border: 1px solid rgba(158, 158, 158, 0.3);
}
.status-dot {
width: 8px;
height: 8px;
@ -109,11 +100,7 @@
margin-top: var(--ov-meet-spacing-lg);
.info-card {
@include ov-card;
mat-card-content {
padding: 0;
}
background-color: var(--ov-meet-surface-container);
.info-grid {
display: grid;
@ -132,6 +119,7 @@
display: flex;
flex-direction: column;
gap: var(--ov-meet-spacing-xs);
margin: auto;
.info-label {
font-size: var(--ov-meet-font-size-xs);
@ -140,6 +128,8 @@
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0;
text-align: center;
margin-bottom: var(--ov-meet-spacing-xs);
}
.info-value {
@ -180,6 +170,231 @@
}
}
}
// Tabs card
.tabs-card {
@include ov-card;
overflow: hidden;
box-shadow: none;
&:hover {
box-shadow: none;
}
::ng-deep {
.mat-mdc-tab-group {
.mat-mdc-tab-labels {
border-bottom: 1px solid var(--ov-meet-border-color);
}
.mat-mdc-tab {
min-width: 160px;
.tab-icon {
@include ov-icon(sm);
margin-right: var(--ov-meet-spacing-xs);
}
.tab-count {
margin-left: var(--ov-meet-spacing-xs);
background-color: var(--ov-meet-background-elevated);
color: var(--ov-meet-text-secondary);
padding: 2px 8px;
border-radius: var(--ov-meet-radius-sm);
font-size: var(--ov-meet-font-size-xs);
font-weight: var(--ov-meet-font-weight-semibold);
}
&.mat-mdc-tab-active .tab-count {
background-color: var(--ov-meet-color-primary);
color: white;
}
}
}
}
.tab-content {
padding: var(--ov-meet-spacing-lg);
min-height: 400px;
&.recordings-tab {
padding: 0;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--ov-meet-spacing-md);
padding: var(--ov-meet-spacing-xxl);
p {
color: var(--ov-meet-text-secondary);
margin: 0;
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--ov-meet-spacing-md);
padding: var(--ov-meet-spacing-xxl);
mat-icon {
@include ov-icon(xxl);
color: var(--ov-meet-text-hint);
}
h3 {
margin: 0;
color: var(--ov-meet-text-primary);
font-size: var(--ov-meet-font-size-lg);
}
p {
margin: 0;
color: var(--ov-meet-text-secondary);
font-size: var(--ov-meet-font-size-sm);
}
}
}
}
// Members list
.members-list {
.members-header {
margin-bottom: var(--ov-meet-spacing-md);
padding-bottom: var(--ov-meet-spacing-sm);
border-bottom: 1px solid var(--ov-meet-border-color);
h3 {
margin: 0;
font-size: var(--ov-meet-font-size-md);
font-weight: var(--ov-meet-font-weight-semibold);
color: var(--ov-meet-text-primary);
}
}
.member-item {
display: flex;
align-items: center;
gap: var(--ov-meet-spacing-md);
padding: var(--ov-meet-spacing-md);
border: 1px solid var(--ov-meet-border-color);
border-radius: var(--ov-meet-radius-md);
margin-bottom: var(--ov-meet-spacing-sm);
transition: all 0.2s ease;
&:hover {
background-color: var(--ov-meet-background-hover);
border-color: var(--ov-meet-color-primary);
}
.member-info {
display: flex;
align-items: center;
gap: var(--ov-meet-spacing-sm);
flex: 1;
.member-avatar {
width: 40px;
height: 40px;
border-radius: var(--ov-meet-radius-circle);
background: linear-gradient(135deg, var(--ov-meet-color-primary), var(--ov-meet-color-accent));
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--ov-meet-font-size-sm);
font-weight: var(--ov-meet-font-weight-bold);
flex-shrink: 0;
}
.member-details {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
.member-name {
margin: 0;
font-size: var(--ov-meet-font-size-sm);
font-weight: var(--ov-meet-font-weight-semibold);
color: var(--ov-meet-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.member-id {
margin: 0;
font-size: var(--ov-meet-font-size-xs);
color: var(--ov-meet-text-secondary);
font-family: 'Courier New', monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.member-meta {
display: flex;
align-items: center;
gap: var(--ov-meet-spacing-md);
flex-wrap: wrap;
.member-type {
display: flex;
align-items: center;
gap: var(--ov-meet-spacing-xs);
padding: var(--ov-meet-spacing-xs) var(--ov-meet-spacing-sm);
background-color: var(--ov-meet-background-elevated);
border-radius: var(--ov-meet-radius-sm);
font-size: var(--ov-meet-font-size-xs);
color: var(--ov-meet-text-secondary);
.meta-icon {
@include ov-icon(xs);
}
}
.member-role {
.role-badge {
display: inline-block;
padding: var(--ov-meet-spacing-xs) var(--ov-meet-spacing-sm);
background-color: var(--ov-meet-background-elevated);
border-radius: var(--ov-meet-radius-sm);
font-size: var(--ov-meet-font-size-xs);
font-weight: var(--ov-meet-font-weight-semibold);
color: var(--ov-meet-text-secondary);
text-transform: capitalize;
&.moderator {
background-color: rgba(33, 150, 243, 0.1);
color: var(--ov-meet-color-primary);
border: 1px solid rgba(33, 150, 243, 0.3);
}
}
}
}
.member-actions {
display: flex;
gap: var(--ov-meet-spacing-xs);
button {
@include ov-theme-transition;
mat-icon {
@include ov-icon(sm);
}
}
}
}
}
}
@keyframes pulse {

View File

@ -6,13 +6,18 @@ import { MatChipsModule } from '@angular/material/chips';
import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTabsModule } from '@angular/material/tabs';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { MeetRoom, MeetRoomStatus } from '@openvidu-meet/typings';
import { MeetRecordingInfo, MeetRoom, MeetRoomMember, MeetRoomStatus } from '@openvidu-meet/typings';
import { ILogger, LoggerService } from 'openvidu-components-angular';
import { BreadcrumbComponent, BreadcrumbItem } from '../../../../shared/components/breadcrumb/breadcrumb.component';
import { NavigationService } from '../../../../shared/services/navigation.service';
import { NotificationService } from '../../../../shared/services/notification.service';
import { RecordingListsComponent } from '../../../recordings/components/recording-lists/recording-lists.component';
import { RecordingTableAction, RecordingTableFilter } from '../../../recordings/models/recording-list.model';
import { RecordingService } from '../../../recordings/services/recording.service';
import { RoomMemberService } from '../../../room-members/services/room-member.service';
import { DeleteRoomDialogComponent } from '../../components/delete-room-dialog/delete-room-dialog.component';
import { RoomService } from '../../services/room.service';
import { RoomUiUtils } from '../../utils/ui';
@ -26,8 +31,10 @@ import { RoomUiUtils } from '../../utils/ui';
MatTooltipModule,
MatProgressSpinnerModule,
MatChipsModule,
MatTabsModule,
RouterModule,
BreadcrumbComponent
BreadcrumbComponent,
RecordingListsComponent
],
templateUrl: './room-detail.component.html',
styleUrl: './room-detail.component.scss'
@ -36,6 +43,20 @@ export class RoomDetailComponent implements OnInit {
room = signal<MeetRoom | undefined>(undefined);
isLoading = signal(true);
breadcrumbItems = signal<BreadcrumbItem[]>([]);
// Room Members tab
roomMembers = signal<MeetRoomMember[]>([]);
loadingMembers = signal(false);
// Recordings tab
recordings = signal<MeetRecordingInfo[]>([]);
loadingRecordings = signal(false);
hasMoreRecordings = false;
private nextRecordingsPageToken?: string;
// Tab management
selectedTabIndex = signal(0);
protected log: ILogger;
MeetRoomStatus = MeetRoomStatus;
@ -45,6 +66,8 @@ export class RoomDetailComponent implements OnInit {
private route: ActivatedRoute,
private router: Router,
private roomService: RoomService,
private roomMemberService: RoomMemberService,
private recordingService: RecordingService,
private notificationService: NotificationService,
protected navigationService: NavigationService,
private clipboard: Clipboard,
@ -81,6 +104,12 @@ export class RoomDetailComponent implements OnInit {
label: room.roomName
}
]);
// Load initial data for tabs
await Promise.all([
this.loadRoomMembers(roomId),
this.loadRecordings(roomId)
]);
} catch (error) {
this.log.e('Error loading room details:', error);
this.notificationService.showSnackbar('Failed to load room details');
@ -90,6 +119,87 @@ export class RoomDetailComponent implements OnInit {
}
}
private async loadRoomMembers(roomId: string) {
try {
this.loadingMembers.set(true);
const response = await this.roomMemberService.listRoomMembers(roomId, {
maxItems: 100,
sortField: 'membershipDate',
sortOrder: 'desc'
});
this.roomMembers.set(response.members);
} catch (error) {
this.log.e('Error loading room members:', error);
this.notificationService.showSnackbar('Failed to load room members');
} finally {
this.loadingMembers.set(false);
}
}
private async loadRecordings(roomId: string, refresh = false) {
try {
this.loadingRecordings.set(true);
const response = await this.recordingService.listRecordings({
roomId,
maxItems: 50,
nextPageToken: !refresh ? this.nextRecordingsPageToken : undefined,
sortField: 'startDate',
sortOrder: 'desc'
});
if (!refresh) {
const currentRecordings = this.recordings();
this.recordings.set([...currentRecordings, ...response.recordings]);
} else {
this.recordings.set(response.recordings);
}
this.nextRecordingsPageToken = response.pagination.nextPageToken;
this.hasMoreRecordings = response.pagination.isTruncated;
} catch (error) {
this.log.e('Error loading recordings:', error);
this.notificationService.showSnackbar('Failed to load recordings');
} finally {
this.loadingRecordings.set(false);
}
}
async onRecordingAction(action: RecordingTableAction) {
const recording = action.recordings[0];
switch (action.action) {
case 'play':
await this.recordingService.playRecording(recording.recordingId);
break;
case 'download':
this.recordingService.downloadRecording(recording);
break;
case 'shareLink':
this.recordingService.openShareRecordingDialog(recording.recordingId);
break;
case 'delete':
case 'bulkDelete':
// Handle delete - can be implemented later
break;
}
}
async loadMoreRecordings(filters: RecordingTableFilter) {
if (!this.hasMoreRecordings || this.loadingRecordings()) return;
const roomId = this.room()?.roomId;
if (roomId) {
await this.loadRecordings(roomId);
}
}
async refreshRecordings(filters: RecordingTableFilter) {
const roomId = this.room()?.roomId;
if (roomId) {
this.nextRecordingsPageToken = undefined;
await this.loadRecordings(roomId, true);
}
}
async joinRoom() {
const room = this.room();
if (!room) return;