diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/components/rooms-lists/rooms-lists.component.html b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/components/rooms-lists/rooms-lists.component.html index fc2e8735..f0fbfe8d 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/components/rooms-lists/rooms-lists.component.html +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/components/rooms-lists/rooms-lists.component.html @@ -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)) { @@ -344,7 +346,7 @@ + + + + + + + + + + + +
+ + +
+ +
+

Room ID

+

{{ room()?.roomId }}

+
+ + +
+

Owner

+
+
+ {{ getOwnerInitials() }} +
+

{{ room()?.owner }}

+
+
+ + +
+

Creation Date

+

{{ getFormattedDate(room()!.creationDate) }}

+
+ + +
+

Auto-deletion

+

+ {{ getAutoDeletionStatus() }} +

+
+
+
+
+
+ +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/pages/room-detail/room-detail.component.scss b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/pages/room-detail/room-detail.component.scss new file mode 100644 index 00000000..ac0dc356 --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/pages/room-detail/room-detail.component.scss @@ -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; + } +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/pages/room-detail/room-detail.component.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/pages/room-detail/room-detail.component.ts new file mode 100644 index 00000000..286a18fd --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/pages/room-detail/room-detail.component.ts @@ -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(null); + isLoading = signal(true); + breadcrumbItems = signal([]); + 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(); + } +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/pages/rooms/rooms.component.html b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/pages/rooms/rooms.component.html index 1af4771f..6d7612f3 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/pages/rooms/rooms.component.html +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/pages/rooms/rooms.component.html @@ -42,6 +42,7 @@ [showLoadMore]="hasMoreRooms" [initialFilters]="initialFilters()" (roomAction)="onRoomAction($event)" + (roomClicked)="onRoomClick($event)" (loadMore)="loadMoreRooms($event)" (refresh)="refreshRooms($event)" (filterChange)="refreshRooms($event)" diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/pages/rooms/rooms.component.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/pages/rooms/rooms.component.ts index 32f6cb50..9fd3f4c6 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/pages/rooms/rooms.component.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/pages/rooms/rooms.component.ts @@ -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); diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/routes/rooms.routes.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/routes/rooms.routes.ts index 7c7de9a8..a410d4b9 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/routes/rooms.routes.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/routes/rooms.routes.ts @@ -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) + } } ]; diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/shared/components/breadcrumb/breadcrumb.component.html b/meet-ce/frontend/projects/shared-meet-components/src/lib/shared/components/breadcrumb/breadcrumb.component.html new file mode 100644 index 00000000..9b26f36d --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/shared/components/breadcrumb/breadcrumb.component.html @@ -0,0 +1,10 @@ +
+ @for (item of items(); track $index) { + @if (!isLastItem($index)) { + {{ item.label }} + chevron_right + } @else { + {{ item.label }} + } + } +
diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/shared/components/breadcrumb/breadcrumb.component.scss b/meet-ce/frontend/projects/shared-meet-components/src/lib/shared/components/breadcrumb/breadcrumb.component.scss new file mode 100644 index 00000000..a2518ca1 --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/shared/components/breadcrumb/breadcrumb.component.scss @@ -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); + } +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/shared/components/breadcrumb/breadcrumb.component.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/shared/components/breadcrumb/breadcrumb.component.ts new file mode 100644 index 00000000..27c04baa --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/shared/components/breadcrumb/breadcrumb.component.ts @@ -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; +} + +/** + * 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 + * + * ``` + * + * @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(); + + /** + * 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 { + if (item.action) { + await item.action(); + } + } +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/shared/components/index.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/shared/components/index.ts index e766045a..6c2e2cff 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/shared/components/index.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/shared/components/index.ts @@ -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';