Add OpenVidu Meet Console implementation (#4)
* frontend: update icons for Rooms and Recordings in console navigation * frontend: enhance overview component with user stats and improved layout * frontend: implement theme service and design tokens for light/dark mode support - Added ThemeService to manage theme preferences and system theme detection. - Introduced design tokens for consistent styling across light and dark themes. - Updated components to utilize the new navigation service for routing. - Replaced SecurityPreferencesComponent with PreferencesComponent for settings. - Enhanced UI with new styles and improved navigation structure. - Added tests for ThemeService to ensure functionality. * frontend: enhance overview component with title styling and icon integration * frontend: remove unused Router import from overview component * frontend: implement developers settings with API key and webhook configuration * frontend: update styles and structure for console navigation and overview components * frontend: simplify API key checks and integrate notification service for user feedback * frontend: update openvidu-components-angular to version 3.3.0-dev2 and simplify Material Symbols stylesheet link * frontend: adjust padding and gap for stat card and actions in overview component * frontend: update import paths to use relative paths for better module resolution * frontend: enhance sync-types.sh script with advanced options and detailed usage instructions * typings: update TypeScript declaration files and improve sync-types.sh script for better clarity and functionality * webcomponent: webcomponent typings are now moved to typings directory * Revert "typings: update TypeScript declaration files and improve sync-types.sh script for better clarity and functionality" This reverts commit 7da952bc44be20c3f74ffb82bf941b96b78ad019. * typings: improve sync-types.sh script for clarity and consistency * test: update error message for empty downloaded file check * frontend: update outbound event message types in video room and web component manager services * frontend: enhance styling for console component and adjust nav item border radius * style: update comments in disabled class for clarity and consistency; refactor import paths in room-form component * typings: update import paths in message.type.ts to include file extensions * frontend: enhance rooms management interface with improved loading states, search functionality, and table features * frontend: enhance loading state with improved UI and animations for room loading process * frontend: enhance rooms table with auto-deletion feature and improve status display * frontend: update import paths for services and add containsRoute method to NavigationService * frontend: remove unused components and associated files from shared-meet-components * frontend: add logo selector component and enhance preferences settings with access controls * frontend: add SessionStorageService import to extract query params guard * frontend: add margin-bottom utility class to overview container * frontend: update description for creating a room in the overview component * frontend: Added recording list component * frontend: update padding in mat-sidenav entry to use spacing variable * frontend: update text for delete room button to use lowercase * frontend: enhance console navigation with tooltips and active item styling * frontend: refactor styles in console navigation for consistency and improved theming * frontend: add border to card header for improved visual separation * frontend: created room list reusable component * frontend: adjust vertical alignment of table cells in recording lists * frontend: enhance room status and auto-deletion indicators with tooltips and improved styling * frontend: enhance HTTP service methods to include status codes in responses * frontend: fix duration formatting to use integer seconds instead of fixed-point * frontend: refactor icon styles in status badge for consistency * Add modular SCSS structure for design tokens, mixins, animations, and utility classes - Introduced _animations.scss for keyframes and animation utility classes. - Refactored _design-tokens.scss to import modular design system files for better maintainability. - Created _mixins-components.scss for component-specific mixins like cards and buttons. - Added _mixins-layout.scss for layout-related mixins and responsive design utilities. - Established _mixins-responsive.scss for breakpoint mixins to facilitate responsive design. - Introduced _tokens-core.scss for core design tokens including colors, spacing, and typography. - Created _tokens-themes.scss for theme-specific design tokens for light and dark modes. - Added _utilities.scss for reusable utility classes to streamline common styling patterns. * frontend: Refactor styles for settings preferences component and enhance utility classes - Simplified SCSS for preferences.component.scss by utilizing utility classes for layout and styling. - Introduced new utility classes for Material components, including slide toggle and navigation list. - Enhanced form section styling with consistent spacing and layout adjustments. - Added responsive design patterns for toolbar and table components. - Improved loading and empty state styles for better user experience. * frontend: standardize page layout and loading states across components * frontend: enhance status badge and action button styles for consistency * ci: update unit test workflow to include typings setup * test: update import path for WebComponentCommand to typings directory * frontend: refactor dialog component structure and enhance styles for improved accessibility and responsiveness * frontend: rename batchDelete methods to bulkDelete for consistency * frontend: implement confirmation dialog for room deletion with improved error handling * frontend: extract feature-specific API logic from HttpService into dedicated services * frontend: extract common recording actions into RecordingManagerService * frontend: refactor navigation handling for improved consistency * backend: add endpoint to download multiple recordings in a zip file and refactor bulkDeleteRecordings to delete only recordings from the same room if recording token is provided * openapi: add download endpoint for recordings and update bulk delete logic to enforce room constraints * frontend: enhance recording media URL generation and add bulk delete and download functionalities * frontend: rename 'batchDownload' to 'bulkDownload' for consistency and implement missing methods in RecordingsComponent * frontend: Implement Room Creation Wizard with Step Indicator and Navigation - Added StepIndicatorComponent for visual step tracking in the wizard. - Created WizardNavComponent for navigation controls (Next, Previous, Cancel, Finish). - Developed RoomWizardComponent to manage the wizard's state and steps. - Introduced WizardStateService to handle the wizard's data and navigation logic. - Defined WizardStep and WizardNavigationConfig models for step management. - Implemented basic structure for individual steps: Basic Info, Recording Settings, Recording Trigger, Recording Layout, and Preferences. - Integrated components into the room creation flow, allowing users to navigate through steps. - Added unit tests for all new components and services to ensure functionality. * feat(wizard): enhance wizard functionality and UI - Added validation form groups to WizardStep interface for better form handling. - Updated WizardNavigationConfig to include customizable button labels and states. - Implemented step navigation with event handling in room-wizard component. - Refactored basic info step into a standalone component with reactive form support. - Improved styling for the basic info step and action buttons. - Enhanced wizard state management to handle dynamic step visibility based on user input. - Added methods for loading existing data and saving form changes automatically. - Updated tests to reflect changes in component structure and functionality. * feat(recording-preferences): implement recording preferences step with form and options * feat(room-wizard): add recording trigger step with selectable options and form handling * feat(recording-preferences): refactor option selection to use SelectableCard component and update styles * test: add tests for download recordings endpoint and update tests for bulk delete recordings * openapi: improve descriptions for bulk delete operations and add new response for marked rooms * frontend: update bulk actions and add sharing functionality in RecordingsComponent, and enhace RecordingListsComponent * frontend: implement bulkDeleteRooms method and enhace RoomsComponent and RoomListsComponent * typings: add MeetApiKey interface * backend: update API key handling to use MeetApiKey type * openapi: create API key schema and update response references * backend: add webhook URL testing functionality and validation * frontend: simplify HTTP request methods by removing response observation * frontend: streamline OverviewComponent by removing unused observables and simplifying data loading * frontend: add API key management methods to AuthService * frontend: enhance GlobalPreferencesService by adding webhook preferences management * frontend: refactor DevelopersSettingsComponent to improve API key handling and streamline webhook configuration * openapi: add webhook URL testing endpoint with request and response schemas * test: add tests for webhook URL validation * frontend: enhance PreferencesComponent and add changePassword method in AuthService * feat(recording-preferences): add recording access control options and enhance UI animations * feat(recording-layout): implement recording layout selection step with form handling and visual options * feat(room-preferences): implement room preferences step with form handling and toggle options * feat(room-wizard): enhance form handling and default value saving across components * feat(layouts): add new layout images for grid, single speaker, and speaker configurations * feat(developers): adjust API key field button padding and update spacing in API key display * feat(rooms-lists): enhance button formatting and add tooltips for room status and auto-deletion * feat(room-wizard): update room creation logic and form field names for consistency * feat(room-wizard): add skip button functionality and enhance navigation handling * feat(basic-info): simplify form layout by removing action buttons and related styles * fix(wizard-navigation): change currentStepId type from string to number for consistency * feat(styles): enhance button padding and hover effect in batch actions for improved usability * fix(wizard-navigation): adjust padding for improved layout consistency * feat(basic-info): add clear button for deletion date and enhance time selection layout * feat(rooms-list): enhance deletion date display with new styling and structure * fix(basic-info): remove debugger statement from saveFormData method * refactor(step-indicator): remove commented-out styles for cleaner code * feat(step-indicator): enhance responsive layout handling and emit layout changes * feat(overview): improve loading state handling and update stats management * feat(step-indicator): enable navigation between steps and improve layout handling * feat(room-wizard): refactor state management to use MeetRoomOptions and improve data handling across components * feat(step-indicator): enhance layout handling and improve text overflow management for better responsiveness * feat(step-indicator): update step properties to enhance navigation and state management * feat(room-wizard): add 'Create Room' label to finish button in navigation config * openapi: add force-deletion parameter to delete room endpoint * frontend: split code in ContextService into domain specific services and rename it to AppDataSerivce * frontend: enhace FeatureConfigurationService to use signal-based approach and remove unused preferences and permissions * feat(basic-info): update deletion hint icon and improve warning color consistency * feat(room-service): rename saveRoomPreferences to updateRoom and adjust API path for preference updates * feat(room-wizard): implement edit mode for room configuration, allowing users to update existing room settings * feat(pro-feature-badge): create ProFeatureBadge component and integrate into logo selector and selectable card * fix(recording): adjust compression level for zip archive in downloadRecordingsZip * fix(internal-config): remove FIXME comments related to LK bug for meeting timeouts * frontend: reorganize imports and remove unused components * refactor(console): rename 'Developers' to 'Embedded' in navigation and update related routes * fix(console-nav): update toolbar title from 'OpenVidu Console' to 'OpenVidu Meet' * feat(users-permissions): create UsersPermissions component and update routing * feat(users-permissions): add pro feature badge to user authentication section * fix(overview): update navigation and text from 'Developers' to 'Embedded' * feat(overview): update authentication configuration card and navigation * frontend: refactor RoomRecordingsComponent to use RecordingListsComponent * refactor: update API paths to remove 'meet' prefix for consistency * frontend: update navigation paths to remove 'console' prefix for consistency * feat(video-room): add leave and end meeting functionality with toolbar buttons * fix(overview): remove unnecessary comment on initial loading state * feat(wizard): enable quick create functionality in wizard navigation * feat(step-indicator): implement safe current step index handling for edit mode * feat(wizard): update quick create visibility to show only on first step in edit mode * feat(users-permissions): refactor admin password handling and validation * webcomponent: update Playwright dependencies and refactor leaveRoom functionality - Updated Playwright and Playwright Test versions in package.json to 1.53.2. - Refactored leaveRoom function to accept a role parameter, allowing for different behavior based on user role (moderator or publisher). - Updated E2E tests to utilize the new leaveRoom function, ensuring proper cleanup and behavior for both roles. - Removed unnecessary afterEach cleanup in UI Feature Preferences tests. * frontend: add IDs to leave and end meeting buttons for better accessibility * testapp: update package-lock.json and refactor ConfigService constructor for improved environment variable handling * frontend: update background colors for improved visual consistency * chore: add tslib dependency and enhance target directory validation in sync-types.sh * frontend: enhance accessibility by adding IDs to toolbar and form elements --------- Co-authored-by: juancarmore <juancar_more2@hotmail.com>
This commit is contained in:
parent
dd1c27730e
commit
fbcb70dbc2
2
.github/workflows/wc-unit-test.yaml
vendored
2
.github/workflows/wc-unit-test.yaml
vendored
@ -18,7 +18,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup OpenVidu Meet WebComponent
|
||||
shell: bash
|
||||
run: ./prepare.sh --webcomponent
|
||||
run: ./prepare.sh --typings --webcomponent
|
||||
- name: Run tests
|
||||
run: |
|
||||
cd frontend/webcomponent
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@ -59,3 +59,11 @@ testapp/public/js/app.js
|
||||
testapp/public/js/webcomponent.js
|
||||
frontend/webcomponent/test-results/.last-run.json
|
||||
frontend/webcomponent/test_localstorage_state.json
|
||||
|
||||
**test-results/**
|
||||
frontend/webcomponent/src/typings/ce/command.model.d.ts
|
||||
frontend/webcomponent/src/typings/ce/command.model.ts
|
||||
frontend/webcomponent/src/typings/ce/event.model.d.ts
|
||||
frontend/webcomponent/src/typings/ce/event.model.ts
|
||||
frontend/webcomponent/src/typings/ce/message.type.d.ts
|
||||
frontend/webcomponent/src/typings/ce/message.type.ts
|
||||
|
||||
@ -2,11 +2,9 @@ name: recordingIds
|
||||
in: query
|
||||
required: true
|
||||
description: >
|
||||
A comma-separated list of recording IDs to delete.
|
||||
A comma-separated list of recording IDs.
|
||||
<br>
|
||||
Each recording ID should be in the format 'roomId--sessionId--recordingId'.
|
||||
|
||||
> ⚠️ **Note:** If the recording is in progress, it cannot be deleted.
|
||||
Each recording ID should be in the format 'roomId--egressId--uid'
|
||||
|
||||
schema:
|
||||
type: string
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
description: Webhook URL
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
url:
|
||||
type: string
|
||||
format: uri
|
||||
description: The URL to test the webhook
|
||||
@ -0,0 +1,8 @@
|
||||
description: Recordings not from the same room
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../schemas/error.yaml
|
||||
example:
|
||||
error: 'Recording Error'
|
||||
message: 'None of the provided recording IDs belong to room "room123"'
|
||||
@ -0,0 +1,8 @@
|
||||
description: Webhook URL is unreachable
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../../schemas/error.yaml
|
||||
example:
|
||||
error: Webhook Error
|
||||
message: 'Webhook URL "http://localhost:5080/webhook" is unreachable'
|
||||
@ -2,13 +2,4 @@ description: Successfully created a new API key
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
apiKey:
|
||||
type: string
|
||||
description: The API key that was created.
|
||||
example: 'ovmeet-1234567890abcdef1234567890abcdef'
|
||||
creationDate:
|
||||
type: number
|
||||
description: The date when the API key was created.
|
||||
example: 1620000000000
|
||||
$ref: '../../schemas/internal/meet-api-key.yaml'
|
||||
|
||||
@ -4,13 +4,4 @@ content:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
apiKey:
|
||||
type: string
|
||||
description: The API key that was created.
|
||||
example: 'ovmeet-1234567890abcdef1234567890abcdef'
|
||||
creationDate:
|
||||
type: number
|
||||
description: The date when the API key was created.
|
||||
example: 1620000000000
|
||||
$ref: '../../schemas/internal/meet-api-key.yaml'
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
description: Successfully tested webhook URL
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
example: 'Webhook URL is valid'
|
||||
@ -1,4 +1,6 @@
|
||||
description: Mixed results for bulk deletion operation
|
||||
description: >
|
||||
**Mixed results**. Some recordings were deleted successfully while others
|
||||
could not be deleted (e.g., due to being in progress or not found).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
description: Mixed results for bulk deletion operation
|
||||
description: >
|
||||
**Mixed results**. Some rooms were deleted immediately while others were
|
||||
marked for deletion (due to active participants).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
description: All specified rooms have been marked for deletion.
|
||||
description: >
|
||||
The room was marked for deletion (due to active participants)
|
||||
and will be removed once all participants leave.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@ -7,4 +9,4 @@ content:
|
||||
message:
|
||||
type: string
|
||||
example:
|
||||
message: Rooms room-123 marked for deletion
|
||||
message: Room 'room-123' marked for deletion
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
description: >
|
||||
All specified rooms were marked error for deletion (due to active participants)
|
||||
and will be removed once all participants leave.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
example:
|
||||
message: Rooms 'room-123, room-456' marked for deletion
|
||||
@ -0,0 +1,10 @@
|
||||
type: object
|
||||
properties:
|
||||
apiKey:
|
||||
type: string
|
||||
description: The API key that was created.
|
||||
example: 'ovmeet-1234567890abcdef1234567890abcdef'
|
||||
creationDate:
|
||||
type: number
|
||||
description: The date when the API key was created.
|
||||
example: 1620000000000
|
||||
@ -15,6 +15,8 @@ paths:
|
||||
$ref: './paths/rooms.yaml#/~1rooms~1{roomId}'
|
||||
/recordings:
|
||||
$ref: './paths/recordings.yaml#/~1recordings'
|
||||
/recordings/download:
|
||||
$ref: './paths/recordings.yaml#/~1recordings~1download'
|
||||
/recordings/{recordingId}:
|
||||
$ref: './paths/recordings.yaml#/~1recordings~1{recordingId}'
|
||||
/recordings/{recordingId}/media:
|
||||
|
||||
@ -22,6 +22,8 @@ paths:
|
||||
$ref: './paths/internal/users.yaml#/~1users~1change-password'
|
||||
/preferences/webhooks:
|
||||
$ref: './paths/internal/global-preferences.yaml#/~1preferences~1webhooks'
|
||||
/preferences/webhooks/test:
|
||||
$ref: './paths/internal/global-preferences.yaml#/~1preferences~1webhooks~1test'
|
||||
/preferences/security:
|
||||
$ref: './paths/internal/global-preferences.yaml#/~1preferences~1security'
|
||||
/preferences/appearance:
|
||||
|
||||
@ -40,6 +40,26 @@
|
||||
'500':
|
||||
$ref: '../../components/responses/internal-server-error.yaml'
|
||||
|
||||
/preferences/webhooks/test:
|
||||
post:
|
||||
operationId: testWebhookUrl
|
||||
summary: Test webhook URL
|
||||
description: >
|
||||
Tests the provided webhook URL to ensure it is reachable and valid.
|
||||
tags:
|
||||
- Internal API - Global Preferences
|
||||
requestBody:
|
||||
$ref: '../../components/requestBodies/internal/test-webhook-url-request.yaml'
|
||||
responses:
|
||||
'200':
|
||||
$ref: '../../components/responses/internal/success-test-webhook-url.yaml'
|
||||
'400':
|
||||
$ref: '../../components/responses/internal/error-webhook-url-unreachable.yaml'
|
||||
'422':
|
||||
$ref: '../../components/responses/validation-error.yaml'
|
||||
'500':
|
||||
$ref: '../../components/responses/internal-server-error.yaml'
|
||||
|
||||
/preferences/security:
|
||||
get:
|
||||
operationId: getSecurityPreferences
|
||||
|
||||
@ -57,19 +57,21 @@
|
||||
summary: Bulk delete recordings
|
||||
description: >
|
||||
Deletes multiple recordings at once with the specified recording IDs.
|
||||
|
||||
> **Note:** If this endpoint is called using the `recordingTokenCookie` authentication method,
|
||||
all specified recordings must belong to the same room included in the token.
|
||||
If a recording does not belong to that room, it will not be deleted.
|
||||
tags:
|
||||
- OpenVidu Meet - Recordings
|
||||
security:
|
||||
- apiKeyInHeader: []
|
||||
- accessTokenCookie: []
|
||||
- recordingTokenCookie: []
|
||||
parameters:
|
||||
- $ref: '../components/parameters/recording-ids.yaml'
|
||||
responses:
|
||||
'200':
|
||||
$ref: '../components/responses/success-bulk-delete-recordings.yaml'
|
||||
description: >
|
||||
**Mixed results**. Some recordings were deleted successfully while others
|
||||
could not be deleted (e.g., due to being in progress or not found).
|
||||
'204':
|
||||
description: >
|
||||
All specified recordings were deleted successfully.
|
||||
@ -83,6 +85,52 @@
|
||||
'500':
|
||||
$ref: '../components/responses/internal-server-error.yaml'
|
||||
|
||||
/recordings/download:
|
||||
get:
|
||||
operationId: downloadRecordings
|
||||
summary: Download recordings
|
||||
description: >
|
||||
Downloads multiple recordings as a ZIP file with the specified recording IDs.
|
||||
The ZIP file will contain all recordings in MP4 format.
|
||||
|
||||
> **Note:** If this endpoint is called using the `recordingTokenCookie` authentication method,
|
||||
all specified recordings must belong to the same room included in the token.
|
||||
If a recording does not belong to that room, it will not be included in the ZIP file.
|
||||
tags:
|
||||
- OpenVidu Meet - Recordings
|
||||
security:
|
||||
- apiKeyInHeader: []
|
||||
- accessTokenCookie: []
|
||||
- recordingTokenCookie: []
|
||||
parameters:
|
||||
- $ref: '../components/parameters/recording-ids.yaml'
|
||||
responses:
|
||||
'200':
|
||||
description: Successfully created a ZIP file containing the requested recordings
|
||||
headers:
|
||||
Content-Disposition:
|
||||
description: >
|
||||
Indicates that the response is a file download.
|
||||
The filename will be `recordings.zip`.
|
||||
schema:
|
||||
type: string
|
||||
example: attachment; filename="recordings.zip"
|
||||
content:
|
||||
application/zip:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
'400':
|
||||
$ref: '../components/responses/error-recordings-not-same-room.yaml'
|
||||
'401':
|
||||
$ref: '../components/responses/unauthorized-error.yaml'
|
||||
'403':
|
||||
$ref: '../components/responses/forbidden-error.yaml'
|
||||
'422':
|
||||
$ref: '../components/responses/validation-error.yaml'
|
||||
'500':
|
||||
$ref: '../components/responses/internal-server-error.yaml'
|
||||
|
||||
/recordings/{recordingId}:
|
||||
get:
|
||||
operationId: getRecording
|
||||
|
||||
@ -71,19 +71,13 @@
|
||||
- $ref: '../components/parameters/force-deletion.yaml'
|
||||
responses:
|
||||
'200':
|
||||
description: >
|
||||
**Mixed results**. Some rooms were deleted immediately while others were
|
||||
marked for deletion (due to active participants).
|
||||
$ref: '../components/responses/success-bulk-delete-rooms.yaml'
|
||||
'202':
|
||||
description: >
|
||||
All specified rooms were marked for deletion (due to active participants)
|
||||
and will be removed once all participants leave.
|
||||
$ref: '../components/responses/success-room-marked-for-deletion.yaml'
|
||||
$ref: '../components/responses/success-rooms-marked-for-deletion.yaml'
|
||||
'204':
|
||||
description: >
|
||||
All specified rooms were successfully deleted immediately.
|
||||
No content is returned in the response body.
|
||||
No content is returned.
|
||||
'401':
|
||||
$ref: '../components/responses/unauthorized-error.yaml'
|
||||
'403':
|
||||
@ -138,14 +132,12 @@
|
||||
- accessTokenCookie: []
|
||||
parameters:
|
||||
- $ref: '../components/parameters/room-id-path.yaml'
|
||||
- $ref: '../components/parameters/force-deletion.yaml'
|
||||
responses:
|
||||
'202':
|
||||
description: >
|
||||
The room was marked for deletion (due to active participants)
|
||||
and will be removed once all participants leave.
|
||||
$ref: '../components/responses/success-room-marked-for-deletion.yaml'
|
||||
'204':
|
||||
description: Successfully deleted the OpenVidu Meet room
|
||||
description: Successfully deleted the OpenVidu Meet room. No content is returned.
|
||||
'401':
|
||||
$ref: '../components/responses/unauthorized-error.yaml'
|
||||
'403':
|
||||
|
||||
879
backend/package-lock.json
generated
879
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -52,8 +52,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.673.0",
|
||||
"@sesamecare-oss/redlock": "1.4.0",
|
||||
"@azure/storage-blob": "^12.27.0",
|
||||
"@sesamecare-oss/redlock": "1.4.0",
|
||||
"archiver": "7.0.1",
|
||||
"bcrypt": "5.1.1",
|
||||
"chalk": "5.4.1",
|
||||
"cookie-parser": "1.4.7",
|
||||
@ -74,6 +75,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openapitools/openapi-generator-cli": "^2.16.3",
|
||||
"@types/archiver": "6.0.3",
|
||||
"@types/bcrypt": "5.0.2",
|
||||
"@types/cookie-parser": "1.4.7",
|
||||
"@types/cors": "2.8.17",
|
||||
@ -83,6 +85,7 @@
|
||||
"@types/node": "^20.12.14",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/swagger-ui-express": "^4.1.6",
|
||||
"@types/unzipper": "0.10.11",
|
||||
"@types/validator": "^13.12.2",
|
||||
"@types/yamljs": "^0.2.34",
|
||||
"@typescript-eslint/eslint-plugin": "6.7.5",
|
||||
@ -101,10 +104,11 @@
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-jest-resolver": "^2.0.1",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.4.5"
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "0.12.3"
|
||||
},
|
||||
"jest-junit": {
|
||||
"outputDirectory": "test-results",
|
||||
"outputName": "junit.xml"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,9 +2,8 @@ import { StringValue } from 'ms';
|
||||
|
||||
const INTERNAL_CONFIG = {
|
||||
// Base paths for the API
|
||||
API_BASE_PATH: '/meet/api',
|
||||
INTERNAL_API_BASE_PATH_V1: '/meet/internal-api/v1',
|
||||
API_BASE_PATH_V1: '/meet/api/v1',
|
||||
API_BASE_PATH_V1: '/api/v1',
|
||||
INTERNAL_API_BASE_PATH_V1: '/internal-api/v1',
|
||||
|
||||
// Cookie names
|
||||
ACCESS_TOKEN_COOKIE_NAME: 'OvMeetAccessToken',
|
||||
@ -36,9 +35,7 @@ const INTERNAL_CONFIG = {
|
||||
CRON_JOB_MIN_LOCK_TTL: '59s' as StringValue, // Minimum TTL for cron job locks
|
||||
// Additional intervals
|
||||
MIN_FUTURE_TIME_FOR_ROOM_AUTODELETION_DATE: '1h' as StringValue, // Minimum time for room auto-deletion date
|
||||
// !FIXME (LK BUG): When this is defined, the room will be closed although there are participants
|
||||
MEETING_EMPTY_TIMEOUT: '' as StringValue, // Seconds to keep the meeting (LK room) open until the first participant joins
|
||||
// !FIXME (LK BUG): When this is defined, the room will be closed although there are participants
|
||||
MEETING_DEPARTURE_TIMEOUT: '' as StringValue // Seconds to keep the meeting (LK room) open after the last participant leaves
|
||||
};
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import { WebhookPreferences } from '@typings-ce';
|
||||
import { Request, Response } from 'express';
|
||||
import { container } from '../../config/index.js';
|
||||
import { handleError } from '../../models/error.model.js';
|
||||
import { LoggerService, MeetStorageService } from '../../services/index.js';
|
||||
import { LoggerService, MeetStorageService, OpenViduWebhookService } from '../../services/index.js';
|
||||
|
||||
export const updateWebhookPreferences = async (req: Request, res: Response) => {
|
||||
const logger = container.get(LoggerService);
|
||||
@ -44,3 +44,20 @@ export const getWebhookPreferences = async (_req: Request, res: Response) => {
|
||||
handleError(res, error, 'getting webhooks preferences');
|
||||
}
|
||||
};
|
||||
|
||||
export const testWebhook = async (req: Request, res: Response) => {
|
||||
const logger = container.get(LoggerService);
|
||||
const webhookService = container.get(OpenViduWebhookService);
|
||||
|
||||
logger.verbose(`Testing webhook URL: ${req.body.url}`);
|
||||
const url = req.body.url;
|
||||
|
||||
try {
|
||||
await webhookService.testWebhookUrl(url);
|
||||
logger.info(`Webhook URL '${url}' is valid`);
|
||||
// If the URL is valid, we can return a success response
|
||||
return res.status(200).json({ message: 'Webhook URL is valid' });
|
||||
} catch (error) {
|
||||
handleError(res, error, 'testing webhook URL');
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
import archiver from 'archiver';
|
||||
import { Request, Response } from 'express';
|
||||
import { Readable } from 'stream';
|
||||
import { container } from '../config/index.js';
|
||||
import INTERNAL_CONFIG from '../config/internal-config.js';
|
||||
import { RecordingHelper } from '../helpers/index.js';
|
||||
import {
|
||||
errorRecordingNotFound,
|
||||
errorRecordingsNotFromSameRoom,
|
||||
handleError,
|
||||
internalError,
|
||||
rejectRequestFromMeetError
|
||||
@ -67,13 +70,22 @@ export const bulkDeleteRecordings = async (req: Request, res: Response) => {
|
||||
const recordingService = container.get(RecordingService);
|
||||
const { recordingIds } = req.query;
|
||||
|
||||
// If recording token is present, delete only recordings for the room associated with the token
|
||||
const payload = req.session?.tokenClaims;
|
||||
let roomId: string | undefined;
|
||||
|
||||
if (payload && payload.video) {
|
||||
roomId = payload.video.room;
|
||||
}
|
||||
|
||||
logger.info(`Deleting recordings: ${recordingIds}`);
|
||||
|
||||
try {
|
||||
// TODO: Check role to determine if the request is from an admin or a participant
|
||||
const recordingIdsArray = (recordingIds as string).split(',');
|
||||
const { deleted, notDeleted } =
|
||||
await recordingService.bulkDeleteRecordingsAndAssociatedFiles(recordingIdsArray);
|
||||
const { deleted, notDeleted } = await recordingService.bulkDeleteRecordingsAndAssociatedFiles(
|
||||
recordingIdsArray,
|
||||
roomId
|
||||
);
|
||||
|
||||
// All recordings were successfully deleted
|
||||
if (deleted.length > 0 && notDeleted.length === 0) {
|
||||
@ -246,3 +258,73 @@ export const getRecordingUrl = async (req: Request, res: Response) => {
|
||||
handleError(res, error, `getting URL for recording '${recordingId}'`);
|
||||
}
|
||||
};
|
||||
|
||||
export const downloadRecordingsZip = async (req: Request, res: Response) => {
|
||||
const logger = container.get(LoggerService);
|
||||
const recordingService = container.get(RecordingService);
|
||||
|
||||
const recordingIds = req.query.recordingIds as string;
|
||||
const recordingIdsArray = (recordingIds as string).split(',');
|
||||
|
||||
// If recording token is present, download only recordings for the room associated with the token
|
||||
const payload = req.session?.tokenClaims;
|
||||
let roomId: string | undefined;
|
||||
|
||||
if (payload && payload.video) {
|
||||
roomId = payload.video.room;
|
||||
}
|
||||
|
||||
// Filter recording IDs if a room ID is provided
|
||||
let validRecordingIds = recordingIdsArray;
|
||||
|
||||
if (roomId) {
|
||||
validRecordingIds = recordingIdsArray.filter((recordingId) => {
|
||||
const { roomId: recRoomId } = RecordingHelper.extractInfoFromRecordingId(recordingId);
|
||||
const isValid = recRoomId === roomId;
|
||||
|
||||
if (!isValid) {
|
||||
logger.warn(`Skipping recording '${recordingId}' as it does not belong to room '${roomId}'`);
|
||||
}
|
||||
|
||||
return isValid;
|
||||
});
|
||||
}
|
||||
|
||||
if (validRecordingIds.length === 0) {
|
||||
logger.warn(`None of the provided recording IDs belong to room '${roomId}'`);
|
||||
const error = errorRecordingsNotFromSameRoom(roomId!);
|
||||
return rejectRequestFromMeetError(res, error);
|
||||
}
|
||||
|
||||
logger.info(`Creating ZIP for recordings: ${recordingIds}`);
|
||||
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="recordings.zip"');
|
||||
|
||||
const archive = archiver('zip', { zlib: { level: 0 } });
|
||||
|
||||
// Handle errors in the archive
|
||||
archive.on('error', (err) => {
|
||||
logger.error(`ZIP archive error: ${err.message}`);
|
||||
res.status(500).end();
|
||||
});
|
||||
|
||||
// Pipe the archive to the response
|
||||
archive.pipe(res);
|
||||
|
||||
for (const recordingId of validRecordingIds) {
|
||||
try {
|
||||
logger.debug(`Adding recording '${recordingId}' to ZIP`);
|
||||
const result = await recordingService.getRecordingAsStream(recordingId);
|
||||
const recordingInfo = await recordingService.getRecording(recordingId, 'filename');
|
||||
|
||||
const filename = recordingInfo.filename || `${recordingId}.mp4`;
|
||||
archive.append(result.fileStream, { name: filename });
|
||||
} catch (error) {
|
||||
logger.error(`Error adding recording '${recordingId}' to ZIP: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize the archive
|
||||
archive.finalize();
|
||||
};
|
||||
|
||||
@ -77,7 +77,7 @@ export const deleteRoom = async (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
// Room was marked as deleted
|
||||
return res.status(202).json({ message: `Room '${roomId}' marked as deleted` });
|
||||
return res.status(202).json({ message: `Room '${roomId}' marked for deletion` });
|
||||
} catch (error) {
|
||||
handleError(res, error, `deleting room '${roomId}'`);
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { MeetApiKey } from '@typings-ce';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { uid } from 'uid/secure';
|
||||
|
||||
@ -13,7 +14,7 @@ export class PasswordHelper {
|
||||
}
|
||||
|
||||
// Generate a secure API key using uid with a length of 32 characters
|
||||
static generateApiKey(): { key: string; creationDate: number } {
|
||||
static generateApiKey(): MeetApiKey {
|
||||
return { key: `ovmeet-${uid(32)}`, creationDate: new Date().getTime() };
|
||||
}
|
||||
}
|
||||
|
||||
@ -100,7 +100,7 @@ export const withCanDeleteRecordingsPermission = async (req: Request, res: Respo
|
||||
return next();
|
||||
}
|
||||
|
||||
const sameRoom = payload.video?.room === roomId;
|
||||
const sameRoom = roomId ? payload.video?.room === roomId : true;
|
||||
const metadata = JSON.parse(payload.metadata || '{}');
|
||||
const permissions = metadata.recordingPermissions as RecordingPermissions | undefined;
|
||||
const canDeleteRecordings = permissions?.canDeleteRecordings;
|
||||
|
||||
@ -31,6 +31,13 @@ const WebhookPreferencesSchema: z.ZodType<WebhookPreferences> = z
|
||||
}
|
||||
);
|
||||
|
||||
const WebhookTestSchema = z.object({
|
||||
url: z
|
||||
.string()
|
||||
.url('Must be a valid URL')
|
||||
.regex(/^https?:\/\//, { message: 'URL must start with http:// or https://' })
|
||||
});
|
||||
|
||||
const AuthModeSchema: z.ZodType<AuthMode> = z.enum([AuthMode.NONE, AuthMode.MODERATORS_ONLY, AuthMode.ALL_USERS]);
|
||||
|
||||
const AuthTypeSchema: z.ZodType<AuthType> = z.enum([AuthType.SINGLE_USER]);
|
||||
@ -61,6 +68,17 @@ export const validateWebhookPreferences = (req: Request, res: Response, next: Ne
|
||||
next();
|
||||
};
|
||||
|
||||
export const withValidWebhookTestRequest = (req: Request, res: Response, next: NextFunction) => {
|
||||
const { success, error, data } = WebhookTestSchema.safeParse(req.body);
|
||||
|
||||
if (!success) {
|
||||
return rejectUnprocessableRequest(res, error);
|
||||
}
|
||||
|
||||
req.body = data;
|
||||
next();
|
||||
};
|
||||
|
||||
export const validateSecurityPreferences = (req: Request, res: Response, next: NextFunction) => {
|
||||
const { success, error, data } = SecurityPreferencesSchema.safeParse(req.body);
|
||||
|
||||
|
||||
@ -64,7 +64,7 @@ const GetRecordingSchema = z.object({
|
||||
})
|
||||
});
|
||||
|
||||
const BulkDeleteRecordingsSchema = z.object({
|
||||
const MultipleRecordingIdsSchema = z.object({
|
||||
recordingIds: z.preprocess(
|
||||
(arg) => {
|
||||
if (typeof arg === 'string') {
|
||||
@ -187,8 +187,8 @@ export const withValidRecordingFiltersRequest = (req: Request, res: Response, ne
|
||||
next();
|
||||
};
|
||||
|
||||
export const withValidRecordingBulkDeleteRequest = (req: Request, res: Response, next: NextFunction) => {
|
||||
const { success, error, data } = BulkDeleteRecordingsSchema.safeParse(req.query);
|
||||
export const withValidMultipleRecordingIds = (req: Request, res: Response, next: NextFunction) => {
|
||||
const { success, error, data } = MultipleRecordingIdsSchema.safeParse(req.query);
|
||||
|
||||
if (!success) {
|
||||
return rejectUnprocessableRequest(res, error);
|
||||
|
||||
@ -62,6 +62,10 @@ export const errorAzureNotAvailable = (error: any): OpenViduMeetError => {
|
||||
return new OpenViduMeetError('ABS Error', `Azure Blob Storage is not available ${error}`, 503);
|
||||
};
|
||||
|
||||
export const errorWebhookUrlUnreachable = (url: string): OpenViduMeetError => {
|
||||
return new OpenViduMeetError('Webhook Error', `Webhook URL '${url}' is unreachable`, 400);
|
||||
};
|
||||
|
||||
// Auth errors
|
||||
|
||||
export const errorInvalidCredentials = (): OpenViduMeetError => {
|
||||
@ -154,6 +158,14 @@ export const errorInvalidRecordingSecret = (recordingId: string, secret: string)
|
||||
);
|
||||
};
|
||||
|
||||
export const errorRecordingsNotFromSameRoom = (roomId: string): OpenViduMeetError => {
|
||||
return new OpenViduMeetError(
|
||||
'Recording Error',
|
||||
`None of the provided recording IDs belong to room '${roomId}'`,
|
||||
400
|
||||
);
|
||||
};
|
||||
|
||||
const isMatchingError = (error: OpenViduMeetError, originalError: OpenViduMeetError): boolean => {
|
||||
return (
|
||||
error instanceof OpenViduMeetError &&
|
||||
|
||||
@ -8,7 +8,8 @@ import {
|
||||
tokenAndRoleValidator,
|
||||
validateSecurityPreferences,
|
||||
validateWebhookPreferences,
|
||||
withAuth
|
||||
withAuth,
|
||||
withValidWebhookTestRequest
|
||||
} from '../middlewares/index.js';
|
||||
|
||||
export const preferencesRouter = Router();
|
||||
@ -27,6 +28,7 @@ preferencesRouter.get(
|
||||
withAuth(tokenAndRoleValidator(UserRole.ADMIN)),
|
||||
webhookPrefCtrl.getWebhookPreferences
|
||||
);
|
||||
preferencesRouter.post('/webhooks/test', withValidWebhookTestRequest, webhookPrefCtrl.testWebhook);
|
||||
|
||||
// Security preferences
|
||||
preferencesRouter.put(
|
||||
|
||||
@ -16,7 +16,7 @@ import {
|
||||
withValidGetRecordingMediaRequest,
|
||||
withValidGetRecordingRequest,
|
||||
withValidGetRecordingUrlRequest,
|
||||
withValidRecordingBulkDeleteRequest,
|
||||
withValidMultipleRecordingIds,
|
||||
withValidRecordingFiltersRequest,
|
||||
withValidRecordingId,
|
||||
withValidStartRecordingRequest
|
||||
@ -36,10 +36,18 @@ recordingRouter.get(
|
||||
);
|
||||
recordingRouter.delete(
|
||||
'/',
|
||||
withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN)),
|
||||
withValidRecordingBulkDeleteRequest,
|
||||
withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN), recordingTokenValidator),
|
||||
withValidMultipleRecordingIds,
|
||||
withCanDeleteRecordingsPermission,
|
||||
recordingCtrl.bulkDeleteRecordings
|
||||
);
|
||||
recordingRouter.get(
|
||||
'/download',
|
||||
withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN), recordingTokenValidator),
|
||||
withValidMultipleRecordingIds,
|
||||
withCanRetrieveRecordingsPermission,
|
||||
recordingCtrl.downloadRecordingsZip
|
||||
);
|
||||
recordingRouter.get(
|
||||
'/:recordingId',
|
||||
withValidGetRecordingRequest,
|
||||
|
||||
@ -64,12 +64,12 @@ const createApp = () => {
|
||||
app.use(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/recordings`, internalRecordingRouter);
|
||||
app.use(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/preferences`, preferencesRouter);
|
||||
|
||||
app.use('/meet/health', (_req: Request, res: Response) => res.status(200).send('OK'));
|
||||
app.use('/health', (_req: Request, res: Response) => res.status(200).send('OK'));
|
||||
|
||||
// LiveKit Webhook route
|
||||
app.use('/livekit/webhook', livekitWebhookRouter);
|
||||
// Serve OpenVidu Meet webcomponent bundle file
|
||||
app.get('/meet/v1/openvidu-meet.js', (_req: Request, res: Response) => res.sendFile(webcomponentBundlePath));
|
||||
app.get('/v1/openvidu-meet.js', (_req: Request, res: Response) => res.sendFile(webcomponentBundlePath));
|
||||
// Serve OpenVidu Meet index.html file for all non-API routes
|
||||
app.get(/^(?!.*\/(api|internal-api)\/).*$/, (_req: Request, res: Response) => res.sendFile(frontendHtmlPath));
|
||||
// Catch all other routes and return 404
|
||||
|
||||
@ -10,6 +10,7 @@ import crypto from 'crypto';
|
||||
import { inject, injectable } from 'inversify';
|
||||
import { MEET_API_KEY } from '../environment.js';
|
||||
import { AuthService, LoggerService, MeetStorageService } from './index.js';
|
||||
import { errorWebhookUrlUnreachable } from '../models/error.model.js';
|
||||
|
||||
@injectable()
|
||||
export class OpenViduWebhookService {
|
||||
@ -92,6 +93,38 @@ export class OpenViduWebhookService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests a webhook URL by sending a test event to it.
|
||||
*
|
||||
* This method sends a test event to the specified webhook URL to verify if it is reachable and functioning correctly.
|
||||
* If the request fails, it throws an error indicating that the webhook URL is unreachable.
|
||||
*
|
||||
* @param url - The webhook URL to test
|
||||
*/
|
||||
async testWebhookUrl(url: string) {
|
||||
const creationDate = Date.now();
|
||||
const data = {
|
||||
event: 'testEvent',
|
||||
creationDate,
|
||||
data: {
|
||||
message: 'This is a test webhook event'
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await this.sendRequest(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Error testing webhook URL ${url}: ${error}`);
|
||||
throw errorWebhookUrlUnreachable(url);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a webhook event asynchronously in the background without blocking the main execution flow.
|
||||
* If the webhook fails, logs a warning message with the error details and optional context information.
|
||||
@ -153,11 +186,7 @@ export class OpenViduWebhookService {
|
||||
|
||||
protected async fetchWithRetry(url: string, options: RequestInit, retries = 5, delay = 300): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed with status ${response.status}`);
|
||||
}
|
||||
await this.sendRequest(url, options);
|
||||
} catch (error) {
|
||||
if (retries <= 0) {
|
||||
throw new Error(`Request failed: ${error}`);
|
||||
@ -170,6 +199,14 @@ export class OpenViduWebhookService {
|
||||
}
|
||||
}
|
||||
|
||||
protected async sendRequest(url: string, options: RequestInit): Promise<void> {
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed with status ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
protected async getWebhookPreferences(): Promise<WebhookPreferences> {
|
||||
try {
|
||||
const { webhooksPreferences } = await this.globalPrefService.getGlobalPreferences();
|
||||
|
||||
@ -233,19 +233,34 @@ export class RecordingService {
|
||||
* For each provided egressId, the metadata and recording file are deleted (only if the status is stopped).
|
||||
*
|
||||
* @param recordingIds Array of recording identifiers.
|
||||
* @returns An array with the MeetRecordingInfo of the successfully deleted recordings.
|
||||
* @param roomId Optional room identifier to delete only recordings from a specific room.
|
||||
* @returns An object containing:
|
||||
* - `deleted`: An array of successfully deleted recording IDs.
|
||||
* - `notDeleted`: An array of objects containing recording IDs and error messages for those that could not be deleted.
|
||||
*/
|
||||
async bulkDeleteRecordingsAndAssociatedFiles(
|
||||
recordingIds: string[]
|
||||
recordingIds: string[],
|
||||
roomId?: string
|
||||
): Promise<{ deleted: string[]; notDeleted: { recordingId: string; error: string }[] }> {
|
||||
const validRecordingIds: Set<string> = new Set<string>();
|
||||
const deletedRecordings: Set<string> = new Set<string>();
|
||||
const notDeletedRecordings: Set<{ recordingId: string; error: string }> = new Set();
|
||||
const roomsToCheck: Set<string> = new Set();
|
||||
|
||||
// Check if the recording is in progress
|
||||
for (const recordingId of recordingIds) {
|
||||
// If a roomId is provided, only process recordings from that room
|
||||
if (roomId) {
|
||||
const { roomId: recRoomId } = RecordingHelper.extractInfoFromRecordingId(recordingId);
|
||||
|
||||
if (recRoomId !== roomId) {
|
||||
this.logger.warn(`Skipping recording '${recordingId}' as it does not belong to room '${roomId}'`);
|
||||
notDeletedRecordings.add({ recordingId, error: `Recording '${recordingId}' does not belong to room '${roomId}'` });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if the recording is in progress
|
||||
const { recordingInfo } = await this.storageService.getRecordingMetadata(recordingId);
|
||||
|
||||
if (!RecordingHelper.canBeDeleted(recordingInfo)) {
|
||||
|
||||
@ -9,9 +9,11 @@ import {
|
||||
} from '@typings-ce';
|
||||
import { inject, injectable } from 'inversify';
|
||||
import { CreateOptions, Room, SendDataOptions } from 'livekit-server-sdk';
|
||||
import ms from 'ms';
|
||||
import { uid as secureUid } from 'uid/secure';
|
||||
import { uid } from 'uid/single';
|
||||
import INTERNAL_CONFIG from '../config/internal-config.js';
|
||||
import { MEET_NAME_ID } from '../environment.js';
|
||||
import { MeetRoomHelper, OpenViduComponentsAdapterHelper, UtilsHelper } from '../helpers/index.js';
|
||||
import {
|
||||
errorInvalidRoomSecret,
|
||||
@ -28,8 +30,6 @@ import {
|
||||
TaskSchedulerService,
|
||||
TokenService
|
||||
} from './index.js';
|
||||
import ms from 'ms';
|
||||
import { MEET_NAME_ID } from '../environment.js';
|
||||
|
||||
/**
|
||||
* Service for managing OpenVidu Meet rooms.
|
||||
@ -230,25 +230,6 @@ export class RoomService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a room as deleted in the storage system.
|
||||
*
|
||||
* @param roomId - The unique identifier of the room to mark for deletion
|
||||
* @returns A promise that resolves when the room has been successfully marked as deleted
|
||||
* @throws May throw an error if the room cannot be found or if saving fails
|
||||
*/
|
||||
protected async markRoomAsDeleted(roomId: string): Promise<void> {
|
||||
const room = await this.storageService.getMeetRoom(roomId);
|
||||
|
||||
if (!room) {
|
||||
this.logger.error(`Room with ID ${roomId} not found for deletion.`);
|
||||
throw errorRoomNotFound(roomId);
|
||||
}
|
||||
|
||||
room.markedForDeletion = true;
|
||||
await this.storageService.saveMeetRoom(room);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a secret against a room's moderator and publisher secrets and returns the corresponding role.
|
||||
*
|
||||
|
||||
@ -1,4 +1,13 @@
|
||||
import { AuthMode, AuthType, GlobalPreferences, MeetRecordingInfo, MeetRoom, User, UserRole } from '@typings-ce';
|
||||
import {
|
||||
AuthMode,
|
||||
AuthType,
|
||||
GlobalPreferences,
|
||||
MeetApiKey,
|
||||
MeetRecordingInfo,
|
||||
MeetRoom,
|
||||
User,
|
||||
UserRole
|
||||
} from '@typings-ce';
|
||||
import { inject, injectable } from 'inversify';
|
||||
import ms from 'ms';
|
||||
import { Readable } from 'stream';
|
||||
@ -579,21 +588,18 @@ export class MeetStorageService<
|
||||
return await this.saveCacheAndStorage(userRedisKey, storageUserKey, user);
|
||||
}
|
||||
|
||||
async saveApiKey(apiKeyData: { key: string; creationDate: number }): Promise<void> {
|
||||
async saveApiKey(apiKeyData: MeetApiKey): Promise<void> {
|
||||
const redisKey = RedisKeyName.API_KEYS;
|
||||
const storageKey = this.keyBuilder.buildApiKeysKey();
|
||||
this.logger.debug(`Saving API key to Redis and storage: ${redisKey}`);
|
||||
await this.saveCacheAndStorage(redisKey, storageKey, [apiKeyData]);
|
||||
}
|
||||
|
||||
async getApiKeys(): Promise<{ key: string; creationDate: number }[]> {
|
||||
async getApiKeys(): Promise<MeetApiKey[]> {
|
||||
const redisKey = RedisKeyName.API_KEYS;
|
||||
const storageKey = this.keyBuilder.buildApiKeysKey();
|
||||
this.logger.debug(`Retrieving API key from Redis and storage: ${redisKey}`);
|
||||
const apiKeys = await this.getFromCacheAndStorage<{ key: string; creationDate: number }[]>(
|
||||
redisKey,
|
||||
storageKey
|
||||
);
|
||||
const apiKeys = await this.getFromCacheAndStorage<MeetApiKey[]>(redisKey, storageKey);
|
||||
|
||||
if (!apiKeys || apiKeys.length === 0) {
|
||||
this.logger.warn('API key not found in cache or storage');
|
||||
|
||||
@ -117,6 +117,15 @@ export const updateWebbhookPreferences = async (preferences: WebhookPreferences)
|
||||
return response;
|
||||
};
|
||||
|
||||
export const testWebhookUrl = async (url: string) => {
|
||||
checkAppIsRunning();
|
||||
|
||||
const response = await request(app)
|
||||
.post(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/preferences/webhooks/test`)
|
||||
.send({ url });
|
||||
return response;
|
||||
};
|
||||
|
||||
export const getSecurityPreferences = async () => {
|
||||
checkAppIsRunning();
|
||||
|
||||
@ -594,14 +603,48 @@ export const deleteRecording = async (recordingId: string) => {
|
||||
.set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_API_KEY);
|
||||
};
|
||||
|
||||
export const bulkDeleteRecordings = async (recordingIds: any[]): Promise<Response> => {
|
||||
export const bulkDeleteRecordings = async (recordingIds: any[], recordingTokenCookie?: string): Promise<Response> => {
|
||||
checkAppIsRunning();
|
||||
|
||||
const response = await request(app)
|
||||
const req = request(app)
|
||||
.delete(`${INTERNAL_CONFIG.API_BASE_PATH_V1}/recordings`)
|
||||
.query({ recordingIds: recordingIds.join(',') })
|
||||
.set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_API_KEY);
|
||||
return response;
|
||||
.query({ recordingIds: recordingIds.join(',') });
|
||||
|
||||
if (recordingTokenCookie) {
|
||||
req.set('Cookie', recordingTokenCookie);
|
||||
} else {
|
||||
req.set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_API_KEY);
|
||||
}
|
||||
|
||||
return await req;
|
||||
};
|
||||
|
||||
export const downloadRecordings = async (
|
||||
recordingIds: string[],
|
||||
asBuffer = true,
|
||||
recordingTokenCookie?: string
|
||||
): Promise<Response> => {
|
||||
checkAppIsRunning();
|
||||
|
||||
const req = request(app)
|
||||
.get(`${INTERNAL_CONFIG.API_BASE_PATH_V1}/recordings/download`)
|
||||
.query({ recordingIds: recordingIds.join(',') });
|
||||
|
||||
if (recordingTokenCookie) {
|
||||
req.set('Cookie', recordingTokenCookie);
|
||||
} else {
|
||||
req.set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_API_KEY);
|
||||
}
|
||||
|
||||
if (asBuffer) {
|
||||
return await req.buffer().parse((res, cb) => {
|
||||
const data: Buffer[] = [];
|
||||
res.on('data', (chunk) => data.push(chunk));
|
||||
res.on('end', () => cb(null, Buffer.concat(data)));
|
||||
});
|
||||
}
|
||||
|
||||
return await req;
|
||||
};
|
||||
|
||||
export const stopAllRecordings = async (moderatorCookie: string) => {
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
import { afterEach, beforeAll, describe, expect, it } from '@jest/globals';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from '@jest/globals';
|
||||
import { Request } from 'express';
|
||||
import { MEET_WEBHOOK_ENABLED, MEET_WEBHOOK_URL } from '../../../../src/environment.js';
|
||||
import { expectValidationError } from '../../../helpers/assertion-helpers.js';
|
||||
import {
|
||||
getWebbhookPreferences,
|
||||
startTestServer,
|
||||
testWebhookUrl,
|
||||
updateWebbhookPreferences
|
||||
} from '../../../helpers/request-helpers.js';
|
||||
import { startWebhookServer, stopWebhookServer } from '../../../helpers/test-scenarios.js';
|
||||
|
||||
const restoreDefaultWebhookPreferences = async () => {
|
||||
const defaultPreferences = {
|
||||
@ -116,4 +119,33 @@ describe('Webhook Preferences API Tests', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test webhook URL', () => {
|
||||
beforeAll(async () => {
|
||||
// Start a webhook server to test against
|
||||
await startWebhookServer(5080, (req: Request) => {
|
||||
console.log('Webhook received:', req.body);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await stopWebhookServer();
|
||||
});
|
||||
|
||||
it('should return 200 if the webhook URL is reachable', async () => {
|
||||
const response = await testWebhookUrl('http://localhost:5080/webhook');
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should return 400 if the webhook URL is not reachable', async () => {
|
||||
const response = await testWebhookUrl('http://localhost:5999/doesnotexist');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return 422 if the webhook URL is invalid', async () => {
|
||||
const response = await testWebhookUrl('not-a-valid-url');
|
||||
expectValidationError(response, 'url', 'URL must start with http:// or https://');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -7,12 +7,13 @@ import {
|
||||
deleteAllRecordings,
|
||||
deleteAllRooms,
|
||||
disconnectFakeParticipants,
|
||||
generateRecordingTokenCookie,
|
||||
getAllRecordings,
|
||||
startRecording,
|
||||
startTestServer,
|
||||
stopRecording
|
||||
} from '../../../helpers/request-helpers';
|
||||
import { setupMultiRecordingsTestContext } from '../../../helpers/test-scenarios';
|
||||
import { setupMultiRecordingsTestContext, setupSingleRoomWithRecording } from '../../../helpers/test-scenarios';
|
||||
|
||||
describe('Recording API Tests', () => {
|
||||
beforeAll(async () => {
|
||||
@ -108,6 +109,36 @@ describe('Recording API Tests', () => {
|
||||
expect(deleteResponse.status).toBe(204);
|
||||
});
|
||||
|
||||
it('should only delete recordings belonging to the room when using a recording token', async () => {
|
||||
// Create a room and start a recording
|
||||
const roomData = await setupSingleRoomWithRecording(true);
|
||||
const roomId = roomData.room.roomId;
|
||||
const recordingId = roomData.recordingId;
|
||||
|
||||
// Generate a recording token for the room
|
||||
const recordingCookie = await generateRecordingTokenCookie(roomId, roomData.moderatorSecret);
|
||||
|
||||
// Create another room and start a recording
|
||||
const otherRoomData = await setupSingleRoomWithRecording(true);
|
||||
const otherRecordingId = otherRoomData.recordingId;
|
||||
|
||||
// Intenta eliminar ambas grabaciones usando el token de la primera sala
|
||||
const deleteResponse = await bulkDeleteRecordings([recordingId, otherRecordingId], recordingCookie);
|
||||
|
||||
expect(deleteResponse.status).toBe(200);
|
||||
expect(deleteResponse.body).toEqual({
|
||||
deleted: [recordingId],
|
||||
notDeleted: [
|
||||
{
|
||||
recordingId: otherRecordingId,
|
||||
error: expect.stringContaining(
|
||||
`Recording '${otherRecordingId}' does not belong to room '${roomId}'`
|
||||
)
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete all recordings and their secrets', async () => {
|
||||
const response = await setupMultiRecordingsTestContext(3, 3, 3);
|
||||
const recordingIds = response.rooms.map((room) => room.recordingId);
|
||||
|
||||
@ -0,0 +1,112 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from '@jest/globals';
|
||||
import stream from 'stream';
|
||||
import unzipper from 'unzipper';
|
||||
import { expectValidationError } from '../../../helpers/assertion-helpers.js';
|
||||
import {
|
||||
deleteAllRecordings,
|
||||
deleteAllRooms,
|
||||
disconnectFakeParticipants,
|
||||
downloadRecordings,
|
||||
generateRecordingTokenCookie,
|
||||
startTestServer
|
||||
} from '../../../helpers/request-helpers';
|
||||
import { setupMultiRecordingsTestContext, setupSingleRoomWithRecording } from '../../../helpers/test-scenarios';
|
||||
|
||||
describe('Recording API Tests', () => {
|
||||
beforeAll(async () => {
|
||||
startTestServer();
|
||||
await deleteAllRecordings();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await disconnectFakeParticipants();
|
||||
await Promise.all([deleteAllRecordings(), deleteAllRooms()]);
|
||||
});
|
||||
|
||||
const getZipEntries = async (buffer: Buffer) => {
|
||||
const entries: string[] = [];
|
||||
await stream.Readable.from(buffer)
|
||||
.pipe(unzipper.Parse())
|
||||
.on('entry', (entry) => {
|
||||
entries.push(entry.path);
|
||||
entry.autodrain();
|
||||
})
|
||||
.promise();
|
||||
return entries;
|
||||
};
|
||||
|
||||
describe('Download Recordings as ZIP Tests', () => {
|
||||
it('should download a ZIP with multiple recordings', async () => {
|
||||
const testContext = await setupMultiRecordingsTestContext(2, 2, 2);
|
||||
const recordingIds = testContext.rooms.map((room) => room.recordingId!);
|
||||
const roomIds = testContext.rooms.map((roomData) => roomData.room.roomId);
|
||||
|
||||
const res = await downloadRecordings(recordingIds);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('application/zip');
|
||||
expect(res.headers['content-disposition']).toContain('attachment; filename="recordings.zip"');
|
||||
|
||||
const entries = await getZipEntries(res.body);
|
||||
expect(entries.length).toBe(2);
|
||||
// Check that filenames match expected
|
||||
roomIds.forEach((id) => {
|
||||
expect(entries.some((name) => name.includes(id))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should only include recordings from the room when using a recording token', async () => {
|
||||
const roomData = await setupSingleRoomWithRecording(true);
|
||||
const roomId = roomData.room.roomId;
|
||||
const recordingId = roomData.recordingId!;
|
||||
const recordingCookie = await generateRecordingTokenCookie(roomId, roomData.moderatorSecret);
|
||||
|
||||
const otherRoomData = await setupSingleRoomWithRecording(true);
|
||||
const otherRecordingId = otherRoomData.recordingId!;
|
||||
|
||||
const res = await downloadRecordings([recordingId, otherRecordingId], true, recordingCookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const entries = await getZipEntries(res.body);
|
||||
expect(entries.length).toBe(1);
|
||||
expect(entries[0]).toContain(roomId);
|
||||
});
|
||||
|
||||
it('should return an error if none of the recordings belong to the room in the token', async () => {
|
||||
const roomData = await setupSingleRoomWithRecording(true);
|
||||
const roomId = roomData.room.roomId;
|
||||
const recordingCookie = await generateRecordingTokenCookie(roomId, roomData.moderatorSecret);
|
||||
|
||||
const otherRoomData = await setupSingleRoomWithRecording(true);
|
||||
const otherRecordingId = otherRoomData.recordingId!;
|
||||
|
||||
const res = await downloadRecordings([otherRecordingId], false, recordingCookie);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toHaveProperty('error');
|
||||
expect(res.body.message).toContain(`None of the provided recording IDs belong to room '${roomId}'`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Download Recordings as ZIP Validation', () => {
|
||||
it('should handle empty recordingIds array gracefully', async () => {
|
||||
const response = await downloadRecordings([], false);
|
||||
|
||||
expectValidationError(response, 'recordingIds', 'recordingIds must contain at least one item');
|
||||
});
|
||||
|
||||
it('should reject an array with mixed valid and totally invalid IDs', async () => {
|
||||
const invalidRecordingIds = ['valid--EG_111--5678', 'invalid--recording.id'];
|
||||
const response = await downloadRecordings(invalidRecordingIds, false);
|
||||
|
||||
expectValidationError(response, 'recordingIds.1', 'recordingId does not follow the expected format');
|
||||
});
|
||||
|
||||
it('should reject an array containing empty strings after sanitization', async () => {
|
||||
const invalidRecordingIds = ['', ' '];
|
||||
const response = await downloadRecordings(invalidRecordingIds, false);
|
||||
|
||||
expectValidationError(response, 'recordingIds', 'recordingIds must contain at least one item');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -369,17 +369,18 @@ describe('Recording API Security Tests', () => {
|
||||
});
|
||||
|
||||
describe('Bulk Delete Recordings Tests', () => {
|
||||
let roomData: RoomData;
|
||||
let recordingId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const roomData = await setupSingleRoomWithRecording(true);
|
||||
roomData = await setupSingleRoomWithRecording(true);
|
||||
recordingId = roomData.recordingId!;
|
||||
});
|
||||
|
||||
it('should succeed when request includes API key', async () => {
|
||||
const response = await request(app)
|
||||
.delete(RECORDINGS_PATH)
|
||||
.query({ recordingIds: [recordingId] })
|
||||
.query({ recordingIds: recordingId })
|
||||
.set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_API_KEY);
|
||||
expect(response.status).toBe(204);
|
||||
});
|
||||
@ -387,10 +388,60 @@ describe('Recording API Security Tests', () => {
|
||||
it('should succeed when user is authenticated as admin', async () => {
|
||||
const response = await request(app)
|
||||
.delete(RECORDINGS_PATH)
|
||||
.query({ recordingIds: [recordingId] })
|
||||
.query({ recordingIds: recordingId })
|
||||
.set('Cookie', adminCookie);
|
||||
expect(response.status).toBe(204);
|
||||
});
|
||||
|
||||
it('should fail when recording access is admin-moderator-publisher and participant is publisher', async () => {
|
||||
await updateRecordingAccessPreferencesInRoom(
|
||||
roomData.room.roomId,
|
||||
MeetRecordingAccess.ADMIN_MODERATOR_PUBLISHER
|
||||
);
|
||||
const recordingCookie = await generateRecordingTokenCookie(roomData.room.roomId, roomData.publisherSecret);
|
||||
|
||||
const response = await request(app)
|
||||
.delete(RECORDINGS_PATH)
|
||||
.query({ recordingIds: recordingId })
|
||||
.set('Cookie', recordingCookie);
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('should succeed when recording access is admin-moderator-publisher and participant is moderator', async () => {
|
||||
await updateRecordingAccessPreferencesInRoom(
|
||||
roomData.room.roomId,
|
||||
MeetRecordingAccess.ADMIN_MODERATOR_PUBLISHER
|
||||
);
|
||||
const recordingCookie = await generateRecordingTokenCookie(roomData.room.roomId, roomData.moderatorSecret);
|
||||
|
||||
const response = await request(app)
|
||||
.delete(RECORDINGS_PATH)
|
||||
.query({ recordingIds: recordingId })
|
||||
.set('Cookie', recordingCookie);
|
||||
expect(response.status).toBe(204);
|
||||
});
|
||||
|
||||
it('should fail when recording access is admin-moderator and participant is publisher', async () => {
|
||||
await updateRecordingAccessPreferencesInRoom(roomData.room.roomId, MeetRecordingAccess.ADMIN_MODERATOR);
|
||||
const recordingCookie = await generateRecordingTokenCookie(roomData.room.roomId, roomData.publisherSecret);
|
||||
|
||||
const response = await request(app)
|
||||
.delete(RECORDINGS_PATH)
|
||||
.query({ recordingIds: recordingId })
|
||||
.set('Cookie', recordingCookie);
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('should succeed when recording access is admin-moderator and participant is moderator', async () => {
|
||||
await updateRecordingAccessPreferencesInRoom(roomData.room.roomId, MeetRecordingAccess.ADMIN_MODERATOR);
|
||||
const recordingCookie = await generateRecordingTokenCookie(roomData.room.roomId, roomData.moderatorSecret);
|
||||
|
||||
const response = await request(app)
|
||||
.delete(RECORDINGS_PATH)
|
||||
.query({ recordingIds: recordingId })
|
||||
.set('Cookie', recordingCookie);
|
||||
expect(response.status).toBe(204);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Get Recording Media Tests', () => {
|
||||
@ -580,4 +631,80 @@ describe('Recording API Security Tests', () => {
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Download Recordings as ZIP Tests', () => {
|
||||
let roomData: RoomData;
|
||||
let recordingId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
roomData = await setupSingleRoomWithRecording(true);
|
||||
recordingId = roomData.recordingId!;
|
||||
});
|
||||
|
||||
it('should succeed when request includes API key', async () => {
|
||||
const response = await request(app)
|
||||
.get(`${RECORDINGS_PATH}/download`)
|
||||
.query({ recordingIds: recordingId })
|
||||
.set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_API_KEY);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should succeed when user is authenticated as admin', async () => {
|
||||
const response = await request(app)
|
||||
.get(`${RECORDINGS_PATH}/download`)
|
||||
.query({ recordingIds: recordingId })
|
||||
.set('Cookie', adminCookie);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should succeed when recording access is admin-moderator-publisher and participant is publisher', async () => {
|
||||
await updateRecordingAccessPreferencesInRoom(
|
||||
roomData.room.roomId,
|
||||
MeetRecordingAccess.ADMIN_MODERATOR_PUBLISHER
|
||||
);
|
||||
const recordingCookie = await generateRecordingTokenCookie(roomData.room.roomId, roomData.publisherSecret);
|
||||
|
||||
const response = await request(app)
|
||||
.get(`${RECORDINGS_PATH}/download`)
|
||||
.query({ recordingIds: recordingId })
|
||||
.set('Cookie', recordingCookie);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should succeed when recording access is admin-moderator-publisher and participant is moderator', async () => {
|
||||
await updateRecordingAccessPreferencesInRoom(
|
||||
roomData.room.roomId,
|
||||
MeetRecordingAccess.ADMIN_MODERATOR_PUBLISHER
|
||||
);
|
||||
const recordingCookie = await generateRecordingTokenCookie(roomData.room.roomId, roomData.moderatorSecret);
|
||||
|
||||
const response = await request(app)
|
||||
.get(`${RECORDINGS_PATH}/download`)
|
||||
.query({ recordingIds: recordingId })
|
||||
.set('Cookie', recordingCookie);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should fail when recording access is admin-moderator and participant is publisher', async () => {
|
||||
await updateRecordingAccessPreferencesInRoom(roomData.room.roomId, MeetRecordingAccess.ADMIN_MODERATOR);
|
||||
const recordingCookie = await generateRecordingTokenCookie(roomData.room.roomId, roomData.publisherSecret);
|
||||
|
||||
const response = await request(app)
|
||||
.get(`${RECORDINGS_PATH}/download`)
|
||||
.query({ recordingIds: recordingId })
|
||||
.set('Cookie', recordingCookie);
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('should succeed when recording access is admin-moderator and participant is moderator', async () => {
|
||||
await updateRecordingAccessPreferencesInRoom(roomData.room.roomId, MeetRecordingAccess.ADMIN_MODERATOR);
|
||||
const recordingCookie = await generateRecordingTokenCookie(roomData.room.roomId, roomData.moderatorSecret);
|
||||
|
||||
const response = await request(app)
|
||||
.get(`${RECORDINGS_PATH}/download`)
|
||||
.query({ recordingIds: recordingId })
|
||||
.set('Cookie', recordingCookie);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
8
frontend/package-lock.json
generated
8
frontend/package-lock.json
generated
@ -21,7 +21,7 @@
|
||||
"core-js": "^3.38.1",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"livekit-server-sdk": "^2.10.2",
|
||||
"openvidu-components-angular": "^3.3.0-dev1",
|
||||
"openvidu-components-angular": "^3.3.0-dev2",
|
||||
"rxjs": "7.8.1",
|
||||
"tslib": "^2.3.0",
|
||||
"unique-names-generator": "^4.7.1",
|
||||
@ -13899,9 +13899,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/openvidu-components-angular": {
|
||||
"version": "3.3.0-dev1",
|
||||
"resolved": "https://registry.npmjs.org/openvidu-components-angular/-/openvidu-components-angular-3.3.0-dev1.tgz",
|
||||
"integrity": "sha512-AlfIXD9CNj2T/4cCU7ae9Iw23FlzGhYBE2HXFlr8MTOWCiLu03jNm8k8wcfe+BdhLKvqKmIBAr4kWvp+clnv5A==",
|
||||
"version": "3.3.0-dev2",
|
||||
"resolved": "https://registry.npmjs.org/openvidu-components-angular/-/openvidu-components-angular-3.3.0-dev2.tgz",
|
||||
"integrity": "sha512-4zvWi32BuCwrQZ8MqQrMlmAF6kMg2NiR3nmZGHoaBPE+TwTEPwu8EAoG9A+pgbRqNpqiGmqJgVzWrYn9B7Y1HA==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
|
||||
@ -37,7 +37,7 @@
|
||||
"core-js": "^3.38.1",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"livekit-server-sdk": "^2.10.2",
|
||||
"openvidu-components-angular": "^3.3.0-dev1",
|
||||
"openvidu-components-angular": "^3.3.0-dev2",
|
||||
"rxjs": "7.8.1",
|
||||
"tslib": "^2.3.0",
|
||||
"unique-names-generator": "^4.7.1",
|
||||
|
||||
@ -1,26 +0,0 @@
|
||||
<mat-card class="base-card" [style.background-color]="cardBackgroundColor" [class]="{ disabled: disabled }">
|
||||
<div class="card-header" [class.balancedPadding]="!showCardContent">
|
||||
<div class="icon-container" [style.background-color]="iconBackgroundColor">
|
||||
@if (iconUrl) {
|
||||
<img [src]="iconUrl" alt="App Logo" class="app-logo" />
|
||||
} @else {
|
||||
<mat-icon class="card-icon" [style.color]="iconColor">{{ icon }}</mat-icon>
|
||||
}
|
||||
</div>
|
||||
<div class="text-container">
|
||||
<div class="card-title">{{ title }}</div>
|
||||
<div class="card-subtitle">{{ description }}</div>
|
||||
</div>
|
||||
|
||||
<ng-content select="[card-header-tag]"></ng-content>
|
||||
</div>
|
||||
@if (showCardContent) {
|
||||
<div class="divider"></div>
|
||||
}
|
||||
|
||||
<div class="card-content" #cardContent [class.hidden]="!showCardContent">
|
||||
<ng-content select="[card-content]"></ng-content>
|
||||
</div>
|
||||
|
||||
<ng-content select="[card-footer]"></ng-content>
|
||||
</mat-card>
|
||||
@ -1,87 +0,0 @@
|
||||
.base-card {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: box-shadow 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.base-card:hover {
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.card-header,
|
||||
.card-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px 20px 10px 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card-header.balancedPadding {
|
||||
padding: 20px !important;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 0px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
.divider {
|
||||
width: 80%;
|
||||
margin: auto;
|
||||
height: 1px;
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
background-color: #673ab7;
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon-container:has(.app-logo) {
|
||||
padding: 0px;
|
||||
}
|
||||
.app-logo {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 4px;
|
||||
object-fit: contain;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.text-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
opacity: 0.5; // Visualmente se ve deshabilitado
|
||||
pointer-events: none; // Desactiva todos los eventos de interacción
|
||||
user-select: none; // Evita que el usuario seleccione el texto o elementos dentro de la tarjeta
|
||||
filter: grayscale(80%); // Hace que los colores parezcan apagados (opcional)
|
||||
}
|
||||
@ -1,73 +0,0 @@
|
||||
import { AfterViewInit, ChangeDetectorRef, Component, ElementRef, Input, ViewChild } from '@angular/core';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
|
||||
@Component({
|
||||
selector: 'ov-base-card',
|
||||
standalone: true,
|
||||
imports: [MatCardModule, MatIconModule, MatDividerModule],
|
||||
templateUrl: './base-card.component.html',
|
||||
styleUrl: './base-card.component.scss'
|
||||
})
|
||||
export class BaseCardComponent implements AfterViewInit {
|
||||
/**
|
||||
* Whether the card is disabled or not. Defaults to false.
|
||||
* If true, the card will not be clickable.
|
||||
**/
|
||||
@Input() disabled: boolean = false;
|
||||
/**
|
||||
* The title of the dynamic card component.
|
||||
* This input property allows setting a custom title for the card.
|
||||
*/
|
||||
@Input() title: string = '';
|
||||
/**
|
||||
* A brief description of the dynamic card component.
|
||||
* This input property allows setting a description for the card.
|
||||
*/
|
||||
@Input() description: string = '';
|
||||
/**
|
||||
* The name of the icon to be displayed. Defaults to "settings".
|
||||
*
|
||||
* @type {string}
|
||||
* @default 'settings'
|
||||
*/
|
||||
@Input() icon: string = 'settings';
|
||||
|
||||
/**
|
||||
* The color of the icon.
|
||||
*
|
||||
* @default '#ffffff'
|
||||
*/
|
||||
@Input() iconColor: string = '#ffffff';
|
||||
|
||||
/**
|
||||
*
|
||||
* The URL of the icon to be displayed.
|
||||
*
|
||||
*
|
||||
*/
|
||||
@Input() iconUrl: string = '';
|
||||
/**
|
||||
* The background color of the icon.
|
||||
*
|
||||
* @default '#000000'
|
||||
*/
|
||||
@Input() iconBackgroundColor: string = '#000000';
|
||||
/**
|
||||
* The background color of the card component.
|
||||
* Accepts any valid CSS color string.
|
||||
*/
|
||||
@Input() cardBackgroundColor: string = '#ffffff';
|
||||
|
||||
// Content child reference to the card content.
|
||||
@ViewChild('cardContent', { static: false }) cardContent: ElementRef | undefined;
|
||||
showCardContent: boolean = false;
|
||||
|
||||
constructor(protected cdr: ChangeDetectorRef) {}
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.showCardContent = this.cardContent?.nativeElement.children.length > 0;
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
}
|
||||
@ -1,65 +0,0 @@
|
||||
import { ChangeDetectorRef, Component } from '@angular/core';
|
||||
import { BaseCardComponent } from '../base-card/base-card.component';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { NotificationService } from '../../../services';
|
||||
|
||||
@Component({
|
||||
selector: 'ov-pro-feature-card',
|
||||
standalone: true,
|
||||
imports: [BaseCardComponent, MatButtonModule, MatChipsModule],
|
||||
template: `
|
||||
<ov-base-card
|
||||
class="pro-feature-card"
|
||||
[title]="title"
|
||||
[description]="description"
|
||||
[icon]="icon"
|
||||
[iconUrl]="iconUrl"
|
||||
[iconBackgroundColor]="iconBackgroundColor"
|
||||
(click)="showDialog()"
|
||||
>
|
||||
<div card-header-tag>
|
||||
<mat-chip-set aria-label="OpenVidu Edition">
|
||||
<mat-chip [disableRipple]="true">PRO</mat-chip>
|
||||
</mat-chip-set>
|
||||
</div>
|
||||
</ov-base-card>
|
||||
`,
|
||||
styles: `
|
||||
.pro-feature-card {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-standard-chip:not(.mdc-evolution-chip--disabled) {
|
||||
background-color: #077692 !important;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #077692;
|
||||
}
|
||||
::ng-deep .mat-mdc-standard-chip:not(.mdc-evolution-chip--disabled) .mdc-evolution-chip__text-label {
|
||||
color: #ffffff !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
`
|
||||
})
|
||||
export class ProFeatureCardComponent extends BaseCardComponent {
|
||||
constructor(
|
||||
cdr: ChangeDetectorRef,
|
||||
private notificationService: NotificationService
|
||||
) {
|
||||
super(cdr);
|
||||
}
|
||||
|
||||
showDialog() {
|
||||
this.notificationService.showDialog({
|
||||
title: 'Upgrade to OpenVidu PRO',
|
||||
message:
|
||||
'Unlock this feature by upgrading to a Pro account. You can upgrade your account and start using this feature.',
|
||||
confirmText: 'Upgrade to Pro',
|
||||
cancelText: 'Cancel',
|
||||
confirmCallback: this.goToOpenVidu.bind(this)
|
||||
});
|
||||
}
|
||||
goToOpenVidu() {
|
||||
window.open('https://openvidu.io/pricing/', '_blank');
|
||||
}
|
||||
}
|
||||
@ -1,43 +0,0 @@
|
||||
<ov-base-card
|
||||
[disabled]="disabled"
|
||||
[title]="title"
|
||||
[description]="description"
|
||||
[icon]="icon"
|
||||
[iconBackgroundColor]="iconBackgroundColor"
|
||||
>
|
||||
<div card-content class="card-content">
|
||||
@if (disabled) {
|
||||
<div class="disabled-overlay">
|
||||
<p>This card is only available in the PRO version</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (selectionType === 'text') {
|
||||
<mat-form-field card-content>
|
||||
<mat-select [(value)]="selectedOption" (selectionChange)="onSelectionChange($event)">
|
||||
@for (option of options; track option.value) {
|
||||
<mat-option [value]="option.value">
|
||||
{{ option.label }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
} @else if (selectionType === 'color') {
|
||||
@for (option of options; track option.label) {
|
||||
<div class="item-option">
|
||||
<p>{{ option.label }}</p>
|
||||
|
||||
<input
|
||||
id="round-input"
|
||||
type="color"
|
||||
class="color-picker"
|
||||
[value]="option.value"
|
||||
(change)="onColorChange(option.label, $event)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
} @else if (selectionType === 'custom') {
|
||||
<ng-content></ng-content>
|
||||
}
|
||||
</div>
|
||||
</ov-base-card>
|
||||
@ -1,88 +0,0 @@
|
||||
.mat-form-field {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
|
||||
.mat-select {
|
||||
font-size: 1rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.mat-select-placeholder {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.mat-option-text {
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.card-content {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
align-items: center;
|
||||
// gap: 16px;
|
||||
// padding: 20px;
|
||||
}
|
||||
|
||||
.item-option {
|
||||
text-align: center;
|
||||
flex: 0 31%;
|
||||
height: 100px;
|
||||
// margin-bottom: 1%; /* (100-32*3)/2 */
|
||||
}
|
||||
|
||||
.item-option + .item-option {
|
||||
margin-left: 2%;
|
||||
}
|
||||
|
||||
$radius2: 50%;
|
||||
$height2: 35px;
|
||||
|
||||
#round-input[type='color'] {
|
||||
display: inline-flex;
|
||||
vertical-align: bottom;
|
||||
border: 1px solid #c5c5c5;
|
||||
border-radius: $radius2;
|
||||
padding: 0;
|
||||
height: $height2;
|
||||
width: 35px;
|
||||
cursor: pointer;
|
||||
|
||||
&::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&::-webkit-color-swatch {
|
||||
border: 0;
|
||||
border-radius: $radius2 $radius2 0 0;
|
||||
}
|
||||
|
||||
&::-moz-color-swatch {
|
||||
border: 0;
|
||||
border-radius: $radius2 $radius2 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.disabled-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: #000000;
|
||||
text-align: center;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.disabled-overlay p {
|
||||
margin: 0;
|
||||
padding: 25px;
|
||||
background-color: #ffffff;
|
||||
align-content: center;
|
||||
height: 30%;
|
||||
width: 100%;
|
||||
}
|
||||
@ -1,29 +0,0 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { BaseCardComponent } from '../base-card/base-card.component';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
@Component({
|
||||
selector: 'ov-selection-card',
|
||||
standalone: true,
|
||||
imports: [FormsModule, BaseCardComponent, MatSelectModule, MatFormFieldModule, MatInputModule],
|
||||
templateUrl: './selection-card.component.html',
|
||||
styleUrl: './selection-card.component.scss'
|
||||
})
|
||||
export class SelectionCardComponent extends BaseCardComponent {
|
||||
@Input() options: { label: string; value: any }[] = [];
|
||||
@Input() selectedOption: any;
|
||||
|
||||
@Input() selectionType: 'text' | 'color' = 'text';
|
||||
@Output() onOptionSelected = new EventEmitter<any>();
|
||||
@Output() onColorChanged = new EventEmitter<{ label: string; value: string }>();
|
||||
onSelectionChange(event: any) {
|
||||
this.onOptionSelected.emit(event.value);
|
||||
}
|
||||
|
||||
onColorChange(label: string, event: any) {
|
||||
this.onColorChanged.emit({ label, value: event.target.value });
|
||||
}
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
<ov-base-card
|
||||
[title]="title"
|
||||
[description]="description"
|
||||
[icon]="icon"
|
||||
[iconBackgroundColor]="iconBackgroundColor"
|
||||
[cardBackgroundColor]="cardBackgroundColor"
|
||||
>
|
||||
<ng-content card-content select="[card-content]"></ng-content>
|
||||
|
||||
<div card-footer class="card-footer">
|
||||
<span>{{ title }} is {{ toggleValue ? 'enabled' : 'disabled' }}</span>
|
||||
<mat-slide-toggle
|
||||
class="ov-slide-toggle"
|
||||
[checked]="toggleValue"
|
||||
(change)="onToggleChange($event)"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</ov-base-card>
|
||||
@ -1,29 +0,0 @@
|
||||
.card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 8px;
|
||||
padding: 10px;
|
||||
background-color: #f8f9fa;
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
mat-slide-toggle.ov-slide-toggle {
|
||||
--mdc-switch-selected-handle-color: #f9faf8;
|
||||
--mdc-switch-selected-pressed-handle-color: var(--mdc-switch-selected-handle-color);
|
||||
--mdc-switch-selected-hover-handle-color: var(--mdc-switch-selected-handle-color);
|
||||
--mdc-switch-selected-focus-handle-color: var(--mdc-switch-selected-handle-color);
|
||||
|
||||
--mdc-switch-selected-track-color: #4fb64f;
|
||||
--mdc-switch-selected-pressed-track-color: var(--mdc-switch-selected-track-color);
|
||||
--mdc-switch-selected-hover-track-color: var(--mdc-switch-selected-track-color);
|
||||
--mdc-switch-selected-focus-track-color: var(--mdc-switch-selected-track-color);
|
||||
|
||||
--mdc-switch-selected-pressed-state-layer-color: var(--mdc-switch-selected-track-color);
|
||||
--mdc-switch-selected-hover-state-layer-color: var(--mdc-switch-selected-handle-color);
|
||||
--mdc-switch-selected-focus-state-layer-color: var(--mdc-switch-selected-handle-color);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { BaseCardComponent } from '../base-card/base-card.component';
|
||||
|
||||
@Component({
|
||||
selector: 'ov-toggle-card',
|
||||
standalone: true,
|
||||
imports: [MatSlideToggleModule, BaseCardComponent],
|
||||
templateUrl: './toggle-card.component.html',
|
||||
styleUrl: './toggle-card.component.scss'
|
||||
})
|
||||
export class ToggleCardComponent extends BaseCardComponent {
|
||||
/**
|
||||
* A boolean input property that determines the toggle state. Only applicable when `footerType` is set to `'toggle'`.
|
||||
* Defaults to `false`.
|
||||
*/
|
||||
@Input() toggleValue: boolean = false;
|
||||
|
||||
@Output() onToggleValueChanged = new EventEmitter<boolean>();
|
||||
|
||||
onToggleChange(event: any) {
|
||||
this.onToggleValueChanged.emit(event.checked);
|
||||
}
|
||||
}
|
||||
@ -1,39 +1,47 @@
|
||||
<mat-toolbar color="primary">
|
||||
<button mat-icon-button aria-label="Menu icon" (click)="toggleSideMenu()">
|
||||
<mat-toolbar color="primary" id="main-toolbar">
|
||||
<button mat-icon-button aria-label="Menu icon" (click)="toggleSideMenu()" id="menu-toggle-btn">
|
||||
<mat-icon>menu</mat-icon>
|
||||
</button>
|
||||
|
||||
<span class="toolbar-title">OpenVidu Console</span>
|
||||
<span class="toolbar-title" id="app-title">OpenVidu Meet</span>
|
||||
|
||||
<span class="toolbar-spacer"></span>
|
||||
|
||||
<button mat-icon-button aria-label="Profile">
|
||||
<button mat-icon-button aria-label="Profile" id="profile-btn">
|
||||
<mat-icon>account_circle</mat-icon>
|
||||
</button>
|
||||
|
||||
<button mat-icon-button (click)="onLogoutClicked.emit()" aria-label="Logout">
|
||||
<button mat-icon-button (click)="onLogoutClicked.emit()" aria-label="Logout" id="logout-btn">
|
||||
<mat-icon>logout</mat-icon>
|
||||
</button>
|
||||
</mat-toolbar>
|
||||
|
||||
<mat-sidenav-container autosize class="sidenav-container">
|
||||
<mat-sidenav-container autosize class="sidenav-container" id="sidenav-container">
|
||||
<mat-sidenav
|
||||
#sidenav
|
||||
[ngClass]="isSideMenuCollapsed ? 'collapsed' : 'expanded'"
|
||||
[mode]="isMobile ? 'over' : 'side'"
|
||||
[opened]="isMobile ? 'false' : 'true'"
|
||||
id="side-navigation"
|
||||
>
|
||||
<mat-nav-list>
|
||||
<mat-nav-list id="nav-list">
|
||||
@for (link of navLinks; track link.route) {
|
||||
<ng-container>
|
||||
<a
|
||||
mat-list-item
|
||||
class="menu-button"
|
||||
[routerLink]="link.route"
|
||||
routerLinkActive="active-nav-item"
|
||||
[routerLinkActiveOptions]="{ exact: false }"
|
||||
(click)="link.clickHandler ? link.clickHandler() : null"
|
||||
[matTooltip]="isSideMenuCollapsed ? link.label : ''"
|
||||
[matTooltipPosition]="'right'"
|
||||
[matTooltipClass]="'nav-tooltip'"
|
||||
[matTooltipDisabled]="!isSideMenuCollapsed"
|
||||
[id]="'nav-link-' + link.route!.replace('/', '')"
|
||||
>
|
||||
<span class="entry" [ngClass]="isSideMenuCollapsed ? 'centeredEntry' : 'entry'">
|
||||
<mat-icon>{{ link.icon }}</mat-icon>
|
||||
<mat-icon [ngClass]="link.iconClass">{{ link.icon }}</mat-icon>
|
||||
@if (!isSideMenuCollapsed) {
|
||||
{{ link.label }}
|
||||
}
|
||||
@ -44,16 +52,16 @@
|
||||
</mat-nav-list>
|
||||
|
||||
@if (!isSideMenuCollapsed) {
|
||||
<div class="separator"></div>
|
||||
<div class="version">
|
||||
<div class="separator" id="nav-separator"></div>
|
||||
<div class="version" id="version-info">
|
||||
<p>v{{ version }}</p>
|
||||
</div>
|
||||
}
|
||||
</mat-sidenav>
|
||||
|
||||
<!-- Main content -->
|
||||
<mat-sidenav-content>
|
||||
<div class="page-content">
|
||||
<mat-sidenav-content id="main-content">
|
||||
<div class="page-content" id="page-content">
|
||||
<router-outlet></router-outlet>
|
||||
</div>
|
||||
</mat-sidenav-content>
|
||||
|
||||
@ -1,103 +1,84 @@
|
||||
@import '../../../../../../src/assets/styles/design-tokens';
|
||||
|
||||
#dashboard-container,
|
||||
mat-sidenav-container {
|
||||
height: 100%;
|
||||
@extend .ov-nav-container;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
height: calc(100% - (64px + 40px));
|
||||
padding: 20px;
|
||||
}
|
||||
.toolbar-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.toolbar-spacer {
|
||||
flex: 1 1 auto;
|
||||
@extend .page-content;
|
||||
}
|
||||
|
||||
mat-toolbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
@extend .ov-nav-toolbar;
|
||||
}
|
||||
|
||||
mat-sidenav-container {
|
||||
height: 100%;
|
||||
.toolbar-title {
|
||||
@extend .toolbar-title;
|
||||
}
|
||||
|
||||
.toolbar-spacer {
|
||||
@extend .toolbar-spacer;
|
||||
}
|
||||
|
||||
.expanded {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.collapsed {
|
||||
width: 70px;
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
a {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
// Move the content down so that it won't be hidden by the toolbar
|
||||
mat-sidenav {
|
||||
background-color: white;
|
||||
padding-top: 3.5rem;
|
||||
@media screen and (min-width: 600px) {
|
||||
padding-top: 4rem;
|
||||
}
|
||||
@extend .ov-sidenav;
|
||||
|
||||
.entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem;
|
||||
justify-content: start;
|
||||
@extend .nav-entry;
|
||||
}
|
||||
|
||||
.centeredEntry {
|
||||
justify-content: center !important;
|
||||
@extend .centered;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-mdc-nav-list {
|
||||
height: calc(100% - 3.5rem - 25px);
|
||||
@extend .nav-list;
|
||||
}
|
||||
|
||||
// Move the content down so that it won't be hidden by the toolbar
|
||||
mat-sidenav-content {
|
||||
padding-top: 3.5rem;
|
||||
@media screen and (min-width: 600px) {
|
||||
padding-top: 4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-spacer {
|
||||
flex: 1 1 auto;
|
||||
@extend .ov-sidenav-content;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
padding: 0px 2rem;
|
||||
height: 100%;
|
||||
@extend .main-container;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
padding-bottom: 0px;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.menu-button {
|
||||
height: 60px !important;
|
||||
text-align: justify;
|
||||
@extend .ov-menu-button;
|
||||
|
||||
&.active-nav-item {
|
||||
@extend .active;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-hr {
|
||||
margin: 0px !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.version {
|
||||
margin-top: auto;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #757575;
|
||||
text-align: center;
|
||||
@extend .ov-nav-version;
|
||||
}
|
||||
|
||||
.separator {
|
||||
margin: 0px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
@extend .ov-nav-separator;
|
||||
}
|
||||
|
||||
@ -5,9 +5,10 @@ import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatSidenav, MatSidenavModule } from '@angular/material/sidenav';
|
||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { ConsoleNavLink } from '../../models/sidenav.model';
|
||||
import { ContextService } from 'shared-meet-components';
|
||||
import { ConsoleNavLink } from '@lib/models';
|
||||
import { AppDataService } from '@lib/services';
|
||||
|
||||
@Component({
|
||||
selector: 'ov-console-nav',
|
||||
@ -19,6 +20,7 @@ import { ContextService } from 'shared-meet-components';
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatSidenavModule,
|
||||
MatTooltipModule,
|
||||
RouterModule
|
||||
],
|
||||
templateUrl: './console-nav.component.html',
|
||||
@ -34,8 +36,8 @@ export class ConsoleNavComponent {
|
||||
@Input() navLinks: ConsoleNavLink[] = [];
|
||||
@Output() onLogoutClicked: EventEmitter<void> = new EventEmitter<void>();
|
||||
|
||||
constructor(private contextService: ContextService) {
|
||||
this.version = this.contextService.getVersion();
|
||||
constructor(private appDataService: AppDataService) {
|
||||
this.version = this.appDataService.getVersion();
|
||||
}
|
||||
|
||||
async toggleSideMenu() {
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
<h2 mat-dialog-title>Delete file</h2>
|
||||
<mat-dialog-content> Would you like to delete? </mat-dialog-content>
|
||||
<mat-dialog-actions>
|
||||
<button mat-button mat-dialog-close>No</button>
|
||||
<button mat-button mat-dialog-close cdkFocusInitial>Ok</button>
|
||||
</mat-dialog-actions>
|
||||
@ -1,15 +1,167 @@
|
||||
// Import design system utilities
|
||||
@import '../../../../../../../src/assets/styles/design-tokens';
|
||||
|
||||
// === DIALOG CONTAINER ===
|
||||
::ng-deep {
|
||||
.mat-mdc-dialog-container {
|
||||
border-radius: var(--ov-meet-card-border-radius) !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mat-mdc-dialog-surface {
|
||||
border-radius: var(--ov-meet-card-border-radius) !important;
|
||||
}
|
||||
}
|
||||
|
||||
:host {
|
||||
.dialog-container {
|
||||
@include ov-theme-transition;
|
||||
display: block;
|
||||
animation: dialogFadeIn 0.2s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
// === DIALOG ANIMATION ===
|
||||
@keyframes dialogFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
// === DIALOG TITLE ===
|
||||
.dialog-title {
|
||||
@extend .ov-text-center;
|
||||
@include ov-theme-transition;
|
||||
color: var(--ov-meet-text-primary);
|
||||
font-size: var(--ov-meet-font-size-xl) !important;
|
||||
font-weight: var(--ov-meet-font-weight-medium);
|
||||
margin-bottom: var(--ov-meet-spacing-md);
|
||||
padding: var(--ov-meet-spacing-lg) var(--ov-meet-spacing-lg) 0;
|
||||
line-height: var(--ov-meet-line-height-tight);
|
||||
}
|
||||
|
||||
// === DIALOG CONTENT ===
|
||||
::ng-deep mat-dialog-content {
|
||||
@include ov-theme-transition;
|
||||
color: var(--ov-meet-text-secondary);
|
||||
font-size: var(--ov-meet-font-size-md) !important;
|
||||
line-height: var(--ov-meet-line-height-relaxed);
|
||||
padding: var(--ov-meet-spacing-md) var(--ov-meet-spacing-lg);
|
||||
text-align: center;
|
||||
}
|
||||
button {
|
||||
margin-right: 8px;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
// === DIALOG ACTIONS ===
|
||||
.dialog-action {
|
||||
margin: auto;
|
||||
@include ov-flex-center;
|
||||
gap: var(--ov-meet-spacing-sm);
|
||||
padding: var(--ov-meet-spacing-lg);
|
||||
margin: 0;
|
||||
|
||||
// Button styling
|
||||
button {
|
||||
@include ov-button-base;
|
||||
@include ov-theme-transition;
|
||||
min-width: 80px;
|
||||
margin: 0; // Reset default margin
|
||||
|
||||
// Cancel/Secondary button
|
||||
&[mat-button] {
|
||||
color: var(--ov-meet-text-secondary);
|
||||
border: 1px solid var(--ov-meet-border-color);
|
||||
background: transparent;
|
||||
|
||||
&:hover {
|
||||
@include ov-hover-lift(-1px);
|
||||
|
||||
// background: var(--ov-meet-surface-hover);
|
||||
// color: var(--ov-meet-text-primary);
|
||||
}
|
||||
|
||||
// &:focus {
|
||||
// outline: 2px solid var(--ov-meet-color-primary);
|
||||
// outline-offset: 2px;
|
||||
// }
|
||||
}
|
||||
|
||||
// Confirm/Primary button
|
||||
&[mat-flat-button] {
|
||||
// background: var(--ov-meet-color-primary);
|
||||
color: var(--ov-meet-text-on-primary);
|
||||
|
||||
&:hover {
|
||||
@include ov-hover-lift(-1px);
|
||||
// background: var(--ov-meet-color-primary-dark);
|
||||
// box-shadow: var(--ov-meet-shadow-hover);
|
||||
}
|
||||
|
||||
// &:focus {
|
||||
// outline: 2px solid var(--ov-meet-color-primary-light);
|
||||
// outline-offset: 2px;
|
||||
// }
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.confirm-button {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
// === RESPONSIVE DESIGN ===
|
||||
@include ov-mobile-down {
|
||||
.dialog-title {
|
||||
font-size: var(--ov-meet-font-size-lg);
|
||||
padding: var(--ov-meet-spacing-md) var(--ov-meet-spacing-md) 0;
|
||||
}
|
||||
|
||||
mat-dialog-content {
|
||||
padding: var(--ov-meet-spacing-sm) var(--ov-meet-spacing-md);
|
||||
font-size: var(--ov-meet-font-size-sm);
|
||||
}
|
||||
|
||||
.dialog-action {
|
||||
padding: var(--ov-meet-spacing-md);
|
||||
flex-direction: column;
|
||||
gap: var(--ov-meet-spacing-xs);
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === ACCESSIBILITY ENHANCEMENTS ===
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:host {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes dialogFadeIn {
|
||||
from,
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === HIGH CONTRAST MODE ===
|
||||
@media (prefers-contrast: high) {
|
||||
.dialog-title {
|
||||
font-weight: var(--ov-meet-font-weight-bold);
|
||||
}
|
||||
|
||||
.dialog-action button {
|
||||
border-width: 2px;
|
||||
|
||||
&[mat-button] {
|
||||
border-color: currentColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,20 +1,22 @@
|
||||
import { ChangeDetectionStrategy, Component, Inject, inject } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MAT_DIALOG_DATA, MatDialogActions, MatDialogContent, MatDialogRef } from '@angular/material/dialog';
|
||||
import type { DialogOptions } from '../../../models';
|
||||
import type { DialogOptions } from '@lib/models';
|
||||
|
||||
@Component({
|
||||
selector: 'ov-dialog',
|
||||
standalone: true,
|
||||
imports: [MatButtonModule, MatDialogActions, MatDialogContent],
|
||||
template: ` <h2 mat-dialog-title class="dialog-title">{{ data.title }}</h2>
|
||||
<mat-dialog-content> {{ data.message }} </mat-dialog-content>
|
||||
template: ` <div class="dialog-container">
|
||||
<h2 mat-dialog-title class="dialog-title ov-text-center">{{ data.title }}</h2>
|
||||
<mat-dialog-content [innerHTML]="data.message"></mat-dialog-content>
|
||||
<mat-dialog-actions class="dialog-action">
|
||||
<button mat-button mat-dialog-close (click)="close('cancel')">{{ data.cancelText }}</button>
|
||||
<button mat-flat-button mat-dialog-close cdkFocusInitial (click)="close('confirm')">
|
||||
{{ data.confirmText }}
|
||||
</button>
|
||||
</mat-dialog-actions>`,
|
||||
</mat-dialog-actions>
|
||||
</div>`,
|
||||
styleUrl: './dialog.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
|
||||
@ -15,7 +15,7 @@ import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatRadioModule } from '@angular/material/radio';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { HttpService } from 'shared-meet-components';
|
||||
import { RecordingManagerService } from '@lib/services';
|
||||
|
||||
@Component({
|
||||
selector: 'ov-share-recording-dialog',
|
||||
@ -47,7 +47,7 @@ export class ShareRecordingDialogComponent {
|
||||
|
||||
constructor(
|
||||
@Inject(MAT_DIALOG_DATA) public data: { recordingId: string; recordingUrl?: string },
|
||||
private httpService: HttpService,
|
||||
private recordingService: RecordingManagerService,
|
||||
private clipboard: Clipboard
|
||||
) {
|
||||
this.recordingUrl = data.recordingUrl;
|
||||
@ -59,7 +59,7 @@ export class ShareRecordingDialogComponent {
|
||||
|
||||
try {
|
||||
const privateAccess = this.accessType === 'private';
|
||||
const { url } = await this.httpService.generateRecordingUrl(this.data.recordingId, privateAccess);
|
||||
const { url } = await this.recordingService.generateRecordingUrl(this.data.recordingId, privateAccess);
|
||||
this.recordingUrl = url;
|
||||
} catch (error) {
|
||||
this.erroMessage = 'Failed to generate recording URL. Please try again later.';
|
||||
|
||||
@ -1,17 +0,0 @@
|
||||
<div class="dynamic-grid-container">
|
||||
<ng-container *ngIf="withHeader">
|
||||
<ng-content select="[header]"></ng-content>
|
||||
</ng-container>
|
||||
|
||||
<div
|
||||
class="card-container"
|
||||
[ngClass]="layoutMode"
|
||||
[ngStyle]="{
|
||||
'grid-template-columns': 'repeat(' + columns + ', 1fr)',
|
||||
gap: gutter
|
||||
}"
|
||||
role="grid"
|
||||
>
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
@ -1,64 +0,0 @@
|
||||
.dynamic-grid-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.grid-header {
|
||||
padding: 16px;
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
color: #3f51b5;
|
||||
}
|
||||
}
|
||||
|
||||
.card-container {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
margin: 16px 0;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&.masonry {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&.grid {
|
||||
}
|
||||
|
||||
::ng-deep .grid-item {
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
::ng-deep .grid-item[data-span='2'] {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
::ng-deep .grid-item[data-span='3'] {
|
||||
grid-column: span 3;
|
||||
}
|
||||
|
||||
::ng-deep .grid-item[data-span='4'] {
|
||||
grid-column: span 4;
|
||||
}
|
||||
|
||||
&.masonry .grid-item {
|
||||
width: calc(100% / var(--columns));
|
||||
margin-bottom: var(--gutter);
|
||||
}
|
||||
|
||||
&.masonry {
|
||||
--columns: 1;
|
||||
--gutter: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,65 +0,0 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, Input, OnInit, ViewEncapsulation } from '@angular/core';
|
||||
import { MatGridListModule } from '@angular/material/grid-list';
|
||||
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
|
||||
|
||||
@Component({
|
||||
selector: 'ov-dynamic-grid',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatGridListModule],
|
||||
templateUrl: './dynamic-grid.component.html',
|
||||
styleUrls: ['./dynamic-grid.component.scss'],
|
||||
encapsulation: ViewEncapsulation.Emulated // Ensures styles do not affect other components
|
||||
})
|
||||
export class DynamicGridComponent implements OnInit {
|
||||
/** Maximum number of columns */
|
||||
@Input() maxColumns: number = 3;
|
||||
|
||||
/** Spacing between items (e.g., '16px') */
|
||||
@Input() gutter: string = '16px';
|
||||
|
||||
/** Layout mode: 'grid' | 'masonry' */
|
||||
@Input() layoutMode: 'grid' | 'masonry' = 'grid';
|
||||
|
||||
/** Enable or disable the header */
|
||||
@Input() withHeader: boolean = false;
|
||||
|
||||
/** Controls the current number of columns */
|
||||
columns: number = 1;
|
||||
|
||||
constructor(private breakpointObserver: BreakpointObserver) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.setupGrid();
|
||||
}
|
||||
|
||||
/** Configures the grid to respond to size changes */
|
||||
private setupGrid(): void {
|
||||
this.breakpointObserver
|
||||
.observe([Breakpoints.XSmall, Breakpoints.Small, Breakpoints.Medium, Breakpoints.Large, Breakpoints.XLarge])
|
||||
.subscribe((result) => {
|
||||
if (result.breakpoints[Breakpoints.XSmall]) {
|
||||
this.columns = Math.min(1, this.maxColumns);
|
||||
} else if (result.breakpoints[Breakpoints.Small]) {
|
||||
this.columns = Math.min(2, this.maxColumns);
|
||||
} else if (result.breakpoints[Breakpoints.Medium]) {
|
||||
this.columns = Math.min(3, this.maxColumns);
|
||||
} else if (result.breakpoints[Breakpoints.Large]) {
|
||||
this.columns = Math.min(4, this.maxColumns);
|
||||
} else if (result.breakpoints[Breakpoints.XLarge]) {
|
||||
this.columns = Math.min(5, this.maxColumns);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the span of a card.
|
||||
* @param span Number of columns the card should occupy.
|
||||
*/
|
||||
getColSpan(span: number | undefined): number {
|
||||
if (span && span >= 1 && span <= this.maxColumns) {
|
||||
return span;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ListComponent } from './list.component';
|
||||
|
||||
describe('ListComponent', () => {
|
||||
let component: ListComponent;
|
||||
let fixture: ComponentFixture<ListComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ListComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ListComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@ -1,12 +0,0 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'ov-list',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
templateUrl: './list.component.html',
|
||||
styleUrl: './list.component.scss'
|
||||
})
|
||||
export class ListComponent {
|
||||
|
||||
}
|
||||
@ -1,10 +1,11 @@
|
||||
export * from './cards/base-card/base-card.component';
|
||||
export * from './cards/toggle-card/toggle-card.component';
|
||||
export * from './cards/selection-card/selection-card.component';
|
||||
export * from './cards/pro-feature/pro-feature.component';
|
||||
export * from './console-nav/console-nav.component';
|
||||
export * from './dialogs/basic-dialog/dialog.component';
|
||||
export * from './dialogs/share-recording-dialog/share-recording-dialog.component';
|
||||
export * from './dynamic-grid/dynamic-grid.component';
|
||||
export * from './generics/list/list.component';
|
||||
export * from './logo-selector/logo-selector.component';
|
||||
export * from './pro-feature-badge/pro-feature-badge.component';
|
||||
export * from './recording-lists/recording-lists.component';
|
||||
export * from './rooms-lists/rooms-lists.component';
|
||||
export * from './selectable-card/selectable-card.component';
|
||||
export * from './spinner/spinner.component';
|
||||
export * from './step-indicator/step-indicator.component';
|
||||
export * from './wizard-nav/wizard-nav.component';
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
<form>
|
||||
<!-- Brand Logo -->
|
||||
<div class="form-section">
|
||||
<div class="form-field-header">
|
||||
<h3>Brand Logo</h3>
|
||||
<ov-pro-feature-badge></ov-pro-feature-badge>
|
||||
</div>
|
||||
<p class="field-description">Make it yours—add your brand's logo to the experience.</p>
|
||||
|
||||
<div class="logo-upload-section">
|
||||
<div class="logo-preview">
|
||||
<div class="logo-placeholder">
|
||||
<!-- <mat-icon>image</mat-icon> -->
|
||||
<img
|
||||
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABYCAMAAAA0hKKwAAAB5lBMVEUAAAD/zAD/zAD+zAL/zAAAiKoAiKoAiKoAiKr/zAAAiKoAiKr/zAD/zAAAiKr/zAAAiKoAiKr/zAAAh6sAiKoAiKr/zAAAiKoAiKoAiKoAiKr/zAD/zAD/zAD/zAD/zAAAiKr/zQAAiKoAiKr/zAD/zAD/zAAAiKr/zAD/zAAAiKoAiKr/zAD/zAD/zAAAiKr/zAD/zAAAiKr/zAD/zAAAiKr/zAD/zAAAiKr/zAAAiKr/zAD/zAAAiKr/zAAAiKoAiKr/zAD/zAD/zAD/zAAAiKr/zAD/zAAAiKr/zAAAiKr/zAD/zAAAiKoAiKqYzylJv1gAiKr/zAAAiKpQ0UYHs4D/zAD/zAAG02IAiKr///8C02MD02D9zAEDvHhl0D4Aian8zAFk0D0DsYOH6rP7//0W1msK1GL3/vpj45wr2nkj2HTB9NgBkaIDsIQG0WQp0lTj+u7Z+OjT9+PH9dy989Vx5qRZ4pYCnZYCpI9L340DrYcf13Ec128Fxm8Q1Wdr0Dt+0DPPzRPkzQvz/ffs/PPL9t628tGn8Mei7sR+6K1356kAjKZq5aEBmJo73YMEtn4Fy2oe0llG0Ulf0ECEzzGnziOwzh/VzRDd+eqX7L2K6rVU4ZI0234EwHQFzGnczQ74zAMAnwkOAAAAV3RSTlMAW/kHo7MKwhD2nN3KHPzkwE83BvGagXQV6S4K+7CojwgFBPny7MCroFE7JBMQ0M+2rKGZlH5CPTIpHxgMuqmUjoZ/cnBtaV5cSUcjHxgN/eDX19bLmmEnrDDeAAADcUlEQVRo3uXZB1faUBjG8atWWwS3qCgqbgFx772tWu0uRuJsxQqIe1tnh6NurXa337RU7LmNeWOA3OvxtP8v8CNPxuEk6FrLKCnUeklIG1omIvgUxSuCZN6SSsnML/K5wriXn2IkUUp8gKBRkmokVXYrgjPkGMmVY4CRRjVBRN0IX1apRpIpwIusMJkoImuGkHoj2SIgJJ4wkg4YCemEkRzoZr9FGIlNuNFIl8v1xfp4hKyfrq70uFj/8QN/Z8qqghB9IoCAxMnooMmNunuZPyXJq/UuIWdHHaYOd/qN4LJaXEDWjwBCHME9LBZHVt0CMILL1YghP0ZNUhFVsRjyYVDykTANYsiqSTqiTLwa6VshgKRpRJB+Aoiv3/+MsNeAmK1m7FBCZmwfJ7esmKGAsHtjnY4mtq0sRWS787yBielFlqWFvHAITubt9CuWHoKZHcwQRzAzOWRmaSG4lzbMkEcwMzWDGeIIZt59WWZpIbjXGxZ6CM62yNJHBob+FWRqkf6J37Sw1C/h+WWW+s2ICQoIfnpRRSbw454WMr5lpf2oH/ts6cAGUWRnwEm8+fQVE6QRy/g5sbk3yFL8SzRvGxvfmMcEFYQ1W6wAIYbEufs3lb0Bf7j3GT6SkEcYWQAQVEsWGZmFEK2MKLI0DCEBmUQROwMhqE4tiHStmCQdCEYyKo2CnbiJHMwyMIIC0mWEXhZ822X45TrfR5U3pSbDyNmhy4cyctA9954BikQXGYrq82KzFWreme/5Cxnptj+Hs9sX5maHexkolY7zDi+mydt4uTX8BmcJ+qHiKcMQp3xgsNML5fvCMONJwXquUQbeMWvHh46l9nc9IlTKUsStGT77P9fuP3nqG3wbKpwRKkklz6oo0IWhS9UY4YIiApCm/Q5QVEhwoNAxtJS2+SFehlSQkFWGIuGiIuUwEngXQYWmQIbCqxxdWbTSsRl8DwLVCSwlVhi82eNSxC8mlr9UHLCUq5slhSB+JUFuL4WL9udvVo34PVOLLOXuZllRiFe68FKebRau4381fcRdKgbhPNysAF1OKxNYyvPNKtqB72fQUlI2UxUjbhkKKUvB92ZgA/BJE1hK2mZpYYhTLbCU5M3keu7tni1tKfg6S+I+JBPy8FIk0jg3UyJOrTXxoYhkbZFyfFLoFV2VpkPUS9QgZ78AEp7b67CRSAIAAAAASUVORK5CYII="
|
||||
alt="Brand Logo"
|
||||
class="logo-image"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pro-upgrade-message">
|
||||
<p>Upgrade to Pro to customize your branding</p>
|
||||
<button mat-raised-button color="primary">
|
||||
<mat-icon>upgrade</mat-icon>
|
||||
Upgrade to Pro
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@ -0,0 +1,16 @@
|
||||
@import '../../../../../../src/assets/styles/design-tokens';
|
||||
|
||||
// Form styling
|
||||
.form-section {
|
||||
@extend .ov-form-section;
|
||||
}
|
||||
|
||||
.form-field-header {
|
||||
position: relative;
|
||||
margin-top: var(--ov-meet-spacing-md);
|
||||
}
|
||||
|
||||
// Logo upload section
|
||||
.logo-upload-section {
|
||||
@extend .ov-logo-upload;
|
||||
}
|
||||
@ -1,18 +1,18 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { DynamicGridComponent } from './dynamic-grid.component';
|
||||
import { LogoSelectorComponent } from './logo-selector.component';
|
||||
|
||||
describe('DynamicGridComponent', () => {
|
||||
let component: DynamicGridComponent;
|
||||
let fixture: ComponentFixture<DynamicGridComponent>;
|
||||
describe('LogoSelectorComponent', () => {
|
||||
let component: LogoSelectorComponent;
|
||||
let fixture: ComponentFixture<LogoSelectorComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DynamicGridComponent]
|
||||
imports: [LogoSelectorComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(DynamicGridComponent);
|
||||
fixture = TestBed.createComponent(LogoSelectorComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
@ -0,0 +1,13 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { ProFeatureBadgeComponent } from '@lib/components';
|
||||
|
||||
@Component({
|
||||
selector: 'ov-logo-selector',
|
||||
standalone: true,
|
||||
imports: [MatButtonModule, MatIconModule, ProFeatureBadgeComponent],
|
||||
templateUrl: './logo-selector.component.html',
|
||||
styleUrl: './logo-selector.component.scss'
|
||||
})
|
||||
export class LogoSelectorComponent {}
|
||||
@ -0,0 +1,4 @@
|
||||
<div class="pro-badge">
|
||||
<mat-icon class="material-symbols-outlined">{{ badgeIcon }}</mat-icon>
|
||||
<span>{{ badgeText }}</span>
|
||||
</div>
|
||||
@ -0,0 +1,23 @@
|
||||
@import '../../../../../../src/assets/styles/design-tokens';
|
||||
|
||||
.pro-badge {
|
||||
position: absolute;
|
||||
top: var(--ov-meet-spacing-xs);
|
||||
right: var(--ov-meet-spacing-xs);
|
||||
background: linear-gradient(45deg, var(--ov-meet-color-warning), var(--ov-meet-color-accent));
|
||||
color: white;
|
||||
border-radius: var(--ov-meet-radius-sm);
|
||||
padding: var(--ov-meet-spacing-xs) var(--ov-meet-spacing-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ov-meet-spacing-xs);
|
||||
font-size: var(--ov-meet-font-size-xxs);
|
||||
font-weight: var(--ov-meet-font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
z-index: 2;
|
||||
|
||||
mat-icon {
|
||||
@include ov-icon(xs);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ProFeatureBadgeComponent } from './pro-feature-badge.component';
|
||||
|
||||
describe('ProFeatureBadgeComponent', () => {
|
||||
let component: ProFeatureBadgeComponent;
|
||||
let fixture: ComponentFixture<ProFeatureBadgeComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ProFeatureBadgeComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ProFeatureBadgeComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,14 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
|
||||
@Component({
|
||||
selector: 'ov-pro-feature-badge',
|
||||
standalone: true,
|
||||
imports: [MatIconModule],
|
||||
templateUrl: './pro-feature-badge.component.html',
|
||||
styleUrl: './pro-feature-badge.component.scss'
|
||||
})
|
||||
export class ProFeatureBadgeComponent {
|
||||
@Input() badgeIcon: string = 'crown'; // Default icon
|
||||
@Input() badgeText: string = 'Pro Feature'; // Default text
|
||||
}
|
||||
@ -0,0 +1,259 @@
|
||||
<!-- Loading Spinner -->
|
||||
@if (loading) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
<span>Loading recordings...</span>
|
||||
</div>
|
||||
} @else if (recordings.length > 0) {
|
||||
<!-- Recordings Toolbar -->
|
||||
<mat-toolbar class="recordings-toolbar">
|
||||
<!-- Left Section: Search -->
|
||||
<div class="toolbar-left">
|
||||
<mat-form-field class="search-field" appearance="outline">
|
||||
<mat-label>Search recordings</mat-label>
|
||||
<input matInput [formControl]="nameFilterControl" placeholder="Search by room name" />
|
||||
<mat-icon matSuffix>search</mat-icon>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!-- Center Section: Bulk Actions (visible when items selected) -->
|
||||
@if (showSelection && selectedRecordings().size > 0) {
|
||||
<div class="toolbar-center">
|
||||
<div class="batch-actions">
|
||||
@if (canDeleteRecordings) {
|
||||
<button
|
||||
mat-icon-button
|
||||
color="warn"
|
||||
(click)="bulkDeleteSelected()"
|
||||
[disabled]="loading"
|
||||
matTooltip="Delete selected recordings"
|
||||
>
|
||||
<mat-icon [matBadge]="selectedRecordings().size" matBadgePosition="below after">
|
||||
delete
|
||||
</mat-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="bulkDownloadSelected()"
|
||||
[disabled]="loading"
|
||||
matTooltip="Download selected recordings"
|
||||
>
|
||||
<mat-icon [matBadge]="selectedRecordings().size" matBadgePosition="below after">
|
||||
download
|
||||
</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Right Section: Filters -->
|
||||
<div class="toolbar-right">
|
||||
<button
|
||||
mat-icon-button
|
||||
class="refresh-btn"
|
||||
(click)="refresh.emit()"
|
||||
[disabled]="loading"
|
||||
matTooltip="Refresh recordings"
|
||||
>
|
||||
<mat-icon> refresh </mat-icon>
|
||||
</button>
|
||||
|
||||
@if (showFilters) {
|
||||
<button mat-icon-button [matMenuTriggerFor]="filtersMenu" matTooltip="Filter recordings">
|
||||
<mat-icon>tune</mat-icon>
|
||||
</button>
|
||||
|
||||
<mat-menu #filtersMenu="matMenu" class="filters-menu">
|
||||
<div class="filter-content" (click)="$event.stopPropagation()">
|
||||
<h4>Filter by Status</h4>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Status</mat-label>
|
||||
<mat-select [formControl]="statusFilterControl">
|
||||
@for (option of statusOptions; track option.value) {
|
||||
<mat-option [value]="option.value">{{ option.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</mat-menu>
|
||||
}
|
||||
</div>
|
||||
</mat-toolbar>
|
||||
|
||||
<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>
|
||||
}
|
||||
|
||||
<!-- Room ID Column -->
|
||||
<ng-container matColumnDef="roomId">
|
||||
<th mat-header-cell *matHeaderCellDef class="room-header">Room ID</th>
|
||||
<td mat-cell *matCellDef="let recording" class="room-cell">
|
||||
<div class="room-info">
|
||||
<span class="room-id">{{ recording.roomId }}</span>
|
||||
@if (recording.filename) {
|
||||
<span class="filename">{{ recording.filename }}</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 [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"
|
||||
(click)="playRecording(recording)"
|
||||
[disabled]="loading"
|
||||
>
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Download Button -->
|
||||
@if (canDownloadRecording(recording)) {
|
||||
<button
|
||||
mat-icon-button
|
||||
matTooltip="Download Recording"
|
||||
(click)="downloadRecording(recording)"
|
||||
[disabled]="loading"
|
||||
>
|
||||
<mat-icon>download</mat-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- More Actions Menu -->
|
||||
<button
|
||||
mat-icon-button
|
||||
[matMenuTriggerFor]="actionsMenu"
|
||||
matTooltip="More Actions"
|
||||
[disabled]="loading"
|
||||
>
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
|
||||
<mat-menu #actionsMenu="matMenu">
|
||||
<button mat-menu-item (click)="shareRecordingLink(recording)">
|
||||
<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">
|
||||
<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>
|
||||
} @else {
|
||||
<!-- Empty State -->
|
||||
<div class="no-recordings-state">
|
||||
<div class="empty-content">
|
||||
<h3>No recordings yet</h3>
|
||||
<p>Recordings from your meetings will appear here. Start a recording in any room to see them listed.</p>
|
||||
<div class="getting-started-actions">
|
||||
<button mat-raised-button color="primary" (click)="refresh.emit()" class="refresh-recordings-btn">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
Refresh Recordings
|
||||
</button>
|
||||
</div>
|
||||
<!-- TODO: Show this when no recordings match the filters
|
||||
@if (hasActiveFilters()) {
|
||||
<h3>No recordings match your search criteria</h3>
|
||||
<p>Try adjusting or clearing your filters to see more recordings.</p>
|
||||
<div class="getting-started-actions">
|
||||
<button mat-raised-button color="primary" (click)="clearFilters()" class="clear-filters-btn">
|
||||
<mat-icon>filter_alt_off</mat-icon>
|
||||
Clear Filters
|
||||
</button>
|
||||
</div>
|
||||
} -->
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@ -0,0 +1,159 @@
|
||||
@import '../../../../../../src/assets/styles/design-tokens';
|
||||
|
||||
// Use utility classes for recordings toolbar
|
||||
.recordings-toolbar {
|
||||
@extend .ov-data-toolbar;
|
||||
|
||||
.toolbar-right {
|
||||
gap: var(--ov-meet-spacing-sm);
|
||||
|
||||
::ng-deep .refresh-btn {
|
||||
padding: var(--ov-meet-spacing-sm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// Filters Menu Styling
|
||||
.filters-menu {
|
||||
@extend .ov-filters-menu;
|
||||
}
|
||||
|
||||
.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-id {
|
||||
@extend .primary-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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
.refresh-recordings-btn {
|
||||
@extend .refresh-btn;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply focus states for accessibility
|
||||
.mat-mdc-checkbox,
|
||||
.mat-mdc-icon-button,
|
||||
.mat-mdc-button {
|
||||
@extend .ov-focus-visible;
|
||||
}
|
||||
@ -1,18 +1,18 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { BaseCardComponent } from './base-card.component';
|
||||
import { RecordingListsComponent } from './recording-lists.component';
|
||||
|
||||
describe('BaseCardComponent', () => {
|
||||
let component: BaseCardComponent;
|
||||
let fixture: ComponentFixture<BaseCardComponent>;
|
||||
describe('RecordingListsComponent', () => {
|
||||
let component: RecordingListsComponent;
|
||||
let fixture: ComponentFixture<RecordingListsComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [BaseCardComponent]
|
||||
imports: [RecordingListsComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(BaseCardComponent);
|
||||
fixture = TestBed.createComponent(RecordingListsComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
@ -0,0 +1,377 @@
|
||||
import { CommonModule, DatePipe } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
HostBinding,
|
||||
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';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MeetRecordingInfo, MeetRecordingStatus } from '@lib/typings/ce';
|
||||
import { debounceTime, distinctUntilChanged } from 'rxjs';
|
||||
|
||||
export interface RecordingTableAction {
|
||||
recordings: MeetRecordingInfo[];
|
||||
action: 'play' | 'download' | 'shareLink' | 'delete' | 'bulkDelete' | 'bulkDownload';
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable component for displaying a list of recordings with filtering, selection, and bulk operations.
|
||||
*
|
||||
* Features:
|
||||
* - Display recordings in a Material Design table
|
||||
* - Filter by room name and status
|
||||
* - Multi-selection for bulk operations
|
||||
* - Individual recording actions (play, download, share, delete)
|
||||
* - Responsive design with mobile optimization
|
||||
* - Status-based styling using design tokens
|
||||
*
|
||||
* @example
|
||||
* ```html
|
||||
* <ov-recording-lists
|
||||
* [recordings]="recordings"
|
||||
* [canDeleteRecordings]="true"
|
||||
* [loading]="isLoading"
|
||||
* (recordingAction)="handleRecordingAction($event)"
|
||||
* (filterChange)="handleFilterChange($event)">
|
||||
* </ov-recording-lists>
|
||||
* ```
|
||||
*/
|
||||
|
||||
@Component({
|
||||
selector: 'ov-recording-lists',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatTableModule,
|
||||
MatCheckboxModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatMenuModule,
|
||||
MatTooltipModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatToolbarModule,
|
||||
MatBadgeModule,
|
||||
MatDividerModule,
|
||||
DatePipe
|
||||
],
|
||||
templateUrl: './recording-lists.component.html',
|
||||
styleUrl: './recording-lists.component.scss'
|
||||
})
|
||||
export class RecordingListsComponent implements OnInit, OnChanges {
|
||||
// Input properties
|
||||
@Input() recordings: MeetRecordingInfo[] = [];
|
||||
@Input() canDeleteRecordings = false;
|
||||
@Input() showFilters = false;
|
||||
@Input() showSelection = true;
|
||||
@Input() loading = false;
|
||||
|
||||
// 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 }>();
|
||||
@Output() refresh = new EventEmitter<void>();
|
||||
|
||||
// Filter controls
|
||||
nameFilterControl = new FormControl('');
|
||||
statusFilterControl = new FormControl('');
|
||||
|
||||
// Selection state
|
||||
selectedRecordings = signal<Set<string>>(new Set());
|
||||
allSelected = signal(false);
|
||||
someSelected = signal(false);
|
||||
|
||||
// Table configuration
|
||||
displayedColumns: string[] = ['select', 'roomId', 'status', 'startDate', 'duration', 'size', 'actions'];
|
||||
|
||||
// Status options using enum values
|
||||
statusOptions = [
|
||||
{ value: '', label: 'All statuses' },
|
||||
{ value: MeetRecordingStatus.STARTING, label: 'Starting' },
|
||||
{ value: MeetRecordingStatus.ACTIVE, label: 'Active' },
|
||||
{ value: MeetRecordingStatus.ENDING, label: 'Ending' },
|
||||
{ value: MeetRecordingStatus.COMPLETE, label: 'Complete' },
|
||||
{ value: MeetRecordingStatus.FAILED, label: 'Failed' },
|
||||
{ value: MeetRecordingStatus.ABORTED, label: 'Aborted' },
|
||||
{ value: MeetRecordingStatus.LIMIT_REACHED, label: 'Limit Reached' }
|
||||
];
|
||||
|
||||
// Recording status sets for different states using enum constants
|
||||
private readonly STATUS_GROUPS = {
|
||||
ACTIVE: [MeetRecordingStatus.ACTIVE] as readonly MeetRecordingStatus[],
|
||||
COMPLETED: [MeetRecordingStatus.COMPLETE] as readonly MeetRecordingStatus[],
|
||||
ERROR: [
|
||||
MeetRecordingStatus.FAILED,
|
||||
MeetRecordingStatus.ABORTED,
|
||||
MeetRecordingStatus.LIMIT_REACHED
|
||||
] as readonly MeetRecordingStatus[],
|
||||
IN_PROGRESS: [MeetRecordingStatus.STARTING, MeetRecordingStatus.ENDING] as readonly MeetRecordingStatus[],
|
||||
SELECTABLE: [
|
||||
MeetRecordingStatus.COMPLETE,
|
||||
MeetRecordingStatus.FAILED,
|
||||
MeetRecordingStatus.ABORTED,
|
||||
MeetRecordingStatus.LIMIT_REACHED
|
||||
] as readonly MeetRecordingStatus[],
|
||||
PLAYABLE: [MeetRecordingStatus.COMPLETE] as readonly MeetRecordingStatus[],
|
||||
DOWNLOADABLE: [MeetRecordingStatus.COMPLETE] as readonly MeetRecordingStatus[]
|
||||
} as const;
|
||||
|
||||
constructor() {}
|
||||
|
||||
ngOnInit() {
|
||||
this.setupFilters();
|
||||
this.updateDisplayedColumns();
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges) {
|
||||
if (changes['recordings']) {
|
||||
const validIds = new Set(this.recordings.map((r) => r.recordingId));
|
||||
const filteredSelection = new Set([...this.selectedRecordings()].filter((id) => validIds.has(id)));
|
||||
this.selectedRecordings.set(filteredSelection);
|
||||
this.updateSelectionState();
|
||||
}
|
||||
}
|
||||
|
||||
// ===== INITIALIZATION METHODS =====
|
||||
|
||||
private setupFilters() {
|
||||
// Set up name filter with debounce
|
||||
this.nameFilterControl.valueChanges.pipe(debounceTime(300), distinctUntilChanged()).subscribe((value) => {
|
||||
this.filterChange.emit({
|
||||
nameFilter: value || '',
|
||||
statusFilter: this.statusFilterControl.value || ''
|
||||
});
|
||||
});
|
||||
|
||||
// Set up status filter
|
||||
this.statusFilterControl.valueChanges.subscribe((value) => {
|
||||
this.filterChange.emit({
|
||||
nameFilter: this.nameFilterControl.value || '',
|
||||
statusFilter: value || ''
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private updateDisplayedColumns() {
|
||||
this.displayedColumns = [];
|
||||
|
||||
if (this.showSelection) {
|
||||
this.displayedColumns.push('select');
|
||||
}
|
||||
|
||||
this.displayedColumns.push('roomId', 'status', 'startDate', 'duration', 'size', 'actions');
|
||||
}
|
||||
|
||||
// ===== SELECTION METHODS =====
|
||||
|
||||
toggleAllSelection() {
|
||||
const selected = this.selectedRecordings();
|
||||
if (this.allSelected()) {
|
||||
selected.clear();
|
||||
} else {
|
||||
this.recordings.forEach((recording) => {
|
||||
if (this.canSelectRecording(recording)) {
|
||||
selected.add(recording.recordingId);
|
||||
}
|
||||
});
|
||||
}
|
||||
this.selectedRecordings.set(new Set(selected));
|
||||
this.updateSelectionState();
|
||||
}
|
||||
|
||||
toggleRecordingSelection(recording: MeetRecordingInfo) {
|
||||
const selected = this.selectedRecordings();
|
||||
if (selected.has(recording.recordingId)) {
|
||||
selected.delete(recording.recordingId);
|
||||
} else {
|
||||
selected.add(recording.recordingId);
|
||||
}
|
||||
this.selectedRecordings.set(new Set(selected));
|
||||
this.updateSelectionState();
|
||||
}
|
||||
|
||||
private updateSelectionState() {
|
||||
const selectableRecordings = this.recordings.filter((r) => this.canSelectRecording(r));
|
||||
const selectedCount = this.selectedRecordings().size;
|
||||
const selectableCount = selectableRecordings.length;
|
||||
|
||||
this.allSelected.set(selectedCount > 0 && selectedCount === selectableCount);
|
||||
this.someSelected.set(selectedCount > 0 && selectedCount < selectableCount);
|
||||
}
|
||||
|
||||
isRecordingSelected(recording: MeetRecordingInfo): boolean {
|
||||
return this.selectedRecordings().has(recording.recordingId);
|
||||
}
|
||||
|
||||
canSelectRecording(recording: MeetRecordingInfo): boolean {
|
||||
return this.isStatusInGroup(recording.status, this.STATUS_GROUPS.SELECTABLE);
|
||||
}
|
||||
|
||||
getSelectedRecordings(): MeetRecordingInfo[] {
|
||||
const selected = this.selectedRecordings();
|
||||
return this.recordings.filter((r) => selected.has(r.recordingId));
|
||||
}
|
||||
|
||||
// ===== ACTION METHODS =====
|
||||
|
||||
playRecording(recording: MeetRecordingInfo) {
|
||||
this.recordingAction.emit({ recordings: [recording], action: 'play' });
|
||||
}
|
||||
|
||||
downloadRecording(recording: MeetRecordingInfo) {
|
||||
this.recordingAction.emit({ recordings: [recording], action: 'download' });
|
||||
}
|
||||
|
||||
shareRecordingLink(recording: MeetRecordingInfo) {
|
||||
this.recordingAction.emit({ recordings: [recording], action: 'shareLink' });
|
||||
}
|
||||
|
||||
deleteRecording(recording: MeetRecordingInfo) {
|
||||
this.recordingAction.emit({ recordings: [recording], action: 'delete' });
|
||||
}
|
||||
|
||||
bulkDeleteSelected() {
|
||||
const selectedRecordings = this.getSelectedRecordings();
|
||||
if (selectedRecordings.length > 0) {
|
||||
this.recordingAction.emit({ recordings: selectedRecordings, action: 'bulkDelete' });
|
||||
}
|
||||
}
|
||||
|
||||
bulkDownloadSelected() {
|
||||
const selectedRecordings = this.getSelectedRecordings();
|
||||
if (selectedRecordings.length > 0) {
|
||||
this.recordingAction.emit({ recordings: selectedRecordings, action: 'bulkDownload' });
|
||||
}
|
||||
}
|
||||
|
||||
// ===== FILTER METHODS =====
|
||||
|
||||
hasActiveFilters(): boolean {
|
||||
return !!(this.nameFilterControl.value || this.statusFilterControl.value);
|
||||
}
|
||||
|
||||
clearFilters() {
|
||||
this.nameFilterControl.setValue('');
|
||||
this.statusFilterControl.setValue('');
|
||||
}
|
||||
|
||||
// ===== STATUS UTILITY METHODS =====
|
||||
|
||||
private isStatusInGroup(status: MeetRecordingStatus, group: readonly MeetRecordingStatus[]): boolean {
|
||||
return group.includes(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable status label
|
||||
*/
|
||||
getStatusLabel(status: MeetRecordingStatus): string {
|
||||
const statusOption = this.statusOptions.find((option) => option.value === status);
|
||||
const label = statusOption?.label || status;
|
||||
return label.toUpperCase().replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
// ===== PERMISSION AND CAPABILITY METHODS =====
|
||||
|
||||
canPlayRecording(recording: MeetRecordingInfo): boolean {
|
||||
return this.isStatusInGroup(recording.status, this.STATUS_GROUPS.PLAYABLE);
|
||||
}
|
||||
|
||||
canDownloadRecording(recording: MeetRecordingInfo): boolean {
|
||||
return this.isStatusInGroup(recording.status, this.STATUS_GROUPS.DOWNLOADABLE);
|
||||
}
|
||||
|
||||
canDeleteRecording(recording: MeetRecordingInfo): boolean {
|
||||
return this.canDeleteRecordings && this.isStatusInGroup(recording.status, this.STATUS_GROUPS.SELECTABLE);
|
||||
}
|
||||
|
||||
// ===== UI HELPER METHODS =====
|
||||
|
||||
getStatusIcon(status: MeetRecordingStatus): string {
|
||||
switch (status) {
|
||||
case MeetRecordingStatus.COMPLETE:
|
||||
return 'check_circle';
|
||||
case MeetRecordingStatus.ACTIVE:
|
||||
return 'radio_button_checked';
|
||||
case MeetRecordingStatus.STARTING:
|
||||
return 'hourglass_top';
|
||||
case MeetRecordingStatus.ENDING:
|
||||
return 'hourglass_bottom';
|
||||
case MeetRecordingStatus.FAILED:
|
||||
return 'error';
|
||||
case MeetRecordingStatus.ABORTED:
|
||||
return 'cancel';
|
||||
case MeetRecordingStatus.LIMIT_REACHED:
|
||||
return 'warning';
|
||||
default:
|
||||
return 'help';
|
||||
}
|
||||
}
|
||||
|
||||
getStatusColor(status: MeetRecordingStatus): string {
|
||||
if (this.isStatusInGroup(status, this.STATUS_GROUPS.COMPLETED)) {
|
||||
return 'var(--ov-meet-color-success)';
|
||||
}
|
||||
if (this.isStatusInGroup(status, this.STATUS_GROUPS.ACTIVE)) {
|
||||
return 'var(--ov-meet-color-primary)';
|
||||
}
|
||||
if (this.isStatusInGroup(status, this.STATUS_GROUPS.IN_PROGRESS)) {
|
||||
return 'var(--ov-meet-color-warning)';
|
||||
}
|
||||
if (this.isStatusInGroup(status, this.STATUS_GROUPS.ERROR)) {
|
||||
return 'var(--ov-meet-color-error)';
|
||||
}
|
||||
return 'var(--ov-meet-text-secondary)';
|
||||
}
|
||||
|
||||
formatFileSize(bytes: number | undefined): string {
|
||||
if (!bytes || bytes === 0) return '-';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
formatDuration(duration: number | undefined): string {
|
||||
if (!duration || duration === 0) return '-';
|
||||
|
||||
const hours = Math.floor(duration / 3600);
|
||||
const minutes = Math.floor((duration % 3600) / 60);
|
||||
const seconds = Math.floor(duration % 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m ${seconds}s`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${seconds}s`;
|
||||
} else {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,341 @@
|
||||
<!-- Loading Spinner -->
|
||||
@if (loading) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
<span>Loading rooms...</span>
|
||||
</div>
|
||||
} @else if (rooms.length > 0) {
|
||||
<!-- Rooms Toolbar -->
|
||||
<mat-toolbar class="rooms-toolbar" id="rooms-toolbar">
|
||||
<!-- Left Section: Search -->
|
||||
<div class="toolbar-left" id="toolbar-left">
|
||||
<mat-form-field class="search-field" appearance="outline" id="search-field">
|
||||
<mat-label>Search rooms</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[formControl]="nameFilterControl"
|
||||
placeholder="Search by room ID or prefix"
|
||||
id="search-input"
|
||||
/>
|
||||
<mat-icon matSuffix>search</mat-icon>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!-- Center Section: Batch Actions (visible when items selected) -->
|
||||
@if (showSelection && selectedRooms().size > 0) {
|
||||
<div class="toolbar-center" id="toolbar-center">
|
||||
<div class="batch-actions" id="batch-actions">
|
||||
<button
|
||||
mat-icon-button
|
||||
color="warn"
|
||||
(click)="bulkDeleteSelected()"
|
||||
[disabled]="loading"
|
||||
matTooltip="Delete selected rooms"
|
||||
id="bulk-delete-btn"
|
||||
>
|
||||
<mat-icon [matBadge]="selectedRooms().size" matBadgePosition="below after"> delete </mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Right Section: Actions -->
|
||||
<div class="toolbar-right" id="toolbar-right">
|
||||
<button
|
||||
mat-icon-button
|
||||
class="refresh-btn"
|
||||
(click)="refresh.emit()"
|
||||
[disabled]="loading"
|
||||
matTooltip="Refresh rooms"
|
||||
id="refresh-btn"
|
||||
>
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
|
||||
<button mat-raised-button (click)="createRoom()" class="create-room-btn" id="create-room-btn">
|
||||
<mat-icon>add</mat-icon>
|
||||
Create New Room
|
||||
</button>
|
||||
|
||||
@if (showFilters) {
|
||||
<button mat-icon-button [matMenuTriggerFor]="filtersMenu" matTooltip="Filter rooms" id="filters-btn">
|
||||
<mat-icon>tune</mat-icon>
|
||||
</button>
|
||||
|
||||
<mat-menu #filtersMenu="matMenu" class="filters-menu" id="filters-menu">
|
||||
<div class="filter-content" (click)="$event.stopPropagation()" id="filter-content">
|
||||
<h4>Filter by Status</h4>
|
||||
<mat-form-field appearance="outline" id="status-filter-field">
|
||||
<mat-label>Status</mat-label>
|
||||
<mat-select [formControl]="statusFilterControl" id="status-filter-select">
|
||||
@for (option of statusOptions; track option.value) {
|
||||
<mat-option [value]="option.value" id="status-option-{{ option.value }}">{{
|
||||
option.label
|
||||
}}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</mat-menu>
|
||||
}
|
||||
</div>
|
||||
</mat-toolbar>
|
||||
|
||||
<div class="table-container" id="table-container">
|
||||
<table mat-table [dataSource]="rooms" class="rooms-table" id="rooms-table">
|
||||
<!-- Selection Column -->
|
||||
@if (showSelection) {
|
||||
<ng-container matColumnDef="select">
|
||||
<th mat-header-cell *matHeaderCellDef id="select-header">
|
||||
<mat-checkbox
|
||||
[checked]="allSelected()"
|
||||
[indeterminate]="someSelected()"
|
||||
(change)="toggleAllSelection()"
|
||||
[disabled]="loading"
|
||||
id="select-all-checkbox"
|
||||
>
|
||||
</mat-checkbox>
|
||||
</th>
|
||||
<td mat-cell *matCellDef="let room" id="select-cell-{{ room.roomId }}">
|
||||
@if (canSelectRoom(room)) {
|
||||
<mat-checkbox
|
||||
[checked]="isRoomSelected(room)"
|
||||
(change)="toggleRoomSelection(room)"
|
||||
[disabled]="loading"
|
||||
id="select-room-{{ room.roomId }}"
|
||||
>
|
||||
</mat-checkbox>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
}
|
||||
|
||||
<!-- Room ID Column -->
|
||||
<ng-container matColumnDef="roomId">
|
||||
<th mat-header-cell *matHeaderCellDef class="room-header" id="room-id-header">Room ID</th>
|
||||
<td mat-cell *matCellDef="let room" class="room-cell" id="room-id-cell-{{ room.roomId }}">
|
||||
<div class="room-info" id="room-info-{{ room.roomId }}">
|
||||
<span class="room-id" id="room-id-{{ room.roomId }}">{{ room.roomId }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Status Column -->
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef id="status-header">Status</th>
|
||||
<td mat-cell *matCellDef="let room" id="status-cell-{{ room.roomId }}">
|
||||
<div
|
||||
class="status-badge"
|
||||
[style.color]="getStatusColor(room)"
|
||||
[matTooltip]="getStatusTooltip(room)"
|
||||
id="status-badge-{{ room.roomId }}"
|
||||
>
|
||||
<mat-icon
|
||||
class="status-icon"
|
||||
[style.color]="getStatusColor(room)"
|
||||
id="status-icon-{{ room.roomId }}"
|
||||
>
|
||||
{{ getStatusIcon(room) }}
|
||||
</mat-icon>
|
||||
<span class="status-label" id="status-label-{{ room.roomId }}">{{ getRoomStatus(room) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Creation Date Column -->
|
||||
<ng-container matColumnDef="creationDate">
|
||||
<th mat-header-cell *matHeaderCellDef id="creation-date-header">Created</th>
|
||||
<td mat-cell *matCellDef="let room" id="creation-date-cell-{{ room.roomId }}">
|
||||
@if (room.creationDate) {
|
||||
<div class="date-info" id="date-info-{{ room.roomId }}">
|
||||
<span class="date" id="creation-date-{{ room.roomId }}">{{
|
||||
room.creationDate | date: 'mediumDate'
|
||||
}}</span>
|
||||
<span class="time" id="creation-time-{{ room.roomId }}">{{
|
||||
room.creationDate | date: 'shortTime'
|
||||
}}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<span class="no-data" id="no-creation-date-{{ room.roomId }}">-</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Auto Deletion Column -->
|
||||
<ng-container matColumnDef="autoDeletion">
|
||||
<th mat-header-cell *matHeaderCellDef id="auto-deletion-header">Auto Deletion</th>
|
||||
<td mat-cell *matCellDef="let room" id="auto-deletion-cell-{{ room.roomId }}">
|
||||
<div class="auto-deletion-content" id="auto-deletion-content-{{ room.roomId }}">
|
||||
<div class="auto-deletion-info" id="auto-deletion-info-{{ room.roomId }}">
|
||||
@if (hasAutoDeletion(room)) {
|
||||
<div
|
||||
class="deletion-badge"
|
||||
[ngClass]="getAutoDeletionClass(room)"
|
||||
[matTooltip]="getAutoDeletionTooltip(room)"
|
||||
id="deletion-badge-{{ room.roomId }}"
|
||||
>
|
||||
<mat-icon
|
||||
class="deletion-icon material-symbols-outlined"
|
||||
id="deletion-icon-{{ room.roomId }}"
|
||||
>{{ getAutoDeletionIcon(room) }}</mat-icon
|
||||
>
|
||||
<span class="deletion-text" id="deletion-text-{{ room.roomId }}">{{
|
||||
getAutoDeletionStatus(room)
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
@if (!room.markedForDeletion) {
|
||||
<div class="deletion-date-time" id="deletion-date-time-{{ room.roomId }}">
|
||||
<div class="deletion-date" id="deletion-date-container-{{ room.roomId }}">
|
||||
<span class="date" id="deletion-date-{{ room.roomId }}">{{
|
||||
room.autoDeletionDate | date: 'mediumDate'
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="deletion-time" id="deletion-time-container-{{ room.roomId }}">
|
||||
<span class="time" id="deletion-time-{{ room.roomId }}">{{
|
||||
room.autoDeletionDate | date: 'shortTime'
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div
|
||||
class="deletion-badge"
|
||||
[ngClass]="getAutoDeletionClass(room)"
|
||||
[matTooltip]="getAutoDeletionTooltip(room)"
|
||||
id="deletion-badge-{{ room.roomId }}"
|
||||
>
|
||||
<mat-icon
|
||||
class="deletion-icon material-symbols-outlined"
|
||||
id="deletion-icon-{{ room.roomId }}"
|
||||
>{{ getAutoDeletionIcon(room) }}</mat-icon
|
||||
>
|
||||
<span class="deletion-text" id="deletion-text-{{ room.roomId }}">{{
|
||||
getAutoDeletionStatus(room)
|
||||
}}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Actions Column -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef class="actions-header" id="actions-header">Actions</th>
|
||||
<td mat-cell *matCellDef="let room" class="actions-cell" id="actions-cell-{{ room.roomId }}">
|
||||
<div class="action-buttons" id="action-buttons-{{ room.roomId }}">
|
||||
<!-- Open Room Button -->
|
||||
@if (canOpenRoom(room)) {
|
||||
<button
|
||||
mat-icon-button
|
||||
matTooltip="Open Room"
|
||||
(click)="openRoom(room)"
|
||||
[disabled]="loading"
|
||||
class="primary-action"
|
||||
id="open-room-btn-{{ room.roomId }}"
|
||||
>
|
||||
<mat-icon>open_in_new</mat-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Settings Button -->
|
||||
@if (canEditRoom(room)) {
|
||||
<button
|
||||
mat-icon-button
|
||||
matTooltip="Room Settings"
|
||||
(click)="editRoom(room)"
|
||||
[disabled]="loading"
|
||||
id="edit-room-btn-{{ room.roomId }}"
|
||||
>
|
||||
<mat-icon class="ov-settings-icon">settings</mat-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- More Actions Menu -->
|
||||
<button
|
||||
mat-icon-button
|
||||
[matMenuTriggerFor]="actionsMenu"
|
||||
matTooltip="More Actions"
|
||||
[disabled]="loading"
|
||||
id="more-actions-btn-{{ room.roomId }}"
|
||||
>
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
|
||||
<mat-menu #actionsMenu="matMenu" id="actions-menu-{{ room.roomId }}">
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="viewRecordings(room)"
|
||||
[disabled]="!room.preferences.recordingPreferences.enabled"
|
||||
[matTooltip]="
|
||||
!room.preferences.recordingPreferences.enabled
|
||||
? 'Recording is disabled for this room'
|
||||
: ''
|
||||
"
|
||||
id="view-recordings-btn-{{ room.roomId }}"
|
||||
>
|
||||
<mat-icon class="ov-recording-icon">video_library</mat-icon>
|
||||
<span>View Recordings</span>
|
||||
</button>
|
||||
|
||||
@if (!room.markedForDeletion) {
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="copyModeratorLink(room)"
|
||||
id="copy-moderator-link-btn-{{ room.roomId }}"
|
||||
>
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
<span>Copy Moderator Link</span>
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="copyPublisherLink(room)"
|
||||
id="copy-publisher-link-btn-{{ room.roomId }}"
|
||||
>
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
<span>Copy Publisher Link</span>
|
||||
</button>
|
||||
|
||||
<mat-divider id="actions-divider-{{ room.roomId }}"></mat-divider>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="deleteRoom(room)"
|
||||
class="delete-action"
|
||||
id="delete-room-btn-{{ room.roomId }}"
|
||||
>
|
||||
<mat-icon>delete</mat-icon>
|
||||
<span>Delete room</span>
|
||||
</button>
|
||||
}
|
||||
</mat-menu>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns" id="table-header-row"></tr>
|
||||
<tr
|
||||
mat-row
|
||||
*matRowDef="let row; columns: displayedColumns"
|
||||
[class.selected-row]="isRoomSelected(row)"
|
||||
[class.marked-for-deletion]="row.markedForDeletion"
|
||||
id="table-row-{{ row.roomId }}"
|
||||
></tr>
|
||||
</table>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- Empty State -->
|
||||
<div class="no-rooms-state">
|
||||
<div class="empty-content">
|
||||
<h3>No rooms created yet</h3>
|
||||
<p>No rooms found. Create your first room to start hosting meetings and manage your video conferences.</p>
|
||||
|
||||
<div class="getting-started-actions">
|
||||
<button mat-raised-button (click)="createRoom()" class="create-room-btn">
|
||||
<mat-icon>add</mat-icon>
|
||||
Create Your First Room
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@ -0,0 +1,271 @@
|
||||
@import '../../../../../../src/assets/styles/design-tokens';
|
||||
|
||||
// Use utility classes for rooms toolbar
|
||||
.rooms-toolbar {
|
||||
@extend .ov-data-toolbar;
|
||||
|
||||
.toolbar-right {
|
||||
gap: var(--ov-meet-spacing-sm);
|
||||
|
||||
::ng-deep .refresh-btn {
|
||||
padding: var(--ov-meet-spacing-sm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use utility classes
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
.filters-menu {
|
||||
@extend .ov-filters-menu;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
@extend .ov-loading-container;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
@extend .ov-table-container;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.rooms-toolbar + .table-container {
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-top: none;
|
||||
box-shadow: var(--ov-meet-shadow-sm);
|
||||
}
|
||||
|
||||
:host:not(:has(.rooms-toolbar)) .table-container {
|
||||
margin-top: var(--ov-meet-spacing-md);
|
||||
}
|
||||
|
||||
.rooms-table {
|
||||
@extend .ov-data-table;
|
||||
|
||||
.mat-mdc-header-cell {
|
||||
&.room-header {
|
||||
@extend .primary-header;
|
||||
}
|
||||
|
||||
&.status-header {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
&.creation-date-header {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
&.auto-deletion-header {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
&.actions-header {
|
||||
@extend .actions-header;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-mdc-cell {
|
||||
&.room-cell {
|
||||
@extend .primary-cell;
|
||||
}
|
||||
|
||||
&.status-cell {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
&.creation-date-cell {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
&.auto-deletion-cell {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
&.actions-cell {
|
||||
@extend .actions-cell;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-mdc-row {
|
||||
&.marked-for-deletion {
|
||||
background-color: rgba(244, 67, 54, 0.05);
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(244, 67, 54, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.room-info {
|
||||
@extend .ov-info-display;
|
||||
|
||||
.room-id {
|
||||
@extend .primary-text;
|
||||
}
|
||||
}
|
||||
|
||||
.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-info {
|
||||
@extend .ov-date-info;
|
||||
}
|
||||
|
||||
.auto-deletion-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ov-meet-spacing-xs);
|
||||
|
||||
.deletion-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--ov-meet-spacing-xs);
|
||||
padding: var(--ov-meet-spacing-xs) var(--ov-meet-spacing-sm);
|
||||
border-radius: var(--ov-meet-radius-sm);
|
||||
font-size: var(--ov-meet-font-size-xs);
|
||||
font-weight: var(--ov-meet-font-weight-medium);
|
||||
width: fit-content;
|
||||
text-transform: uppercase;
|
||||
|
||||
.deletion-icon {
|
||||
@include ov-icon(sm);
|
||||
}
|
||||
}
|
||||
|
||||
.deletion-date-time {
|
||||
padding: var(--ov-meet-spacing-xs) var(--ov-meet-spacing-sm);
|
||||
.deletion-date {
|
||||
font-size: var(--ov-meet-font-size-sm);
|
||||
color: var(--ov-meet-text-primary);
|
||||
}
|
||||
|
||||
.deletion-time {
|
||||
font-size: var(--ov-meet-font-size-xs);
|
||||
color: var(--ov-meet-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.auto-deletion-pending {
|
||||
color: var(--ov-meet-color-warn);
|
||||
|
||||
.deletion-icon {
|
||||
color: var(--ov-meet-color-warn);
|
||||
}
|
||||
}
|
||||
|
||||
.auto-deletion-not-scheduled {
|
||||
color: var(--ov-meet-text-hint);
|
||||
|
||||
.deletion-icon {
|
||||
color: var(--ov-meet-text-hint);
|
||||
}
|
||||
}
|
||||
|
||||
.auto-deletion-scheduled {
|
||||
font-weight: var(--ov-meet-font-weight-semibold);
|
||||
color: var(--ov-meet-color-warning);
|
||||
|
||||
.deletion-icon {
|
||||
color: var(--ov-meet-color-warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-data {
|
||||
@extend .ov-no-data;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
@extend .ov-action-buttons;
|
||||
|
||||
::ng-deep button {
|
||||
padding: var(--ov-meet-spacing-sm);
|
||||
}
|
||||
|
||||
.mat-mdc-icon-button {
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
&.primary-action {
|
||||
color: var(--ov-meet-color-primary);
|
||||
}
|
||||
|
||||
&.room-preferences-btn {
|
||||
color: var(--ov-meet-icon-settings);
|
||||
}
|
||||
|
||||
&.copy-link-btn {
|
||||
color: var(--ov-meet-text-secondary);
|
||||
}
|
||||
|
||||
&.view-recordings-btn {
|
||||
color: var(--ov-meet-icon-recordings);
|
||||
}
|
||||
|
||||
&.delete-room-btn {
|
||||
color: var(--ov-meet-color-error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.delete-action {
|
||||
@extend .ov-delete-action;
|
||||
}
|
||||
|
||||
.no-rooms-state {
|
||||
@extend .ov-empty-state;
|
||||
|
||||
.empty-icon {
|
||||
@include ov-icon(xl);
|
||||
color: var(--ov-meet-text-hint);
|
||||
margin-bottom: var(--ov-meet-spacing-lg);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.getting-started-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ov-meet-spacing-md);
|
||||
align-items: center;
|
||||
|
||||
.create-room-btn,
|
||||
.refresh-rooms-btn {
|
||||
@include ov-button-base;
|
||||
|
||||
mat-icon {
|
||||
@include ov-icon(md);
|
||||
margin-right: var(--ov-meet-spacing-sm);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mat-mdc-checkbox,
|
||||
.mat-mdc-icon-button,
|
||||
.mat-mdc-button {
|
||||
@extend .ov-focus-visible;
|
||||
}
|
||||
@ -0,0 +1,222 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
|
||||
import { RoomsListsComponent } from './rooms-lists.component';
|
||||
import { MeetRoom } from '../../typings/ce';
|
||||
|
||||
describe('RoomsListsComponent', () => {
|
||||
let component: RoomsListsComponent;
|
||||
let fixture: ComponentFixture<RoomsListsComponent>;
|
||||
|
||||
const mockRooms: MeetRoom[] = [
|
||||
{
|
||||
roomId: 'test-room-1',
|
||||
creationDate: 1642248000000, // 2024-01-15T10:00:00Z
|
||||
markedForDeletion: false,
|
||||
autoDeletionDate: undefined,
|
||||
roomIdPrefix: 'test',
|
||||
moderatorRoomUrl: 'http://localhost/room/test-room-1?secret=mod-123',
|
||||
publisherRoomUrl: 'http://localhost/room/test-room-1?secret=pub-123',
|
||||
preferences: {
|
||||
chatPreferences: { enabled: true },
|
||||
recordingPreferences: { enabled: false },
|
||||
virtualBackgroundPreferences: { enabled: true }
|
||||
}
|
||||
},
|
||||
{
|
||||
roomId: 'test-room-2',
|
||||
creationDate: 1642334400000, // 2024-01-16T14:30:00Z
|
||||
markedForDeletion: true,
|
||||
autoDeletionDate: 1643673600000, // 2024-02-01T00:00:00Z
|
||||
roomIdPrefix: 'test',
|
||||
moderatorRoomUrl: 'http://localhost/room/test-room-2?secret=mod-456',
|
||||
publisherRoomUrl: 'http://localhost/room/test-room-2?secret=pub-456',
|
||||
preferences: {
|
||||
chatPreferences: { enabled: true },
|
||||
recordingPreferences: { enabled: false },
|
||||
virtualBackgroundPreferences: { enabled: true }
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
RoomsListsComponent,
|
||||
NoopAnimationsModule,
|
||||
MatSnackBarModule
|
||||
]
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(RoomsListsComponent);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
// Set up test data
|
||||
component.rooms = mockRooms;
|
||||
component.loading = false;
|
||||
component.canDeleteRooms = true;
|
||||
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should initialize with correct default values', () => {
|
||||
expect(component.rooms).toEqual(mockRooms);
|
||||
expect(component.canDeleteRooms).toBe(true);
|
||||
expect(component.loading).toBe(false);
|
||||
expect(component.showFilters).toBe(false);
|
||||
expect(component.showSelection).toBe(true);
|
||||
expect(component.emptyMessage).toBe('No rooms found');
|
||||
});
|
||||
|
||||
it('should update displayed columns based on showSelection', () => {
|
||||
component.showSelection = false;
|
||||
component.ngOnInit();
|
||||
expect(component.displayedColumns).not.toContain('select');
|
||||
|
||||
component.showSelection = true;
|
||||
component.ngOnInit();
|
||||
expect(component.displayedColumns).toContain('select');
|
||||
});
|
||||
|
||||
it('should determine room status correctly', () => {
|
||||
expect(component.isRoomActive(mockRooms[0])).toBe(true);
|
||||
expect(component.isRoomActive(mockRooms[1])).toBe(false);
|
||||
expect(component.isRoomInactive(mockRooms[0])).toBe(false);
|
||||
expect(component.isRoomInactive(mockRooms[1])).toBe(true);
|
||||
});
|
||||
|
||||
it('should return correct status information', () => {
|
||||
expect(component.getRoomStatus(mockRooms[0])).toBe('Active');
|
||||
expect(component.getRoomStatus(mockRooms[1])).toBe('Inactive');
|
||||
|
||||
expect(component.getStatusIcon(mockRooms[0])).toBe('check_circle');
|
||||
expect(component.getStatusIcon(mockRooms[1])).toBe('delete_outline');
|
||||
|
||||
expect(component.getStatusColor(mockRooms[0])).toBe('var(--ov-meet-color-success)');
|
||||
expect(component.getStatusColor(mockRooms[1])).toBe('var(--ov-meet-color-error)');
|
||||
});
|
||||
|
||||
it('should handle auto-deletion information correctly', () => {
|
||||
expect(component.hasAutoDeletion(mockRooms[0])).toBe(false);
|
||||
expect(component.hasAutoDeletion(mockRooms[1])).toBe(true);
|
||||
|
||||
expect(component.getAutoDeletionStatus(mockRooms[0])).toBe('Not scheduled');
|
||||
expect(component.getAutoDeletionStatus(mockRooms[1])).toBe('Scheduled');
|
||||
|
||||
expect(component.getAutoDeletionIcon(mockRooms[0])).toBe('close');
|
||||
expect(component.getAutoDeletionIcon(mockRooms[1])).toBe('auto_delete');
|
||||
});
|
||||
|
||||
it('should handle room selection correctly', () => {
|
||||
const room = mockRooms[0];
|
||||
|
||||
expect(component.isRoomSelected(room)).toBe(false);
|
||||
|
||||
component.toggleRoomSelection(room);
|
||||
expect(component.isRoomSelected(room)).toBe(true);
|
||||
|
||||
component.toggleRoomSelection(room);
|
||||
expect(component.isRoomSelected(room)).toBe(false);
|
||||
});
|
||||
|
||||
it('should determine if room can be selected', () => {
|
||||
expect(component.canSelectRoom(mockRooms[0])).toBe(true); // Active room
|
||||
expect(component.canSelectRoom(mockRooms[1])).toBe(false); // Marked for deletion
|
||||
});
|
||||
|
||||
it('should determine room permissions correctly', () => {
|
||||
expect(component.canOpenRoom(mockRooms[0])).toBe(true);
|
||||
expect(component.canOpenRoom(mockRooms[1])).toBe(false);
|
||||
|
||||
expect(component.canEditRoom(mockRooms[0])).toBe(true);
|
||||
expect(component.canEditRoom(mockRooms[1])).toBe(false);
|
||||
|
||||
expect(component.canDeleteRoom(mockRooms[0])).toBe(true);
|
||||
expect(component.canDeleteRoom(mockRooms[1])).toBe(false);
|
||||
});
|
||||
|
||||
it('should emit room actions correctly', () => {
|
||||
spyOn(component.roomAction, 'emit');
|
||||
|
||||
component.openRoom(mockRooms[0]);
|
||||
expect(component.roomAction.emit).toHaveBeenCalledWith({
|
||||
rooms: [mockRooms[0]],
|
||||
action: 'open'
|
||||
});
|
||||
|
||||
component.deleteRoom(mockRooms[0]);
|
||||
expect(component.roomAction.emit).toHaveBeenCalledWith({
|
||||
rooms: [mockRooms[0]],
|
||||
action: 'delete'
|
||||
});
|
||||
|
||||
component.viewSettings(mockRooms[0]);
|
||||
expect(component.roomAction.emit).toHaveBeenCalledWith({
|
||||
rooms: [mockRooms[0]],
|
||||
action: 'settings'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle batch delete correctly', () => {
|
||||
spyOn(component.roomAction, 'emit');
|
||||
|
||||
// Select some rooms
|
||||
component.toggleRoomSelection(mockRooms[0]);
|
||||
component.bulkDeleteSelected();
|
||||
|
||||
expect(component.roomAction.emit).toHaveBeenCalledWith({
|
||||
rooms: [mockRooms[0]],
|
||||
action: 'bulkDelete'
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit filter changes', () => {
|
||||
spyOn(component.filterChange, 'emit');
|
||||
|
||||
component.nameFilterControl.setValue('test');
|
||||
|
||||
// Trigger change detection to simulate the valueChanges observable
|
||||
fixture.detectChanges();
|
||||
|
||||
// The filter change should be emitted through the form control subscription
|
||||
expect(component.filterChange.emit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show empty state when no rooms', () => {
|
||||
component.rooms = [];
|
||||
fixture.detectChanges();
|
||||
|
||||
const emptyState = fixture.nativeElement.querySelector('.no-rooms-state');
|
||||
expect(emptyState).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show loading state', () => {
|
||||
component.loading = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
const loadingContainer = fixture.nativeElement.querySelector('.loading-container');
|
||||
expect(loadingContainer).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should clear selection correctly', () => {
|
||||
// Select a room first
|
||||
component.toggleRoomSelection(mockRooms[0]);
|
||||
expect(component.selectedRooms().size).toBe(1);
|
||||
|
||||
// Clear selection
|
||||
component.clearSelection();
|
||||
expect(component.selectedRooms().size).toBe(0);
|
||||
});
|
||||
|
||||
it('should get selected rooms correctly', () => {
|
||||
component.toggleRoomSelection(mockRooms[0]);
|
||||
const selectedRooms = component.getSelectedRooms();
|
||||
|
||||
expect(selectedRooms).toEqual([mockRooms[0]]);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,341 @@
|
||||
import { CommonModule, DatePipe } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
HostBinding,
|
||||
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';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MeetRoom } from '@lib/typings/ce';
|
||||
import { debounceTime, distinctUntilChanged } from 'rxjs';
|
||||
|
||||
export interface RoomTableAction {
|
||||
rooms: MeetRoom[];
|
||||
action:
|
||||
| 'create'
|
||||
| 'open'
|
||||
| 'edit'
|
||||
| 'copyModeratorLink'
|
||||
| 'copyPublisherLink'
|
||||
| 'viewRecordings'
|
||||
| 'delete'
|
||||
| 'bulkDelete';
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable component for displaying a list of rooms with filtering, selection, and bulk operations.
|
||||
*
|
||||
* Features:
|
||||
* - Display rooms in a Material Design table
|
||||
* - Filter by room name and status
|
||||
* - Multi-selection for bulk operations
|
||||
* - Individual room actions (open, edit, copy links, view recordings, delete)
|
||||
* - Responsive design with mobile optimization
|
||||
* - Status-based styling using design tokens
|
||||
*
|
||||
* @example
|
||||
* ```html
|
||||
* <ov-rooms-lists
|
||||
* [rooms]="rooms"
|
||||
* [loading]="isLoading"
|
||||
* [showFilters]="true"
|
||||
* [showSelection]="true"
|
||||
* (roomAction)="handleRoomAction($event)"
|
||||
* (filterChange)="handleFilterChange($event)"
|
||||
* (refresh)="refreshRooms()">
|
||||
* </ov-rooms-lists>
|
||||
* ```
|
||||
*/
|
||||
|
||||
@Component({
|
||||
selector: 'ov-rooms-lists',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatTableModule,
|
||||
MatCheckboxModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatMenuModule,
|
||||
MatTooltipModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatToolbarModule,
|
||||
MatBadgeModule,
|
||||
MatDividerModule,
|
||||
DatePipe
|
||||
],
|
||||
templateUrl: './rooms-lists.component.html',
|
||||
styleUrl: './rooms-lists.component.scss'
|
||||
})
|
||||
export class RoomsListsComponent implements OnInit, OnChanges {
|
||||
// Input properties
|
||||
@Input() rooms: MeetRoom[] = [];
|
||||
@Input() showFilters = false;
|
||||
@Input() showSelection = true;
|
||||
@Input() loading = false;
|
||||
|
||||
// Host binding for styling when rooms are selected
|
||||
@HostBinding('class.has-selections')
|
||||
get hasSelections(): boolean {
|
||||
return this.selectedRooms().size > 0;
|
||||
}
|
||||
|
||||
// Output events
|
||||
@Output() roomAction = new EventEmitter<RoomTableAction>();
|
||||
@Output() filterChange = new EventEmitter<{ nameFilter: string; statusFilter: string }>();
|
||||
@Output() refresh = new EventEmitter<void>();
|
||||
|
||||
// Filter controls
|
||||
nameFilterControl = new FormControl('');
|
||||
statusFilterControl = new FormControl('');
|
||||
|
||||
// Selection state
|
||||
selectedRooms = signal<Set<string>>(new Set());
|
||||
allSelected = signal(false);
|
||||
someSelected = signal(false);
|
||||
|
||||
// Table configuration
|
||||
displayedColumns: string[] = ['select', 'roomId', 'status', 'creationDate', 'autoDeletion', 'actions'];
|
||||
|
||||
// Status options
|
||||
statusOptions = [
|
||||
{ value: '', label: 'All statuses' },
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'inactive', label: 'Inactive' }
|
||||
];
|
||||
|
||||
constructor() {}
|
||||
|
||||
ngOnInit() {
|
||||
this.setupFilters();
|
||||
this.updateDisplayedColumns();
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges) {
|
||||
if (changes['rooms']) {
|
||||
const validIds = new Set(this.rooms.map((r) => r.roomId));
|
||||
const filteredSelection = new Set([...this.selectedRooms()].filter((id) => validIds.has(id)));
|
||||
this.selectedRooms.set(filteredSelection);
|
||||
this.updateSelectionState();
|
||||
}
|
||||
}
|
||||
|
||||
// ===== INITIALIZATION METHODS =====
|
||||
|
||||
private setupFilters() {
|
||||
// Set up name filter with debounce
|
||||
this.nameFilterControl.valueChanges.pipe(debounceTime(300), distinctUntilChanged()).subscribe((value) => {
|
||||
this.filterChange.emit({
|
||||
nameFilter: value || '',
|
||||
statusFilter: this.statusFilterControl.value || ''
|
||||
});
|
||||
});
|
||||
|
||||
// Set up status filter
|
||||
this.statusFilterControl.valueChanges.subscribe((value) => {
|
||||
this.filterChange.emit({
|
||||
nameFilter: this.nameFilterControl.value || '',
|
||||
statusFilter: value || ''
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private updateDisplayedColumns() {
|
||||
this.displayedColumns = [];
|
||||
|
||||
if (this.showSelection) {
|
||||
this.displayedColumns.push('select');
|
||||
}
|
||||
|
||||
this.displayedColumns.push('roomId', 'status', 'creationDate', 'autoDeletion', 'actions');
|
||||
}
|
||||
|
||||
// ===== SELECTION METHODS =====
|
||||
|
||||
toggleAllSelection() {
|
||||
const selected = this.selectedRooms();
|
||||
if (this.allSelected()) {
|
||||
selected.clear();
|
||||
} else {
|
||||
this.rooms.forEach((room) => {
|
||||
if (this.canSelectRoom(room)) {
|
||||
selected.add(room.roomId);
|
||||
}
|
||||
});
|
||||
}
|
||||
this.selectedRooms.set(new Set(selected));
|
||||
this.updateSelectionState();
|
||||
}
|
||||
|
||||
toggleRoomSelection(room: MeetRoom) {
|
||||
const selected = this.selectedRooms();
|
||||
if (selected.has(room.roomId)) {
|
||||
selected.delete(room.roomId);
|
||||
} else {
|
||||
selected.add(room.roomId);
|
||||
}
|
||||
this.selectedRooms.set(new Set(selected));
|
||||
this.updateSelectionState();
|
||||
}
|
||||
|
||||
private updateSelectionState() {
|
||||
const selectableRooms = this.rooms.filter((r) => this.canSelectRoom(r));
|
||||
const selectedCount = this.selectedRooms().size;
|
||||
const selectableCount = selectableRooms.length;
|
||||
|
||||
this.allSelected.set(selectedCount > 0 && selectedCount === selectableCount);
|
||||
this.someSelected.set(selectedCount > 0 && selectedCount < selectableCount);
|
||||
}
|
||||
|
||||
isRoomSelected(room: MeetRoom): boolean {
|
||||
return this.selectedRooms().has(room.roomId);
|
||||
}
|
||||
|
||||
canSelectRoom(room: MeetRoom): boolean {
|
||||
return !room.markedForDeletion; // Only active rooms can be selected
|
||||
}
|
||||
|
||||
getSelectedRooms(): MeetRoom[] {
|
||||
const selected = this.selectedRooms();
|
||||
return this.rooms.filter((r) => selected.has(r.roomId));
|
||||
}
|
||||
|
||||
// ===== ACTION METHODS =====
|
||||
|
||||
createRoom() {
|
||||
this.roomAction.emit({ rooms: [], action: 'create' });
|
||||
}
|
||||
|
||||
openRoom(room: MeetRoom) {
|
||||
this.roomAction.emit({ rooms: [room], action: 'open' });
|
||||
}
|
||||
|
||||
editRoom(room: MeetRoom) {
|
||||
this.roomAction.emit({ rooms: [room], action: 'edit' });
|
||||
}
|
||||
|
||||
copyModeratorLink(room: MeetRoom) {
|
||||
this.roomAction.emit({ rooms: [room], action: 'copyModeratorLink' });
|
||||
}
|
||||
|
||||
copyPublisherLink(room: MeetRoom) {
|
||||
this.roomAction.emit({ rooms: [room], action: 'copyPublisherLink' });
|
||||
}
|
||||
|
||||
viewRecordings(room: MeetRoom) {
|
||||
this.roomAction.emit({ rooms: [room], action: 'viewRecordings' });
|
||||
}
|
||||
|
||||
deleteRoom(room: MeetRoom) {
|
||||
this.roomAction.emit({ rooms: [room], action: 'delete' });
|
||||
}
|
||||
|
||||
bulkDeleteSelected() {
|
||||
const selectedRooms = this.getSelectedRooms();
|
||||
if (selectedRooms.length > 0) {
|
||||
this.roomAction.emit({ rooms: selectedRooms, action: 'bulkDelete' });
|
||||
}
|
||||
}
|
||||
|
||||
// ===== FILTER METHODS =====
|
||||
|
||||
hasActiveFilters(): boolean {
|
||||
return !!(this.nameFilterControl.value || this.statusFilterControl.value);
|
||||
}
|
||||
|
||||
clearFilters() {
|
||||
this.nameFilterControl.setValue('');
|
||||
this.statusFilterControl.setValue('');
|
||||
}
|
||||
|
||||
// ===== STATUS UTILITY METHODS =====
|
||||
|
||||
getRoomStatus(room: MeetRoom): string {
|
||||
return room.markedForDeletion ? 'INACTIVE' : 'ACTIVE';
|
||||
}
|
||||
|
||||
// ===== PERMISSION AND CAPABILITY METHODS =====
|
||||
|
||||
canOpenRoom(room: MeetRoom): boolean {
|
||||
return !room.markedForDeletion;
|
||||
}
|
||||
|
||||
canEditRoom(room: MeetRoom): boolean {
|
||||
return !room.markedForDeletion;
|
||||
}
|
||||
|
||||
// ===== UI HELPER METHODS =====
|
||||
|
||||
getStatusIcon(room: MeetRoom): string {
|
||||
return room.markedForDeletion ? 'delete_outline' : 'check_circle';
|
||||
}
|
||||
|
||||
getStatusColor(room: MeetRoom): string {
|
||||
if (room.markedForDeletion) {
|
||||
return 'var(--ov-meet-color-error)';
|
||||
}
|
||||
return 'var(--ov-meet-color-success)';
|
||||
}
|
||||
|
||||
getStatusTooltip(room: MeetRoom): string {
|
||||
return room.markedForDeletion
|
||||
? 'Room is inactive and marked for deletion'
|
||||
: 'Room is active and accepting participants';
|
||||
}
|
||||
|
||||
hasAutoDeletion(room: MeetRoom): boolean {
|
||||
return !!room.autoDeletionDate;
|
||||
}
|
||||
|
||||
getAutoDeletionStatus(room: MeetRoom): string {
|
||||
if (room.markedForDeletion) {
|
||||
return 'Immediate';
|
||||
}
|
||||
return room.autoDeletionDate ? 'Scheduled' : 'Disabled';
|
||||
}
|
||||
|
||||
getAutoDeletionIcon(room: MeetRoom): string {
|
||||
if (room.markedForDeletion) {
|
||||
return 'acute';
|
||||
}
|
||||
return room.autoDeletionDate ? 'auto_delete' : 'close';
|
||||
}
|
||||
|
||||
getAutoDeletionClass(room: MeetRoom): string {
|
||||
if (room.markedForDeletion) {
|
||||
return 'auto-deletion-pending';
|
||||
}
|
||||
return room.autoDeletionDate ? 'auto-deletion-scheduled' : 'auto-deletion-not-scheduled';
|
||||
}
|
||||
|
||||
getAutoDeletionTooltip(room: MeetRoom): string {
|
||||
if (room.markedForDeletion) {
|
||||
return 'Deletes when last participant leaves';
|
||||
}
|
||||
return room.autoDeletionDate
|
||||
? 'Auto-deletion scheduled'
|
||||
: 'No auto-deletion. Room remains until manually deleted';
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,54 @@
|
||||
<div
|
||||
[class]="getCardClasses()"
|
||||
[attr.aria-label]="getAriaLabel()"
|
||||
[attr.role]="allowMultiSelect ? 'checkbox' : 'radio'"
|
||||
[attr.aria-checked]="isOptionSelected(option.id)"
|
||||
[attr.aria-disabled]="option.disabled"
|
||||
[attr.tabindex]="option.disabled ? -1 : 0"
|
||||
(click)="onOptionSelect(option.id)"
|
||||
(mouseenter)="onMouseEnter()"
|
||||
(mouseleave)="onMouseLeave()"
|
||||
(keydown.enter)="onOptionSelect(option.id)"
|
||||
(keydown.space)="onOptionSelect(option.id)"
|
||||
>
|
||||
<!-- Pro Badge -->
|
||||
@if (option.isPro && showProBadge) {
|
||||
<ov-pro-feature-badge badgeText="PRO"></ov-pro-feature-badge>
|
||||
} @else if (showSelectionIndicator) {
|
||||
<!-- Selection Indicator -->
|
||||
<div class="selection-indicator">
|
||||
<mat-icon class="selection-icon" [class.selected]="isOptionSelected(option.id)">
|
||||
{{ getSelectionIcon() }}
|
||||
</mat-icon>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Card Content -->
|
||||
<div class="card-content">
|
||||
<!-- Image Section -->
|
||||
@if (shouldShowImage()) {
|
||||
<div class="card-image" [style.aspect-ratio]="getImageAspectRatio()">
|
||||
<img [src]="option.imageUrl" [alt]="option.title + ' preview'" loading="lazy" />
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Icon and Title -->
|
||||
<div class="card-header">
|
||||
@if (shouldShowIcon()) {
|
||||
<mat-icon class="option-icon ov-action-icon">{{ option.icon }}</mat-icon>
|
||||
}
|
||||
<h4 class="option-title" [ngClass]="{ 'has-icon': shouldShowIcon(), 'has-image': shouldShowImage() }">
|
||||
{{ option.title }}
|
||||
@if (option.recommended && showRecommendedBadge) {
|
||||
<span class="recommended-badge">Recommended</span>
|
||||
}
|
||||
@if (option.badge) {
|
||||
<span class="custom-badge">{{ option.badge }}</span>
|
||||
}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="option-description">{{ option.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,147 @@
|
||||
@import '../../../../../../src/assets/styles/design-tokens';
|
||||
|
||||
.option-card {
|
||||
@include ov-card;
|
||||
@include ov-theme-transition;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
padding: var(--ov-meet-spacing-md);
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&:hover:not(.no-hover):not(.selected) {
|
||||
@include ov-hover-lift(-2px);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: var(--ov-meet-color-primary);
|
||||
box-shadow:
|
||||
var(--ov-meet-shadow-md),
|
||||
0 0 0 1px var(--ov-meet-color-primary);
|
||||
|
||||
.selection-indicator .selection-icon {
|
||||
color: var(--ov-meet-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&.pro-feature {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
&.no-hover {
|
||||
&:hover {
|
||||
box-shadow: var(--ov-meet-card-shadow) !important;
|
||||
}
|
||||
}
|
||||
.selection-indicator {
|
||||
position: absolute;
|
||||
top: var(--ov-meet-spacing-sm);
|
||||
right: var(--ov-meet-spacing-sm);
|
||||
z-index: 2;
|
||||
|
||||
.selection-icon {
|
||||
@include ov-icon(md);
|
||||
color: var(--ov-meet-text-hint);
|
||||
@include ov-theme-transition;
|
||||
|
||||
&.selected {
|
||||
color: var(--ov-meet-color-primary);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: var(--ov-meet-spacing-xs);
|
||||
|
||||
.card-image {
|
||||
margin-bottom: var(--ov-meet-spacing-md);
|
||||
border-radius: var(--ov-meet-border-radius-sm);
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
@include ov-theme-transition;
|
||||
}
|
||||
|
||||
// &:hover img {
|
||||
// transform: scale(1.02);
|
||||
// }
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--ov-meet-spacing-sm);
|
||||
margin-bottom: var(--ov-meet-spacing-sm);
|
||||
align-items: center;
|
||||
font-weight: var(--ov-meet-font-weight-semibold);
|
||||
|
||||
.option-icon {
|
||||
@include ov-icon(md);
|
||||
@include ov-theme-transition;
|
||||
}
|
||||
|
||||
.option-title {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: var(--ov-meet-font-size-md);
|
||||
font-weight: var(--ov-meet-font-weight-medium);
|
||||
color: var(--ov-meet-text-primary);
|
||||
line-height: var(--ov-meet-line-height-tight);
|
||||
|
||||
&.has-image {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.recommended-badge {
|
||||
display: inline-block;
|
||||
background: linear-gradient(45deg, var(--ov-meet-color-primary), var(--ov-meet-color-accent));
|
||||
color: var(--ov-meet-text-on-primary);
|
||||
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-xxs);
|
||||
font-weight: var(--ov-meet-font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-left: var(--ov-meet-spacing-xs);
|
||||
vertical-align: middle;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.option-description {
|
||||
margin: 0;
|
||||
font-size: var(--ov-meet-font-size-sm);
|
||||
color: var(--ov-meet-text-secondary);
|
||||
line-height: var(--ov-meet-line-height-normal);
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Card selection animation
|
||||
@keyframes cardSelect {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.option-card.selected {
|
||||
animation: cardSelect 0.3s ease-out;
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { SelectableCardComponent } from './selectable-card.component';
|
||||
|
||||
describe('SelectableCardComponent', () => {
|
||||
let component: SelectableCardComponent;
|
||||
let fixture: ComponentFixture<SelectableCardComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SelectableCardComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SelectableCardComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,322 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { ProFeatureBadgeComponent } from '@lib/components';
|
||||
|
||||
/**
|
||||
* Interface for selectable card option data
|
||||
*/
|
||||
export interface SelectableOption {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
imageUrl?: string; // Optional image URL for visual layouts
|
||||
recommended?: boolean;
|
||||
isPro?: boolean;
|
||||
disabled?: boolean;
|
||||
badge?: string;
|
||||
value?: any; // Additional data associated with the option
|
||||
}
|
||||
|
||||
/**
|
||||
* Event emitted when an option is selected
|
||||
*/
|
||||
export interface SelectionEvent {
|
||||
optionId: string;
|
||||
option: SelectableOption;
|
||||
previousSelection?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'ov-selectable-card',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatIconModule, ProFeatureBadgeComponent],
|
||||
templateUrl: './selectable-card.component.html',
|
||||
styleUrl: './selectable-card.component.scss'
|
||||
})
|
||||
export class SelectableCardComponent {
|
||||
/**
|
||||
* The option data to display in the card
|
||||
*/
|
||||
@Input({ required: true }) option!: SelectableOption;
|
||||
|
||||
/**
|
||||
* Currently selected value(s)
|
||||
* Can be a string (single select) or string[] (multi select)
|
||||
*/
|
||||
@Input() selectedValue: string | string[] | null = null;
|
||||
|
||||
/**
|
||||
* Whether multiple options can be selected simultaneously
|
||||
* @default false
|
||||
*/
|
||||
@Input() allowMultiSelect: boolean = false;
|
||||
|
||||
/**
|
||||
* Whether the card should show hover effects
|
||||
* @default true
|
||||
*/
|
||||
@Input() enableHover: boolean = true;
|
||||
|
||||
/**
|
||||
* Whether the card should show selection animations
|
||||
* @default true
|
||||
*/
|
||||
@Input() enableAnimations: boolean = true;
|
||||
|
||||
/**
|
||||
* Custom CSS classes to apply to the card
|
||||
*/
|
||||
@Input() customClasses: string = '';
|
||||
|
||||
/**
|
||||
* Whether to show the selection indicator (radio button)
|
||||
* @default true
|
||||
*/
|
||||
@Input() showSelectionIndicator: boolean = true;
|
||||
|
||||
/**
|
||||
* Whether to show the PRO badge for premium features
|
||||
* @default true
|
||||
*/
|
||||
@Input() showProBadge: boolean = true;
|
||||
|
||||
/**
|
||||
* Whether to show the recommended badge
|
||||
* @default true
|
||||
*/
|
||||
@Input() showRecommendedBadge: boolean = true;
|
||||
|
||||
/**
|
||||
* Whether to show image instead of icon (when imageUrl is provided)
|
||||
* @default false
|
||||
*/
|
||||
@Input() showImage: boolean = false;
|
||||
|
||||
/**
|
||||
* Whether to show both image and icon (when both are provided)
|
||||
* @default false
|
||||
*/
|
||||
@Input() showImageAndIcon: boolean = false;
|
||||
|
||||
/**
|
||||
* Image aspect ratio for layout control
|
||||
* @default '16/9'
|
||||
*/
|
||||
@Input() imageAspectRatio: string = '16/9';
|
||||
|
||||
/**
|
||||
* Custom icon for the PRO badge
|
||||
* @default 'star'
|
||||
*/
|
||||
@Input() proBadgeIcon: string = 'star';
|
||||
|
||||
/**
|
||||
* Custom text for the PRO badge
|
||||
* @default 'PRO'
|
||||
*/
|
||||
@Input() proBadgeText: string = 'PRO';
|
||||
|
||||
/**
|
||||
* Event emitted when an option is selected
|
||||
*/
|
||||
@Output() optionSelected = new EventEmitter<SelectionEvent>();
|
||||
|
||||
/**
|
||||
* Event emitted when the card is clicked (even if selection doesn't change)
|
||||
*/
|
||||
@Output() cardClicked = new EventEmitter<SelectableOption>();
|
||||
|
||||
/**
|
||||
* Event emitted when the card is hovered
|
||||
*/
|
||||
@Output() cardHover = new EventEmitter<{ option: SelectableOption; isHovering: boolean }>();
|
||||
|
||||
/**
|
||||
* Check if the current option is selected
|
||||
*/
|
||||
isOptionSelected(optionId: string): boolean {
|
||||
if (!this.selectedValue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Array.isArray(this.selectedValue)) {
|
||||
return this.selectedValue.includes(optionId);
|
||||
}
|
||||
|
||||
return this.selectedValue === optionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle option selection click
|
||||
*/
|
||||
onOptionSelect(optionId: string): void {
|
||||
// Don't allow selection if option is disabled
|
||||
if (this.option.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Emit card clicked event
|
||||
this.cardClicked.emit(this.option);
|
||||
|
||||
// Handle selection logic
|
||||
const wasSelected = this.isOptionSelected(optionId);
|
||||
let newSelection: string | string[] | null;
|
||||
let previousSelection: string | undefined;
|
||||
|
||||
if (this.allowMultiSelect) {
|
||||
// Multi-select logic
|
||||
const currentArray = Array.isArray(this.selectedValue) ? [...this.selectedValue] : [];
|
||||
|
||||
if (wasSelected) {
|
||||
// Remove from selection
|
||||
newSelection = currentArray.filter((id) => id !== optionId);
|
||||
if (newSelection.length === 0) {
|
||||
newSelection = null;
|
||||
}
|
||||
} else {
|
||||
// Add to selection
|
||||
newSelection = [...currentArray, optionId];
|
||||
}
|
||||
} else {
|
||||
// Single-select logic
|
||||
if (wasSelected) {
|
||||
// Deselect (optional behavior)
|
||||
newSelection = null;
|
||||
previousSelection = optionId;
|
||||
} else {
|
||||
// Select new option
|
||||
previousSelection = Array.isArray(this.selectedValue) ? undefined : this.selectedValue || undefined;
|
||||
newSelection = optionId;
|
||||
}
|
||||
}
|
||||
|
||||
// Emit selection event
|
||||
const selectionEvent: SelectionEvent = {
|
||||
optionId,
|
||||
option: this.option,
|
||||
previousSelection
|
||||
};
|
||||
|
||||
this.optionSelected.emit(selectionEvent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse enter event
|
||||
*/
|
||||
onMouseEnter(): void {
|
||||
if (this.enableHover) {
|
||||
this.cardHover.emit({ option: this.option, isHovering: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse leave event
|
||||
*/
|
||||
onMouseLeave(): void {
|
||||
if (this.enableHover) {
|
||||
this.cardHover.emit({ option: this.option, isHovering: false });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dynamic CSS classes for the card
|
||||
*/
|
||||
getCardClasses(): string {
|
||||
const classes = ['option-card'];
|
||||
|
||||
if (this.isOptionSelected(this.option.id)) {
|
||||
classes.push('selected');
|
||||
}
|
||||
|
||||
if (this.option.recommended && this.showRecommendedBadge) {
|
||||
classes.push('recommended');
|
||||
}
|
||||
|
||||
if (this.option.isPro && this.showProBadge) {
|
||||
classes.push('pro-feature');
|
||||
}
|
||||
|
||||
if (this.option.disabled) {
|
||||
classes.push('disabled');
|
||||
}
|
||||
|
||||
if (!this.enableHover) {
|
||||
classes.push('no-hover');
|
||||
}
|
||||
|
||||
if (!this.enableAnimations) {
|
||||
classes.push('no-animations');
|
||||
}
|
||||
|
||||
if (this.customClasses) {
|
||||
classes.push(this.customClasses);
|
||||
}
|
||||
|
||||
return classes.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the selection icon based on current state
|
||||
*/
|
||||
getSelectionIcon(): string {
|
||||
if (this.allowMultiSelect) {
|
||||
return this.isOptionSelected(this.option.id) ? 'check_box' : 'check_box_outline_blank';
|
||||
} else {
|
||||
return this.isOptionSelected(this.option.id) ? 'radio_button_checked' : 'radio_button_unchecked';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get aria-label for accessibility
|
||||
*/
|
||||
getAriaLabel(): string {
|
||||
const baseLabel = `${this.option.title}. ${this.option.description}`;
|
||||
const statusParts = [];
|
||||
|
||||
if (this.option.recommended) {
|
||||
statusParts.push('Recommended');
|
||||
}
|
||||
|
||||
if (this.option.isPro) {
|
||||
statusParts.push('PRO feature');
|
||||
}
|
||||
|
||||
if (this.option.disabled) {
|
||||
statusParts.push('Disabled');
|
||||
}
|
||||
|
||||
if (this.isOptionSelected(this.option.id)) {
|
||||
statusParts.push('Selected');
|
||||
}
|
||||
|
||||
const statusLabel = statusParts.length > 0 ? `. ${statusParts.join(', ')}` : '';
|
||||
|
||||
return `${baseLabel}${statusLabel}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if should show image
|
||||
*/
|
||||
shouldShowImage(): boolean {
|
||||
return this.showImage && !!this.option.imageUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if should show icon
|
||||
*/
|
||||
shouldShowIcon(): boolean {
|
||||
if (this.showImageAndIcon) {
|
||||
return !!this.option.icon;
|
||||
}
|
||||
return !this.shouldShowImage() && !!this.option.icon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image aspect ratio styles
|
||||
*/
|
||||
getImageAspectRatio(): string {
|
||||
return this.imageAspectRatio;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
<div class="step-indicator-wrapper" [attr.data-layout]="layoutType$ | async">
|
||||
<mat-stepper
|
||||
#stepper
|
||||
[selectedIndex]="safeCurrentStepIndex"
|
||||
[orientation]="(stepperOrientation$ | async)!"
|
||||
[linear]="false"
|
||||
class="wizard-stepper"
|
||||
[attr.data-layout]="layoutType$ | async"
|
||||
(selectionChange)="onStepClick($event)"
|
||||
>
|
||||
@for (step of visibleSteps; track step.id; let i = $index) {
|
||||
<mat-step
|
||||
[stepControl]="step.validationFormGroup"
|
||||
[state]="getStepState(step)"
|
||||
[editable]="true"
|
||||
[label]="step.label"
|
||||
[completed]="step.isCompleted"
|
||||
>
|
||||
<!-- Custom step icon template -->
|
||||
<ng-template matStepperIcon="edit">
|
||||
<mat-icon>edit</mat-icon>
|
||||
</ng-template>
|
||||
|
||||
<ng-template matStepperIcon="done">
|
||||
<mat-icon>check</mat-icon>
|
||||
</ng-template>
|
||||
</mat-step>
|
||||
}
|
||||
</mat-stepper>
|
||||
</div>
|
||||
@ -0,0 +1,195 @@
|
||||
@import '../../../../../../src/assets/styles/design-tokens';
|
||||
|
||||
.step-indicator-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
// Large Desktop: Vertical Sidebar Layout (>1200px)
|
||||
&[data-layout='vertical-sidebar'] {
|
||||
.wizard-stepper {
|
||||
padding: var(--ov-meet-spacing-lg);
|
||||
@include ov-theme-transition;
|
||||
|
||||
::ng-deep {
|
||||
.mat-stepper-vertical {
|
||||
background: transparent;
|
||||
|
||||
.mat-vertical-content-container {
|
||||
margin-left: 0;
|
||||
border-left: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.mat-vertical-stepper-content {
|
||||
padding: 0;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.mat-step-header {
|
||||
padding: var(--ov-meet-spacing-md) 0;
|
||||
margin-bottom: var(--ov-meet-spacing-sm);
|
||||
pointer-events: none;
|
||||
|
||||
.mat-step-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-right: var(--ov-meet-spacing-md);
|
||||
}
|
||||
|
||||
.mat-step-label {
|
||||
font-size: var(--ov-meet-font-size-md);
|
||||
font-weight: var(--ov-meet-font-weight-medium);
|
||||
color: var(--ov-meet-text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Medium Desktop: Horizontal Compact Layout (768-1200px)
|
||||
&[data-layout='horizontal-compact'] {
|
||||
.wizard-stepper {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: var(--ov-meet-spacing-md) var(--ov-meet-spacing-xs);
|
||||
@include ov-theme-transition;
|
||||
|
||||
::ng-deep {
|
||||
.mat-step-text-label {
|
||||
white-space: normal;
|
||||
overflow: hidden;
|
||||
text-wrap: auto;
|
||||
text-overflow: clip !important;
|
||||
max-width: 120px;
|
||||
}
|
||||
.mat-stepper-horizontal-line {
|
||||
flex: 0 0 24px !important;
|
||||
min-width: var(--ov-meet-spacing-sm) !important;
|
||||
margin: auto !important;
|
||||
}
|
||||
|
||||
.mat-stepper-horizontal {
|
||||
background: transparent;
|
||||
|
||||
.mat-horizontal-content-container {
|
||||
padding: 0;
|
||||
display: none; // Hide content in compact mode
|
||||
}
|
||||
|
||||
.mat-horizontal-stepper-header-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: var(--ov-meet-spacing-md);
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.mat-step-header {
|
||||
padding: var(--ov-meet-spacing-sm);
|
||||
flex: 0 0 auto;
|
||||
pointer-events: none;
|
||||
|
||||
.mat-step-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-right: var(--ov-meet-spacing-xs);
|
||||
}
|
||||
|
||||
.mat-step-label {
|
||||
font-size: var(--ov-meet-font-size-sm);
|
||||
font-weight: var(--ov-meet-font-weight-medium);
|
||||
white-space: normal;
|
||||
overflow: hidden;
|
||||
// text-overflow: ellipsis;
|
||||
text-wrap: auto;
|
||||
text-overflow: clip !important;
|
||||
max-width: 120px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile/Tablet: Vertical Compact Layout (<768px)
|
||||
&[data-layout='vertical-compact'] {
|
||||
.wizard-stepper {
|
||||
width: 100%;
|
||||
padding: var(--ov-meet-spacing-md);
|
||||
background-color: var(--ov-meet-surface-variant);
|
||||
border-radius: var(--ov-meet-radius-md);
|
||||
margin-bottom: var(--ov-meet-spacing-md);
|
||||
@include ov-theme-transition;
|
||||
|
||||
::ng-deep {
|
||||
.mat-stepper-vertical {
|
||||
background: transparent;
|
||||
|
||||
.mat-vertical-content-container {
|
||||
margin-left: 0;
|
||||
border-left: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.mat-vertical-stepper-content {
|
||||
padding: 0;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.mat-step-header {
|
||||
padding: var(--ov-meet-spacing-sm) 0;
|
||||
margin-bottom: var(--ov-meet-spacing-xs);
|
||||
pointer-events: none;
|
||||
|
||||
.mat-step-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: var(--ov-meet-spacing-sm);
|
||||
}
|
||||
|
||||
.mat-step-label {
|
||||
font-size: var(--ov-meet-font-size-sm);
|
||||
font-weight: var(--ov-meet-font-weight-medium);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global stepper overrides
|
||||
::ng-deep {
|
||||
.mat-horizontal-content-container {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
mat-stepper {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.mat-horizontal-stepper-header {
|
||||
height: auto !important;
|
||||
padding: 5px 15px !important;
|
||||
|
||||
.mat-step-label {
|
||||
white-space: normal !important;
|
||||
text-wrap: auto;
|
||||
text-overflow: clip;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-stepper-vertical {
|
||||
position: absolute;
|
||||
left: 10;
|
||||
}
|
||||
|
||||
.mat-step-header .mat-step-icon-selected {
|
||||
background-color: var(--ov-meet-color-primary) !important;
|
||||
}
|
||||
.mat-step-icon-state-done {
|
||||
background-color: var(--ov-meet-color-success) !important;
|
||||
color: var(--ov-meet-text-on-primary) !important;
|
||||
}
|
||||
}
|
||||
@ -1,18 +1,18 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { SelectionCardComponent } from './selection-card.component';
|
||||
import { StepIndicatorComponent } from './step-indicator.component';
|
||||
|
||||
describe('SelectionCardComponent', () => {
|
||||
let component: SelectionCardComponent;
|
||||
let fixture: ComponentFixture<SelectionCardComponent>;
|
||||
describe('StepIndicatorComponent', () => {
|
||||
let component: StepIndicatorComponent;
|
||||
let fixture: ComponentFixture<StepIndicatorComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SelectionCardComponent]
|
||||
imports: [StepIndicatorComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SelectionCardComponent);
|
||||
fixture = TestBed.createComponent(StepIndicatorComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
@ -0,0 +1,185 @@
|
||||
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
|
||||
import { StepperOrientation, StepperSelectionEvent } from '@angular/cdk/stepper';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
|
||||
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatStepperModule } from '@angular/material/stepper';
|
||||
import { WizardStep } from '@lib/models';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
selector: 'ov-step-indicator',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatStepperModule, MatIcon, MatButtonModule, ReactiveFormsModule],
|
||||
templateUrl: './step-indicator.component.html',
|
||||
styleUrl: './step-indicator.component.scss'
|
||||
})
|
||||
export class StepIndicatorComponent implements OnChanges {
|
||||
@Input() steps: WizardStep[] = [];
|
||||
@Input() allowNavigation: boolean = false;
|
||||
@Input() editMode: boolean = false; // New input for edit mode
|
||||
@Input() currentStepIndex: number = 0;
|
||||
@Output() stepClick = new EventEmitter<{ step: WizardStep; index: number }>();
|
||||
@Output() layoutChange = new EventEmitter<'vertical-sidebar' | 'horizontal-compact' | 'vertical-compact'>();
|
||||
|
||||
visibleSteps: WizardStep[] = [];
|
||||
stepperOrientation$: Observable<StepperOrientation>;
|
||||
layoutType$: Observable<'vertical-sidebar' | 'horizontal-compact' | 'vertical-compact'>;
|
||||
stepControls: { [key: string]: FormControl } = {};
|
||||
|
||||
constructor(private breakpointObserver: BreakpointObserver) {
|
||||
// Enhanced responsive strategy:
|
||||
// - Large desktop (>1200px): Vertical sidebar for space efficiency
|
||||
// - Medium desktop (768-1200px): Horizontal compact
|
||||
// - Tablet/Mobile (<768px): Vertical compact
|
||||
|
||||
const breakpointState$ = this.breakpointObserver.observe([
|
||||
'(min-width: 1200px)',
|
||||
'(min-width: 768px)',
|
||||
Breakpoints.HandsetPortrait
|
||||
]);
|
||||
|
||||
this.layoutType$ = breakpointState$.pipe(
|
||||
map(() => {
|
||||
const isLargeDesktop = this.breakpointObserver.isMatched('(min-width: 1200px)');
|
||||
const isMediumDesktop = this.breakpointObserver.isMatched('(min-width: 768px)') && !isLargeDesktop;
|
||||
|
||||
if (isLargeDesktop) return 'vertical-sidebar';
|
||||
if (isMediumDesktop) return 'horizontal-compact';
|
||||
return 'vertical-compact';
|
||||
})
|
||||
);
|
||||
|
||||
this.stepperOrientation$ = this.layoutType$.pipe(
|
||||
map((layoutType) => {
|
||||
return layoutType === 'horizontal-compact' ? 'horizontal' : 'vertical';
|
||||
})
|
||||
);
|
||||
|
||||
// Emit layout changes for parent component
|
||||
this.layoutType$.subscribe((layoutType) => {
|
||||
this.layoutChange.emit(layoutType);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges) {
|
||||
if (changes['steps']) {
|
||||
this.updateVisibleSteps();
|
||||
this.createStepControls();
|
||||
}
|
||||
if (changes['currentStepIndex'] || changes['steps']) {
|
||||
this.updateStepControls();
|
||||
}
|
||||
}
|
||||
|
||||
private updateVisibleSteps() {
|
||||
this.visibleSteps = this.steps.filter((step) => step.isVisible);
|
||||
}
|
||||
|
||||
private createStepControls() {
|
||||
this.stepControls = {};
|
||||
this.visibleSteps.forEach((step) => {
|
||||
this.stepControls[step.id] = new FormControl({
|
||||
value: step.isCompleted,
|
||||
disabled: !step.isCompleted && !step.isActive
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private updateStepControls() {
|
||||
this.visibleSteps.forEach((step) => {
|
||||
const control = this.stepControls[step.id];
|
||||
if (control) {
|
||||
control.setValue(step.isCompleted);
|
||||
if (step.isCompleted || step.isActive) {
|
||||
control.enable();
|
||||
} else {
|
||||
control.disable();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getStepControl(step: WizardStep): FormGroup {
|
||||
return step.validationFormGroup;
|
||||
}
|
||||
|
||||
get safeCurrentStepIndex(): number {
|
||||
if (this.visibleSteps.length === 0) {
|
||||
console.warn('No visible steps available. Defaulting to index 0.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
// In edit mode, ensure the index is valid for visible steps
|
||||
let adjustedIndex = this.currentStepIndex;
|
||||
|
||||
// If we are in edit mode and the current index is greater than available visible steps
|
||||
if (this.editMode && this.currentStepIndex >= this.visibleSteps.length) {
|
||||
// Find the first active step in the visible steps
|
||||
const activeStepIndex = this.visibleSteps.findIndex((step) => step.isActive);
|
||||
adjustedIndex = activeStepIndex >= 0 ? activeStepIndex : 0;
|
||||
}
|
||||
|
||||
const safeIndex = Math.min(Math.max(0, adjustedIndex), this.visibleSteps.length - 1);
|
||||
console.log('Safe current step index:', safeIndex, 'for visibleSteps length:', this.visibleSteps.length);
|
||||
return safeIndex;
|
||||
}
|
||||
|
||||
onStepClick(event: StepperSelectionEvent) {
|
||||
if (this.allowNavigation) {
|
||||
const step = this.visibleSteps[event.selectedIndex];
|
||||
this.stepClick.emit({ step, index: event.selectedIndex });
|
||||
} else {
|
||||
console.warn('Navigation is not allowed. Step click ignored:', event.selectedIndex);
|
||||
}
|
||||
}
|
||||
|
||||
isStepClickable(step: WizardStep): boolean {
|
||||
if (!this.allowNavigation) {
|
||||
return false;
|
||||
}
|
||||
if (this.editMode) {
|
||||
// In edit mode, allow clicking on any step
|
||||
return true;
|
||||
}
|
||||
|
||||
return step.isActive || step.isCompleted;
|
||||
}
|
||||
|
||||
isStepEditable(step: WizardStep): boolean {
|
||||
return this.isStepClickable(step);
|
||||
}
|
||||
|
||||
getStepState(step: WizardStep): 'done' | 'edit' | 'error' | 'number' {
|
||||
if (step.isCompleted && !step.isActive) {
|
||||
return 'done';
|
||||
}
|
||||
|
||||
if (step.isActive && step.validationFormGroup?.invalid) {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
if (step.isActive) {
|
||||
return 'edit';
|
||||
}
|
||||
|
||||
if (step.isCompleted) {
|
||||
return 'done';
|
||||
}
|
||||
|
||||
return 'number';
|
||||
}
|
||||
|
||||
getStepIcon(step: WizardStep): string {
|
||||
if (step.isCompleted) {
|
||||
return 'check';
|
||||
}
|
||||
if (step.isActive) {
|
||||
return 'edit';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,85 @@
|
||||
<nav class="wizard-navigation" [class.loading]="config.isLoading" [class.compact]="config.isCompact" id="wizard-navigation">
|
||||
<div class="nav-buttons">
|
||||
<!-- Cancel Button -->
|
||||
@if (config.showCancel) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="cancel-btn"
|
||||
id="wizard-cancel-btn"
|
||||
(click)="onCancel()"
|
||||
[attr.aria-label]="'Cancel wizard and return to previous page'"
|
||||
>
|
||||
<mat-icon class="leading-icon">close</mat-icon>
|
||||
{{ config.cancelLabel || 'Cancel' }}
|
||||
</button>
|
||||
}
|
||||
|
||||
<div class="spacer"></div>
|
||||
|
||||
<!-- Navigation Group -->
|
||||
<div class="nav-group">
|
||||
<!-- Previous Button -->
|
||||
@if (config.showPrevious) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="prev-btn"
|
||||
id="wizard-previous-btn"
|
||||
[disabled]="config.isPreviousDisabled"
|
||||
(click)="onPrevious()"
|
||||
[attr.aria-label]="'Go to previous step'"
|
||||
>
|
||||
<mat-icon class="leading-icon">chevron_left</mat-icon>
|
||||
{{ config.previousLabel || 'Previous' }}
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (config.showQuickCreate) {
|
||||
<!-- Skip Wizard Button -->
|
||||
<button
|
||||
mat-raised-button
|
||||
class="skip-btn"
|
||||
id="wizard-quick-create-btn"
|
||||
(click)="skipAndFinish()"
|
||||
type="button"
|
||||
>
|
||||
<mat-icon>bolt</mat-icon>
|
||||
Create with defaults
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Next/Continue Button -->
|
||||
@if (config.showNext) {
|
||||
<button
|
||||
mat-raised-button
|
||||
class="next-btn"
|
||||
id="wizard-next-btn"
|
||||
[disabled]="config.isNextDisabled"
|
||||
(click)="onNext()"
|
||||
[attr.aria-label]="'Continue to next step'"
|
||||
>
|
||||
{{ config.nextLabel || 'Next' }}
|
||||
<mat-icon class="trailing-icon">
|
||||
{{ config.isLoading ? 'hourglass_empty' : 'chevron_right' }}
|
||||
</mat-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Finish/Save Button -->
|
||||
@if (config.showFinish) {
|
||||
<button
|
||||
mat-raised-button
|
||||
class="finish-btn"
|
||||
id="wizard-finish-btn"
|
||||
[disabled]="config.isFinishDisabled"
|
||||
(click)="onFinish()"
|
||||
[attr.aria-label]="'Complete wizard and save changes'"
|
||||
>
|
||||
<mat-icon class="leading-icon">
|
||||
{{ config.isLoading ? 'hourglass_empty' : 'check' }}
|
||||
</mat-icon>
|
||||
{{ config.finishLabel || 'Finish' }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@ -0,0 +1,209 @@
|
||||
@import '../../../../../../src/assets/styles/design-tokens';
|
||||
|
||||
.wizard-navigation {
|
||||
width: 100%;
|
||||
padding: var(--ov-meet-spacing-md) 0 0 0;
|
||||
|
||||
.nav-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@include ov-container;
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ov-meet-spacing-md);
|
||||
}
|
||||
|
||||
button {
|
||||
@include ov-button-base;
|
||||
border-radius: var(--ov-meet-radius-sm);
|
||||
min-width: 120px;
|
||||
|
||||
&:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
&.cancel-btn {
|
||||
color: var(--ov-meet-text-secondary);
|
||||
border-color: var(--ov-meet-border-color-strong);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--ov-meet-surface-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&.skip-btn {
|
||||
background-color: var(--ov-meet-color-success);
|
||||
color: var(--ov-meet-text-on-primary);
|
||||
box-shadow: var(--ov-meet-shadow-sm);
|
||||
}
|
||||
|
||||
&.prev-btn {
|
||||
color: var(--ov-meet-color-primary);
|
||||
border-color: var(--ov-meet-color-primary);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--ov-meet-surface-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: var(--ov-meet-text-disabled);
|
||||
border-color: var(--ov-meet-border-color);
|
||||
}
|
||||
}
|
||||
|
||||
&.next-btn,
|
||||
&.continue-btn {
|
||||
background-color: var(--ov-meet-color-primary);
|
||||
color: var(--ov-meet-text-on-primary);
|
||||
box-shadow: var(--ov-meet-shadow-sm);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--ov-meet-color-primary-dark);
|
||||
box-shadow: var(--ov-meet-shadow-md);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--ov-meet-border-color);
|
||||
color: var(--ov-meet-text-disabled);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.finish-btn,
|
||||
&.save-btn {
|
||||
background-color: var(--ov-meet-color-success);
|
||||
color: var(--ov-meet-text-on-primary);
|
||||
box-shadow: var(--ov-meet-shadow-sm);
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--ov-meet-shadow-md);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--ov-meet-border-color);
|
||||
color: var(--ov-meet-text-disabled);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
@include ov-icon(sm);
|
||||
margin: 0 var(--ov-meet-spacing-xs);
|
||||
|
||||
&.leading-icon {
|
||||
margin-right: var(--ov-meet-spacing-sm);
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
&.trailing-icon {
|
||||
margin-left: var(--ov-meet-spacing-sm);
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include ov-mobile-down {
|
||||
padding: var(--ov-meet-spacing-md) 0;
|
||||
|
||||
.nav-buttons {
|
||||
padding: 0 var(--ov-meet-spacing-sm);
|
||||
flex-direction: column;
|
||||
gap: var(--ov-meet-spacing-md);
|
||||
|
||||
.cancel-btn {
|
||||
margin-right: 0;
|
||||
order: 3;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
min-width: auto;
|
||||
max-width: 150px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include ov-tablet-down {
|
||||
.nav-buttons {
|
||||
.nav-group {
|
||||
gap: var(--ov-meet-spacing-sm);
|
||||
|
||||
button {
|
||||
min-width: 100px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--ov-meet-color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&[aria-disabled='true'] {
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
&.loading {
|
||||
button:not(.cancel-btn) {
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
|
||||
mat-icon {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
&.compact {
|
||||
padding: var(--ov-meet-spacing-sm) 0;
|
||||
border-top: none;
|
||||
|
||||
.nav-buttons {
|
||||
padding: 0 var(--ov-meet-spacing-sm);
|
||||
|
||||
button {
|
||||
min-width: 80px;
|
||||
padding: var(--ov-meet-spacing-sm) var(--ov-meet-spacing-md);
|
||||
|
||||
mat-icon {
|
||||
@include ov-icon(xs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,18 +1,18 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { RoomFormComponent } from './room-form.component';
|
||||
import { WizardNavComponent } from './wizard-nav.component';
|
||||
|
||||
describe('RoomFormComponent', () => {
|
||||
let component: RoomFormComponent;
|
||||
let fixture: ComponentFixture<RoomFormComponent>;
|
||||
describe('WizardNavComponent', () => {
|
||||
let component: WizardNavComponent;
|
||||
let fixture: ComponentFixture<WizardNavComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [RoomFormComponent]
|
||||
imports: [WizardNavComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(RoomFormComponent);
|
||||
fixture = TestBed.createComponent(WizardNavComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
@ -0,0 +1,142 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import type { WizardNavigationConfig, WizardNavigationEvent } from '@lib/models';
|
||||
|
||||
@Component({
|
||||
selector: 'ov-wizard-nav',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatButton, MatIcon],
|
||||
templateUrl: './wizard-nav.component.html',
|
||||
styleUrl: './wizard-nav.component.scss'
|
||||
})
|
||||
export class WizardNavComponent implements OnInit, OnChanges {
|
||||
/**
|
||||
* Navigation configuration with default values
|
||||
*/
|
||||
@Input() config: WizardNavigationConfig = {
|
||||
showPrevious: true,
|
||||
showNext: true,
|
||||
showCancel: true,
|
||||
showFinish: false,
|
||||
showQuickCreate: true,
|
||||
nextLabel: 'Next',
|
||||
previousLabel: 'Previous',
|
||||
cancelLabel: 'Cancel',
|
||||
finishLabel: 'Finish',
|
||||
isNextDisabled: false,
|
||||
isPreviousDisabled: false,
|
||||
isFinishDisabled: false,
|
||||
isLoading: false,
|
||||
isCompact: false,
|
||||
ariaLabel: 'Wizard navigation'
|
||||
};
|
||||
|
||||
/**
|
||||
* Current step identifier for context
|
||||
*/
|
||||
@Input() currentStepId?: number;
|
||||
|
||||
/**
|
||||
* Event emitters for navigation actions
|
||||
*/
|
||||
@Output() previous = new EventEmitter<WizardNavigationEvent>();
|
||||
@Output() next = new EventEmitter<WizardNavigationEvent>();
|
||||
@Output() cancel = new EventEmitter<WizardNavigationEvent>();
|
||||
@Output() finish = new EventEmitter<WizardNavigationEvent>();
|
||||
|
||||
/**
|
||||
* Generic navigation event for centralized handling
|
||||
*/
|
||||
@Output() navigate = new EventEmitter<WizardNavigationEvent>();
|
||||
|
||||
ngOnInit() {
|
||||
this.validateConfig();
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges) {
|
||||
if (changes['config']) {
|
||||
this.validateConfig();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates navigation configuration
|
||||
*/
|
||||
private validateConfig() {
|
||||
if (!this.config.nextLabel) this.config.nextLabel = 'Next';
|
||||
if (!this.config.previousLabel) this.config.previousLabel = 'Previous';
|
||||
if (!this.config.cancelLabel) this.config.cancelLabel = 'Cancel';
|
||||
if (!this.config.finishLabel) this.config.finishLabel = 'Finish';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle previous step navigation
|
||||
*/
|
||||
onPrevious() {
|
||||
if (!this.config.isPreviousDisabled && !this.config.isLoading) {
|
||||
const event: WizardNavigationEvent = {
|
||||
action: 'previous',
|
||||
currentStepId: this.currentStepId
|
||||
};
|
||||
|
||||
this.previous.emit(event);
|
||||
this.navigate.emit(event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle next step navigation
|
||||
*/
|
||||
onNext() {
|
||||
if (!this.config.isNextDisabled && !this.config.isLoading) {
|
||||
const event: WizardNavigationEvent = {
|
||||
action: 'next',
|
||||
currentStepId: this.currentStepId
|
||||
};
|
||||
|
||||
this.next.emit(event);
|
||||
this.navigate.emit(event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle wizard cancellation
|
||||
*/
|
||||
onCancel() {
|
||||
if (!this.config.isLoading) {
|
||||
const event: WizardNavigationEvent = {
|
||||
action: 'cancel',
|
||||
currentStepId: this.currentStepId
|
||||
};
|
||||
|
||||
this.cancel.emit(event);
|
||||
this.navigate.emit(event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle wizard completion
|
||||
*/
|
||||
onFinish() {
|
||||
if (!this.config.isFinishDisabled && !this.config.isLoading) {
|
||||
const event: WizardNavigationEvent = {
|
||||
action: 'finish',
|
||||
currentStepId: this.currentStepId
|
||||
};
|
||||
|
||||
this.finish.emit(event);
|
||||
this.navigate.emit(event);
|
||||
}
|
||||
}
|
||||
|
||||
skipAndFinish() {
|
||||
const event: WizardNavigationEvent = {
|
||||
action: 'finish',
|
||||
currentStepId: this.currentStepId
|
||||
};
|
||||
this.finish.emit(event);
|
||||
this.navigate.emit(event);
|
||||
}
|
||||
}
|
||||
@ -1,21 +1,18 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot } from '@angular/router';
|
||||
import { WebComponentManagerService, ContextService } from '../services';
|
||||
import { ApplicationMode } from 'projects/shared-meet-components/src/public-api';
|
||||
import { ApplicationMode } from '@lib/models';
|
||||
import { AppDataService, WebComponentManagerService } from '@lib/services';
|
||||
|
||||
export const applicationModeGuard: CanActivateFn = (
|
||||
_route: ActivatedRouteSnapshot,
|
||||
_state: RouterStateSnapshot
|
||||
) => {
|
||||
const contextService = inject(ContextService);
|
||||
export const applicationModeGuard: CanActivateFn = (_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot) => {
|
||||
const appDataService = inject(AppDataService);
|
||||
const commandsManagerService = inject(WebComponentManagerService);
|
||||
|
||||
const isRequestedFromIframe = window.self !== window.top;
|
||||
|
||||
const applicationMode = isRequestedFromIframe ? ApplicationMode.EMBEDDED : ApplicationMode.STANDALONE;
|
||||
contextService.setApplicationMode(applicationMode);
|
||||
appDataService.setApplicationMode(applicationMode);
|
||||
|
||||
if (contextService.isEmbeddedMode()) {
|
||||
if (appDataService.isEmbeddedMode()) {
|
||||
// Start listening for commands from the iframe
|
||||
commandsManagerService.startCommandsListener();
|
||||
}
|
||||
|
||||
@ -1,23 +1,28 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot, CanActivateFn, Router, RouterStateSnapshot } from '@angular/router';
|
||||
import { ErrorReason } from '@lib/models/navigation.model';
|
||||
import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot } from '@angular/router';
|
||||
import { ErrorReason } from '@lib/models';
|
||||
import {
|
||||
AuthService,
|
||||
GlobalPreferencesService,
|
||||
NavigationService,
|
||||
ParticipantTokenService,
|
||||
RecordingManagerService,
|
||||
RoomService
|
||||
} from '@lib/services';
|
||||
import { AuthMode, ParticipantRole } from '@lib/typings/ce';
|
||||
import { AuthService, ContextService, HttpService, NavigationService, SessionStorageService } from '../services';
|
||||
|
||||
export const checkUserAuthenticatedGuard: CanActivateFn = async (
|
||||
_route: ActivatedRouteSnapshot,
|
||||
state: RouterStateSnapshot
|
||||
) => {
|
||||
const authService = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
const navigationService = inject(NavigationService);
|
||||
|
||||
// Check if user is authenticated
|
||||
const isAuthenticated = await authService.isUserAuthenticated();
|
||||
if (!isAuthenticated) {
|
||||
// Redirect to the login page
|
||||
return router.createUrlTree(['login'], {
|
||||
queryParams: { redirectTo: state.url }
|
||||
});
|
||||
return navigationService.redirectToLoginPage(state.url);
|
||||
}
|
||||
|
||||
// Allow access to the requested page
|
||||
@ -29,13 +34,13 @@ export const checkUserNotAuthenticatedGuard: CanActivateFn = async (
|
||||
_state: RouterStateSnapshot
|
||||
) => {
|
||||
const authService = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
const navigationService = inject(NavigationService);
|
||||
|
||||
// Check if user is not authenticated
|
||||
const isAuthenticated = await authService.isUserAuthenticated();
|
||||
if (isAuthenticated) {
|
||||
// Redirect to the console page
|
||||
return router.createUrlTree(['console']);
|
||||
// Redirect to home page
|
||||
return navigationService.createRedirectionTo('');
|
||||
}
|
||||
|
||||
// Allow access to the requested page
|
||||
@ -48,36 +53,35 @@ export const checkParticipantRoleAndAuthGuard: CanActivateFn = async (
|
||||
) => {
|
||||
const navigationService = inject(NavigationService);
|
||||
const authService = inject(AuthService);
|
||||
const contextService = inject(ContextService);
|
||||
const sessionStorageService = inject(SessionStorageService);
|
||||
const httpService = inject(HttpService);
|
||||
const preferencesService = inject(GlobalPreferencesService);
|
||||
const roomService = inject(RoomService);
|
||||
const participantService = inject(ParticipantTokenService);
|
||||
|
||||
// Get the role that the participant will have in the room based on the room ID and secret
|
||||
let participantRole: ParticipantRole;
|
||||
|
||||
try {
|
||||
const roomId = contextService.getRoomId();
|
||||
const secret = contextService.getSecret();
|
||||
const storageSecret = sessionStorageService.getModeratorSecret(roomId);
|
||||
const roomId = roomService.getRoomId();
|
||||
const secret = roomService.getRoomSecret();
|
||||
|
||||
const roomRoleAndPermissions = await httpService.getRoomRoleAndPermissions(roomId, storageSecret || secret);
|
||||
const roomRoleAndPermissions = await roomService.getRoomRoleAndPermissions(roomId, secret);
|
||||
participantRole = roomRoleAndPermissions.role;
|
||||
contextService.setParticipantRole(participantRole);
|
||||
participantService.setParticipantRole(participantRole);
|
||||
} catch (error: any) {
|
||||
console.error('Error getting participant role:', error);
|
||||
switch (error.status) {
|
||||
case 400:
|
||||
// Invalid secret
|
||||
return navigationService.createRedirectionToErrorPage(ErrorReason.INVALID_ROOM_SECRET);
|
||||
return navigationService.redirectToErrorPage(ErrorReason.INVALID_ROOM_SECRET);
|
||||
case 404:
|
||||
// Room not found
|
||||
return navigationService.createRedirectionToErrorPage(ErrorReason.INVALID_ROOM);
|
||||
return navigationService.redirectToErrorPage(ErrorReason.INVALID_ROOM);
|
||||
default:
|
||||
return navigationService.createRedirectionToErrorPage(ErrorReason.INTERNAL_ERROR);
|
||||
return navigationService.redirectToErrorPage(ErrorReason.INTERNAL_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
const authMode = await contextService.getAuthModeToAccessRoom();
|
||||
const authMode = await preferencesService.getAuthModeToAccessRoom();
|
||||
|
||||
// If the user is a moderator and the room requires authentication for moderators only,
|
||||
// or if the room requires authentication for all users,
|
||||
@ -91,7 +95,7 @@ export const checkParticipantRoleAndAuthGuard: CanActivateFn = async (
|
||||
const isAuthenticated = await authService.isUserAuthenticated();
|
||||
if (!isAuthenticated) {
|
||||
// Redirect to the login page with query param to redirect back to the room
|
||||
return navigationService.createRedirectionToLoginPage(state.url);
|
||||
return navigationService.redirectToLoginPage(state.url);
|
||||
}
|
||||
}
|
||||
|
||||
@ -103,7 +107,7 @@ export const checkRecordingAuthGuard: CanActivateFn = async (
|
||||
route: ActivatedRouteSnapshot,
|
||||
state: RouterStateSnapshot
|
||||
) => {
|
||||
const httpService = inject(HttpService);
|
||||
const recordingService = inject(RecordingManagerService);
|
||||
const navigationService = inject(NavigationService);
|
||||
|
||||
const recordingId = route.params['recording-id'];
|
||||
@ -111,29 +115,29 @@ export const checkRecordingAuthGuard: CanActivateFn = async (
|
||||
|
||||
if (!secret) {
|
||||
// If no secret is provided, redirect to the error page
|
||||
return navigationService.createRedirectionToErrorPage(ErrorReason.MISSING_RECORDING_SECRET);
|
||||
return navigationService.redirectToErrorPage(ErrorReason.MISSING_RECORDING_SECRET);
|
||||
}
|
||||
|
||||
try {
|
||||
// Attempt to access the recording to check if the secret is valid
|
||||
await httpService.getRecording(recordingId, secret);
|
||||
await recordingService.getRecording(recordingId, secret);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.error('Error checking recording access:', error);
|
||||
switch (error.status) {
|
||||
case 400:
|
||||
// Invalid secret
|
||||
return navigationService.createRedirectionToErrorPage(ErrorReason.INVALID_RECORDING_SECRET);
|
||||
return navigationService.redirectToErrorPage(ErrorReason.INVALID_RECORDING_SECRET);
|
||||
case 401:
|
||||
// Unauthorized access
|
||||
// Redirect to the login page with query param to redirect back to the recording
|
||||
return navigationService.createRedirectionToLoginPage(state.url);
|
||||
return navigationService.redirectToLoginPage(state.url);
|
||||
case 404:
|
||||
// Recording not found
|
||||
return navigationService.createRedirectionToErrorPage(ErrorReason.INVALID_RECORDING);
|
||||
return navigationService.redirectToErrorPage(ErrorReason.INVALID_RECORDING);
|
||||
default:
|
||||
// Internal error
|
||||
return navigationService.createRedirectionToErrorPage(ErrorReason.INTERNAL_ERROR);
|
||||
return navigationService.redirectToErrorPage(ErrorReason.INTERNAL_ERROR);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,29 +1,33 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot, CanActivateFn } from '@angular/router';
|
||||
import { ContextService, NavigationService, SessionStorageService } from '../services';
|
||||
import { ErrorReason } from '@lib/models/navigation.model';
|
||||
import { ErrorReason } from '@lib/models';
|
||||
import { NavigationService, ParticipantTokenService, RoomService, SessionStorageService } from '@lib/services';
|
||||
|
||||
export const extractRoomQueryParamsGuard: CanActivateFn = (route: ActivatedRouteSnapshot) => {
|
||||
const navigationService = inject(NavigationService);
|
||||
const contextService = inject(ContextService);
|
||||
const { roomId, participantName, secret, leaveRedirectUrl, viewRecordings } = extractParams(route);
|
||||
|
||||
if (isValidUrl(leaveRedirectUrl)) {
|
||||
contextService.setLeaveRedirectUrl(leaveRedirectUrl);
|
||||
}
|
||||
const roomService = inject(RoomService);
|
||||
const participantService = inject(ParticipantTokenService);
|
||||
const { roomId, participantName, secret, leaveRedirectUrl, showOnlyRecordings } = extractParams(route);
|
||||
|
||||
if (!secret) {
|
||||
// If no secret is provided, redirect to the error page
|
||||
return navigationService.createRedirectionToErrorPage(ErrorReason.MISSING_ROOM_SECRET);
|
||||
return navigationService.redirectToErrorPage(ErrorReason.MISSING_ROOM_SECRET);
|
||||
}
|
||||
|
||||
contextService.setRoomId(roomId);
|
||||
contextService.setParticipantName(participantName);
|
||||
contextService.setSecret(secret);
|
||||
roomService.setRoomId(roomId);
|
||||
roomService.setRoomSecret(secret);
|
||||
|
||||
if (viewRecordings === 'true') {
|
||||
if (participantName) {
|
||||
participantService.setParticipantName(participantName);
|
||||
}
|
||||
|
||||
if (isValidUrl(leaveRedirectUrl)) {
|
||||
navigationService.setLeaveRedirectUrl(leaveRedirectUrl);
|
||||
}
|
||||
|
||||
if (showOnlyRecordings === 'true') {
|
||||
// Redirect to the room recordings page
|
||||
return navigationService.createRedirectionToRecordingsPage(roomId, secret);
|
||||
return navigationService.createRedirectionTo(`room/${roomId}/recordings`, { secret });
|
||||
}
|
||||
|
||||
return true;
|
||||
@ -31,7 +35,7 @@ export const extractRoomQueryParamsGuard: CanActivateFn = (route: ActivatedRoute
|
||||
|
||||
export const extractRecordingQueryParamsGuard: CanActivateFn = (route: ActivatedRouteSnapshot) => {
|
||||
const navigationService = inject(NavigationService);
|
||||
const contextService = inject(ContextService);
|
||||
const roomService = inject(RoomService);
|
||||
const sessionStorageService = inject(SessionStorageService);
|
||||
|
||||
const { roomId, secret } = extractParams(route);
|
||||
@ -39,11 +43,11 @@ export const extractRecordingQueryParamsGuard: CanActivateFn = (route: Activated
|
||||
|
||||
if (!secret && !storedSecret) {
|
||||
// If no secret is provided, redirect to the error page
|
||||
return navigationService.createRedirectionToErrorPage(ErrorReason.MISSING_ROOM_SECRET);
|
||||
return navigationService.redirectToErrorPage(ErrorReason.MISSING_ROOM_SECRET);
|
||||
}
|
||||
|
||||
contextService.setRoomId(roomId);
|
||||
contextService.setSecret(secret);
|
||||
roomService.setRoomId(roomId);
|
||||
roomService.setRoomSecret(secret);
|
||||
|
||||
return true;
|
||||
};
|
||||
@ -53,7 +57,7 @@ const extractParams = (route: ActivatedRouteSnapshot) => ({
|
||||
participantName: route.queryParams['participant-name'],
|
||||
secret: route.queryParams['secret'],
|
||||
leaveRedirectUrl: route.queryParams['leave-redirect-url'],
|
||||
viewRecordings: route.queryParams['view-recordings']
|
||||
showOnlyRecordings: route.queryParams['show-only-recordings']
|
||||
});
|
||||
|
||||
const isValidUrl = (url: string) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user