frontend: add room detail page with loading state and actions

This commit is contained in:
CSantosM 2026-02-09 16:16:40 +01:00
parent 0e37d8cc09
commit 35e727cbd0
13 changed files with 636 additions and 8 deletions

View File

@ -158,6 +158,7 @@
[checked]="allSelected()"
[indeterminate]="someSelected()"
(change)="toggleAllSelection()"
(click)="$event.stopPropagation()"
[disabled]="loading()"
id="select-all-checkbox"
>
@ -167,6 +168,7 @@
@if (canSelectRoom(room)) {
<mat-checkbox
[checked]="isRoomSelected(room)"
(click)="$event.stopPropagation()"
(change)="toggleRoomSelection(room)"
[disabled]="loading()"
id="select-room-{{ room.roomId }}"
@ -308,7 +310,7 @@
<button
mat-icon-button
matTooltip="Open Room"
(click)="openRoom(room)"
(click)="$event.stopPropagation(); openRoom(room)"
[disabled]="loading()"
class="primary-action"
id="open-room-btn-{{ room.roomId }}"
@ -322,7 +324,7 @@
<button
mat-icon-button
matTooltip="Room Settings"
(click)="editRoom(room)"
(click)="$event.stopPropagation(); editRoom(room)"
[disabled]="loading()"
id="edit-room-btn-{{ room.roomId }}"
>
@ -344,7 +346,7 @@
<mat-menu #actionsMenu="matMenu" id="actions-menu-{{ room.roomId }}">
<button
mat-menu-item
(click)="viewRecordings(room)"
(click)="$event.stopPropagation(); viewRecordings(room)"
id="view-recordings-btn-{{ room.roomId }}"
>
<mat-icon class="ov-recording-icon">video_library</mat-icon>
@ -354,7 +356,7 @@
@if (canOpenRoom(room)) {
<button
mat-menu-item
(click)="copyModeratorLink(room)"
(click)="$event.stopPropagation(); copyModeratorLink(room)"
id="copy-moderator-link-btn-{{ room.roomId }}"
>
<mat-icon>content_copy</mat-icon>
@ -362,7 +364,7 @@
</button>
<button
mat-menu-item
(click)="copySpeakerLink(room)"
(click)="$event.stopPropagation(); copySpeakerLink(room)"
id="copy-speaker-link-btn-{{ room.roomId }}"
>
<mat-icon>content_copy</mat-icon>
@ -374,7 +376,7 @@
<button
mat-menu-item
(click)="toggleRoomStatus(room)"
(click)="$event.stopPropagation(); toggleRoomStatus(room)"
[ngClass]="getRoomToggleIconClass(room)"
id="toggle-room-status-btn-{{ room.roomId }}"
>
@ -383,7 +385,7 @@
</button>
<button
mat-menu-item
(click)="deleteRoom(room)"
(click)="$event.stopPropagation(); deleteRoom(room)"
class="delete-action"
id="delete-room-btn-{{ room.roomId }}"
>
@ -401,6 +403,7 @@
*matRowDef="let row; columns: displayedColumns"
[class.selected-row]="isRoomSelected(row)"
id="table-row-{{ row.roomId }}"
(click)="onRoomClick(row)"
></tr>
</table>
</div>

View File

@ -114,6 +114,7 @@ export class RoomsListsComponent implements OnInit {
@Output() filterChange = new EventEmitter<RoomTableFilter>();
@Output() loadMore = new EventEmitter<RoomTableFilter>();
@Output() refresh = new EventEmitter<RoomTableFilter>();
@Output() roomClicked = new EventEmitter<string>();
// Filter controls
nameFilterControl = new FormControl('');
@ -151,7 +152,6 @@ export class RoomsListsComponent implements OnInit {
const currentSelection = untracked(() => this.selectedRooms());
const filteredSelection = new Set([...currentSelection].filter((id) => validRoomIds.has(id)));
// Only update if the selection has actually changed
if (!setsAreEqual(filteredSelection, currentSelection)) {
this.selectedRooms.set(filteredSelection);
@ -261,6 +261,10 @@ export class RoomsListsComponent implements OnInit {
this.roomAction.emit({ rooms: [room], action: 'open' });
}
onRoomClick(room: MeetRoom) {
this.roomClicked.emit(room.roomId);
}
editRoom(room: MeetRoom) {
this.roomAction.emit({ rooms: [room], action: 'edit' });
}

View File

@ -1,3 +1,5 @@
export * from './room-basic-creation/room-basic-creation.component';
export * from './room-detail/room-detail.component';
export * from './room-wizard/room-wizard.component';
export * from './rooms/rooms.component';

View File

@ -0,0 +1,127 @@
<!-- Loading State -->
@if (isLoading()) {
<div class="ov-page-loading">
<div class="loading-content">
<div class="loading-header">
<div class="loading-title">
<mat-icon class="ov-room-icon loading-icon">video_chat</mat-icon>
<h1>Loading Room Details</h1>
</div>
<p class="loading-subtitle">Please wait while we fetch the room information...</p>
</div>
<div class="loading-spinner-container">
<mat-spinner diameter="48"></mat-spinner>
</div>
</div>
</div>
}
@if (!isLoading() && room()) {
<div class="ov-page-container ov-mb-xxl">
<!-- Room Detail Header -->
<div class="page-header">
<div class="header-top">
<!-- Breadcrumb -->
<ov-breadcrumb [items]="breadcrumbItems()"></ov-breadcrumb>
</div>
<div class="header-content">
<div class="title-section">
<div class="title">
<mat-icon class="ov-room-icon">video_chat</mat-icon>
<h1>{{ room()?.roomName }}</h1>
</div>
<!-- Status Badge -->
<div class="status-badge" [class.active]="isActiveRoom()" [class.closed]="isClosedRoom()">
<span class="status-dot" [class.active]="isActiveRoom()"></span>
<span class="status-text">{{ room()?.status }}</span>
</div>
</div>
<!-- Action Buttons -->
<div class="actions">
<button
mat-flat-button
color="primary"
class="action-btn primary-btn"
(click)="joinRoom()"
matTooltip="Join the room"
>
<mat-icon>play_arrow</mat-icon>
<span>Join Room</span>
</button>
<button
mat-stroked-button
class="action-btn"
(click)="copyAccessLink()"
matTooltip="Copy access link to clipboard"
>
<mat-icon>link</mat-icon>
<span>Copy Link</span>
</button>
<button
mat-icon-button
class="icon-action-btn"
(click)="editRoom()"
matTooltip="Edit room configuration"
>
<mat-icon>edit</mat-icon>
</button>
<button
mat-icon-button
class="icon-action-btn delete-btn"
(click)="deleteRoom()"
matTooltip="Delete room"
>
<mat-icon>delete</mat-icon>
</button>
</div>
</div>
</div>
<!-- Room Information Section -->
<div class="page-content">
<mat-card class="info-card">
<mat-card-content>
<div class="info-grid">
<!-- Room ID -->
<div class="info-item">
<p class="info-label">Room ID</p>
<p class="info-value monospace">{{ room()?.roomId }}</p>
</div>
<!-- Owner -->
<div class="info-item">
<p class="info-label">Owner</p>
<div class="owner-info">
<div class="owner-avatar">
<span>{{ getOwnerInitials() }}</span>
</div>
<p class="info-value">{{ room()?.owner }}</p>
</div>
</div>
<!-- Creation Date -->
<div class="info-item">
<p class="info-label">Creation Date</p>
<p class="info-value">{{ getFormattedDate(room()!.creationDate) }}</p>
</div>
<!-- Auto-deletion -->
<div class="info-item">
<p class="info-label">Auto-deletion</p>
<p class="info-value" [class.disabled]="!room()?.autoDeletionDate">
{{ getAutoDeletionStatus() }}
</p>
</div>
</div>
</mat-card-content>
</mat-card>
</div>
</div>
}

View File

@ -0,0 +1,199 @@
@use '../../../../../../../../src/assets/styles/design-tokens' as *;
.page-header {
@include ov-page-header;
.header-top {
margin-bottom: var(--ov-meet-spacing-sm);
}
.header-content {
display: flex;
flex-direction: column;
gap: var(--ov-meet-spacing-md);
@media (min-width: 768px) {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
}
.title-section {
display: flex;
flex-direction: column;
gap: var(--ov-meet-spacing-sm);
@media (min-width: 768px) {
flex-direction: row;
align-items: center;
}
}
.status-badge {
display: inline-flex;
align-items: center;
gap: var(--ov-meet-spacing-xs);
padding: var(--ov-meet-spacing-xs) var(--ov-meet-spacing-sm);
border-radius: var(--ov-meet-radius-md);
font-size: var(--ov-meet-font-size-xs);
font-weight: var(--ov-meet-font-weight-semibold);
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;
border-radius: var(--ov-meet-radius-circle);
background-color: var(--ov-meet-text-secondary);
&.active {
background-color: var(--ov-meet-color-success);
animation: pulse 2s ease-in-out infinite;
}
}
}
.actions {
display: flex;
flex-wrap: wrap;
gap: var(--ov-meet-spacing-sm);
align-items: center;
.action-btn {
@include ov-button-base;
display: flex;
align-items: center;
gap: var(--ov-meet-spacing-xs);
mat-icon {
@include ov-icon(sm);
}
span {
display: none;
@media (min-width: 768px) {
display: inline;
}
}
}
.icon-action-btn {
@include ov-theme-transition;
border: 1px solid var(--ov-meet-border-color);
border-radius: var(--ov-meet-radius-sm);
mat-icon {
@include ov-icon(sm);
}
&.delete-btn {
color: var(--ov-meet-color-error);
&:hover {
background-color: rgba(244, 67, 54, 0.1);
}
}
}
}
}
.page-content {
margin-top: var(--ov-meet-spacing-lg);
.info-card {
@include ov-card;
mat-card-content {
padding: 0;
}
.info-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--ov-meet-spacing-lg);
@media (min-width: 768px) {
grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 1024px) {
grid-template-columns: repeat(4, 1fr);
}
.info-item {
display: flex;
flex-direction: column;
gap: var(--ov-meet-spacing-xs);
.info-label {
font-size: var(--ov-meet-font-size-xs);
font-weight: var(--ov-meet-font-weight-semibold);
color: var(--ov-meet-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0;
}
.info-value {
font-size: var(--ov-meet-font-size-sm);
color: var(--ov-meet-text-primary);
margin: 0;
font-weight: var(--ov-meet-font-weight-medium);
&.monospace {
font-family: 'Courier New', monospace;
font-size: var(--ov-meet-font-size-xs);
color: var(--ov-meet-text-secondary);
}
&.disabled {
color: var(--ov-meet-text-hint);
}
}
.owner-info {
display: flex;
align-items: center;
gap: var(--ov-meet-spacing-sm);
.owner-avatar {
width: 32px;
height: 32px;
border-radius: var(--ov-meet-radius-circle);
background-color: var(--ov-meet-color-primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--ov-meet-font-size-xs);
font-weight: var(--ov-meet-font-weight-bold);
}
}
}
}
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}

View File

@ -0,0 +1,175 @@
import { Clipboard } from '@angular/cdk/clipboard';
import { Component, OnInit, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
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 { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { MeetRoom, 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 { DeleteRoomDialogComponent } from '../../components/delete-room-dialog/delete-room-dialog.component';
import { RoomService } from '../../services/room.service';
@Component({
selector: 'ov-room-detail',
imports: [
MatCardModule,
MatButtonModule,
MatIconModule,
MatTooltipModule,
MatProgressSpinnerModule,
MatChipsModule,
RouterModule,
BreadcrumbComponent
],
templateUrl: './room-detail.component.html',
styleUrl: './room-detail.component.scss'
})
export class RoomDetailComponent implements OnInit {
room = signal<MeetRoom | null>(null);
isLoading = signal(true);
breadcrumbItems = signal<BreadcrumbItem[]>([]);
protected log: ILogger;
MeetRoomStatus = MeetRoomStatus;
constructor(
private route: ActivatedRoute,
private router: Router,
private roomService: RoomService,
private notificationService: NotificationService,
protected navigationService: NavigationService,
private clipboard: Clipboard,
private dialog: MatDialog,
protected loggerService: LoggerService
) {
this.log = this.loggerService.get('OpenVidu Meet - RoomDetailComponent');
}
async ngOnInit() {
const roomId = this.route.snapshot.paramMap.get('roomId');
if (!roomId) {
this.notificationService.showSnackbar('Room ID is required');
await this.navigationService.navigateTo('rooms');
return;
}
await this.loadRoomDetails(roomId);
}
private async loadRoomDetails(roomId: string) {
try {
this.isLoading.set(true);
const room = await this.roomService.getRoom(roomId);
debugger;
this.room.set(room);
// Update breadcrumb items
this.breadcrumbItems.set([
{
label: 'Rooms',
action: () => this.navigationService.navigateTo('rooms')
},
{
label: room.roomName
}
]);
} catch (error) {
this.log.e('Error loading room details:', error);
this.notificationService.showSnackbar('Failed to load room details');
await this.navigationService.navigateTo('rooms');
} finally {
this.isLoading.set(false);
}
}
async joinRoom() {
const room = this.room();
if (!room) return;
window.open(room.accessUrl, '_blank');
}
copyAccessLink() {
const room = this.room();
if (!room) return;
this.clipboard.copy(room.accessUrl);
this.notificationService.showSnackbar('Access link copied to clipboard');
}
async editRoom() {
const room = this.room();
if (!room) return;
await this.navigationService.navigateTo(`rooms/${room.roomId}/edit`);
}
async deleteRoom() {
const room = this.room();
if (!room) return;
const dialogRef = this.dialog.open(DeleteRoomDialogComponent, {
data: {
rooms: [room],
hasMeetings: room.status === MeetRoomStatus.ACTIVE_MEETING,
hasRecordings: false // You may need to check this separately
},
width: '500px',
disableClose: true
});
const result = await dialogRef.afterClosed().toPromise();
if (result?.confirmed) {
try {
await this.roomService.deleteRoom(room.roomId, result.deletionPolicy);
this.notificationService.showSnackbar('Room deleted successfully');
await this.navigationService.navigateTo('rooms');
} catch (error) {
this.log.e('Error deleting room:', error);
this.notificationService.showSnackbar('Failed to delete room');
}
}
}
getFormattedDate(timestamp: number): string {
return new Date(timestamp).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
getAutoDeletionStatus(): string {
const room = this.room();
if (!room) return 'N/A';
if (room.autoDeletionDate) {
return this.getFormattedDate(room.autoDeletionDate);
}
return 'Disabled';
}
isActiveRoom(): boolean {
return this.room()?.status === MeetRoomStatus.ACTIVE_MEETING;
}
isClosedRoom(): boolean {
return this.room()?.status === MeetRoomStatus.CLOSED;
}
getOwnerInitials(): string {
const room = this.room();
if (!room || !room.owner) return '';
return room.owner.substring(0, 2).toUpperCase();
}
}

View File

@ -42,6 +42,7 @@
[showLoadMore]="hasMoreRooms"
[initialFilters]="initialFilters()"
(roomAction)="onRoomAction($event)"
(roomClicked)="onRoomClick($event)"
(loadMore)="loadMoreRooms($event)"
(refresh)="refreshRooms($event)"
(filterChange)="refreshRooms($event)"

View File

@ -239,6 +239,15 @@ export class RoomsComponent implements OnInit {
}
}
async onRoomClick(roomId: string) {
try {
await this.navigationService.navigateTo(`rooms/${roomId}/detail`);
} catch (error) {
this.notificationService.showSnackbar('Error navigating to room detail');
this.log.e('Error navigating to room detail:', error);
}
}
private async reopenRoom(room: MeetRoom) {
try {
const { room: updatedRoom } = await this.roomService.updateRoomStatus(room.roomId, MeetRoomStatus.OPEN);

View File

@ -36,5 +36,11 @@ export const roomsConsoleRoutes: DomainRouteConfig[] = [
import('../pages/room-wizard/room-wizard.component').then((m) => m.RoomWizardComponent),
canActivate: [checkEditableRoomGuard]
}
},
{
route: {
path: 'rooms/:roomId/detail',
loadComponent: () => import('../pages/room-detail/room-detail.component').then((m) => m.RoomDetailComponent)
}
}
];

View File

@ -0,0 +1,10 @@
<div class="ov-breadcrumb">
@for (item of items(); track $index) {
@if (!isLastItem($index)) {
<a class="breadcrumb-link" (click)="onItemClick(item)">{{ item.label }}</a>
<mat-icon class="breadcrumb-separator">chevron_right</mat-icon>
} @else {
<span class="breadcrumb-current">{{ item.label }}</span>
}
}
</div>

View File

@ -0,0 +1,30 @@
@use '../../../../../../../src/assets/styles/design-tokens' as *;
.ov-breadcrumb {
display: flex;
align-items: center;
gap: var(--ov-meet-spacing-xs);
color: var(--ov-meet-text-secondary);
font-size: var(--ov-meet-font-size-sm);
.breadcrumb-link {
color: var(--ov-meet-color-primary);
cursor: pointer;
text-decoration: none;
transition: color var(--ov-meet-transition-fast);
&:hover {
text-decoration: underline;
}
}
.breadcrumb-separator {
@include ov-icon(xs);
color: var(--ov-meet-text-hint);
}
.breadcrumb-current {
color: var(--ov-meet-text-primary);
font-weight: var(--ov-meet-font-weight-medium);
}
}

View File

@ -0,0 +1,61 @@
import { Component, input } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
export interface BreadcrumbItem {
label: string;
route?: string;
action?: () => void | Promise<void>;
}
/**
* Reusable breadcrumb navigation component.
*
* Features:
* - Display hierarchical navigation path
* - Support for route-based or action-based navigation
* - Responsive design
* - Consistent styling with design tokens
*
* @example
* ```html
* <ov-breadcrumb [items]="breadcrumbItems"></ov-breadcrumb>
* ```
*
* @example
* ```typescript
* breadcrumbItems: BreadcrumbItem[] = [
* { label: 'Home', route: '/home' },
* { label: 'Users', action: () => this.navigateToUsers() },
* { label: 'John Doe' }
* ];
* ```
*/
@Component({
selector: 'ov-breadcrumb',
imports: [MatIconModule],
templateUrl: './breadcrumb.component.html',
styleUrl: './breadcrumb.component.scss'
})
export class BreadcrumbComponent {
/**
* Array of breadcrumb items to display.
* The last item is automatically treated as the current page (non-clickable).
*/
items = input.required<BreadcrumbItem[]>();
/**
* Checks if an item is the last item in the breadcrumb
*/
isLastItem(index: number): boolean {
return index === this.items().length - 1;
}
/**
* Handles click on a breadcrumb item
*/
async onItemClick(item: BreadcrumbItem): Promise<void> {
if (item.action) {
await item.action();
}
}
}

View File

@ -1,3 +1,4 @@
export * from './breadcrumb/breadcrumb.component';
export * from './dialogs/confirm-dialog/confirm-dialog.component';
export * from './pro-feature-badge/pro-feature-badge.component';
export * from './selectable-card/selectable-card.component';