frontend: implement responsive recording lists with mobile cards view and viewport service

This commit is contained in:
Carlos Santos 2025-09-15 17:10:43 +02:00
parent 75e2485a03
commit 681cf24e22
8 changed files with 966 additions and 288 deletions

View File

@ -13,32 +13,34 @@
</div>
</div>
} @else {
<!-- Recordings Toolbar -->
<mat-toolbar class="recordings-toolbar">
<!-- Recordings Toolbar (hidden in mobile) -->
<mat-toolbar class="recordings-toolbar" [class.mobile-hidden]="isMobileView()">
<!-- Left Section: Search -->
<div class="toolbar-left">
@if (showSearchBox) {
<mat-form-field class="search-field" appearance="outline">
<mat-label>Search recordings</mat-label>
<input
matInput
[formControl]="nameFilterControl"
placeholder="Search by room name or room ID"
(keydown.enter)="triggerSearch()"
/>
<button
mat-icon-button
matSuffix
class="search-btn"
(click)="triggerSearch()"
[disabled]="loading || !nameFilterControl.value"
matTooltip="Search"
>
<mat-icon>search</mat-icon>
</button>
</mat-form-field>
}
</div>
@if (!isMobileView()) {
<div class="toolbar-left">
@if (showSearchBox) {
<mat-form-field class="search-field" appearance="outline">
<mat-label>Search recordings</mat-label>
<input
matInput
[formControl]="nameFilterControl"
placeholder="Search by room name or room ID"
(keydown.enter)="triggerSearch()"
/>
<button
mat-icon-button
matSuffix
class="search-btn"
(click)="triggerSearch()"
[disabled]="loading || !nameFilterControl.value"
matTooltip="Search"
>
<mat-icon>search</mat-icon>
</button>
</mat-form-field>
}
</div>
}
<!-- Center Section: Bulk Actions (visible when items selected) -->
@if (showSelection && selectedRecordings().size > 0) {
@ -118,6 +120,72 @@
</div>
</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 -->
@if (loading) {
<div class="loading-container">
@ -139,119 +207,101 @@
</div>
</div>
} @else {
<!-- Recordings Table -->
<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>
<mat-checkbox
[checked]="allSelected()"
[indeterminate]="someSelected()"
(change)="toggleAllSelection()"
[disabled]="loading"
>
</mat-checkbox>
</th>
<td mat-cell *matCellDef="let recording">
@if (canSelectRecording(recording)) {
<mat-checkbox
[checked]="isRecordingSelected(recording)"
(change)="toggleRecordingSelection(recording)"
[disabled]="loading"
>
</mat-checkbox>
}
</td>
</ng-container>
}
@if (isMobileView()) {
<!-- Mobile Cards View -->
<div class="cards-container">
@for (recording of recordings; track recording.recordingId) {
<div class="recording-card" [class.selected-card]="isRecordingSelected(recording)">
<!-- Card Header with Selection and Status -->
<div class="card-header">
<div class="card-header-left">
@if (showSelection && canSelectRecording(recording)) {
<mat-checkbox
[checked]="isRecordingSelected(recording)"
(change)="toggleRecordingSelection(recording)"
[disabled]="loading"
class="card-checkbox"
[attr.aria-label]="'Select recording ' + recording.recordingId"
>
</mat-checkbox>
}
<!-- Room Info Column -->
@if (showRoomInfo) {
<ng-container matColumnDef="roomInfo">
<th mat-header-cell *matHeaderCellDef class="room-header">Room</th>
<td mat-cell *matCellDef="let recording" class="room-cell">
<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>
} -->
@if (showRoomInfo) {
<div class="room-info">
<div class="room-name">{{ recording.roomName }}</div>
<div class="room-id">{{ recording.recordingId }}</div>
</div>
}
</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 [style.color]="getStatusColor(recording.status)">
{{ getStatusIcon(recording.status) }}
</mat-icon>
<span class="status-label">{{ getStatusLabel(recording.status) }}</span>
<div class="status-info">
<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>
</div>
</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>
<!-- Card Content -->
<div class="card-content">
<div class="recording-details">
<div class="detail-item">
<mat-icon class="detail-icon">schedule</mat-icon>
<div class="detail-content">
<div class="detail-label">Start Date</div>
@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>
} @else {
<span class="no-data">-</span>
}
</td>
</ng-container>
</div>
<!-- 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 -->
<!-- Card Actions -->
<div class="card-actions">
@if (canPlayRecording(recording)) {
<button
mat-icon-button
matTooltip="Play Recording"
(click)="playRecording(recording)"
[disabled]="loading"
class="action-button play-button"
matTooltip="Play Recording"
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"
(click)="downloadRecording(recording)"
[disabled]="loading"
class="action-button download-button"
matTooltip="Download Recording"
id="download-recording-btn-{{ recording.recordingId }}"
>
<mat-icon>download</mat-icon>
@ -260,28 +310,28 @@
@if (isRecordingFailed(recording)) {
<button
class="delete-action"
mat-icon-button
matTooltip="Delete Recording"
(click)="deleteRecording(recording)"
[disabled]="loading"
class="action-button delete-button"
matTooltip="Delete Recording"
id="delete-recording-btn-{{ recording.recordingId }}"
>
<mat-icon>delete</mat-icon>
</button>
} @else {
<!-- More Actions Menu -->
<button
mat-icon-button
[matMenuTriggerFor]="actionsMenu"
matTooltip="More Actions"
[matMenuTriggerFor]="cardActionsMenu"
[disabled]="loading"
class="action-button more-button"
matTooltip="More Actions"
id="more-actions-btn-{{ recording.recordingId }}"
>
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #actionsMenu="matMenu">
<mat-menu #cardActionsMenu="matMenu">
<button
mat-menu-item
(click)="shareRecordingLink(recording)"
@ -290,7 +340,6 @@
<mat-icon>share</mat-icon>
<span>Share link</span>
</button>
@if (canDeleteRecording(recording)) {
<mat-divider></mat-divider>
<button
@ -306,17 +355,192 @@
</mat-menu>
}
</div>
</td>
</ng-container>
</div>
}
</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>
<tr
mat-row
*matRowDef="let row; columns: displayedColumns"
[class.selected-row]="isRecordingSelected(row)"
></tr>
</table>
</div>
<!-- Room Info Column -->
@if (showRoomInfo) {
<ng-container matColumnDef="roomInfo">
<th mat-header-cell *matHeaderCellDef class="room-header">Room</th>
<td mat-cell *matCellDef="let recording" class="room-cell">
<div class="room-info">
<span class="room-name">{{ recording.roomName }}</span>
<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 -->
@if (showLoadMore) {

View File

@ -1,45 +1,333 @@
@import '../../../../../../src/assets/styles/design-tokens';
// Use utility classes for recordings toolbar
.recordings-toolbar {
@extend .ov-data-toolbar;
// === MOBILE CARDS VIEW ===
.cards-container {
@include ov-grid-responsive(280px);
gap: var(--ov-meet-spacing-md);
padding: var(--ov-meet-spacing-md) 0;
.toolbar-left {
::ng-deep .search-btn {
padding: var(--ov-meet-spacing-sm);
}
@include ov-mobile-down {
grid-template-columns: 1fr;
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 {
gap: var(--ov-meet-spacing-sm);
&:hover:not(.selected-card) {
@include ov-hover-lift(-2px);
}
::ng-deep .refresh-btn {
padding: var(--ov-meet-spacing-sm);
@include ov-mobile-down {
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 {
padding: var(--ov-meet-spacing-sm);
.detail-value {
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 {
@extend .ov-search-field;
}
.selection-info {
@extend .ov-selection-info;
}
.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 {
@extend .ov-filters-menu;
}
@ -48,130 +336,63 @@
@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 {
@extend .ov-date-info;
}
.no-data {
@extend .ov-no-data;
}
// Action buttons
.action-buttons {
@extend .ov-action-buttons;
::ng-deep button {
padding: var(--ov-meet-spacing-sm);
}
.mat-mdc-icon-button {
&:hover {
background-color: transparent;
@include ov-tablet-down {
.play-btn,
.download-btn {
display: none;
}
}
}
// Menu item styles
.delete-action {
@extend .ov-delete-action;
}
// Empty State Styling
.no-recordings-state {
@extend .ov-empty-state;
.getting-started-actions {
@extend .action-buttons;
@include ov-mobile-down {
flex-direction: column;
gap: var(--ov-meet-spacing-sm);
.refresh-recordings-btn {
@extend .refresh-btn;
}
.clear-filters-btn {
@extend .refresh-btn;
button {
width: 100%;
padding: var(--ov-meet-spacing-md);
}
}
}
}
// Apply focus states for accessibility
// === ACCESSIBILITY AND ANIMATIONS ===
.mat-mdc-checkbox,
.mat-mdc-icon-button,
.mat-mdc-button {
@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;
}
}

View File

@ -1,15 +1,5 @@
import { CommonModule, DatePipe } from '@angular/common';
import {
Component,
EventEmitter,
HostBinding,
Input,
OnChanges,
OnInit,
Output,
signal,
SimpleChanges
} from '@angular/core';
import { Component, EventEmitter, Input, OnChanges, OnInit, Output, signal, SimpleChanges } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatBadgeModule } from '@angular/material/badge';
import { MatButtonModule } from '@angular/material/button';
@ -26,7 +16,7 @@ import { MatToolbarModule } from '@angular/material/toolbar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MeetRecordingInfo, MeetRecordingStatus } from '@lib/typings/ce';
import { formatBytes, formatDurationToHMS } from '@lib/utils';
import { debounceTime, distinctUntilChanged } from 'rxjs';
import { ViewportService } from '@lib/services';
export interface RecordingTableAction {
recordings: MeetRecordingInfo[];
@ -91,13 +81,6 @@ export class RecordingListsComponent implements OnInit, OnChanges {
@Input() showLoadMore = false;
@Input() loading = false;
@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() recordingAction = new EventEmitter<RecordingTableAction>();
@Output() filterChange = new EventEmitter<{ nameFilter: string; statusFilter: string }>();
@ -150,7 +133,11 @@ export class RecordingListsComponent implements OnInit, OnChanges {
DOWNLOADABLE: [MeetRecordingStatus.COMPLETE] as readonly MeetRecordingStatus[]
} as const;
constructor() {}
constructor(private viewportService: ViewportService) {}
get isMobileView() {
return this.viewportService.isMobileView;
}
ngOnInit() {
this.setupFilters();

View File

@ -188,19 +188,3 @@
}
}
}
// Animation classes
.fade-in {
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@ -134,6 +134,7 @@
.recordings-content {
margin-top: var(--ov-meet-spacing-md);
padding: var(--ov-meet-spacing-sm);
}
.loading-card {

View File

@ -12,4 +12,5 @@ export * from './navigation.service';
export * from './notification.service';
export * from './session-storage.service';
export * from './theme.service';
export * from './viewport.service';
export * from './wizard-state.service';

View File

@ -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();
}
}

View File

@ -1006,6 +1006,7 @@
overflow: auto;
button {
@include ov-button-base;
padding: var(--ov-meet-spacing-xs);
}
@include ov-tablet-down {
padding: var(--ov-meet-spacing-md);