frontend: implement responsive recording lists with mobile cards view and viewport service
This commit is contained in:
parent
75e2485a03
commit
681cf24e22
@ -13,32 +13,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<!-- Recordings Toolbar -->
|
<!-- Recordings Toolbar (hidden in mobile) -->
|
||||||
<mat-toolbar class="recordings-toolbar">
|
<mat-toolbar class="recordings-toolbar" [class.mobile-hidden]="isMobileView()">
|
||||||
<!-- Left Section: Search -->
|
<!-- Left Section: Search -->
|
||||||
<div class="toolbar-left">
|
@if (!isMobileView()) {
|
||||||
@if (showSearchBox) {
|
<div class="toolbar-left">
|
||||||
<mat-form-field class="search-field" appearance="outline">
|
@if (showSearchBox) {
|
||||||
<mat-label>Search recordings</mat-label>
|
<mat-form-field class="search-field" appearance="outline">
|
||||||
<input
|
<mat-label>Search recordings</mat-label>
|
||||||
matInput
|
<input
|
||||||
[formControl]="nameFilterControl"
|
matInput
|
||||||
placeholder="Search by room name or room ID"
|
[formControl]="nameFilterControl"
|
||||||
(keydown.enter)="triggerSearch()"
|
placeholder="Search by room name or room ID"
|
||||||
/>
|
(keydown.enter)="triggerSearch()"
|
||||||
<button
|
/>
|
||||||
mat-icon-button
|
<button
|
||||||
matSuffix
|
mat-icon-button
|
||||||
class="search-btn"
|
matSuffix
|
||||||
(click)="triggerSearch()"
|
class="search-btn"
|
||||||
[disabled]="loading || !nameFilterControl.value"
|
(click)="triggerSearch()"
|
||||||
matTooltip="Search"
|
[disabled]="loading || !nameFilterControl.value"
|
||||||
>
|
matTooltip="Search"
|
||||||
<mat-icon>search</mat-icon>
|
>
|
||||||
</button>
|
<mat-icon>search</mat-icon>
|
||||||
</mat-form-field>
|
</button>
|
||||||
}
|
</mat-form-field>
|
||||||
</div>
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Center Section: Bulk Actions (visible when items selected) -->
|
<!-- Center Section: Bulk Actions (visible when items selected) -->
|
||||||
@if (showSelection && selectedRecordings().size > 0) {
|
@if (showSelection && selectedRecordings().size > 0) {
|
||||||
@ -118,6 +120,72 @@
|
|||||||
</div>
|
</div>
|
||||||
</mat-toolbar>
|
</mat-toolbar>
|
||||||
|
|
||||||
|
<!-- Mobile Sticky Toolbar (always visible in mobile) -->
|
||||||
|
<!-- @if (isMobileView()) {
|
||||||
|
<div class="mobile-sticky-toolbar">
|
||||||
|
<div class="mobile-toolbar-content">
|
||||||
|
<-- Always visible: Refresh button --
|
||||||
|
<div class="toolbar-section primary-actions">
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
class="refresh-btn"
|
||||||
|
(click)="refreshRecordings()"
|
||||||
|
[disabled]="loading"
|
||||||
|
matTooltip="Refresh recordings"
|
||||||
|
>
|
||||||
|
<mat-icon>refresh</mat-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (hasActiveFilters()) {
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
class="clear-btn"
|
||||||
|
(click)="clearFilters()"
|
||||||
|
[disabled]="loading"
|
||||||
|
matTooltip="Clear all filters"
|
||||||
|
>
|
||||||
|
<mat-icon>filter_alt_off</mat-icon>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
-- Bulk actions: visible when items selected --
|
||||||
|
@if (showSelection && selectedRecordings().size > 0) {
|
||||||
|
<div class="toolbar-section bulk-actions">
|
||||||
|
<div class="selection-indicator">
|
||||||
|
<span class="selection-count">{{ selectedRecordings().size }} selected</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-buttons">
|
||||||
|
@if (canDeleteRecordings) {
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
color="warn"
|
||||||
|
(click)="bulkDeleteSelected()"
|
||||||
|
[disabled]="loading"
|
||||||
|
matTooltip="Delete selected recordings"
|
||||||
|
class="bulk-delete-btn"
|
||||||
|
>
|
||||||
|
<mat-icon>delete</mat-icon>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
(click)="bulkDownloadSelected()"
|
||||||
|
[disabled]="loading"
|
||||||
|
matTooltip="Download selected recordings"
|
||||||
|
class="bulk-download-btn"
|
||||||
|
>
|
||||||
|
<mat-icon>download</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} -->
|
||||||
|
|
||||||
<!-- Loading Spinner -->
|
<!-- Loading Spinner -->
|
||||||
@if (loading) {
|
@if (loading) {
|
||||||
<div class="loading-container">
|
<div class="loading-container">
|
||||||
@ -139,119 +207,101 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<!-- Recordings Table -->
|
@if (isMobileView()) {
|
||||||
<div class="table-container">
|
<!-- Mobile Cards View -->
|
||||||
<table mat-table [dataSource]="recordings" class="recordings-table">
|
<div class="cards-container">
|
||||||
<!-- Selection Column -->
|
@for (recording of recordings; track recording.recordingId) {
|
||||||
@if (showSelection) {
|
<div class="recording-card" [class.selected-card]="isRecordingSelected(recording)">
|
||||||
<ng-container matColumnDef="select">
|
<!-- Card Header with Selection and Status -->
|
||||||
<th mat-header-cell *matHeaderCellDef>
|
<div class="card-header">
|
||||||
<mat-checkbox
|
<div class="card-header-left">
|
||||||
[checked]="allSelected()"
|
@if (showSelection && canSelectRecording(recording)) {
|
||||||
[indeterminate]="someSelected()"
|
<mat-checkbox
|
||||||
(change)="toggleAllSelection()"
|
[checked]="isRecordingSelected(recording)"
|
||||||
[disabled]="loading"
|
(change)="toggleRecordingSelection(recording)"
|
||||||
>
|
[disabled]="loading"
|
||||||
</mat-checkbox>
|
class="card-checkbox"
|
||||||
</th>
|
[attr.aria-label]="'Select recording ' + recording.recordingId"
|
||||||
<td mat-cell *matCellDef="let recording">
|
>
|
||||||
@if (canSelectRecording(recording)) {
|
</mat-checkbox>
|
||||||
<mat-checkbox
|
}
|
||||||
[checked]="isRecordingSelected(recording)"
|
|
||||||
(change)="toggleRecordingSelection(recording)"
|
|
||||||
[disabled]="loading"
|
|
||||||
>
|
|
||||||
</mat-checkbox>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Room Info Column -->
|
@if (showRoomInfo) {
|
||||||
@if (showRoomInfo) {
|
<div class="room-info">
|
||||||
<ng-container matColumnDef="roomInfo">
|
<div class="room-name">{{ recording.roomName }}</div>
|
||||||
<th mat-header-cell *matHeaderCellDef class="room-header">Room</th>
|
<div class="room-id">{{ recording.recordingId }}</div>
|
||||||
<td mat-cell *matCellDef="let recording" class="room-cell">
|
</div>
|
||||||
<div class="room-info">
|
}
|
||||||
<span class="room-name">{{ recording.roomName }}</span>
|
|
||||||
<span class="room-id">{{ recording.roomId }}</span>
|
|
||||||
<!-- @if (recording.filename) {
|
|
||||||
<span class="filename">{{ recording.filename }}</span>
|
|
||||||
} -->
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Status Column -->
|
<div class="status-info">
|
||||||
<ng-container matColumnDef="status">
|
<div class="status-badge" [style.color]="getStatusColor(recording.status)">
|
||||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
<mat-icon class="status-icon" [style.color]="getStatusColor(recording.status)">
|
||||||
<td mat-cell *matCellDef="let recording">
|
{{ getStatusIcon(recording.status) }}
|
||||||
<div class="status-badge" [style.color]="getStatusColor(recording.status)">
|
</mat-icon>
|
||||||
<mat-icon [style.color]="getStatusColor(recording.status)">
|
<span class="status-label">{{ getStatusLabel(recording.status) }}</span>
|
||||||
{{ getStatusIcon(recording.status) }}
|
</div>
|
||||||
</mat-icon>
|
</div>
|
||||||
<span class="status-label">{{ getStatusLabel(recording.status) }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<!-- Start Date Column -->
|
<!-- Card Content -->
|
||||||
<ng-container matColumnDef="startDate">
|
<div class="card-content">
|
||||||
<th mat-header-cell *matHeaderCellDef>Start Date</th>
|
<div class="recording-details">
|
||||||
<td mat-cell *matCellDef="let recording">
|
<div class="detail-item">
|
||||||
@if (recording.startDate) {
|
<mat-icon class="detail-icon">schedule</mat-icon>
|
||||||
<div class="date-info">
|
<div class="detail-content">
|
||||||
<span class="date">{{ recording.startDate | date: 'mediumDate' }}</span>
|
<div class="detail-label">Start Date</div>
|
||||||
<span class="time">{{ recording.startDate | date: 'shortTime' }}</span>
|
@if (recording.startDate) {
|
||||||
|
<div class="detail-value">
|
||||||
|
{{ recording.startDate | date: 'mediumDate' }}
|
||||||
|
</div>
|
||||||
|
<div class="detail-time">{{ recording.startDate | date: 'shortTime' }}</div>
|
||||||
|
} @else {
|
||||||
|
<div class="detail-value no-data">-</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-item">
|
||||||
|
<mat-icon class="detail-icon">timer</mat-icon>
|
||||||
|
<div class="detail-content">
|
||||||
|
<div class="detail-label">Duration</div>
|
||||||
|
<div class="detail-value">{{ formatDuration(recording.duration) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-item">
|
||||||
|
<mat-icon class="detail-icon">storage</mat-icon>
|
||||||
|
<div class="detail-content">
|
||||||
|
<div class="detail-label">Size</div>
|
||||||
|
<div class="detail-value">{{ formatFileSize(recording.size) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
</div>
|
||||||
<span class="no-data">-</span>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<!-- Duration Column -->
|
<!-- Card Actions -->
|
||||||
<ng-container matColumnDef="duration">
|
<div class="card-actions">
|
||||||
<th mat-header-cell *matHeaderCellDef>Duration</th>
|
|
||||||
<td mat-cell *matCellDef="let recording">
|
|
||||||
<span>{{ formatDuration(recording.duration) }}</span>
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<!-- Size Column -->
|
|
||||||
<ng-container matColumnDef="size">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>Size</th>
|
|
||||||
<td mat-cell *matCellDef="let recording">
|
|
||||||
<span>{{ formatFileSize(recording.size) }}</span>
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<!-- Actions Column -->
|
|
||||||
<ng-container matColumnDef="actions">
|
|
||||||
<th mat-header-cell *matHeaderCellDef class="actions-header">Actions</th>
|
|
||||||
<td mat-cell *matCellDef="let recording" class="actions-cell">
|
|
||||||
<div class="action-buttons">
|
|
||||||
<!-- Play Button -->
|
|
||||||
@if (canPlayRecording(recording)) {
|
@if (canPlayRecording(recording)) {
|
||||||
<button
|
<button
|
||||||
mat-icon-button
|
mat-icon-button
|
||||||
matTooltip="Play Recording"
|
|
||||||
(click)="playRecording(recording)"
|
(click)="playRecording(recording)"
|
||||||
[disabled]="loading"
|
[disabled]="loading"
|
||||||
|
class="action-button play-button"
|
||||||
|
matTooltip="Play Recording"
|
||||||
id="play-recording-btn-{{ recording.recordingId }}"
|
id="play-recording-btn-{{ recording.recordingId }}"
|
||||||
>
|
>
|
||||||
<mat-icon>play_arrow</mat-icon>
|
<mat-icon>play_arrow</mat-icon>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Download Button -->
|
|
||||||
@if (canDownloadRecording(recording)) {
|
@if (canDownloadRecording(recording)) {
|
||||||
<button
|
<button
|
||||||
mat-icon-button
|
mat-icon-button
|
||||||
matTooltip="Download Recording"
|
|
||||||
(click)="downloadRecording(recording)"
|
(click)="downloadRecording(recording)"
|
||||||
[disabled]="loading"
|
[disabled]="loading"
|
||||||
|
class="action-button download-button"
|
||||||
|
matTooltip="Download Recording"
|
||||||
id="download-recording-btn-{{ recording.recordingId }}"
|
id="download-recording-btn-{{ recording.recordingId }}"
|
||||||
>
|
>
|
||||||
<mat-icon>download</mat-icon>
|
<mat-icon>download</mat-icon>
|
||||||
@ -260,28 +310,28 @@
|
|||||||
|
|
||||||
@if (isRecordingFailed(recording)) {
|
@if (isRecordingFailed(recording)) {
|
||||||
<button
|
<button
|
||||||
class="delete-action"
|
|
||||||
mat-icon-button
|
mat-icon-button
|
||||||
matTooltip="Delete Recording"
|
|
||||||
(click)="deleteRecording(recording)"
|
(click)="deleteRecording(recording)"
|
||||||
[disabled]="loading"
|
[disabled]="loading"
|
||||||
|
class="action-button delete-button"
|
||||||
|
matTooltip="Delete Recording"
|
||||||
id="delete-recording-btn-{{ recording.recordingId }}"
|
id="delete-recording-btn-{{ recording.recordingId }}"
|
||||||
>
|
>
|
||||||
<mat-icon>delete</mat-icon>
|
<mat-icon>delete</mat-icon>
|
||||||
</button>
|
</button>
|
||||||
} @else {
|
} @else {
|
||||||
<!-- More Actions Menu -->
|
|
||||||
<button
|
<button
|
||||||
mat-icon-button
|
mat-icon-button
|
||||||
[matMenuTriggerFor]="actionsMenu"
|
[matMenuTriggerFor]="cardActionsMenu"
|
||||||
matTooltip="More Actions"
|
|
||||||
[disabled]="loading"
|
[disabled]="loading"
|
||||||
|
class="action-button more-button"
|
||||||
|
matTooltip="More Actions"
|
||||||
id="more-actions-btn-{{ recording.recordingId }}"
|
id="more-actions-btn-{{ recording.recordingId }}"
|
||||||
>
|
>
|
||||||
<mat-icon>more_vert</mat-icon>
|
<mat-icon>more_vert</mat-icon>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<mat-menu #actionsMenu="matMenu">
|
<mat-menu #cardActionsMenu="matMenu">
|
||||||
<button
|
<button
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
(click)="shareRecordingLink(recording)"
|
(click)="shareRecordingLink(recording)"
|
||||||
@ -290,7 +340,6 @@
|
|||||||
<mat-icon>share</mat-icon>
|
<mat-icon>share</mat-icon>
|
||||||
<span>Share link</span>
|
<span>Share link</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@if (canDeleteRecording(recording)) {
|
@if (canDeleteRecording(recording)) {
|
||||||
<mat-divider></mat-divider>
|
<mat-divider></mat-divider>
|
||||||
<button
|
<button
|
||||||
@ -306,17 +355,192 @@
|
|||||||
</mat-menu>
|
</mat-menu>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
</ng-container>
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<!-- Desktop Table View -->
|
||||||
|
<div class="table-container">
|
||||||
|
<table mat-table [dataSource]="recordings" class="recordings-table">
|
||||||
|
<!-- Selection Column -->
|
||||||
|
@if (showSelection) {
|
||||||
|
<ng-container matColumnDef="select">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="select-header">
|
||||||
|
<mat-checkbox
|
||||||
|
[checked]="allSelected()"
|
||||||
|
[indeterminate]="someSelected()"
|
||||||
|
(change)="toggleAllSelection()"
|
||||||
|
[disabled]="loading"
|
||||||
|
[attr.aria-label]="allSelected() ? 'Deselect all' : 'Select all'"
|
||||||
|
>
|
||||||
|
</mat-checkbox>
|
||||||
|
</th>
|
||||||
|
<td mat-cell *matCellDef="let recording" class="select-cell">
|
||||||
|
@if (canSelectRecording(recording)) {
|
||||||
|
<mat-checkbox
|
||||||
|
[checked]="isRecordingSelected(recording)"
|
||||||
|
(change)="toggleRecordingSelection(recording)"
|
||||||
|
[disabled]="loading"
|
||||||
|
[attr.aria-label]="'Select recording ' + recording.recordingId"
|
||||||
|
>
|
||||||
|
</mat-checkbox>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
}
|
||||||
|
|
||||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
<!-- Room Info Column -->
|
||||||
<tr
|
@if (showRoomInfo) {
|
||||||
mat-row
|
<ng-container matColumnDef="roomInfo">
|
||||||
*matRowDef="let row; columns: displayedColumns"
|
<th mat-header-cell *matHeaderCellDef class="room-header">Room</th>
|
||||||
[class.selected-row]="isRecordingSelected(row)"
|
<td mat-cell *matCellDef="let recording" class="room-cell">
|
||||||
></tr>
|
<div class="room-info">
|
||||||
</table>
|
<span class="room-name">{{ recording.roomName }}</span>
|
||||||
</div>
|
<span class="room-id">{{ recording.recordingId }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Status Column -->
|
||||||
|
<ng-container matColumnDef="status">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||||
|
<td mat-cell *matCellDef="let recording">
|
||||||
|
<div class="status-badge" [style.color]="getStatusColor(recording.status)">
|
||||||
|
<mat-icon class="status-icon" [style.color]="getStatusColor(recording.status)">
|
||||||
|
{{ getStatusIcon(recording.status) }}
|
||||||
|
</mat-icon>
|
||||||
|
<span class="status-label">{{ getStatusLabel(recording.status) }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Start Date Column -->
|
||||||
|
<ng-container matColumnDef="startDate">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Start Date</th>
|
||||||
|
<td mat-cell *matCellDef="let recording">
|
||||||
|
@if (recording.startDate) {
|
||||||
|
<div class="date-info">
|
||||||
|
<span class="date">{{ recording.startDate | date: 'mediumDate' }}</span>
|
||||||
|
<span class="time">{{ recording.startDate | date: 'shortTime' }}</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<span class="no-data">-</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Duration Column -->
|
||||||
|
<ng-container matColumnDef="duration">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Duration</th>
|
||||||
|
<td mat-cell *matCellDef="let recording">
|
||||||
|
<span>{{ formatDuration(recording.duration) }}</span>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Size Column -->
|
||||||
|
<ng-container matColumnDef="size">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Size</th>
|
||||||
|
<td mat-cell *matCellDef="let recording">
|
||||||
|
<span>{{ formatFileSize(recording.size) }}</span>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Actions Column -->
|
||||||
|
<ng-container matColumnDef="actions">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="actions-header">Actions</th>
|
||||||
|
<td mat-cell *matCellDef="let recording" class="actions-cell">
|
||||||
|
<div class="action-buttons">
|
||||||
|
<!-- Play Button -->
|
||||||
|
@if (canPlayRecording(recording)) {
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
matTooltip="Play Recording"
|
||||||
|
class="action-button play-button"
|
||||||
|
(click)="playRecording(recording)"
|
||||||
|
[disabled]="loading"
|
||||||
|
id="play-recording-btn-{{ recording.recordingId }}"
|
||||||
|
>
|
||||||
|
<mat-icon>play_arrow</mat-icon>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Download Button -->
|
||||||
|
@if (canDownloadRecording(recording)) {
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
matTooltip="Download Recording"
|
||||||
|
class="action-button download-button"
|
||||||
|
(click)="downloadRecording(recording)"
|
||||||
|
[disabled]="loading"
|
||||||
|
id="download-recording-btn-{{ recording.recordingId }}"
|
||||||
|
>
|
||||||
|
<mat-icon>download</mat-icon>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isRecordingFailed(recording)) {
|
||||||
|
<button
|
||||||
|
class="delete-action"
|
||||||
|
mat-icon-button
|
||||||
|
matTooltip="Delete Recording"
|
||||||
|
class="action-button delete-button"
|
||||||
|
(click)="deleteRecording(recording)"
|
||||||
|
[disabled]="loading"
|
||||||
|
id="delete-recording-btn-{{ recording.recordingId }}"
|
||||||
|
>
|
||||||
|
<mat-icon>delete</mat-icon>
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<!-- More Actions Menu -->
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
class="action-button more-button"
|
||||||
|
[matMenuTriggerFor]="actionsMenu"
|
||||||
|
matTooltip="More Actions"
|
||||||
|
[disabled]="loading"
|
||||||
|
id="more-actions-btn-{{ recording.recordingId }}"
|
||||||
|
>
|
||||||
|
<mat-icon>more_vert</mat-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<mat-menu #actionsMenu="matMenu">
|
||||||
|
<button
|
||||||
|
mat-menu-item
|
||||||
|
(click)="shareRecordingLink(recording)"
|
||||||
|
id="share-recording-link-{{ recording.recordingId }}"
|
||||||
|
>
|
||||||
|
<mat-icon>share</mat-icon>
|
||||||
|
<span>Share link</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (canDeleteRecording(recording)) {
|
||||||
|
<mat-divider></mat-divider>
|
||||||
|
<button
|
||||||
|
mat-menu-item
|
||||||
|
(click)="deleteRecording(recording)"
|
||||||
|
class="delete-action"
|
||||||
|
id="delete-recording-btn-{{ recording.recordingId }}"
|
||||||
|
>
|
||||||
|
<mat-icon>delete</mat-icon>
|
||||||
|
<span>Delete recording</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</mat-menu>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||||
|
<tr
|
||||||
|
mat-row
|
||||||
|
*matRowDef="let row; columns: displayedColumns"
|
||||||
|
[class.selected-row]="isRecordingSelected(row)"
|
||||||
|
></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Load More Section -->
|
<!-- Load More Section -->
|
||||||
@if (showLoadMore) {
|
@if (showLoadMore) {
|
||||||
|
|||||||
@ -1,45 +1,333 @@
|
|||||||
@import '../../../../../../src/assets/styles/design-tokens';
|
@import '../../../../../../src/assets/styles/design-tokens';
|
||||||
|
|
||||||
// Use utility classes for recordings toolbar
|
// === MOBILE CARDS VIEW ===
|
||||||
.recordings-toolbar {
|
.cards-container {
|
||||||
@extend .ov-data-toolbar;
|
@include ov-grid-responsive(280px);
|
||||||
|
gap: var(--ov-meet-spacing-md);
|
||||||
|
padding: var(--ov-meet-spacing-md) 0;
|
||||||
|
|
||||||
.toolbar-left {
|
@include ov-mobile-down {
|
||||||
::ng-deep .search-btn {
|
grid-template-columns: 1fr;
|
||||||
padding: var(--ov-meet-spacing-sm);
|
gap: var(--ov-meet-spacing-sm);
|
||||||
}
|
padding: var(--ov-meet-spacing-sm) 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.recording-card {
|
||||||
|
@include ov-card;
|
||||||
|
@include ov-theme-transition;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--ov-meet-spacing-md);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&.selected-card {
|
||||||
|
background-color: color-mix(in srgb, var(--ov-meet-color-primary) 8%, var(--ov-meet-card-background));
|
||||||
|
border: 1px solid var(--ov-meet-color-primary);
|
||||||
|
box-shadow: var(--ov-meet-card-shadow-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-right {
|
&:hover:not(.selected-card) {
|
||||||
gap: var(--ov-meet-spacing-sm);
|
@include ov-hover-lift(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
::ng-deep .refresh-btn {
|
@include ov-mobile-down {
|
||||||
padding: var(--ov-meet-spacing-sm);
|
gap: var(--ov-meet-spacing-sm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--ov-meet-spacing-md);
|
||||||
|
|
||||||
|
.card-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--ov-meet-spacing-sm);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-checkbox {
|
||||||
|
margin: 0;
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-info {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.recording-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--ov-meet-spacing-md);
|
||||||
|
|
||||||
|
@include ov-mobile-down {
|
||||||
|
gap: var(--ov-meet-spacing-sm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--ov-meet-spacing-sm);
|
||||||
|
|
||||||
|
.detail-icon {
|
||||||
|
@include ov-icon(sm);
|
||||||
|
color: var(--ov-meet-text-secondary);
|
||||||
|
margin-top: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
font-size: var(--ov-meet-font-size-xs);
|
||||||
|
font-weight: var(--ov-meet-font-weight-medium);
|
||||||
|
color: var(--ov-meet-text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::ng-deep .clear-btn {
|
.detail-value {
|
||||||
padding: var(--ov-meet-spacing-sm);
|
font-size: var(--ov-meet-font-size-sm);
|
||||||
|
font-weight: var(--ov-meet-font-weight-medium);
|
||||||
|
color: var(--ov-meet-text-primary);
|
||||||
|
line-height: var(--ov-meet-line-height-tight);
|
||||||
|
|
||||||
|
&.no-data {
|
||||||
|
@extend .ov-no-data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-time {
|
||||||
|
font-size: var(--ov-meet-font-size-xs);
|
||||||
|
color: var(--ov-meet-text-secondary);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
&.play-button mat-icon {
|
||||||
|
color: var(--ov-meet-color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.download-button mat-icon {
|
||||||
|
color: var(--ov-meet-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.delete-button mat-icon {
|
||||||
|
color: var(--ov-meet-color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.more-button mat-icon {
|
||||||
|
color: var(--ov-meet-text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
@extend .ov-action-buttons;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: var(--ov-meet-spacing-sm);
|
||||||
|
border-top: 1px solid var(--ov-meet-border-color-light);
|
||||||
|
margin-top: var(--ov-meet-spacing-sm);
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
@include ov-icon(lg);
|
||||||
|
|
||||||
|
padding: 0;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 24px !important;
|
||||||
|
width: 24px !important;
|
||||||
|
height: 24px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include ov-mobile-down {
|
||||||
|
gap: var(--ov-meet-spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.card-actions {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === TOOLBAR AND SHARED UTILITIES ===
|
||||||
|
.recordings-toolbar {
|
||||||
|
@extend .ov-data-toolbar;
|
||||||
|
|
||||||
|
// Responsive toolbar adjustments
|
||||||
|
@include ov-tablet-down {
|
||||||
|
.toolbar-center {
|
||||||
|
z-index: 1000;
|
||||||
|
position: fixed;
|
||||||
|
|
||||||
|
.batch-actions {
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include ov-mobile-down {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--ov-meet-spacing-sm);
|
||||||
|
|
||||||
|
.toolbar-left {
|
||||||
|
order: 2;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-right {
|
||||||
|
order: 1;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
|
||||||
|
.refresh-btn {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use utility classes for search field, selection info, and batch actions
|
|
||||||
.search-field {
|
.search-field {
|
||||||
@extend .ov-search-field;
|
@extend .ov-search-field;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selection-info {
|
|
||||||
@extend .ov-selection-info;
|
|
||||||
}
|
|
||||||
|
|
||||||
.batch-actions {
|
.batch-actions {
|
||||||
@extend .ov-batch-actions;
|
@extend .ov-batch-actions;
|
||||||
::ng-deep button {
|
}
|
||||||
padding: var(--ov-meet-spacing-sm);
|
|
||||||
|
// === DESKTOP TABLE VIEW ===
|
||||||
|
.table-container {
|
||||||
|
@extend .ov-table-container;
|
||||||
|
margin-top: 0;
|
||||||
|
|
||||||
|
@include ov-tablet-down {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filters Menu Styling
|
.recordings-table {
|
||||||
|
@extend .ov-data-table;
|
||||||
|
min-width: 600px;
|
||||||
|
|
||||||
|
.mat-mdc-header-cell {
|
||||||
|
&.room-header {
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.select-header {
|
||||||
|
width: 48px;
|
||||||
|
min-width: 48px;
|
||||||
|
max-width: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.actions-header {
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-cell {
|
||||||
|
&.select-cell {
|
||||||
|
width: 48px;
|
||||||
|
min-width: 48px;
|
||||||
|
max-width: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include ov-tablet-down {
|
||||||
|
.mat-column-duration,
|
||||||
|
.mat-column-size {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.mat-column-startDate .time {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === SHARED COMPONENTS ===
|
||||||
|
.room-info {
|
||||||
|
@extend .ov-info-display;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.room-name {
|
||||||
|
@include ov-text-truncate;
|
||||||
|
font-weight: var(--ov-meet-font-weight-medium);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
|
||||||
|
.cards-container & {
|
||||||
|
font-size: var(--ov-meet-font-size-md);
|
||||||
|
line-height: var(--ov-meet-line-height-tight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-id {
|
||||||
|
@include ov-text-truncate;
|
||||||
|
font-size: var(--ov-meet-font-size-xs);
|
||||||
|
color: var(--ov-meet-text-secondary);
|
||||||
|
|
||||||
|
.cards-container & {
|
||||||
|
font-size: var(--ov-meet-font-size-xxs);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
@extend .ov-status-badge;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--ov-meet-spacing-xs);
|
||||||
|
font-weight: var(--ov-meet-font-weight-medium);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
|
||||||
|
.status-icon {
|
||||||
|
@include ov-icon(sm);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-label {
|
||||||
|
font-size: var(--ov-meet-font-size-xxs);
|
||||||
|
.cards-container & {
|
||||||
|
font-size: var(--ov-meet-font-size-xs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include ov-mobile-down {
|
||||||
|
padding: var(--ov-meet-spacing-xxs) var(--ov-meet-spacing-xs);
|
||||||
|
font-size: var(--ov-meet-font-size-xs);
|
||||||
|
|
||||||
|
.status-icon {
|
||||||
|
@include ov-icon(xs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === UTILITY EXTENSIONS ===
|
||||||
.filters-menu {
|
.filters-menu {
|
||||||
@extend .ov-filters-menu;
|
@extend .ov-filters-menu;
|
||||||
}
|
}
|
||||||
@ -48,130 +336,63 @@
|
|||||||
@extend .ov-loading-container;
|
@extend .ov-loading-container;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enhanced Table Container
|
|
||||||
.table-container {
|
|
||||||
@extend .ov-table-container;
|
|
||||||
margin-top: 0; // Remove top margin since toolbar is attached
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toolbar + Table Integration
|
|
||||||
.recordings-toolbar + .table-container {
|
|
||||||
border-top-left-radius: 0;
|
|
||||||
border-top-right-radius: 0;
|
|
||||||
border-top: none;
|
|
||||||
box-shadow: var(--ov-meet-shadow-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure proper spacing when no toolbar
|
|
||||||
:host:not(:has(.recordings-toolbar)) .table-container {
|
|
||||||
margin-top: var(--ov-meet-spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.recordings-table {
|
|
||||||
@extend .ov-data-table;
|
|
||||||
|
|
||||||
// Header customizations
|
|
||||||
.mat-mdc-header-cell {
|
|
||||||
&.room-header {
|
|
||||||
@extend .primary-header;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.actions-header {
|
|
||||||
@extend .actions-header;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cell customizations
|
|
||||||
.mat-mdc-cell {
|
|
||||||
&.room-cell {
|
|
||||||
@extend .primary-cell;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.actions-cell {
|
|
||||||
@extend .actions-cell;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Room information
|
|
||||||
.room-info {
|
|
||||||
@extend .ov-info-display;
|
|
||||||
|
|
||||||
.room-name {
|
|
||||||
@extend .primary-text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-id {
|
|
||||||
@extend .secondary-text, .monospace-text;
|
|
||||||
}
|
|
||||||
|
|
||||||
// .filename {
|
|
||||||
// @extend .secondary-text, .monospace-text;
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status badge
|
|
||||||
.status-badge {
|
|
||||||
@extend .ov-status-badge;
|
|
||||||
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);
|
|
||||||
width: fit-content;
|
|
||||||
|
|
||||||
.status-icon {
|
|
||||||
@include ov-icon(sm);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Date information
|
|
||||||
.date-info {
|
.date-info {
|
||||||
@extend .ov-date-info;
|
@extend .ov-date-info;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-data {
|
|
||||||
@extend .ov-no-data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Action buttons
|
|
||||||
.action-buttons {
|
.action-buttons {
|
||||||
@extend .ov-action-buttons;
|
@extend .ov-action-buttons;
|
||||||
|
|
||||||
::ng-deep button {
|
@include ov-tablet-down {
|
||||||
padding: var(--ov-meet-spacing-sm);
|
.play-btn,
|
||||||
}
|
.download-btn {
|
||||||
|
display: none;
|
||||||
.mat-mdc-icon-button {
|
|
||||||
&:hover {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Menu item styles
|
|
||||||
.delete-action {
|
.delete-action {
|
||||||
@extend .ov-delete-action;
|
@extend .ov-delete-action;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Empty State Styling
|
|
||||||
.no-recordings-state {
|
.no-recordings-state {
|
||||||
@extend .ov-empty-state;
|
@extend .ov-empty-state;
|
||||||
|
|
||||||
.getting-started-actions {
|
.getting-started-actions {
|
||||||
@extend .action-buttons;
|
@include ov-mobile-down {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--ov-meet-spacing-sm);
|
||||||
|
|
||||||
.refresh-recordings-btn {
|
button {
|
||||||
@extend .refresh-btn;
|
width: 100%;
|
||||||
}
|
padding: var(--ov-meet-spacing-md);
|
||||||
|
}
|
||||||
.clear-filters-btn {
|
|
||||||
@extend .refresh-btn;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply focus states for accessibility
|
// === ACCESSIBILITY AND ANIMATIONS ===
|
||||||
.mat-mdc-checkbox,
|
.mat-mdc-checkbox,
|
||||||
.mat-mdc-icon-button,
|
.mat-mdc-icon-button,
|
||||||
.mat-mdc-button {
|
.mat-mdc-button {
|
||||||
@extend .ov-focus-visible;
|
@extend .ov-focus-visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@include ov-mobile-down {
|
||||||
|
.mat-mdc-icon-button {
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.recording-card {
|
||||||
|
animation: fadeIn 0.3s ease-out;
|
||||||
|
|
||||||
|
&:nth-child(even) {
|
||||||
|
animation-delay: 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(odd) {
|
||||||
|
animation-delay: 0.05s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,15 +1,5 @@
|
|||||||
import { CommonModule, DatePipe } from '@angular/common';
|
import { CommonModule, DatePipe } from '@angular/common';
|
||||||
import {
|
import { Component, EventEmitter, Input, OnChanges, OnInit, Output, signal, SimpleChanges } from '@angular/core';
|
||||||
Component,
|
|
||||||
EventEmitter,
|
|
||||||
HostBinding,
|
|
||||||
Input,
|
|
||||||
OnChanges,
|
|
||||||
OnInit,
|
|
||||||
Output,
|
|
||||||
signal,
|
|
||||||
SimpleChanges
|
|
||||||
} from '@angular/core';
|
|
||||||
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
||||||
import { MatBadgeModule } from '@angular/material/badge';
|
import { MatBadgeModule } from '@angular/material/badge';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
@ -26,7 +16,7 @@ import { MatToolbarModule } from '@angular/material/toolbar';
|
|||||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||||
import { MeetRecordingInfo, MeetRecordingStatus } from '@lib/typings/ce';
|
import { MeetRecordingInfo, MeetRecordingStatus } from '@lib/typings/ce';
|
||||||
import { formatBytes, formatDurationToHMS } from '@lib/utils';
|
import { formatBytes, formatDurationToHMS } from '@lib/utils';
|
||||||
import { debounceTime, distinctUntilChanged } from 'rxjs';
|
import { ViewportService } from '@lib/services';
|
||||||
|
|
||||||
export interface RecordingTableAction {
|
export interface RecordingTableAction {
|
||||||
recordings: MeetRecordingInfo[];
|
recordings: MeetRecordingInfo[];
|
||||||
@ -91,13 +81,6 @@ export class RecordingListsComponent implements OnInit, OnChanges {
|
|||||||
@Input() showLoadMore = false;
|
@Input() showLoadMore = false;
|
||||||
@Input() loading = false;
|
@Input() loading = false;
|
||||||
@Input() initialFilters: { nameFilter: string; statusFilter: string } = { nameFilter: '', statusFilter: '' };
|
@Input() initialFilters: { nameFilter: string; statusFilter: string } = { nameFilter: '', statusFilter: '' };
|
||||||
|
|
||||||
// Host binding for styling when recordings are selected
|
|
||||||
@HostBinding('class.has-selections')
|
|
||||||
get hasSelections(): boolean {
|
|
||||||
return this.selectedRecordings().size > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Output events
|
// Output events
|
||||||
@Output() recordingAction = new EventEmitter<RecordingTableAction>();
|
@Output() recordingAction = new EventEmitter<RecordingTableAction>();
|
||||||
@Output() filterChange = new EventEmitter<{ nameFilter: string; statusFilter: string }>();
|
@Output() filterChange = new EventEmitter<{ nameFilter: string; statusFilter: string }>();
|
||||||
@ -150,7 +133,11 @@ export class RecordingListsComponent implements OnInit, OnChanges {
|
|||||||
DOWNLOADABLE: [MeetRecordingStatus.COMPLETE] as readonly MeetRecordingStatus[]
|
DOWNLOADABLE: [MeetRecordingStatus.COMPLETE] as readonly MeetRecordingStatus[]
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
constructor() {}
|
constructor(private viewportService: ViewportService) {}
|
||||||
|
|
||||||
|
get isMobileView() {
|
||||||
|
return this.viewportService.isMobileView;
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.setupFilters();
|
this.setupFilters();
|
||||||
|
|||||||
@ -187,20 +187,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Animation classes
|
|
||||||
.fade-in {
|
|
||||||
animation: fadeIn 0.3s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(10px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -134,6 +134,7 @@
|
|||||||
|
|
||||||
.recordings-content {
|
.recordings-content {
|
||||||
margin-top: var(--ov-meet-spacing-md);
|
margin-top: var(--ov-meet-spacing-md);
|
||||||
|
padding: var(--ov-meet-spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-card {
|
.loading-card {
|
||||||
|
|||||||
@ -12,4 +12,5 @@ export * from './navigation.service';
|
|||||||
export * from './notification.service';
|
export * from './notification.service';
|
||||||
export * from './session-storage.service';
|
export * from './session-storage.service';
|
||||||
export * from './theme.service';
|
export * from './theme.service';
|
||||||
|
export * from './viewport.service';
|
||||||
export * from './wizard-state.service';
|
export * from './wizard-state.service';
|
||||||
|
|||||||
@ -0,0 +1,259 @@
|
|||||||
|
import { Injectable, signal, computed, OnDestroy } from '@angular/core';
|
||||||
|
import { fromEvent, Subject, debounceTime, distinctUntilChanged } from 'rxjs';
|
||||||
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Viewport size categories based on design system breakpoints
|
||||||
|
*/
|
||||||
|
export type ViewportSize = 'mobile' | 'tablet' | 'desktop' | 'wide';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device orientation type
|
||||||
|
*/
|
||||||
|
export type DeviceOrientation = 'portrait' | 'landscape';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Viewport information interface
|
||||||
|
*/
|
||||||
|
export interface ViewportInfo {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
size: ViewportSize;
|
||||||
|
orientation: DeviceOrientation;
|
||||||
|
isMobile: boolean;
|
||||||
|
isTablet: boolean;
|
||||||
|
isDesktop: boolean;
|
||||||
|
isWide: boolean;
|
||||||
|
isTouchDevice: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for responsive viewport detection and device type identification.
|
||||||
|
* Provides reactive signals and utilities for building responsive interfaces.
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class ViewportService implements OnDestroy {
|
||||||
|
// Design system breakpoints
|
||||||
|
private readonly BREAKPOINTS = {
|
||||||
|
mobile: 480,
|
||||||
|
tablet: 768,
|
||||||
|
desktop: 1024,
|
||||||
|
wide: 1200
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Reactive signals
|
||||||
|
private readonly _width = signal(this.getCurrentWidth());
|
||||||
|
private readonly _height = signal(this.getCurrentHeight());
|
||||||
|
private readonly _isTouchDevice = signal(this.detectTouchDevice());
|
||||||
|
|
||||||
|
// Cleanup subject
|
||||||
|
private readonly destroy$ = new Subject<void>();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.initializeResizeListener();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==== PUBLIC REACTIVE SIGNALS ====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current viewport width (reactive)
|
||||||
|
*/
|
||||||
|
readonly width = this._width.asReadonly();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current viewport height (reactive)
|
||||||
|
*/
|
||||||
|
readonly height = this._height.asReadonly();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether device supports touch interactions (reactive)
|
||||||
|
*/
|
||||||
|
readonly isTouchDevice = this._isTouchDevice.asReadonly();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current viewport size category (computed)
|
||||||
|
*/
|
||||||
|
readonly viewportSize = computed<ViewportSize>(() => {
|
||||||
|
const width = this._width();
|
||||||
|
if (width >= this.BREAKPOINTS.wide) return 'wide';
|
||||||
|
if (width >= this.BREAKPOINTS.desktop) return 'desktop';
|
||||||
|
if (width >= this.BREAKPOINTS.tablet) return 'tablet';
|
||||||
|
return 'mobile';
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device orientation (computed)
|
||||||
|
*/
|
||||||
|
readonly orientation = computed<DeviceOrientation>(() => {
|
||||||
|
return this._width() > this._height() ? 'landscape' : 'portrait';
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether current viewport is mobile size (computed)
|
||||||
|
*/
|
||||||
|
readonly isMobile = computed(() => this.viewportSize() === 'mobile');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether current viewport is tablet size (computed)
|
||||||
|
*/
|
||||||
|
readonly isTablet = computed(() => this.viewportSize() === 'tablet');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether current viewport is desktop size (computed)
|
||||||
|
*/
|
||||||
|
readonly isDesktop = computed(() => this.viewportSize() === 'desktop');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether current viewport is wide desktop size (computed)
|
||||||
|
*/
|
||||||
|
readonly isWide = computed(() => this.viewportSize() === 'wide');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether current viewport is mobile or smaller (computed)
|
||||||
|
*/
|
||||||
|
readonly isMobileView = computed(() => this._width() < this.BREAKPOINTS.tablet);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether current viewport is tablet or smaller (computed)
|
||||||
|
*/
|
||||||
|
readonly isTabletDown = computed(() => this._width() < this.BREAKPOINTS.desktop);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether current viewport is tablet or larger (computed)
|
||||||
|
*/
|
||||||
|
readonly isTabletUp = computed(() => this._width() >= this.BREAKPOINTS.tablet);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether current viewport is desktop or larger (computed)
|
||||||
|
*/
|
||||||
|
readonly isDesktopUp = computed(() => this._width() >= this.BREAKPOINTS.desktop);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete viewport information (computed)
|
||||||
|
*/
|
||||||
|
readonly viewportInfo = computed<ViewportInfo>(() => ({
|
||||||
|
width: this._width(),
|
||||||
|
height: this._height(),
|
||||||
|
size: this.viewportSize(),
|
||||||
|
orientation: this.orientation(),
|
||||||
|
isMobile: this.isMobile(),
|
||||||
|
isTablet: this.isTablet(),
|
||||||
|
isDesktop: this.isDesktop(),
|
||||||
|
isWide: this.isWide(),
|
||||||
|
isTouchDevice: this._isTouchDevice()
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ==== PUBLIC UTILITY METHODS ====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if viewport matches specific size
|
||||||
|
*/
|
||||||
|
matchesSize(size: ViewportSize): boolean {
|
||||||
|
return this.viewportSize() === size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if viewport is smaller than specified size
|
||||||
|
*/
|
||||||
|
isSmallerThan(size: ViewportSize): boolean {
|
||||||
|
const currentWidth = this._width();
|
||||||
|
return currentWidth < this.BREAKPOINTS[size];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if viewport is larger than specified size
|
||||||
|
*/
|
||||||
|
isLargerThan(size: ViewportSize): boolean {
|
||||||
|
const currentWidth = this._width();
|
||||||
|
return currentWidth >= this.BREAKPOINTS[size];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get responsive grid columns based on viewport and content count
|
||||||
|
*/
|
||||||
|
getGridColumns(itemCount = 0): string {
|
||||||
|
if (this.isMobileView()) {
|
||||||
|
return 'single-column';
|
||||||
|
}
|
||||||
|
if (this.isTablet()) {
|
||||||
|
return itemCount > 6 ? 'two-columns' : 'single-column';
|
||||||
|
}
|
||||||
|
return itemCount > 10 ? 'three-columns' : 'two-columns';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get appropriate icon size for current viewport
|
||||||
|
*/
|
||||||
|
getIconSize(): 'small' | 'medium' | 'large' {
|
||||||
|
if (this.isMobileView()) return 'medium';
|
||||||
|
if (this.isTablet()) return 'small';
|
||||||
|
return 'small';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get appropriate spacing size for current viewport
|
||||||
|
*/
|
||||||
|
getSpacing(): 'compact' | 'comfortable' | 'spacious' {
|
||||||
|
if (this.isMobileView()) return 'compact';
|
||||||
|
if (this.isTablet()) return 'comfortable';
|
||||||
|
return 'spacious';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if device is in landscape mode (mobile context)
|
||||||
|
*/
|
||||||
|
isLandscape(): boolean {
|
||||||
|
return this.orientation() === 'landscape';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if device is in portrait mode
|
||||||
|
*/
|
||||||
|
isPortrait(): boolean {
|
||||||
|
return this.orientation() === 'portrait';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get breakpoint value for specified size
|
||||||
|
*/
|
||||||
|
getBreakpoint(size: keyof typeof this.BREAKPOINTS): number {
|
||||||
|
return this.BREAKPOINTS[size];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==== PRIVATE METHODS ====
|
||||||
|
|
||||||
|
private getCurrentWidth(): number {
|
||||||
|
return typeof window !== 'undefined' ? window.innerWidth : 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCurrentHeight(): number {
|
||||||
|
return typeof window !== 'undefined' ? window.innerHeight : 768;
|
||||||
|
}
|
||||||
|
|
||||||
|
private detectTouchDevice(): boolean {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
return 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeResizeListener(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
fromEvent(window, 'resize')
|
||||||
|
.pipe(
|
||||||
|
debounceTime(150), // Debounce for performance
|
||||||
|
distinctUntilChanged(),
|
||||||
|
takeUntil(this.destroy$)
|
||||||
|
)
|
||||||
|
.subscribe(() => {
|
||||||
|
this._width.set(this.getCurrentWidth());
|
||||||
|
this._height.set(this.getCurrentHeight());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.destroy$.next();
|
||||||
|
this.destroy$.complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1006,6 +1006,7 @@
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
button {
|
button {
|
||||||
@include ov-button-base;
|
@include ov-button-base;
|
||||||
|
padding: var(--ov-meet-spacing-xs);
|
||||||
}
|
}
|
||||||
@include ov-tablet-down {
|
@include ov-tablet-down {
|
||||||
padding: var(--ov-meet-spacing-md);
|
padding: var(--ov-meet-spacing-md);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user