frontend: implement responsive recording lists with mobile cards view and viewport service
This commit is contained in:
parent
75e2485a03
commit
681cf24e22
@ -13,9 +13,10 @@
|
||||
</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 -->
|
||||
@if (!isMobileView()) {
|
||||
<div class="toolbar-left">
|
||||
@if (showSearchBox) {
|
||||
<mat-form-field class="search-field" appearance="outline">
|
||||
@ -39,6 +40,7 @@
|
||||
</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,27 +207,181 @@
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- Recordings Table -->
|
||||
@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>
|
||||
}
|
||||
|
||||
@if (showRoomInfo) {
|
||||
<div class="room-info">
|
||||
<div class="room-name">{{ recording.roomName }}</div>
|
||||
<div class="room-id">{{ recording.recordingId }}</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
|
||||
<!-- Card Actions -->
|
||||
<div class="card-actions">
|
||||
@if (canPlayRecording(recording)) {
|
||||
<button
|
||||
mat-icon-button
|
||||
(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>
|
||||
}
|
||||
|
||||
@if (canDownloadRecording(recording)) {
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="downloadRecording(recording)"
|
||||
[disabled]="loading"
|
||||
class="action-button download-button"
|
||||
matTooltip="Download Recording"
|
||||
id="download-recording-btn-{{ recording.recordingId }}"
|
||||
>
|
||||
<mat-icon>download</mat-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (isRecordingFailed(recording)) {
|
||||
<button
|
||||
mat-icon-button
|
||||
(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 {
|
||||
<button
|
||||
mat-icon-button
|
||||
[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 #cardActionsMenu="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>
|
||||
</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>
|
||||
<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">
|
||||
<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>
|
||||
}
|
||||
@ -174,10 +396,7 @@
|
||||
<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>
|
||||
} -->
|
||||
<span class="room-id">{{ recording.recordingId }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
@ -188,7 +407,7 @@
|
||||
<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)">
|
||||
<mat-icon class="status-icon" [style.color]="getStatusColor(recording.status)">
|
||||
{{ getStatusIcon(recording.status) }}
|
||||
</mat-icon>
|
||||
<span class="status-label">{{ getStatusLabel(recording.status) }}</span>
|
||||
@ -237,6 +456,7 @@
|
||||
<button
|
||||
mat-icon-button
|
||||
matTooltip="Play Recording"
|
||||
class="action-button play-button"
|
||||
(click)="playRecording(recording)"
|
||||
[disabled]="loading"
|
||||
id="play-recording-btn-{{ recording.recordingId }}"
|
||||
@ -250,6 +470,7 @@
|
||||
<button
|
||||
mat-icon-button
|
||||
matTooltip="Download Recording"
|
||||
class="action-button download-button"
|
||||
(click)="downloadRecording(recording)"
|
||||
[disabled]="loading"
|
||||
id="download-recording-btn-{{ recording.recordingId }}"
|
||||
@ -263,6 +484,7 @@
|
||||
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 }}"
|
||||
@ -273,6 +495,7 @@
|
||||
<!-- More Actions Menu -->
|
||||
<button
|
||||
mat-icon-button
|
||||
class="action-button more-button"
|
||||
[matMenuTriggerFor]="actionsMenu"
|
||||
matTooltip="More Actions"
|
||||
[disabled]="loading"
|
||||
@ -317,6 +540,7 @@
|
||||
></tr>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Load More Section -->
|
||||
@if (showLoadMore) {
|
||||
|
||||
@ -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 {
|
||||
&:hover:not(.selected-card) {
|
||||
@include ov-hover-lift(-2px);
|
||||
}
|
||||
|
||||
@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);
|
||||
|
||||
::ng-deep .refresh-btn {
|
||||
padding: var(--ov-meet-spacing-sm);
|
||||
.detail-icon {
|
||||
@include ov-icon(sm);
|
||||
color: var(--ov-meet-text-secondary);
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
::ng-deep .clear-btn {
|
||||
padding: var(--ov-meet-spacing-sm);
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
button {
|
||||
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-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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -134,6 +134,7 @@
|
||||
|
||||
.recordings-content {
|
||||
margin-top: var(--ov-meet-spacing-md);
|
||||
padding: var(--ov-meet-spacing-sm);
|
||||
}
|
||||
|
||||
.loading-card {
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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;
|
||||
button {
|
||||
@include ov-button-base;
|
||||
padding: var(--ov-meet-spacing-xs);
|
||||
}
|
||||
@include ov-tablet-down {
|
||||
padding: var(--ov-meet-spacing-md);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user