From 8de6d127eb7519e849e1ca258308bcfe3dd80aa9 Mon Sep 17 00:00:00 2001 From: Carlos Santos <4a.santos@gmail.com> Date: Wed, 24 Sep 2025 17:10:58 +0200 Subject: [PATCH] frontend: implement appearance configuration form with theme customization options frontend: update theme initialization to align with OpenVidu Meet themes frontend: remove unused color variables and update styles configuration frontend: enhance theme customization with dynamic color picker and theme loading --- frontend/angular.json | 2 +- .../console/config/config.component.html | 128 ++++++++- .../console/config/config.component.scss | 204 ++++++++++++++ .../pages/console/config/config.component.ts | 248 +++++++++++++++++- .../lib/pages/meeting/meeting.component.html | 1 + .../lib/pages/meeting/meeting.component.ts | 22 +- .../services/feature-configuration.service.ts | 2 + .../src/lib/services/theme.service.ts | 4 + frontend/src/colors.scss | 20 -- typings/src/room-config.ts | 41 +-- 10 files changed, 622 insertions(+), 50 deletions(-) delete mode 100644 frontend/src/colors.scss diff --git a/frontend/angular.json b/frontend/angular.json index bbc0f0c..8e00b53 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -25,7 +25,7 @@ "tsConfig": "src/tsconfig.app.json", "inlineStyleLanguage": "scss", "assets": ["src/favicon.ico", "src/assets"], - "styles": ["src/styles.scss", "src/colors.scss"], + "styles": ["src/styles.scss"], "scripts": [] }, "configurations": { diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/config/config.component.html b/frontend/projects/shared-meet-components/src/lib/pages/console/config/config.component.html index 5ee024a..f1918a9 100644 --- a/frontend/projects/shared-meet-components/src/lib/pages/console/config/config.component.html +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/config/config.component.html @@ -1 +1,127 @@ -

config works!

+
+ + + @if (isLoading()) { +
+
+
+
+ settings +

Loading theme configuration

+
+

Please wait while we fetch your theme settings...

+
+ +
+ +
+
+
+ } @else { +
+ + + +
+ palette +
+ Appearance + Configure custom appearance for your rooms +
+ + +
+ +

Custom Theme

+
+ Enable custom theme + +
+ + + @if (isThemeEnabled) { + +
+

Base Theme

+

+ Select the foundation theme for your custom appearance. +

+ + Base Theme + + @for (theme of baseThemeOptions; track theme) { + + {{ theme.charAt(0).toUpperCase() + theme.slice(1).toLowerCase() }} + + } + + @if (appearanceForm.get('baseTheme')?.hasError('required')) { + Base theme is required + } + +
+ + +
+

Color Customization

+

+ Customize the colors of your theme. Click on any color to modify it. +

+
+ @for (colorConfig of colorFields; track colorConfig.key) { +
+ {{ colorConfig.label }} +
+ +
+
+
+ } +
+
+ } +
+
+ + @if (isThemeEnabled) { + + + + + + } +
+
+ } +
diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/config/config.component.scss b/frontend/projects/shared-meet-components/src/lib/pages/console/config/config.component.scss index e69de29..d1d0c89 100644 --- a/frontend/projects/shared-meet-components/src/lib/pages/console/config/config.component.scss +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/config/config.component.scss @@ -0,0 +1,204 @@ +@import '../../../../../../../src/assets/styles/design-tokens'; + +.ov-page-container { + button { + padding: var(--ov-meet-button-padding-vertical) var(--ov-meet-button-padding-horizontal); + } +} + +// Theme Configuration Section +.theme-config-card { + .theme-form { + @extend .ov-settings-form-section; + + .full-width { + width: 100%; + } + + .section-title { + margin: 0; + } + + .theme-toggle { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 var(--ov-meet-spacing-md) var(--ov-meet-spacing-md) 0; + + ::ng-deep button { + padding: 0 !important; + } + } + + // Input field styling + .mat-mdc-form-field { + margin-bottom: var(--ov-meet-spacing-lg); + + ::ng-deep .mat-mdc-text-field-wrapper { + background-color: var(--ov-meet-surface-variant); + border-radius: var(--ov-meet-border-radius-sm); + } + + ::ng-deep .mdc-notched-outline__leading, + ::ng-deep .mdc-notched-outline__notch, + ::ng-deep .mdc-notched-outline__trailing { + border-color: var(--ov-meet-border-color); + } + } + + // Color picker grid layout - responsive and clean + .color-picker-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--ov-meet-spacing-xl); + margin: var(--ov-meet-spacing-lg) 0; + + // Tablet and larger - maintain 4 columns + @include ov-tablet-up { + gap: var(--ov-meet-spacing-xxl); + } + + // Small tablets - 2 columns + @include ov-tablet-down { + grid-template-columns: repeat(2, 1fr); + gap: var(--ov-meet-spacing-lg); + } + + // Mobile - single column + @include ov-mobile-down { + grid-template-columns: 1fr; + gap: var(--ov-meet-spacing-md); + } + } + + .color-picker-item { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--ov-meet-spacing-sm); + cursor: pointer; + transition: + transform 0.2s ease, + opacity 0.2s ease; + + &:hover { + transform: translateY(-2px); + // opacity: 0.9; + } + + &:active { + transform: translateY(0); + } + + .color-label { + font-size: 0.875rem; + font-weight: 500; + color: var(--ov-meet-text-color-primary); + text-align: center; + margin-bottom: var(--ov-meet-spacing-xs); + user-select: none; + } + + .color-circle-wrapper { + position: relative; + display: flex; + align-items: center; + justify-content: center; + + .color-input { + position: absolute; + opacity: 0; + width: 80px; + height: 80px; + cursor: pointer; + border: none; + background: none; + z-index: 2; + + // &::-webkit-color-swatch-wrapper { + // padding: 0; + // border: none; + // border-radius: 50%; + // } + + // &::-webkit-color-swatch { + // border: none; + // border-radius: 50%; + // } + + // &::-moz-color-swatch { + // border: none; + // border-radius: 50%; + // } + } + + .color-circle { + width: 80px; + height: 80px; + border-radius: var(--ov-meet-radius-lg); + border: 3px solid var(--ov-meet-border-color); + position: relative; + z-index: 1; + } + } + } + + // Reset colors section + .reset-colors-section { + display: flex; + justify-content: center; + margin-top: var(--ov-meet-spacing-lg); + padding-top: var(--ov-meet-spacing-lg); + border-top: 1px solid var(--ov-meet-border-color); + + .reset-colors-btn { + display: flex; + align-items: center; + gap: var(--ov-meet-spacing-xs); + font-size: 0.875rem; + color: var(--ov-meet-text-color-secondary); + border-color: var(--ov-meet-border-color); + + &:hover { + color: var(--ov-meet-color-error); + border-color: var(--ov-meet-color-error); + } + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } + } + } + } +} + +// Card Actions - responsive button layout +.mat-mdc-card-actions { + padding: var(--ov-meet-spacing-lg) var(--ov-meet-spacing-xl); + gap: var(--ov-meet-spacing-sm); + border-top: 1px solid var(--ov-meet-border-color); + margin: auto; + display: flex; + flex-wrap: wrap; + + @include ov-mobile-down { + flex-direction: column; + + .mat-mdc-button, + .mat-mdc-raised-button, + .mat-mdc-stroked-button { + width: 100%; + margin: var(--ov-meet-spacing-xs) 0; + } + } + + @include ov-tablet-up { + justify-content: flex-start; + + button:first-child { + margin-right: auto; + } + } +} diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/config/config.component.ts b/frontend/projects/shared-meet-components/src/lib/pages/console/config/config.component.ts index 7335c28..6e9a659 100644 --- a/frontend/projects/shared-meet-components/src/lib/pages/console/config/config.component.ts +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/config/config.component.ts @@ -1,12 +1,246 @@ -import { Component } from '@angular/core'; +import { Component, OnInit, signal } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +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 { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatSelectModule } from '@angular/material/select'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { GlobalConfigService, NotificationService } from '@lib/services'; +import { MeetAppearanceConfig, MeetRoomTheme, MeetRoomThemeMode } from '@lib/typings/ce'; +import { + OPENVIDU_COMPONENTS_DARK_THEME, + OPENVIDU_COMPONENTS_LIGHT_THEME, + OpenViduThemeService +} from 'openvidu-components-angular'; + +type ColorField = 'backgroundColor' | 'primaryColor' | 'secondaryColor' | 'surfaceColor'; + +interface ThemeColors { + backgroundColor: string; + primaryColor: string; + secondaryColor: string; + surfaceColor: string; +} @Component({ - selector: 'ov-config', - standalone: true, - imports: [], - templateUrl: './config.component.html', - styleUrl: './config.component.scss' + selector: 'ov-config', + standalone: true, + imports: [ + MatCardModule, + MatButtonModule, + MatIconModule, + MatInputModule, + MatFormFieldModule, + MatSelectModule, + MatSlideToggleModule, + MatTooltipModule, + MatProgressSpinnerModule, + MatDividerModule, + ReactiveFormsModule + ], + templateUrl: './config.component.html', + styleUrl: './config.component.scss' }) -export class ConfigComponent { +export class ConfigComponent implements OnInit { + isLoading = signal(true); + hasChanges = signal(false); + appearanceForm = new FormGroup({ + enabled: new FormControl(false, { nonNullable: true }), + baseTheme: new FormControl(MeetRoomThemeMode.LIGHT, { + validators: [Validators.required], + nonNullable: true + }), + backgroundColor: new FormControl('', { nonNullable: true }), + primaryColor: new FormControl('', { nonNullable: true }), + secondaryColor: new FormControl('', { nonNullable: true }), + surfaceColor: new FormControl('', { nonNullable: true }) + }); + + baseThemeOptions: MeetRoomThemeMode[] = [MeetRoomThemeMode.LIGHT, MeetRoomThemeMode.DARK]; + + // Color picker configuration + colorFields: Array<{ key: ColorField; label: string }> = [ + { key: 'backgroundColor', label: 'Background' }, + { key: 'primaryColor', label: 'Primary' }, + { key: 'secondaryColor', label: 'Secondary' }, + { key: 'surfaceColor', label: 'Surface' } + ]; + + private initialFormValue: MeetRoomTheme | null = null; + + // Default color values based on theme + private readonly defaultColors: Record = { + [MeetRoomThemeMode.LIGHT]: { + backgroundColor: OPENVIDU_COMPONENTS_LIGHT_THEME['--ov-background-color'] as string, + primaryColor: OPENVIDU_COMPONENTS_LIGHT_THEME['--ov-primary-action-color'] as string, + secondaryColor: OPENVIDU_COMPONENTS_LIGHT_THEME['--ov-secondary-action-color'] as string, + surfaceColor: OPENVIDU_COMPONENTS_LIGHT_THEME['--ov-surface-color'] as string + }, + [MeetRoomThemeMode.DARK]: { + backgroundColor: OPENVIDU_COMPONENTS_DARK_THEME['--ov-background-color'] as string, + primaryColor: OPENVIDU_COMPONENTS_DARK_THEME['--ov-primary-action-color'] as string, + secondaryColor: OPENVIDU_COMPONENTS_DARK_THEME['--ov-secondary-action-color'] as string, + surfaceColor: OPENVIDU_COMPONENTS_DARK_THEME['--ov-surface-color'] as string + } + }; + + constructor( + private configService: GlobalConfigService, + private notificationService: NotificationService + ) { + // Track form changes + this.appearanceForm.valueChanges.subscribe(() => { + this.checkForChanges(); + }); + } + + async ngOnInit() { + this.isLoading.set(true); + try { + await this.loadAppearanceConfig(); + } catch (error) { + console.error('Error during component initialization:', error); + this.notificationService.showSnackbar('Failed to initialize theme configuration'); + } finally { + this.isLoading.set(false); + } + } + + // Form state getters + get isThemeEnabled(): boolean { + return this.appearanceForm.get('enabled')?.value ?? false; + } + + // Form actions + onResetForm(): void { + if (this.initialFormValue) { + this.appearanceForm.patchValue(this.initialFormValue); + this.hasChanges.set(false); + } + } + + // Color management methods + getColorValue(colorField: ColorField): string { + const formValue = this.appearanceForm.get(colorField)?.value; + if (formValue?.trim()) { + return formValue; + } + + const baseTheme = this.appearanceForm.get('baseTheme')?.value || MeetRoomThemeMode.LIGHT; + return this.defaultColors[baseTheme][colorField]; + } + + focusColorInput(colorField: ColorField): void { + const inputElement = document.getElementById(colorField) as HTMLInputElement; + inputElement?.click(); + } + + hasCustomColor(colorField: ColorField): boolean { + const formValue = this.appearanceForm.get(colorField)?.value; + return Boolean(formValue?.trim()); + } + + // Configuration management + private async loadAppearanceConfig(): Promise { + try { + const { appearance } = await this.configService.getRoomsAppearanceConfig(); + const themeConfig = appearance?.themes?.[0]; + + if (themeConfig) { + this.appearanceForm.patchValue({ + enabled: themeConfig.enabled, + baseTheme: themeConfig.baseTheme, + backgroundColor: themeConfig.backgroundColor || '', + primaryColor: themeConfig.primaryColor || '', + secondaryColor: themeConfig.secondaryColor || '', + surfaceColor: themeConfig.surfaceColor || '' + }); + } else { + // Set default values + this.appearanceForm.patchValue({ + enabled: false, + baseTheme: MeetRoomThemeMode.LIGHT, + backgroundColor: '', + primaryColor: '', + secondaryColor: '', + surfaceColor: '' + }); + } + + this.storeInitialValues(); + } catch (error) { + console.error('Error loading appearance config:', error); + this.appearanceForm.patchValue({ + enabled: false, + baseTheme: MeetRoomThemeMode.LIGHT, + backgroundColor: '', + primaryColor: '', + secondaryColor: '', + surfaceColor: '' + }); + this.storeInitialValues(); + throw error; + } + } + + private storeInitialValues(): void { + this.initialFormValue = { ...this.appearanceForm.value } as MeetRoomTheme; + this.hasChanges.set(false); + } + + private checkForChanges(): void { + if (!this.initialFormValue) { + return; + } + + const currentValue = this.appearanceForm.value; + const hasChangesDetected = JSON.stringify(currentValue) !== JSON.stringify(this.initialFormValue); + this.hasChanges.set(hasChangesDetected); + if (!currentValue.enabled) { + this.onSaveAppearanceConfig(); + } + } + + async onSaveAppearanceConfig(): Promise { + if (this.appearanceForm.invalid) { + this.notificationService.showSnackbar('Please fix form errors before saving'); + return; + } + + const formData = this.appearanceForm.value; + + try { + const appearanceConfig: MeetAppearanceConfig = { + themes: [this.createThemeFromFormData(formData as MeetRoomTheme)] + }; + + await this.configService.saveRoomsAppearanceConfig(appearanceConfig); + this.notificationService.showSnackbar('Theme configuration saved successfully'); + this.storeInitialValues(); + } catch (error) { + console.error('Error saving appearance config:', error); + this.notificationService.showSnackbar('Failed to save theme configuration'); + } + } + + private createThemeFromFormData(formData: MeetRoomTheme): MeetRoomTheme { + const baseTheme = formData.baseTheme ?? MeetRoomThemeMode.LIGHT; + const defaults = this.defaultColors[baseTheme]; + + return { + enabled: formData.enabled, + name: 'default', + baseTheme, + backgroundColor: formData.backgroundColor?.trim() ? formData.backgroundColor : defaults.backgroundColor, + primaryColor: formData.primaryColor?.trim() ? formData.primaryColor : defaults.primaryColor, + secondaryColor: formData.secondaryColor?.trim() ? formData.secondaryColor : defaults.secondaryColor, + surfaceColor: formData.surfaceColor?.trim() ? formData.surfaceColor : defaults.surfaceColor + }; + } } diff --git a/frontend/projects/shared-meet-components/src/lib/pages/meeting/meeting.component.html b/frontend/projects/shared-meet-components/src/lib/pages/meeting/meeting.component.html index 2783e9d..be59d1c 100644 --- a/frontend/projects/shared-meet-components/src/lib/pages/meeting/meeting.component.html +++ b/frontend/projects/shared-meet-components/src/lib/pages/meeting/meeting.component.html @@ -31,6 +31,7 @@ [recordingActivityShowRecordingsList]="false" [activitiesPanelBroadcastingActivity]="false" [showDisconnectionDialog]="false" + [showThemeSelector]="!features().showThemeSelector" (onRoomCreated)="onRoomCreated($event)" (onParticipantConnected)="onParticipantConnected($event)" (onParticipantLeft)="onParticipantLeft($event)" diff --git a/frontend/projects/shared-meet-components/src/lib/pages/meeting/meeting.component.ts b/frontend/projects/shared-meet-components/src/lib/pages/meeting/meeting.component.ts index 761cf2a..cee1f4c 100644 --- a/frontend/projects/shared-meet-components/src/lib/pages/meeting/meeting.component.ts +++ b/frontend/projects/shared-meet-components/src/lib/pages/meeting/meeting.component.ts @@ -20,6 +20,7 @@ import { ApplicationFeatures, AuthService, FeatureConfigurationService, + GlobalConfigService, MeetingService, NavigationService, NotificationService, @@ -49,6 +50,7 @@ import { LeaveButtonDirective, OpenViduComponentsUiModule, OpenViduService, + OpenViduThemeService, ParticipantLeftEvent, ParticipantLeftReason, ParticipantModel, @@ -128,7 +130,9 @@ export class MeetingComponent implements OnInit { protected navigationService: NavigationService, protected notificationService: NotificationService, protected clipboard: Clipboard, - protected viewportService: ViewportService + protected viewportService: ViewportService, + protected ovThemeService: OpenViduThemeService, + protected configService: GlobalConfigService ) { this.features = this.featureConfService.features; } @@ -281,6 +285,22 @@ export class MeetingComponent implements OnInit { await this.roomService.loadRoomConfig(this.roomId); this.showMeeting = true; + const { appearance } = await this.configService.getRoomsAppearanceConfig(); + console.log('Loaded appearance config:', appearance); + if (appearance.themes.length > 0 && appearance.themes[0].enabled) { + const theme = appearance.themes[0]; + this.ovThemeService.updateThemeVariables({ + '--ov-primary-action-color': theme.primaryColor, + '--ov-secondary-action-color': theme.secondaryColor, + '--ov-background-color': theme.backgroundColor, + '--ov-surface-color': theme.surfaceColor + }); + this.features().showThemeSelector = false; + } else { + this.ovThemeService.resetThemeVariables(); + this.features().showThemeSelector = true; + } + combineLatest([ this.ovComponentsParticipantService.remoteParticipants$, this.ovComponentsParticipantService.localParticipant$ diff --git a/frontend/projects/shared-meet-components/src/lib/services/feature-configuration.service.ts b/frontend/projects/shared-meet-components/src/lib/services/feature-configuration.service.ts index bbe5174..e5227bb 100644 --- a/frontend/projects/shared-meet-components/src/lib/services/feature-configuration.service.ts +++ b/frontend/projects/shared-meet-components/src/lib/services/feature-configuration.service.ts @@ -26,6 +26,7 @@ export interface ApplicationFeatures { showParticipantList: boolean; showSettings: boolean; showFullscreen: boolean; + showThemeSelector: boolean; // Permissions canModerateRoom: boolean; @@ -49,6 +50,7 @@ const DEFAULT_FEATURES: ApplicationFeatures = { showParticipantList: true, showSettings: true, showFullscreen: true, + showThemeSelector: true, canModerateRoom: false, canRecordRoom: false, diff --git a/frontend/projects/shared-meet-components/src/lib/services/theme.service.ts b/frontend/projects/shared-meet-components/src/lib/services/theme.service.ts index 0292b0b..5c89d38 100644 --- a/frontend/projects/shared-meet-components/src/lib/services/theme.service.ts +++ b/frontend/projects/shared-meet-components/src/lib/services/theme.service.ts @@ -28,6 +28,10 @@ export class ThemeService { * 3. Light theme as default */ initializeTheme(): void { + // Override available themes in OpenVidu Components to match OpenVidu Meet themes. + // OpenVidu Meet users do not know nothing about "classic" theme. + this.ovComponentsThemeService.getAllThemes = () => [OpenViduThemeMode.Light, OpenViduThemeMode.Dark]; + const savedTheme = this.getSavedTheme(); const systemPreference = this.getSystemPreference(); const initialTheme = savedTheme || systemPreference || 'light'; diff --git a/frontend/src/colors.scss b/frontend/src/colors.scss deleted file mode 100644 index 0917a9a..0000000 --- a/frontend/src/colors.scss +++ /dev/null @@ -1,20 +0,0 @@ -// OpenVidu Components Color Variables -:root { - --ov-background-color: #1f2020; - --ov-surface-color: #ffffff; - - --ov-primary-action-color: #273235; - --ov-secondary-action-color: #f1f1f1; - --ov-accent-action-color: #0089ab; - - --ov-error-color: #eb5144; - --ov-warn-color: #ffba53; - - --ov-text-primary-color: #ffffff; - --ov-text-surface-color: #1d1d1d; - - --ov-toolbar-buttons-radius: 50%; - --ov-leave-button-radius: 10px; - --ov-video-radius: 5px; - --ov-surface-radius: 5px; -} diff --git a/typings/src/room-config.ts b/typings/src/room-config.ts index 3bd3689..7cffba4 100644 --- a/typings/src/room-config.ts +++ b/typings/src/room-config.ts @@ -2,48 +2,49 @@ * Interface representing the config for a room. */ export interface MeetRoomConfig { - chat: MeetChatConfig; - recording: MeetRecordingConfig; - virtualBackground: MeetVirtualBackgroundConfig; - // appearance?: MeetAppearanceConfig; + chat: MeetChatConfig; + recording: MeetRecordingConfig; + virtualBackground: MeetVirtualBackgroundConfig; + // appearance?: MeetAppearanceConfig; } /** * Interface representing the config for recordings in a room. */ export interface MeetRecordingConfig { - enabled: boolean; - allowAccessTo?: MeetRecordingAccess; + enabled: boolean; + allowAccessTo?: MeetRecordingAccess; } export const enum MeetRecordingAccess { - ADMIN = 'admin', // Only admins can access the recording - ADMIN_MODERATOR = 'admin_moderator', // Admins and moderators can access - ADMIN_MODERATOR_SPEAKER = 'admin_moderator_speaker' // Admins, moderators and speakers can access + ADMIN = 'admin', // Only admins can access the recording + ADMIN_MODERATOR = 'admin_moderator', // Admins and moderators can access + ADMIN_MODERATOR_SPEAKER = 'admin_moderator_speaker', // Admins, moderators and speakers can access } export interface MeetChatConfig { - enabled: boolean; + enabled: boolean; } export interface MeetVirtualBackgroundConfig { - enabled: boolean; + enabled: boolean; } export interface MeetAppearanceConfig { - themes: MeetRoomTheme[]; + themes: MeetRoomTheme[]; } export interface MeetRoomTheme { - name: string; - baseTheme: MeetRoomThemeMode; - backgroundColor?: string; - primaryColor?: string; - secondaryColor?: string; - surfaceColor?: string; + enabled: boolean; + name: string; + baseTheme: MeetRoomThemeMode; + backgroundColor?: string; + primaryColor?: string; + secondaryColor?: string; + surfaceColor?: string; } export const enum MeetRoomThemeMode { - LIGHT = 'light', - DARK = 'dark' + LIGHT = 'light', + DARK = 'dark', }