frontend: add room members and recordings tabs with loading states and actions
This commit is contained in:
parent
6c4adfeaaa
commit
5bb9a2f3e1
@ -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>
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user