From 8b37757a7ac87c79444f6ac0a48b7bfa1903c7af Mon Sep 17 00:00:00 2001 From: Colin Michaels Date: Fri, 30 May 2025 20:07:23 -0400 Subject: [PATCH 01/14] Update environment configuration and add example environment file Refined `.gitignore` to include specific environment files, replacing the generic folder exclusion. Introduced `environments.example.ts` with placeholder values for API keys and configurations to provide a template for setup. Signed-off-by: Colin Michaels --- .gitignore | 5 ++++- src/environments/environments.example.ts | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 src/environments/environments.example.ts diff --git a/.gitignore b/.gitignore index cf44da0..4fdc722 100644 --- a/.gitignore +++ b/.gitignore @@ -45,5 +45,8 @@ Thumbs.db .firebase/* # env configs -environments/* +src/environments/environment.dev.ts +src/environments/environment.local.ts +src/environments/environment.prod.ts +src/environments/environment.ts diff --git a/src/environments/environments.example.ts b/src/environments/environments.example.ts new file mode 100644 index 0000000..db831dc --- /dev/null +++ b/src/environments/environments.example.ts @@ -0,0 +1,16 @@ +export const environment = { + production: false, + title: '', + apiUrl: '', // your NestJS backend + openAiApiKey: '', + openWeatherMapApiKey: '', + firebaseConfig: { + apiKey: "", + authDomain: "", + projectId: "", + storageBucket: "", + messagingSenderId: "", + appId: "", + measurementId: "" + } +}; From 38a96f3b535825b8dfa0b6f6f788f95040cde257 Mon Sep 17 00:00:00 2001 From: Colin Michaels Date: Sat, 31 May 2025 07:37:04 -0400 Subject: [PATCH 02/14] Update firebase-hosting-merge.yml Adding environment vars to actions --- .github/workflows/firebase-hosting-merge.yml | 47 ++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/.github/workflows/firebase-hosting-merge.yml b/.github/workflows/firebase-hosting-merge.yml index b6aedde..bc482a0 100644 --- a/.github/workflows/firebase-hosting-merge.yml +++ b/.github/workflows/firebase-hosting-merge.yml @@ -9,7 +9,54 @@ on: jobs: build_and_deploy: runs-on: ubuntu-latest + env: + APP_TITLE: ${{ secrets.APP_TITLE }} + APP_API_URL: ${{ secrets.APP_API_URL }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + OPEN_WEATHER_MAP_API_KEY: ${{ OPEN_WEATHER_MAP_API_KEY }} + # firebase config + FIREBASE_API_KEY: ${{ secrets.FIREBASE_API_KEY }} + FIREBASE_AUTH_DOMAIN: ${{ secrets.FIREBASE_AUTH_DOMAIN }} + FIREBASE_PROJECT_ID: ${{ secrets.FIREBASE_PROJECT_ID }} + FIREBASE_STORAGE_BUCKET: ${{ secrets.FIREBASE_STORAGE_BUCKET }} + FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.FIREBASE_MESSAGING_SENDER_ID }} + FIREBASE_APP_ID: ${{ secrets.FIREBASE_APP_ID }} + FIREBASE_MEASUREMENT_ID: ${{ secrets.FIREBASE_MEASUREMENT_ID }} steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: npm install + + - name: Generate Angular environment file + run: | + cat < src/environments/environment.ts + export const environment = { + production: true, + title: '${APP_TITLE}', + apiUrl: '${APP_API_URL}', + openAiApiKey: '${OPENAI_API_KEY}', + openWeatherMapApiKey: '${OPEN_WEATHER_MAP_API_KEY}', + firebaseConfig: { + apiKey: "${FIREBASE_API_KEY}", + authDomain: "${FIREBASE_AUTH_DOMAIN}", + projectId: "${FIREBASE_PROJECT_ID}", + storageBucket: "${FIREBASE_STORAGE_BUCKET}", + messagingSenderId: "${FIREBASE_MESSAGING_SENDER_ID}", + appId: "${FIREBASE_APP_ID}", + measurementId: "${FIREBASE_MEASUREMENT_ID}" + } + }; + EOF + + - name: Build Angular app + run: npm run build - uses: actions/checkout@v4 - run: npm ci - uses: FirebaseExtended/action-hosting-deploy@v0 From fd4e94c7affdc8be34ff332b641010e6137a9f07 Mon Sep 17 00:00:00 2001 From: Colin Michaels Date: Fri, 6 Jun 2025 07:36:01 -0400 Subject: [PATCH 03/14] fix: rename time-ago pipe and update imports --- src/app/components/game/apps/task-app/task-app.component.ts | 2 +- .../notifications-server/notifications-server.component.ts | 2 +- src/app/pipes/{time-ago,pipe.ts => time-ago.pipe.ts} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename src/app/pipes/{time-ago,pipe.ts => time-ago.pipe.ts} (100%) diff --git a/src/app/components/game/apps/task-app/task-app.component.ts b/src/app/components/game/apps/task-app/task-app.component.ts index 2259ac4..899d7d1 100644 --- a/src/app/components/game/apps/task-app/task-app.component.ts +++ b/src/app/components/game/apps/task-app/task-app.component.ts @@ -5,7 +5,7 @@ import {Task, TaskService} from '../../services/task.service'; import {catchError, map, startWith} from 'rxjs/operators'; import {BehaviorSubject, combineLatest, debounceTime, Observable, of} from 'rxjs'; import {TooltipDirective} from '../../directives/tooltip.directive'; -import {TimeAgoPipe} from '../../../../pipes/time-ago,pipe'; +import {TimeAgoPipe} from '../../../../pipes/time-ago.pipe'; import {FaIconComponent} from '@fortawesome/angular-fontawesome'; import {faArchive, faCheck, faPlus, faRedo, faTrash} from '@fortawesome/free-solid-svg-icons'; import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; diff --git a/src/app/components/game/utils/notifications-server/notifications-server.component.ts b/src/app/components/game/utils/notifications-server/notifications-server.component.ts index 7732070..3740023 100644 --- a/src/app/components/game/utils/notifications-server/notifications-server.component.ts +++ b/src/app/components/game/utils/notifications-server/notifications-server.component.ts @@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common'; import {INotification, NotificationService} from '../../services/notification.service'; import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; import {MediaComponent} from '../../templates/media/media.component'; -import {TimeAgoPipe} from '../../../../pipes/time-ago,pipe'; +import {TimeAgoPipe} from '../../../../pipes/time-ago.pipe'; import {FontAwesomeModule} from '@fortawesome/angular-fontawesome'; import {faTimes} from '@fortawesome/free-solid-svg-icons'; diff --git a/src/app/pipes/time-ago,pipe.ts b/src/app/pipes/time-ago.pipe.ts similarity index 100% rename from src/app/pipes/time-ago,pipe.ts rename to src/app/pipes/time-ago.pipe.ts From dcc650b7fe3b739c62e85d1f0b12ba6f01198bc3 Mon Sep 17 00:00:00 2001 From: Colin Michaels Date: Fri, 1 Aug 2025 00:01:03 -0400 Subject: [PATCH 04/14] ### Commit Message **Integrate Background Service and Chat Module with Firebase enhancements** - Added `BackgroundExampleComponent`, `FullScreenBackgroundComponent`, and `BackgroundService` for advanced background management, including video, image, and parallax configurations. - Introduced a new `ChatBotService` to manage chat functionalities, supporting messages, reactions, and real-time chat features. - Updated environment configuration files (`environment.ts`, `environment.local.ts`, `environment.dev.ts`, `environment.prod.ts`) with Firebase database URL and API keys. - Enhanced Firebase compatibility by upgrading dependencies (`firebase: ^11.8.1`). - Refactored appearance settings in `AppearanceSettingsComponent`, removing unused methods (`getThemeClass()`), and added debug logs. - Added new helper logic in `SettingsService` for improved form syncing, with comprehensive logging for debugging. - Included placeholder-based example environment for easier setup (`environments.example.ts`). **Signed-off-by:** Colin Michaels Signed-off-by: Colin Michaels --- package-lock.json | 1 + package.json | 1 + src/app/app.config.ts | 76 +- src/app/app.routes.ts | 7 +- .../markdown-reader.component.ts | 10 +- .../game/desktop/desktop.component.ts | 11 +- .../services/application-manager.service.ts | 99 ++- .../components/game/services/log.service.ts | 24 +- .../game/services/settings.service.ts | 1 + .../components/game/services/sound.service.ts | 31 +- .../background-example.component.ts | 277 +++++++ .../background.service.ts | 211 +++++ .../appearance-settings.component.ts | 13 +- .../system-tray/system-tray.component.ts | 2 +- .../app-window/app-window.component.ts | 8 +- src/app/components/main/main.component.ts | 1 - src/app/modules/chat/chat.component.ts | 769 ++++++++++++++++++ src/app/modules/chat/chat.module.ts | 20 + src/app/modules/chat/chat.service.ts | 239 ++++++ src/app/providers/sound/sound.module.ts | 35 + src/environments/environment.ts | 17 +- src/environments/environments.example.ts | 1 + 22 files changed, 1788 insertions(+), 66 deletions(-) create mode 100644 src/app/components/game/system/full-screen-background/background-example.component.ts create mode 100644 src/app/components/game/system/full-screen-background/background.service.ts create mode 100644 src/app/modules/chat/chat.component.ts create mode 100644 src/app/modules/chat/chat.module.ts create mode 100644 src/app/modules/chat/chat.service.ts create mode 100644 src/app/providers/sound/sound.module.ts diff --git a/package-lock.json b/package-lock.json index e64e043..7f2a13e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@fortawesome/free-solid-svg-icons": "^6.7.1", "chart.js": "^4.4.9", "dayjs": "^1.11.13", + "firebase": "^11.8.1", "marked": "^15.0.11", "ng2-charts": "^8.0.0", "ngx-markdown": "^19.1.1", diff --git a/package.json b/package.json index 41955a1..c791884 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@fortawesome/free-solid-svg-icons": "^6.7.1", "chart.js": "^4.4.9", "dayjs": "^1.11.13", + "firebase": "^11.8.1", "marked": "^15.0.11", "ng2-charts": "^8.0.0", "ngx-markdown": "^19.1.1", diff --git a/src/app/app.config.ts b/src/app/app.config.ts index dbcd16b..628339d 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,28 +1,92 @@ import {ApplicationConfig, provideZoneChangeDetection} from '@angular/core'; import {provideRouter, withHashLocation} from '@angular/router'; -import { routes } from './app.routes'; +import {routes} from './app.routes'; import {provideHttpClient} from '@angular/common/http'; import {provideMarkdown} from 'ngx-markdown'; import {provideAnimations} from '@angular/platform-browser/animations'; -import {defaultSoundConfig, SOUND_SERVICE_CONFIG} from './components/game/services/sound.service'; import {initializeApp, provideFirebaseApp} from '@angular/fire/app'; import {environment} from '../environments/environment'; import {getAuth, provideAuth} from '@angular/fire/auth'; +import {getFirestore, provideFirestore} from '@angular/fire/firestore'; +import {getStorage, provideStorage} from '@angular/fire/storage'; +import { + SOUND_SERVICE_CONFIG, + SoundServiceConfig +} from './providers/sound/sound.module'; +import {getDatabase, provideDatabase} from '@angular/fire/database'; +export const defaultSoundConfig: SoundServiceConfig = { + debounceInterval: 60, + maxCacheSize: 20, + defaultVolume: 1.0, + basePath: 'assets/audio/efx/' +}; export const appConfig: ApplicationConfig = { providers: [ + provideHttpClient(), - provideZoneChangeDetection({ eventCoalescing: true }), + provideZoneChangeDetection({eventCoalescing: true}), provideRouter(routes, withHashLocation()), - provideFirebaseApp(() => initializeApp(environment.firebaseConfig)), - provideAuth(() => getAuth()), provideMarkdown(), provideAnimations(), { provide: SOUND_SERVICE_CONFIG, useValue: defaultSoundConfig - } + }, + provideFirebaseApp(() => { + try { + const app = initializeApp(environment.firebaseConfig); + console.log('Firebase app initialized successfully'); + return app; + } catch (error) { + console.error('Error initializing Firebase app:', error); + throw error; + } + }), + provideAuth(() => { + try { + const auth = getAuth(); + console.log('Auth initialized successfully'); + return auth; + } catch (error) { + console.error('Error initializing Auth:', error); + throw error; + } + }), + provideDatabase(() => { + try { + const app = initializeApp(environment.firebaseConfig); + const db = getDatabase(app); // Pass the app instance explicitly + console.log('Database initialized successfully'); + return db; + } catch (error) { + console.error('Error initializing Database:', error); + throw error; + } + + }), + provideFirestore(() => { + try { + const firestore = getFirestore(); + console.log('Firestore initialized successfully'); + return firestore; + } catch (error) { + console.error('Error initializing Firestore:', error); + throw error; + } + }), + provideStorage(() => { + try { + const storage = getStorage(); + console.log('Storage initialized successfully'); + return storage; + } catch (error) { + console.error('Error initializing Storage:', error); + throw error; + } + }) + ] }; diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 0e8082e..3da126a 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -11,12 +11,17 @@ export const PATH_NAMES = { OS_LOGIN: 'login', OS_BOOT: 'boot', OS_EXTERNAL: 'external', - OS_SLEEP: 'sleep' + OS_SLEEP: 'sleep', + FS_BACKGROUND: 'background', } export const routes: Routes = [ { path: '', loadComponent: () => import('./components/main/main.component').then(m => m.MainComponent) }, + { + path: 'background', + loadComponent: () => import('./components/game/system/full-screen-background/background-example.component').then(m => m.BackgroundExampleComponent) + }, { path: PATH_NAMES.OS_MAIN, pathMatch: 'full', diff --git a/src/app/components/game/apps/markdown-reader/markdown-reader.component.ts b/src/app/components/game/apps/markdown-reader/markdown-reader.component.ts index 2bcff67..3a801c0 100644 --- a/src/app/components/game/apps/markdown-reader/markdown-reader.component.ts +++ b/src/app/components/game/apps/markdown-reader/markdown-reader.component.ts @@ -10,11 +10,11 @@ import {ApplicationManagerService} from '../../services/application-manager.serv encapsulation: ViewEncapsulation.None, styles: [ `.markdown-body { - @apply prose text-xs leading-5 mx-auto; + @apply prose text-xs leading-5 mx-auto ; }`, ], template: ` -
+
@@ -26,6 +26,8 @@ export class MarkdownReaderComponent { private _filename: string = 'gameplay.doc.md'; + @Input() params: any; + @Input() set filename(value: string) { this._filename = value || 'gameplay.doc.md'; @@ -38,10 +40,12 @@ export class MarkdownReaderComponent { constructor(private readonly appManager: ApplicationManagerService) { - this.document = this.docsPath + this._filename; + const currentApp = this.appManager.getCurrentApp(); this.filename = currentApp?.params?.file; + console.warn('FILE', this.filename); + this.document = this.docsPath + this.filename; } diff --git a/src/app/components/game/desktop/desktop.component.ts b/src/app/components/game/desktop/desktop.component.ts index 085e236..70771b3 100644 --- a/src/app/components/game/desktop/desktop.component.ts +++ b/src/app/components/game/desktop/desktop.component.ts @@ -1,4 +1,4 @@ -import {Component, DestroyRef, OnInit} from '@angular/core'; +import {AfterViewInit, Component, DestroyRef, OnInit} from '@angular/core'; import {NgForOf} from "@angular/common"; import {LevelLoaderComponent} from '../utils/level-loader/level-loader.component'; import {AppWindowComponent} from '../templates/app-window/app-window.component'; @@ -43,7 +43,7 @@ import {LogService} from '../services/log.service'; templateUrl: './desktop.component.html', styles: `` }) -export class DesktopComponent implements OnInit { +export class DesktopComponent implements OnInit, AfterViewInit { showIntro = false; overlayImagePath = 'assets/images/overlays/cracked_corner.webp'; backgroundImage = 'assets/images/backgrounds/night.webp'; @@ -71,6 +71,10 @@ export class DesktopComponent implements OnInit { }) } + ngAfterViewInit() { + this.onBeginInvestigation(); + } + openApp(id: string, params?: any) { this.appManager.openApplication(id, params); } @@ -120,9 +124,10 @@ export class DesktopComponent implements OnInit { onBeginInvestigation() { this.showIntro = false; + this.appManager.openApplication('cli'); this.showNotificationUpdates(); if (!this.userService.user.name) { - this.soundService.play('glitch-1.mp3', {volume: 0.1, forceRestart: true}); + this.soundService.play('glitch-1.mp3', {volume: 0.3, forceRestart: true}); this.typewriter.enqueueLine({ text: '> who_are_you?', agent: 'system', diff --git a/src/app/components/game/services/application-manager.service.ts b/src/app/components/game/services/application-manager.service.ts index 37f3cb1..98471c1 100644 --- a/src/app/components/game/services/application-manager.service.ts +++ b/src/app/components/game/services/application-manager.service.ts @@ -5,7 +5,7 @@ import { faChartSimple, faCircleInfo, faCloudSunRain, faCogs, faComputer, - faExclamationTriangle, faHexagonNodesBolt, faIcons, faKeyboard, faMusic, faNoteSticky, + faExclamationTriangle, faHexagonNodesBolt, faIcons, faKeyboard, faMessage, faMusic, faNoteSticky, faPerson, faRocket } from '@fortawesome/free-solid-svg-icons'; import {faFaceGrin} from '@fortawesome/free-regular-svg-icons'; @@ -31,6 +31,8 @@ import {LogService} from './log.service'; import {PianoComponent} from '../apps/music-apps/piano/piano.component'; import {PatchEditorComponent} from '../apps/music-apps/patch-editor/patch-editor.component'; import {WeatherComponent} from '../apps/weather/weather.component'; +import {MessagesComponent} from '../apps/messages/messages.component'; +import {ChatBotComponent} from '../../../modules/chat/chat.component'; export interface ApplicationInstance extends AppEntry { id: string; @@ -77,6 +79,7 @@ export interface AppEntry { license?: string; website?: string; } + status?: 'development' | 'stable' | 'deprecated' | 'obsolete' autofit?: boolean; windowSize?: { width?: number; @@ -123,6 +126,8 @@ export enum APP_ID { space_x_app = 'space-x-app', icon_playground = 'icon-playground', weather_app = 'weather-app', + messages_app = 'messages-app', + chat_bot = 'chat-bot', } @Injectable({providedIn: 'root'}) @@ -216,7 +221,14 @@ export class ApplicationManagerService { memory: 512, maxInstances: 1, type: AppType.app, - instanceIndex: 0 + instanceIndex: 0, + status: 'development', + metadata: { + version: '0.0.1', + author: '', + license: 'MIT', + website: 'https://github.com/colinmichaels' + } }); this.registerApp({ @@ -233,7 +245,14 @@ export class ApplicationManagerService { memory: 512, maxInstances: 1, type: AppType.app, - instanceIndex: 0 + instanceIndex: 0, + status: 'development', + metadata: { + version: '0.0.1', + author: '', + license: 'MIT', + website: 'https://github.com/colinmichaels' + } }); this.registerApp({ @@ -250,7 +269,14 @@ export class ApplicationManagerService { memory: 512, maxInstances: 1, type: AppType.app, - instanceIndex: 0 + instanceIndex: 0, + status: 'development', + metadata: { + version: '0.0.1', + author: '', + license: 'MIT', + website: 'https://github.com/colinmichaels' + } }); this.registerApp({ @@ -267,7 +293,14 @@ export class ApplicationManagerService { memory: 512, maxInstances: 1, type: AppType.app, - instanceIndex: 0 + instanceIndex: 0, + status: 'development', + metadata: { + version: '0.0.1', + author: '', + license: 'MIT', + website: 'https://github.com/colinmichaels' + } }); @@ -276,7 +309,7 @@ export class ApplicationManagerService { title: 'Space X Launches', component: SpaceXComponent, installed: true, - windowSize: {height: 400, width: 200}, + windowSize: {height: 800, width: 600}, autofit: false, icon: { class: 'text-white p-1 rounded-lg border-2 border-zinc-700', @@ -285,6 +318,47 @@ export class ApplicationManagerService { memory: 512, maxInstances: 1, type: AppType.app, + instanceIndex: 0, + status: 'development', + metadata: { + version: '0.0.1', + author: '', + license: 'MIT', + website: 'https://github.com/colinmichaels' + } + }); + + this.registerApp({ + id: APP_ID.messages_app, + title: 'Messages', + component: MessagesComponent, + installed: true, + windowSize: {height: 800, width: 600}, + autofit: false, + icon: { + class: 'text-white p-1 rounded-lg border-2 border-zinc-700', + svgPath: faMessage + }, + memory: 512, + maxInstances: 1, + type: AppType.app, + instanceIndex: 0 + }); + + this.registerApp({ + id: APP_ID.chat_bot, + title: 'Chat', + component: ChatBotComponent, + installed: true, + windowSize: {height: 800, width: 600}, + autofit: false, + icon: { + class: 'text-white p-1 rounded-lg border-2 border-zinc-700', + svgPath: faMessage + }, + memory: 512, + maxInstances: 1, + type: AppType.app, instanceIndex: 0 }); @@ -298,9 +372,9 @@ export class ApplicationManagerService { svgPath: faCogs }, memory: 512, - maxInstances: 1, + maxInstances: 10, type: AppType.system, - params: {file: 'cipher.md'}, + params: {file: 'colinos-demo.doc.md'}, instanceIndex: 0 }); @@ -316,7 +390,14 @@ export class ApplicationManagerService { memory: 512, maxInstances: 1, type: AppType.app, - instanceIndex: 0 + instanceIndex: 0, + status: 'development', + metadata: { + version: '0.0.1', + author: '', + license: 'MIT', + website: 'https://github.com/colinmichaels' + } }); this.registerApp({ diff --git a/src/app/components/game/services/log.service.ts b/src/app/components/game/services/log.service.ts index 2329dbb..55a263c 100644 --- a/src/app/components/game/services/log.service.ts +++ b/src/app/components/game/services/log.service.ts @@ -1,5 +1,7 @@ -import {Injectable} from '@angular/core'; +import {inject, Injectable} from '@angular/core'; import {BehaviorSubject, Observable} from 'rxjs'; +import {FirestoreService} from '../../../services/firebase/firestore.service'; +import {user} from '@angular/fire/auth'; export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; @@ -15,6 +17,7 @@ export class LogService { private logSubject = new BehaviorSubject([]); private mutedLevels: Set = new Set(); private globalMute = false; + private firestore: FirestoreService = inject(FirestoreService); get logs(): LogEntry[] { return [...this.logBuffer]; @@ -87,6 +90,25 @@ export class LogService { if (this.globalMute || this.mutedLevels.has(level)) return; const entry: LogEntry = {level, message, timestamp: new Date()}; + + this.firestore.saveLogEntry( + entry.level, + { + level: entry.level, + message: typeof entry.message === 'string' ? entry.message : '', + userId: user.name, + metadata: 'log entry' + }, + 'test',// Use the user ID, not the user object + entry.timestamp.toISOString() + ).subscribe({ + next: () => { + // Successfully saved log to Firestore + }, + error: (err) => { + console.error('Failed to save log to Firestore:', err); + } + }); this.logBuffer.push(entry); this.logSubject.next([...this.logBuffer]); diff --git a/src/app/components/game/services/settings.service.ts b/src/app/components/game/services/settings.service.ts index 6471c93..4fa007a 100644 --- a/src/app/components/game/services/settings.service.ts +++ b/src/app/components/game/services/settings.service.ts @@ -264,6 +264,7 @@ export class SettingsService { syncFormGroupWithSettingSet(formGroup: FormGroup, setId: string): void { formGroup.valueChanges.subscribe((newValues) => { const settingSet = this.getSettingSet(setId); + console.warn('settingSet', settingSet?.value); if (!settingSet) { console.warn(`No settings set found with ID: "${setId}".`); return; diff --git a/src/app/components/game/services/sound.service.ts b/src/app/components/game/services/sound.service.ts index a69bc87..56ddb20 100644 --- a/src/app/components/game/services/sound.service.ts +++ b/src/app/components/game/services/sound.service.ts @@ -1,8 +1,9 @@ -import {Inject, Injectable, InjectionToken, OnDestroy, OnInit} from '@angular/core'; +import {Inject, Injectable, OnDestroy, OnInit} from '@angular/core'; import {BehaviorSubject} from 'rxjs'; import {SettingsService} from './settings.service'; import {LogService} from './log.service'; import {PatchService} from './patch.service'; +import {SOUND_SERVICE_CONFIG, SoundServiceConfig} from '../../../providers/sound/sound.module'; interface SoundOptions { loop?: boolean; @@ -11,22 +12,6 @@ interface SoundOptions { onEnded?: () => void; } -export interface SoundServiceConfig { - debounceInterval: number; - maxCacheSize: number; - defaultVolume: number; - basePath: string; -} - -export const SOUND_SERVICE_CONFIG = new InjectionToken('SOUND_SERVICE_CONFIG'); - -export const defaultSoundConfig: SoundServiceConfig = { - debounceInterval: 60, - maxCacheSize: 20, - defaultVolume: 1.0, - basePath: 'assets/audio/efx/' -}; - @Injectable({ providedIn: 'root' }) export class SoundService implements OnDestroy, OnInit { @@ -51,10 +36,10 @@ export class SoundService implements OnDestroy, OnInit { private debounceIntervalMs = 60; // Adjust as needed constructor( - @Inject(SOUND_SERVICE_CONFIG) private config: SoundServiceConfig, private settingsService: SettingsService, private readonly patchService: PatchService, - private readonly logger: LogService + private readonly logger: LogService, + @Inject(SOUND_SERVICE_CONFIG) private soundConfig: SoundServiceConfig ) { } @@ -146,7 +131,7 @@ export class SoundService implements OnDestroy, OnInit { const sanitizedName = this.sanitizeFileName(fileName); - const path = `${this.config.basePath}${sanitizedName}`; + const path = `${this.soundConfig.basePath}${sanitizedName}`; let audio = this.audioCache.get(path); @@ -185,7 +170,7 @@ export class SoundService implements OnDestroy, OnInit { stop(fileName: string) { - const path = this.config.basePath + fileName; + const path = this.soundConfig.basePath + fileName; const audio = this.audioCache.get(path); if (audio) { audio.pause(); @@ -195,7 +180,7 @@ export class SoundService implements OnDestroy, OnInit { } pause(fileName: string) { - const path = this.config.basePath + fileName; + const path = this.soundConfig.basePath + fileName; const audio = this.audioCache.get(path); if (audio) { audio.pause(); @@ -227,7 +212,7 @@ export class SoundService implements OnDestroy, OnInit { setVolume(fileName: string, volume: number) { - const path = this.config.basePath + fileName; + const path = this.soundConfig.basePath + fileName; const audio = this.audioCache.get(path); if (audio) { // Convert volume from 0-100 range to 0-1 range diff --git a/src/app/components/game/system/full-screen-background/background-example.component.ts b/src/app/components/game/system/full-screen-background/background-example.component.ts new file mode 100644 index 0000000..9150935 --- /dev/null +++ b/src/app/components/game/system/full-screen-background/background-example.component.ts @@ -0,0 +1,277 @@ +import {ChangeDetectorRef, Component} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {BackgroundConfig, FullScreenBackgroundComponent, ParallaxElement} from './full-screen-background.component'; +import {MainHeaderComponent} from '../../../main/main-header.component'; +import {SocialsComponent} from '../../../main/socials/socials.component'; +import {RouterLink} from '@angular/router'; + +@Component({ + selector: 'app-background-example', + standalone: true, + imports: [CommonModule, FullScreenBackgroundComponent, MainHeaderComponent, SocialsComponent, RouterLink], + template: ` +
+ +
+ +
+ + + +
+

+ Welcome to the Game. +

+ +
+

This should do something shouldn't it?

+
+ +
+
+
+
+
+
+
+ + +
+ +
+ +
+

More Content Below

+
+ +
+

More Content + Below

+

Just keep scrolling you know you want to

+
+
+
+
+
+
+
+
+
+ +
+

SOMETHING SHOULD BE SAID HERE

+
+ + @defer (on hover; prefetch on immediate) { +
+ + +
+ } @placeholder () { +
+

Hover to see effect!

+
+ } + +
+
+

YOU HAVE BEEN RICK ROLLED

+

Thanks for playing

+ +
+ + `, + styles: [` + .content-container { + position: relative; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + z-index: 10; + } + + .hero-title { + font-size: 4rem; + font-weight: bold; + color: white; + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5); + margin-bottom: 2rem; + text-align: center; + } + + .floating-card { + backdrop-filter: blur(10px); + border-radius: 1rem; + padding: 2rem; + margin: 2rem; + border: 1px solid rgba(255, 255, 255, 0.2); + } + + .background-shapes { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: -1; + } + + .shape { + position: absolute; + border-radius: 50%; + background: rgba(255, 255, 255, 0.1); + } + + .shape-1 { + width: 100px; + height: 100px; + top: 20%; + left: 10%; + } + + .shape-2 { + width: 150px; + height: 150px; + top: 60%; + right: 15%; + } + + .shape-3 { + width: 80px; + height: 80px; + top: 40%; + left: 70%; + } + + .additional-content { + min-height: 100vh; + padding: 4rem 2rem; + @apply flex flex-col items-center justify-center bg-zinc-600 text-gray-200; + } + + .video-section { + @apply flex flex-col items-center justify-center w-full min-h-screen bg-black text-gray-300 font-mono; + } + + .additional-content h2 { + font-size: 2rem; + margin-bottom: 1rem; + } + `] +}) +export class BackgroundExampleComponent { + // Video background configuration + isDark = false; + imageSrc = 'assets/images/backgrounds/night.webp'; + + backgroundConfig: BackgroundConfig = { + type: 'image', + source: this.imageSrc, + fallbackImage: this.imageSrc, + opacity: 0.8, + blur: 6, + overlay: { + color: '#000000', + opacity: 0.3 + } + }; + + // Alternative configurations you can switch between + imageBackgroundConfig: BackgroundConfig = { + type: 'image', + source: this.imageSrc, + opacity: 1, + blur: 2, + overlay: { + color: '#4f46e5', + opacity: 0.4 + } + }; + + gradientBackgroundConfig: BackgroundConfig = { + type: 'gradient', + gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', + opacity: 1 + }; + + // Parallax elements configuration + parallaxElements: ParallaxElement[] = [ + { + id: 'main-title', + speed: 0.3, + direction: 'vertical', + initialOffset: {x: 0, y: 0} + }, + { + id: 'floating-element', + speed: 0.5, + direction: 'vertical', + initialOffset: {x: 0, y: 0} + }, + { + id: 'background-shapes', + speed: 0.2, + direction: 'both', + initialOffset: {x: 0, y: 0} + } + ]; + + constructor(private cd: ChangeDetectorRef) { + + } + + // Method to switch background types + switchToVideo(): void { + this.backgroundConfig = { + type: 'video', + source: 'assets/videos/background-video.mp4', + fallbackImage: 'assets/images/fallback-bg.jpg', + opacity: 0.8, + overlay: { + color: '#000000', + opacity: 0.3 + } + }; + } + + toggleBackground(): void { + + console.warn('TOGGLE BACKGROUND'); + this.isDark = !this.isDark; + if (this.isDark) { + this.imageSrc = 'assets/images/backgrounds/night.webp'; + } else { + this.imageSrc = 'assets/images/backgrounds/day.webp'; + } + this.cd.detectChanges(); + } + + switchToImage(): void { + this.backgroundConfig = this.imageBackgroundConfig; + } + + switchToGradient(): void { + this.backgroundConfig = this.gradientBackgroundConfig; + } +} diff --git a/src/app/components/game/system/full-screen-background/background.service.ts b/src/app/components/game/system/full-screen-background/background.service.ts new file mode 100644 index 0000000..ee0db53 --- /dev/null +++ b/src/app/components/game/system/full-screen-background/background.service.ts @@ -0,0 +1,211 @@ +import {Injectable, signal} from '@angular/core'; +import {BackgroundConfig, ParallaxElement, VideoProvider} from './full-screen-background.component'; + +export interface BackgroundPreset { + id: string; + name: string; + config: BackgroundConfig; + parallaxElements?: ParallaxElement[]; +} + +@Injectable({ + providedIn: 'root' +}) +export class BackgroundService { + private currentConfig = signal({type: 'solid', color: '#000000'}); + private currentParallaxElements = signal([]); + + private presets: BackgroundPreset[] = [ + { + id: 'youtube-hero', + name: 'YouTube Hero Video', + config: { + type: 'video', + videoProvider: { + type: 'youtube', + videoId: 'dQw4w9WgXcQ', // Example video ID + autoplay: true, + muted: true, + loop: true, + controls: false, + quality: 'hd720' + }, + fallbackImage: 'assets/images/youtube-fallback.jpg', + opacity: 0.8, + overlay: {color: '#000000', opacity: 0.3} + } + }, + { + id: 'vimeo-cinematic', + name: 'Vimeo Cinematic', + config: { + type: 'video', + videoProvider: { + type: 'vimeo', + videoId: '148751763', // Example video ID + autoplay: true, + muted: true, + loop: true, + controls: false + }, + fallbackImage: 'assets/images/vimeo-fallback.jpg', + opacity: 1, + overlay: {color: '#1a1a1a', opacity: 0.4} + } + } + ]; + + getCurrentConfig() { + return this.currentConfig.asReadonly(); + } + + getCurrentParallaxElements() { + return this.currentParallaxElements.asReadonly(); + } + + getPresets(): BackgroundPreset[] { + return [...this.presets]; + } + + // YouTube URL parsing utilities + extractYouTubeVideoId(url: string): string | null { + const patterns = [ + /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/, + /youtube\.com\/v\/([^&\n?#]+)/ + ]; + + for (const pattern of patterns) { + const match = url.match(pattern); + if (match) { + return match[1]; + } + } + return null; + } + + // Vimeo URL parsing utilities + extractVimeoVideoId(url: string): string | null { + const patterns = [ + /vimeo\.com\/(\d+)/, + /player\.vimeo\.com\/video\/(\d+)/ + ]; + + for (const pattern of patterns) { + const match = url.match(pattern); + if (match) { + return match[1]; + } + } + return null; + } + + createYouTubeConfigFromUrl( + url: string, + options?: { + videoProvider?: Partial>; + background?: Partial>; + } + ): BackgroundConfig | null { + return this.createVideoConfigFromProviderUrl(url, 'youtube', options); + } + + createVimeoConfigFromUrl( + url: string, + options?: { + videoProvider?: Partial>; + background?: Partial>; + } + ): BackgroundConfig | null { + return this.createVideoConfigFromProviderUrl(url, 'vimeo', options); + } + + + createVideoConfigFromProviderUrl( + url: string, + providerType: 'youtube' | 'vimeo', + options?: { + videoProvider?: Partial>; + background?: Partial>; + } + ): BackgroundConfig | null { + // Extract video ID based on provider type + const videoId = providerType === 'youtube' + ? this.extractYouTubeVideoId(url) + : this.extractVimeoVideoId(url); + + if (!videoId) return null; + + return { + type: 'video', + videoProvider: { + type: providerType, + videoId, + autoplay: true, + muted: true, + loop: true, + controls: false, + ...options?.videoProvider + }, + opacity: 1, + ...options?.background + }; + } + + + private isDirectVideoUrl(url: string): boolean { + const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov', '.avi']; + const lowercaseUrl = url.toLowerCase(); + return videoExtensions.some(ext => lowercaseUrl.includes(ext)); + } + + // Existing methods... + setConfig(config: BackgroundConfig): void { + this.currentConfig.set(config); + } + + setParallaxElements(elements: ParallaxElement[]): void { + this.currentParallaxElements.set(elements); + } + + applyPreset(presetId: string): boolean { + const preset = this.getPreset(presetId); + if (preset) { + this.currentConfig.set(preset.config); + if (preset.parallaxElements) { + this.currentParallaxElements.set(preset.parallaxElements); + } + return true; + } + return false; + } + + getPreset(id: string): BackgroundPreset | undefined { + return this.presets.find(preset => preset.id === id); + } + + addPreset(preset: BackgroundPreset): void { + const existingIndex = this.presets.findIndex(p => p.id === preset.id); + if (existingIndex > -1) { + this.presets[existingIndex] = preset; + } else { + this.presets.push(preset); + } + } + + // YouTube-specific utilities + getYouTubeThumbnail(videoId: string, quality: 'default' | 'medium' | 'high' | 'standard' | 'maxres' = 'high'): string { + return `https://img.youtube.com/vi/${videoId}/${quality}default.jpg`; + } + + // Vimeo-specific utilities + async getVimeoThumbnail(videoId: string): Promise { + try { + const response = await fetch(`https://vimeo.com/api/v2/video/${videoId}.json`); + const data = await response.json(); + return data[0]?.thumbnail_large || null; + } catch (error) { + console.error('Failed to fetch Vimeo thumbnail:', error); + return null; + } + } +} diff --git a/src/app/components/game/system/settings-panel/panels/appearance-settings/appearance-settings.component.ts b/src/app/components/game/system/settings-panel/panels/appearance-settings/appearance-settings.component.ts index d809e95..bde473e 100644 --- a/src/app/components/game/system/settings-panel/panels/appearance-settings/appearance-settings.component.ts +++ b/src/app/components/game/system/settings-panel/panels/appearance-settings/appearance-settings.component.ts @@ -20,16 +20,12 @@ export type ThemeOption = 'light' | 'dark' | 'system'; -
@@ -78,17 +74,12 @@ export class AppearanceSettingsComponent implements OnInit { if (formGroup) { this.formGroup = formGroup; this.settingKeys = Object.keys(this.formGroup.controls); + console.warn('Form group created:', this.formGroup.value, this.settingKeys, this.settingsSetId); this.settingsService.syncFormGroupWithSettingSet(this.formGroup, this.settingsSetId); } } - getThemeClass(theme: string){ - if(!this.formGroup) return ''; - const themeControl = this.formGroup.get('theme'); - return (themeControl && themeControl.value === theme) ? 'border-2 border-blue-500 opacity-100 grayscale-0' : 'opacity-30 grayscale' - } - setTheme(theme: ThemeOption): void { this.settingsService.updateSettingSetWithSingleValue(this.settingsSetId,'theme', theme); } diff --git a/src/app/components/game/system/system-tray/system-tray.component.ts b/src/app/components/game/system/system-tray/system-tray.component.ts index b1f8158..319f7d6 100644 --- a/src/app/components/game/system/system-tray/system-tray.component.ts +++ b/src/app/components/game/system/system-tray/system-tray.component.ts @@ -47,7 +47,7 @@ import {SoundPlayerComponent} from '../sound-player/sound-player.component'; export class SystemTrayComponent { isVisible = signal(true); cursorY = signal(1000); - hoverThreshold = 20; + hoverThreshold = 40; autoHide = signal(false); isHoveringMenu = signal(false); menuOpen = signal(''); diff --git a/src/app/components/game/templates/app-window/app-window.component.ts b/src/app/components/game/templates/app-window/app-window.component.ts index 1b6167c..ee52475 100644 --- a/src/app/components/game/templates/app-window/app-window.component.ts +++ b/src/app/components/game/templates/app-window/app-window.component.ts @@ -6,7 +6,7 @@ import { Input, AfterViewInit, ViewContainerRef, - Type, computed, OnChanges, OnDestroy + Type, computed, OnChanges, OnDestroy, ComponentRef } from '@angular/core'; import {CommonModule} from '@angular/common'; import {CliGameComponent} from '../../apps/cli-game/cli-game.component'; @@ -72,6 +72,8 @@ export class AppWindowComponent implements AfterViewInit, OnChanges, OnDestroy { @Input() focused: boolean = false; @Input() params: any; + private componentRef?: ComponentRef; + /** Font Awesome Icons */ faTimes = faTimes; faMinus = faMinus; @@ -108,7 +110,6 @@ export class AppWindowComponent implements AfterViewInit, OnChanges, OnDestroy { } ngOnChanges(changes: any) { - console.warn('AppWindowComponent: ngOnChanges', changes); if(changes.id){ this.focused = this.embeddedApp()?.id === changes.id.currentValue; } @@ -161,7 +162,8 @@ export class AppWindowComponent implements AfterViewInit, OnChanges, OnDestroy { private loadEmbeddedComponent(): void { if (this.embeddedComponent) { this.containerRef.clear(); - this.containerRef.createComponent(this.embeddedComponent); + this.componentRef = this.containerRef.createComponent(this.embeddedComponent); + this.componentRef.instance.params = this.params; } } diff --git a/src/app/components/main/main.component.ts b/src/app/components/main/main.component.ts index 6849014..17a9cf0 100644 --- a/src/app/components/main/main.component.ts +++ b/src/app/components/main/main.component.ts @@ -19,7 +19,6 @@ import {ProjectsOverviewComponent} from './projects-overview/projects-overview.c import {ProjectItemComponent} from './project-item/project-item.component'; import {DisclaimerComponent} from './disclaimer/disclaimer.component'; - export const HOME_NOTIFY_CLASSES = 'bg-black/80 text-green-500 border-2 border-green-500'; @Component({ diff --git a/src/app/modules/chat/chat.component.ts b/src/app/modules/chat/chat.component.ts new file mode 100644 index 0000000..aed0ed9 --- /dev/null +++ b/src/app/modules/chat/chat.component.ts @@ -0,0 +1,769 @@ +import {Component, ElementRef, ViewChild, signal, computed} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {FormsModule} from '@angular/forms'; +import {FontAwesomeModule} from '@fortawesome/angular-fontawesome'; +import { + faImage, + faPaperPlane, + faSmile, + faPlus, + faPhone, + faVideo, + faInfo, + faUsers +} from '@fortawesome/free-solid-svg-icons'; +import {RealtimeDbService} from '../../services/firebase/realtime-db.service'; + +export interface Message { + id: string; + text: string; + senderId: string; + senderName: string; + timestamp: Date; + isOwn: boolean; + image?: string; + reactions?: { emoji: string; count: number; users: string[] }[]; +} + +export interface Chat { + id: string; + name: string; + participants: string[]; + lastMessage?: Message; + unreadCount: number; + isGroup: boolean; + avatar?: string; + isOnline?: boolean; +} + +export interface User { + id: string; + name: string; + avatar?: string; + isOnline: boolean; +} + +@Component({ + selector: 'app-chat-bot', + standalone: true, + imports: [CommonModule, FormsModule, FontAwesomeModule], + template: ` +
+ +
+ +
+
+

Messages

+ +
+
+ + +
+
+
+
+
+ {{ chat.name.charAt(0).toUpperCase() }} + +
+
+
+
+ +
+
+

{{ chat.name }}

+ + {{ formatTime(chat.lastMessage?.timestamp) }} + +
+

+ {{ chat.lastMessage?.text || 'No messages yet' }} +

+
+ +
+ {{ chat.unreadCount }} +
+
+
+
+
+ + +
+ +
+
+
+
+
+ {{ selectedChat().name }} + +
+
+
+
+
+

{{ selectedChat().name }}

+

+ {{ selectedChat() ? selectedChat().participants.length : 0 }} participants +

+

+ Online +

+
+
+ +
+ + + + +
+
+
+ + +
+
+
+
+ + +
+ {{ message.senderName.charAt(0).toUpperCase() }} +
+ +
+ +
+ {{ message.senderName }} +
+ + +
+ + +
+ +
+ + +
{{ message.text }}
+ + +
+ + {{ reaction.emoji }} {{ reaction.count }} + +
+
+ + +
+ {{ formatMessageTime(message.timestamp) }} +
+
+
+ + +
+ +
+
+
+
+ + +
+
+ + + + + +
+ + + + + + +
+
+ +
+
+
+ + + +
+
+
+ + + +
+
+
💬
+

Select a conversation

+

Choose from your existing conversations or start a new one

+
+
+
+
+ + +
+
+

New Chat

+ +
+
+ + +
+ +
+ +
+ +
+ + +
+
+
+
+ + +
+
+ +
+
+ + +
+
+

Group Info

+ +
+
+
+ {{ selectedChat() ? selectedChat().name.charAt(0).toUpperCase() : '' }} +
+

{{ selectedChat().name }}

+

{{ selectedChat().participants.length }} participants

+
+ +
+
Participants
+
+
+
+ {{ participant.charAt(0).toUpperCase() }} +
+ {{ participant }} +
+
+
+ + +
+
+
+ `, + styles: [` + :host { + display: block; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; + font-size: 12px; + } + `] +}) +export class ChatBotComponent { + @ViewChild('messagesContainer') messagesContainer!: ElementRef; + @ViewChild('fileInput') fileInput!: ElementRef; + + // Icons + faImage = faImage; + faPaperPlane = faPaperPlane; + faSmile = faSmile; + faPlus = faPlus; + faPhone = faPhone; + faVideo = faVideo; + faInfo = faInfo; + faUsers = faUsers; + + // Signals + chats = signal([]); + selectedChat = signal({} as Chat); + messages = signal([]); + currentMessages = computed(() => { + const selected = this.selectedChat(); + if (!selected) return []; + if (selected.participants) { + return this.messages().filter(m => + selected.participants.includes(m.senderId) ?? m.senderId === 'current-user' + ); + } + + return this.messages().filter(m => m.senderId === selected.id); + + }); + + // Component state + newMessage = ''; + showEmojiPicker = false; + showNewChatModal = false; + showGroupInfo = false; + selectedImage: string | null = null; + newChatName = ''; + newChatIsGroup = false; + + // Emojis + emojis = ['😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂', '🙃', '😉', '😌', '😍', '🥰', '😘', '😗', '😙', '😚', '😋', '😛', '😝', '😜', '🤪', '🤨', '🧐', '🤓', '😎', '🤩', '🥳', '😏', '😒', '😞', '😔', '😟', '😕', '🙁', '☹️', '😣', '😖', '😫', '😩', '🥺', '😢', '😭', '😤', '😠', '😡', '🤬', '🤯', '😳', '🥵', '🥶', '😱', '😨', '😰', '😥', '😓']; + quickReactions = ['👍', '❤️', '😂', '😮', '😢', '😡']; + + constructor(private realTimeDb: RealtimeDbService) { + this.realTimeDb.create('users', { + id: 'current-user', + name: 'You', + avatar: 'https://avatars.dicebear.com/api/bottts/john-doe.svg', + isOnline: true + }); + this.initializeData(); + } + + initializeData() { + // Sample chats + const sampleChats: Chat[] = [ + { + id: '1', + name: 'John Doe', + participants: ['john-doe'], + unreadCount: 2, + isGroup: false, + isOnline: true, + lastMessage: { + id: '1', + text: 'Hey! How are you doing?', + senderId: 'john-doe', + senderName: 'John Doe', + timestamp: new Date(Date.now() - 300000), + isOwn: false + } + }, + { + id: '2', + name: 'Work Team', + participants: ['alice-smith', 'bob-wilson', 'carol-brown'], + unreadCount: 0, + isGroup: true, + lastMessage: { + id: '2', + text: 'Great job on the presentation!', + senderId: 'alice-smith', + senderName: 'Alice Smith', + timestamp: new Date(Date.now() - 3600000), + isOwn: false + } + }, + { + id: '3', + name: 'Sarah Johnson', + participants: ['sarah-johnson'], + unreadCount: 0, + isGroup: false, + isOnline: false, + lastMessage: { + id: '3', + text: 'Thanks for the help! 👍', + senderId: 'current-user', + senderName: 'You', + timestamp: new Date(Date.now() - 7200000), + isOwn: true + } + } + ]; + + sampleChats.forEach(chat => { + this.realTimeDb.create('chats', chat); + }) + + // Sample messages + const sampleMessages: Message[] = [ + { + id: '1', + text: 'Hey! How are you doing?', + senderId: 'john-doe', + senderName: 'John Doe', + timestamp: new Date(Date.now() - 300000), + isOwn: false + }, + { + id: '2', + text: 'I\'m doing great! Just working on some new projects.', + senderId: 'current-user', + senderName: 'You', + timestamp: new Date(Date.now() - 240000), + isOwn: true + }, + { + id: '3', + text: 'That sounds exciting! What kind of projects?', + senderId: 'john-doe', + senderName: 'John Doe', + timestamp: new Date(Date.now() - 180000), + isOwn: false + }, + { + id: '4', + text: 'Mainly web development stuff. Building some Angular components.', + senderId: 'current-user', + senderName: 'You', + timestamp: new Date(Date.now() - 120000), + isOwn: true + }, + { + id: '5', + text: 'Cool! I love Angular. Let me know if you need any help!', + senderId: 'john-doe', + senderName: 'John Doe', + timestamp: new Date(Date.now() - 60000), + isOwn: false, + reactions: [ + {emoji: '👍', count: 1, users: ['current-user']} + ] + } + ]; + + this.chats.set(sampleChats); + this.messages.set(sampleMessages); + } + + selectChat(chat: Chat) { + this.selectedChat.set(chat); + // Mark as read + chat.unreadCount = 0; + this.chats.update(chats => [...chats]); + + setTimeout(() => this.scrollToBottom(), 100); + } + + sendMessage() { + if (!this.newMessage.trim() || !this.selectedChat()) return; + + const message: Message = { + id: Date.now().toString(), + text: this.newMessage, + senderId: 'current-user', + senderName: 'You', + timestamp: new Date(), + isOwn: true + }; + + this.messages.update(messages => [...messages, message]); + + // Update last message in chat + const selected = this.selectedChat()!; + selected.lastMessage = message; + this.chats.update(chats => [...chats]); + + this.newMessage = ''; + this.showEmojiPicker = false; + + setTimeout(() => this.scrollToBottom(), 100); + + // Simulate a response + this.simulateResponse(); + } + + simulateResponse() { + setTimeout(() => { + const responses = [ + "That's interesting!", + "I see what you mean.", + "Great point!", + "Thanks for sharing that.", + "I agree!", + "That makes sense.", + "Good to know!", + "Absolutely!", + "I understand.", + "Thanks for the update!" + ]; + + const response: Message = { + id: Date.now().toString(), + text: responses[Math.floor(Math.random() * responses.length)], + senderId: this.selectedChat()!.participants[0], + senderName: this.selectedChat()!.name, + timestamp: new Date(), + isOwn: false + }; + + this.messages.update(messages => [...messages, response]); + + // Update last message in chat + const selected = this.selectedChat()!; + selected.lastMessage = response; + this.chats.update(chats => [...chats]); + + setTimeout(() => this.scrollToBottom(), 100); + }, 1000 + Math.random() * 2000); + } + + onKeyDown(event: KeyboardEvent) { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + this.sendMessage(); + } + } + + addEmoji(emoji: string) { + this.newMessage += emoji; + this.showEmojiPicker = false; + } + + onImageSelect(event: any) { + const file = event.target.files[0]; + if (!file || !this.selectedChat()) return; + + const reader = new FileReader(); + reader.onload = (e) => { + const imageUrl = e.target?.result as string; + + const message: Message = { + id: Date.now().toString(), + text: '', + senderId: 'current-user', + senderName: 'You', + timestamp: new Date(), + isOwn: true, + image: imageUrl + }; + + this.messages.update(messages => [...messages, message]); + + // Update last message in chat + const selected = this.selectedChat()!; + selected.lastMessage = {...message, text: '📷 Photo'}; + this.chats.update(chats => [...chats]); + + setTimeout(() => this.scrollToBottom(), 100); + this.simulateResponse(); + }; + + reader.readAsDataURL(file); + + // Reset file input + this.fileInput.nativeElement.value = ''; + } + + openImageModal(imageUrl: string) { + this.selectedImage = imageUrl; + } + + closeImageModal() { + this.selectedImage = null; + } + + addReaction(message: Message, emoji: string) { + if (!message.reactions) { + message.reactions = []; + } + + const existingReaction = message.reactions.find(r => r.emoji === emoji); + if (existingReaction) { + const userIndex = existingReaction.users.indexOf('current-user'); + if (userIndex > -1) { + existingReaction.users.splice(userIndex, 1); + existingReaction.count--; + if (existingReaction.count === 0) { + message.reactions = message.reactions.filter(r => r.emoji !== emoji); + } + } else { + existingReaction.users.push('current-user'); + existingReaction.count++; + } + } else { + message.reactions.push({ + emoji, + count: 1, + users: ['current-user'] + }); + } + + this.messages.update(messages => [...messages]); + } + + toggleReaction(message: Message, emoji: string) { + this.addReaction(message, emoji); + } + + createNewChat() { + if (!this.newChatName.trim()) return; + + const newChat: Chat = { + id: Date.now().toString(), + name: this.newChatName, + participants: [this.newChatName.toLowerCase().replace(/\s+/g, '-')], + unreadCount: 0, + isGroup: this.newChatIsGroup, + isOnline: !this.newChatIsGroup && Math.random() > 0.5 + }; + + this.chats.update(chats => [newChat, ...chats]); + this.selectChat(newChat); + this.cancelNewChat(); + } + + cancelNewChat() { + this.showNewChatModal = false; + this.newChatName = ''; + this.newChatIsGroup = false; + } + + formatTime(date?: Date): string { + if (!date) return ''; + + const now = new Date(); + const diff = now.getTime() - date.getTime(); + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + + if (minutes < 1) return 'now'; + if (minutes < 60) return `${minutes}m`; + if (hours < 24) return `${hours}h`; + if (days < 7) return `${days}d`; + + return date.toLocaleDateString(); + } + + formatMessageTime(date: Date): string { + return date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true + }); + } + + private scrollToBottom() { + if (this.messagesContainer) { + const element = this.messagesContainer.nativeElement; + element.scrollTop = element.scrollHeight; + } + } +} diff --git a/src/app/modules/chat/chat.module.ts b/src/app/modules/chat/chat.module.ts new file mode 100644 index 0000000..860f508 --- /dev/null +++ b/src/app/modules/chat/chat.module.ts @@ -0,0 +1,20 @@ +import {NgModule} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {FormsModule} from '@angular/forms'; +import {FontAwesomeModule} from '@fortawesome/angular-fontawesome'; +import {ChatBotComponent} from './chat.component'; + +@NgModule({ + declarations: [], + imports: [ + CommonModule, + FormsModule, + FontAwesomeModule, + ChatBotComponent + ], + exports: [ + ChatBotComponent + ] +}) +export class ChatModule { +} diff --git a/src/app/modules/chat/chat.service.ts b/src/app/modules/chat/chat.service.ts new file mode 100644 index 0000000..2a775c4 --- /dev/null +++ b/src/app/modules/chat/chat.service.ts @@ -0,0 +1,239 @@ +import {Injectable} from '@angular/core'; +import {BehaviorSubject, Observable} from 'rxjs'; + +export interface Message { + id: string; + text: string; + senderId: string; + senderName: string; + timestamp: Date; + isOwn: boolean; + image?: string; + reactions?: { emoji: string; count: number; users: string[] }[]; +} + +export interface Chat { + id: string; + name: string; + participants: string[]; + lastMessage?: Message; + unreadCount: number; + isGroup: boolean; + avatar?: string; + isOnline?: boolean; +} + +export interface User { + id: string; + name: string; + avatar?: string; + isOnline: boolean; +} + +@Injectable({ + providedIn: 'root' +}) +export class ChatBotService { + private chatsSubject = new BehaviorSubject([]); + private messagesSubject = new BehaviorSubject([]); + private currentUserSubject = new BehaviorSubject({ + id: 'current-user', + name: 'You', + isOnline: true + }); + + chats$ = this.chatsSubject.asObservable(); + messages$ = this.messagesSubject.asObservable(); + currentUser$ = this.currentUserSubject.asObservable(); + + constructor() { + this.initializeData(); + } + + private initializeData() { + // Initialize with sample data + const sampleChats: Chat[] = [ + { + id: '1', + name: 'John Doe', + participants: ['john-doe'], + unreadCount: 2, + isGroup: false, + isOnline: true, + lastMessage: { + id: '1', + text: 'Hey! How are you doing?', + senderId: 'john-doe', + senderName: 'John Doe', + timestamp: new Date(Date.now() - 300000), + isOwn: false + } + }, + { + id: '2', + name: 'Work Team', + participants: ['alice-smith', 'bob-wilson', 'carol-brown'], + unreadCount: 0, + isGroup: true, + lastMessage: { + id: '2', + text: 'Great job on the presentation!', + senderId: 'alice-smith', + senderName: 'Alice Smith', + timestamp: new Date(Date.now() - 3600000), + isOwn: false + } + }, + { + id: '3', + name: 'Sarah Johnson', + participants: ['sarah-johnson'], + unreadCount: 0, + isGroup: false, + isOnline: false, + lastMessage: { + id: '3', + text: 'Thanks for the help! 👍', + senderId: 'current-user', + senderName: 'You', + timestamp: new Date(Date.now() - 7200000), + isOwn: true + } + } + ]; + + const sampleMessages: Message[] = [ + { + id: '1', + text: 'Hey! How are you doing?', + senderId: 'john-doe', + senderName: 'John Doe', + timestamp: new Date(Date.now() - 300000), + isOwn: false + }, + { + id: '2', + text: 'I\'m doing great! Just working on some new projects.', + senderId: 'current-user', + senderName: 'You', + timestamp: new Date(Date.now() - 240000), + isOwn: true + }, + { + id: '3', + text: 'That sounds exciting! What kind of projects?', + senderId: 'john-doe', + senderName: 'John Doe', + timestamp: new Date(Date.now() - 180000), + isOwn: false + }, + { + id: '4', + text: 'Mainly web development stuff. Building some Angular components.', + senderId: 'current-user', + senderName: 'You', + timestamp: new Date(Date.now() - 120000), + isOwn: true + }, + { + id: '5', + text: 'Cool! I love Angular. Let me know if you need any help!', + senderId: 'john-doe', + senderName: 'John Doe', + timestamp: new Date(Date.now() - 60000), + isOwn: false, + reactions: [ + {emoji: '👍', count: 1, users: ['current-user']} + ] + } + ]; + + this.chatsSubject.next(sampleChats); + this.messagesSubject.next(sampleMessages); + } + + getChats(): Chat[] { + return this.chatsSubject.value; + } + + getMessages(): Message[] { + return this.messagesSubject.value; + } + + getMessagesForChat(chatId: string): Message[] { + const chat = this.chatsSubject.value.find(c => c.id === chatId); + if (!chat) return []; + + return this.messagesSubject.value.filter(m => + chat.participants.includes(m.senderId) || m.senderId === 'current-user' + ); + } + + addMessage(message: Message) { + const messages = this.messagesSubject.value; + this.messagesSubject.next([...messages, message]); + } + + addChat(chat: Chat) { + const chats = this.chatsSubject.value; + this.chatsSubject.next([chat, ...chats]); + } + + updateChat(chatId: string, updates: Partial) { + const chats = this.chatsSubject.value.map(chat => + chat.id === chatId ? {...chat, ...updates} : chat + ); + this.chatsSubject.next(chats); + } + + addReactionToMessage(messageId: string, emoji: string, userId: string) { + const messages = this.messagesSubject.value.map(message => { + if (message.id === messageId) { + if (!message.reactions) { + message.reactions = []; + } + + const existingReaction = message.reactions.find(r => r.emoji === emoji); + if (existingReaction) { + const userIndex = existingReaction.users.indexOf(userId); + if (userIndex > -1) { + existingReaction.users.splice(userIndex, 1); + existingReaction.count--; + if (existingReaction.count === 0) { + message.reactions = message.reactions.filter(r => r.emoji !== emoji); + } + } else { + existingReaction.users.push(userId); + existingReaction.count++; + } + } else { + message.reactions.push({ + emoji, + count: 1, + users: [userId] + }); + } + } + return message; + }); + + this.messagesSubject.next(messages); + } + + markChatAsRead(chatId: string) { + this.updateChat(chatId, {unreadCount: 0}); + } + + simulateTyping(): Observable { + return new BehaviorSubject(false); + } + + // WebSocket or real-time connection methods would go here + connectToRealTime() { + // Implementation for real-time messaging + } + + disconnectFromRealTime() { + // Implementation for disconnecting + } +} diff --git a/src/app/providers/sound/sound.module.ts b/src/app/providers/sound/sound.module.ts new file mode 100644 index 0000000..d6d6b62 --- /dev/null +++ b/src/app/providers/sound/sound.module.ts @@ -0,0 +1,35 @@ +import {InjectionToken, Injector} from '@angular/core'; + +export const defaultSoundConfig: SoundServiceConfig = { + debounceInterval: 60, + maxCacheSize: 20, + defaultVolume: 1.0, + basePath: 'assets/audio/efx/' +}; + +export const SOUND_SERVICE_CONFIG = new InjectionToken('SOUND_SERVICE_CONFIG'); + +export interface SoundServiceConfig { + debounceInterval: number; + maxCacheSize: number; + defaultVolume: number; + basePath: string; +} + +type SoundModuleFactory = (injector: Injector) => SoundModule; + +type SoundModuleProvider = { + provide: InjectionToken; + useFactory: SoundModuleFactory; + deps?: any[]; +} + +export declare class SoundModule { + constructor(); + + playSound(sound: string): void; +} + +export declare function initializeSoundModule(config: SoundServiceConfig): SoundModule; + +export declare function provideSound(fn: () => SoundModule): SoundModuleProvider; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index c60803a..39f1388 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -1,8 +1,17 @@ export const environment = { production: false, - title: 'Colin Michaels', + title: 'Colin Michaels - LOCAL', apiUrl: 'http://localhost:3000', // your NestJS backend - openAiApiKey: '', - openWeatherMapApiKey: '', - firebaseConfig: {} + openAiApiKey: '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', + openWeatherMapApiKey: '94280521bd9339761dcf9ad73816cb64', + firebaseConfig: { + apiKey: "AIzaSyDxAfhvfwY2g3jLHHRPewj8NMgj9P0PP_Y", + authDomain: "colinmichaels.firebaseapp.com", + databaseURL: "https://colinmichaels-default-rtdb.firebaseio.com/", + projectId: "colinmichaels", + storageBucket: "colinmichaels.firebasestorage.app", + messagingSenderId: "695739708994", + appId: "1:695739708994:web:0a44e9b2d8d614b617dfdb", + measurementId: "G-MWYBVKZ7KE" + } }; diff --git a/src/environments/environments.example.ts b/src/environments/environments.example.ts index db831dc..a56af2a 100644 --- a/src/environments/environments.example.ts +++ b/src/environments/environments.example.ts @@ -8,6 +8,7 @@ export const environment = { apiKey: "", authDomain: "", projectId: "", + databaseURL: "", storageBucket: "", messagingSenderId: "", appId: "", From 481493fc5f32d85bafda93c0e15cc94922a4f118 Mon Sep 17 00:00:00 2001 From: Colin Michaels Date: Fri, 1 Aug 2025 23:40:19 -0400 Subject: [PATCH 05/14] ### Add FirestoreService and FullScreenBackgroundComponent for Firebase and UI enhancements - Implemented `FirestoreService` to streamline Firestore operations, including document CRUD operations, real-time updates, and file management in Firebase Storage. - Added `FullScreenBackgroundComponent` with support for video, image, gradient, and parallax background functionalities. Integrated video providers such as YouTube and Vimeo with customizable configurations. - Included unit tests for `FirestoreService` and `FullScreenBackgroundComponent` to ensure reliability and maintainability. - Enhanced modularity and reusability by following Angular's best practices and incorporating RxJS for handling asynchronous operations. **Signed-off-by:** Colin Michaels Signed-off-by: Colin Michaels --- .../apps/messages/messages.component.html | 1 + .../apps/messages/messages.component.spec.ts | 23 + .../game/apps/messages/messages.component.ts | 453 +++++++++++ .../full-screen-background.component.html | 1 + .../full-screen-background.component.spec.ts | 23 + .../full-screen-background.component.ts | 741 ++++++++++++++++++ .../firebase/firestore.service.spec.ts | 16 + .../services/firebase/firestore.service.ts | 481 ++++++++++++ .../firebase/realtime-db.service.spec.ts | 16 + .../services/firebase/realtime-db.service.ts | 338 ++++++++ 10 files changed, 2093 insertions(+) create mode 100644 src/app/components/game/apps/messages/messages.component.html create mode 100644 src/app/components/game/apps/messages/messages.component.spec.ts create mode 100644 src/app/components/game/apps/messages/messages.component.ts create mode 100644 src/app/components/game/system/full-screen-background/full-screen-background.component.html create mode 100644 src/app/components/game/system/full-screen-background/full-screen-background.component.spec.ts create mode 100644 src/app/components/game/system/full-screen-background/full-screen-background.component.ts create mode 100644 src/app/services/firebase/firestore.service.spec.ts create mode 100644 src/app/services/firebase/firestore.service.ts create mode 100644 src/app/services/firebase/realtime-db.service.spec.ts create mode 100644 src/app/services/firebase/realtime-db.service.ts diff --git a/src/app/components/game/apps/messages/messages.component.html b/src/app/components/game/apps/messages/messages.component.html new file mode 100644 index 0000000..89b9fa6 --- /dev/null +++ b/src/app/components/game/apps/messages/messages.component.html @@ -0,0 +1 @@ +

messages works!

diff --git a/src/app/components/game/apps/messages/messages.component.spec.ts b/src/app/components/game/apps/messages/messages.component.spec.ts new file mode 100644 index 0000000..8b7c2d9 --- /dev/null +++ b/src/app/components/game/apps/messages/messages.component.spec.ts @@ -0,0 +1,23 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; + +import {MessagesComponent} from './messages.component'; + +describe('MessagesComponent', () => { + let component: MessagesComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [MessagesComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(MessagesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/components/game/apps/messages/messages.component.ts b/src/app/components/game/apps/messages/messages.component.ts new file mode 100644 index 0000000..4e0439b --- /dev/null +++ b/src/app/components/game/apps/messages/messages.component.ts @@ -0,0 +1,453 @@ +import {Component, ElementRef, ViewChild} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {FormsModule} from '@angular/forms'; + +// +// Simple interfaces for Users, Messages, and Chats +// +interface User { + id: number; + name: string; + avatarUrl: string; +} + +interface Message { + id: number; + sender: User; + content: string; // textual content (including emojis) + timestamp: Date; + imageUrl?: string; // if message is an image attachment +} + +interface Chat { + id: number; + name: string; // chat name (for groups) or counterpart name (for 1:1) + participants: User[]; // list of users in this chat + messages: Message[]; // the history + isGroup: boolean; + avatarUrl?: string; // if you want a single avatar for the chat +} + +@Component({ + selector: 'app-messages', + standalone: true, + imports: [CommonModule, FormsModule], + template: ` +
+ + + + + +
+ + +
+ chat avatar + +
+ avatar +
+
+ +
+
+ {{ selectedChat.isGroup ? selectedChat.name : selectedChat.participants[0].name }} +
+ +
+ + + +
+ + +
+
+ + + avatar +
+
+ + attached + +
{{ msg.content }}
+
+
+ {{ msg.timestamp | date: 'shortTime' }} +
+
+
+ + + +
+
+
+ + attached + +
{{ msg.content }}
+
+
+ {{ msg.timestamp | date: 'shortTime' }} +
+
+
+
+
+ + +
+ + + + + + + + + + + + +
+
+ + + +
+ Select a chat to start messaging +
+
+
+
+ + +
+ {{ emo }} +
+ `, + styles: [], +}) +export class MessagesComponent { + @ViewChild('messageContainer') private messageContainer!: ElementRef; + @ViewChild('fileInput') private fileInput!: ElementRef; + + // Simulate a “logged‐in” user + currentUser: User = { + id: 1, + name: 'You', + avatarUrl: 'https://i.pravatar.cc/150?img=3', // placeholder + }; + + // Sample other users + userAlice: User = {id: 2, name: 'Alice', avatarUrl: 'https://i.pravatar.cc/150?img=4'}; + userBob: User = {id: 3, name: 'Bob', avatarUrl: 'https://i.pravatar.cc/150?img=5'}; + userCarol: User = {id: 4, name: 'Carol', avatarUrl: 'https://i.pravatar.cc/150?img=6'}; + + // Sample chats + chatList: Chat[] = [ + { + id: 101, + name: '', + participants: [this.userAlice], + messages: [ + { + id: 1, + sender: this.userAlice, + content: 'Hey, how are you?', + timestamp: new Date(new Date().getTime() - 1000 * 60 * 60), + }, + { + id: 2, + sender: this.currentUser, + content: 'I’m good—just testing out this new chat UI!', + timestamp: new Date(new Date().getTime() - 1000 * 60 * 30), + }, + ], + isGroup: false, + avatarUrl: '', // if blank, it will show participant’s avatar + }, + { + id: 102, + name: 'Friends Group', + participants: [this.currentUser, this.userBob, this.userCarol], + messages: [ + { + id: 1, + sender: this.userBob, + content: 'Anyone up for lunch today? 😊', + timestamp: new Date(new Date().getTime() - 1000 * 60 * 120), + }, + { + id: 2, + sender: this.userCarol, + content: "Count me in! 🍕", + timestamp: new Date(new Date().getTime() - 1000 * 60 * 90), + }, + ], + isGroup: true, + avatarUrl: '', // group avatar can be custom, otherwise show combined avatars + }, + ]; + + selectedChat: Chat | null = null; + draftMessage: string = ''; + showEmojiPicker = false; + + // A small set of emojis for the basic picker + emojiList = ['😀', '😂', '😍', '🤔', '🙌', '👍', '🐱', '🎉', '❤️', '🔥', '😎', '🤷‍♂️']; + + constructor() { + // Select the first chat by default + this.selectedChat = this.chatList[0]; + // Scroll to bottom on load + setTimeout(() => this.scrollToBottom(), 0); + } + + /**** Helper Methods ****/ + + selectChat(chat: Chat) { + this.selectedChat = chat; + this.scrollToBottom(); + } + + getLastMessagePreview(chat: Chat): string { + if (!chat.messages.length) return 'No messages yet'; + const last = chat.messages[chat.messages.length - 1]; + return (last.sender.id === this.currentUser.id ? 'You: ' : `${last.sender.name}: `) + + (last.content.length > 20 ? last.content.slice(0, 20) + '…' : last.content); + } + + getUnreadCount(chat: Chat): number { + // For demo purposes, just zero + return 0; + } + + /** Sends the message (with optional image). */ + sendMessage() { + if (!this.selectedChat) return; + if (!this.draftMessage.trim() && !this.pendingImageBase64) { + return; + } + + const newMsg: Message = { + id: Date.now(), + sender: this.currentUser, + content: this.draftMessage, + timestamp: new Date(), + }; + + if (this.pendingImageBase64) { + newMsg.imageUrl = this.pendingImageBase64; + this.pendingImageBase64 = null; + } + + this.selectedChat.messages.push(newMsg); + this.draftMessage = ''; + this.scrollToBottom(); + } + + /** When the user picks an image file, convert it to base64 and store. */ + pendingImageBase64: string | null = null; + + onFileSelected(event: Event) { + const input = event.target as HTMLInputElement; + if (!input.files?.length) return; + + const file = input.files[0]; + const reader = new FileReader(); + reader.onload = () => { + this.pendingImageBase64 = reader.result as string; + // Immediately send as an image message (optional: you could wait for “Send” click) + this.sendMessage(); + // Clear the file input + this.fileInput.nativeElement.value = ''; + }; + reader.readAsDataURL(file); + } + + /** Emoji picker toggling */ + toggleEmojiPicker() { + this.showEmojiPicker = !this.showEmojiPicker; + } + + addEmoji(emo: string) { + this.draftMessage += emo; + this.showEmojiPicker = false; + } + + /** Always scroll message list to bottom on new message */ + scrollToBottom() { + try { + const el = this.messageContainer.nativeElement as HTMLElement; + setTimeout(() => { + el.scrollTop = el.scrollHeight; + }, 50); + } catch { + } + } + + /** Creates a brand new one-to-one chat with Alice (demo only) */ + createNewChat() { + const newId = Math.floor(Math.random() * 10000) + 200; + const newChat: Chat = { + id: newId, + name: '', + participants: [this.userAlice], + messages: [], + isGroup: false, + avatarUrl: '', + }; + this.chatList.push(newChat); + this.selectChat(newChat); + } +} diff --git a/src/app/components/game/system/full-screen-background/full-screen-background.component.html b/src/app/components/game/system/full-screen-background/full-screen-background.component.html new file mode 100644 index 0000000..cf7e705 --- /dev/null +++ b/src/app/components/game/system/full-screen-background/full-screen-background.component.html @@ -0,0 +1 @@ +

full-screen-background works!

diff --git a/src/app/components/game/system/full-screen-background/full-screen-background.component.spec.ts b/src/app/components/game/system/full-screen-background/full-screen-background.component.spec.ts new file mode 100644 index 0000000..43e64de --- /dev/null +++ b/src/app/components/game/system/full-screen-background/full-screen-background.component.spec.ts @@ -0,0 +1,23 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; + +import {FullScreenBackgroundComponent} from './full-screen-background.component'; + +describe('FullScreenBackgroundComponent', () => { + let component: FullScreenBackgroundComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [FullScreenBackgroundComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(FullScreenBackgroundComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/components/game/system/full-screen-background/full-screen-background.component.ts b/src/app/components/game/system/full-screen-background/full-screen-background.component.ts new file mode 100644 index 0000000..877c27e --- /dev/null +++ b/src/app/components/game/system/full-screen-background/full-screen-background.component.ts @@ -0,0 +1,741 @@ +import { + Component, + Input, + OnInit, + OnDestroy, + ElementRef, + ViewChild, + HostListener, + AfterViewInit, + ChangeDetectionStrategy, + signal, + computed, + PLATFORM_ID, + Inject +} from '@angular/core'; +import {CommonModule, isPlatformBrowser} from '@angular/common'; +import {DomSanitizer} from '@angular/platform-browser'; + +export interface ParallaxElement { + id: string; + element?: HTMLElement; + speed: number; // 0-1, where 1 is normal scroll speed + direction?: 'vertical' | 'horizontal' | 'both'; + initialOffset?: { x: number; y: number }; +} + +export interface VideoProvider { + type: 'youtube' | 'vimeo' | 'direct'; + videoId?: string; // For YouTube/Vimeo + url?: string; // For direct video files + autoplay?: boolean; + muted?: boolean; + loop?: boolean; + controls?: boolean; + startTime?: number; // In seconds + endTime?: number; // In seconds + quality?: 'auto' | 'small' | 'medium' | 'large' | 'hd720' | 'hd1080'; +} + +export interface BackgroundConfig { + type: 'video' | 'image' | 'gradient' | 'solid'; + source?: string; // video URL or image URL + videoProvider?: VideoProvider; // For YouTube/Vimeo integration + fallbackImage?: string; // fallback for video + gradient?: string; // CSS gradient + color?: string; // solid color + opacity?: number; + blur?: number; + overlay?: { + color: string; + opacity: number; + }; +} + +declare global { + interface Window { + YT: any; + onYouTubeIframeAPIReady: () => void; + Vimeo: any; + } +} + +@Component({ + selector: 'app-full-screen-background', + standalone: true, + imports: [CommonModule], + template: ` +
+ + + + + +
+
+ Video fallback +
+ + +
+ + Video fallback +
+ + + Background + + +
+
+ + +
+
+ + +
+ +
+
+ `, + styles: [` + .fullscreen-background { + position: relative; + width: 100vw; + overflow: hidden; + z-index: 0; + } + + .background-media { + position: absolute; + top: 50%; + left: 50%; + min-width: 100%; + min-height: 100%; + width: auto; + height: auto; + transform: translate(-50%, -50%); + object-fit: cover; + z-index: -2; + } + + .video-container { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: -2; + } + + .youtube-player { + position: absolute; + top: 50%; + left: 50%; + width: 100vw; + height: 56.25vw; /* 16:9 aspect ratio */ + min-height: 100vh; + min-width: 177.77vh; /* 16:9 aspect ratio */ + transform: translate(-50%, -50%); + } + + .vimeo-player { + position: absolute; + top: 50%; + left: 50%; + width: 100vw; + height: 56.25vw; /* 16:9 aspect ratio */ + min-height: 100vh; + min-width: 177.77vh; /* 16:9 aspect ratio */ + transform: translate(-50%, -50%); + border: none; + } + + .background-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: -1; + } + + .parallax-container { + position: relative; + width: 100%; + height: 100%; + z-index: 1; + } + `], + changeDetection: ChangeDetectionStrategy.OnPush +}) + +/** + * Fullscreen background component. + * + * Supports: + * - Solid background + * - Video background + * - Image background + * - Gradient background + * - Overlay + * - Parallax elements + * - YouTube video background + * - Vimeo video background + * - Blur filter + * + * Example Usage: + * + *
+ *
+ * + *
+ * + * + > + * + * + */ +export class FullScreenBackgroundComponent implements OnInit, AfterViewInit, OnDestroy { + @Input() config: BackgroundConfig = {type: 'solid', color: '#000000'}; + @Input() height: number | string = '100vh'; + @Input() enableParallax = true; + @Input() parallaxElements: ParallaxElement[] = []; + @Input() parallaxIntensity = 1; // Global multiplier for parallax effects + + @ViewChild('videoElement') videoElement?: ElementRef; + @ViewChild('youtubePlayer') youtubePlayerElement?: ElementRef; + @ViewChild('vimeoPlayer') vimeoPlayerElement?: ElementRef; + @ViewChild('parallaxContainer') parallaxContainer?: ElementRef; + + private scrollY = signal(0); + private windowHeight = signal(window.innerHeight); + protected youtubeReady = signal(false); + protected vimeoReady = signal(false); + private resizeObserver?: ResizeObserver; + private animationFrame?: number; + private youtubePlayer?: any; + private vimeoPlayer?: any; + + constructor( + @Inject(PLATFORM_ID) private platformId: Object, + private sanitizer: DomSanitizer + ) { + } + + containerHeight = computed(() => { + if (typeof this.height === 'number') { + return this.height; + } + if (this.height === '100vh') { + return this.windowHeight(); + } + return parseInt(this.height) || this.windowHeight(); + }); + + solidBackground = computed(() => { + if (this.config.type === 'solid' && this.config.color) { + return this.config.color; + } + return 'transparent'; + }); + + filterStyles = computed(() => { + const filters: string[] = []; + if (this.config.blur) { + filters.push(`blur(${this.config.blur}px)`); + } + return filters.join(' '); + }); + + vimeoEmbedUrl = computed(() => { + if (this.config.videoProvider?.type === 'vimeo' && this.config.videoProvider.videoId) { + const provider = this.config.videoProvider; + const params = new URLSearchParams(); + + params.set('autoplay', provider.autoplay !== false ? '1' : '0'); + params.set('muted', provider.muted !== false ? '1' : '0'); + params.set('loop', provider.loop !== false ? '1' : '0'); + params.set('controls', provider.controls ? '1' : '0'); + params.set('background', '1'); // Vimeo background mode + + if (provider.startTime) { + params.set('t', `${provider.startTime}s`); + } + + const url = `https://player.vimeo.com/video/${provider.videoId}?${params.toString()}`; + return this.sanitizer.bypassSecurityTrustResourceUrl(url); + } + return null; + }); + + ngOnInit(): void { + this.initializeParallaxElements(); + } + + ngAfterViewInit(): void { + if (this.enableParallax) { + this.setupParallaxListeners(); + } + this.setupResizeObserver(); + + if (isPlatformBrowser(this.platformId)) { + this.initializeVideoProviders(); + } + } + + ngOnDestroy(): void { + if (this.animationFrame) { + cancelAnimationFrame(this.animationFrame); + } + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + } + if (this.youtubePlayer) { + this.youtubePlayer.destroy(); + } + window.removeEventListener('scroll', this.onScroll); + window.removeEventListener('resize', this.onResize); + } + + private async initializeVideoProviders(): Promise { + if (this.config.type === 'video' && this.config.videoProvider) { + switch (this.config.videoProvider.type) { + case 'youtube': + await this.initializeYouTube(); + break; + case 'vimeo': + await this.initializeVimeo(); + break; + default: + // Direct video, no additional setup needed + break; + } + } + } + + private async initializeYouTube(): Promise { + if (!this.config.videoProvider?.videoId || !this.youtubePlayerElement) return; + + try { + await this.loadYouTubeAPI(); + this.createYouTubePlayer(); + } catch (error) { + console.error('Failed to initialize YouTube player:', error); + this.onVideoError(); + } + } + + private async initializeVimeo(): Promise { + if (!this.config.videoProvider?.videoId || !this.vimeoPlayerElement) return; + + try { + await this.loadVimeoAPI(); + this.createVimeoPlayer(); + } catch (error) { + console.error('Failed to initialize Vimeo player:', error); + this.onVideoError(); + } + } + + private loadYouTubeAPI(): Promise { + return new Promise((resolve, reject) => { + if (window.YT && window.YT.Player) { + resolve(); + return; + } + + window.onYouTubeIframeAPIReady = () => { + resolve(); + }; + + if (!document.querySelector('script[src*="youtube.com/iframe_api"]')) { + const script = document.createElement('script'); + script.src = 'https://www.youtube.com/iframe_api'; + script.onerror = reject; + document.body.appendChild(script); + } + + // Timeout after 10 seconds + setTimeout(() => reject(new Error('YouTube API failed to load')), 10000); + }); + } + + private loadVimeoAPI(): Promise { + return new Promise((resolve, reject) => { + if (window.Vimeo && window.Vimeo.Player) { + resolve(); + return; + } + + if (!document.querySelector('script[src*="player.vimeo.com"]')) { + const script = document.createElement('script'); + script.src = 'https://player.vimeo.com/api/player.js'; + script.onload = () => resolve(); + script.onerror = reject; + document.body.appendChild(script); + } else { + resolve(); + } + + // Timeout after 10 seconds + setTimeout(() => reject(new Error('Vimeo API failed to load')), 10000); + }); + } + + private createYouTubePlayer(): void { + if (!this.youtubePlayerElement || !this.config.videoProvider?.videoId) return; + + const provider = this.config.videoProvider; + + this.youtubePlayer = new window.YT.Player(this.youtubePlayerElement.nativeElement, { + videoId: provider.videoId, + width: '100%', + height: '100%', + playerVars: { + autoplay: provider.autoplay !== false ? 1 : 0, + mute: provider.muted !== false ? 1 : 0, + loop: provider.loop !== false ? 1 : 0, + controls: provider.controls ? 1 : 0, + showinfo: 0, + rel: 0, + iv_load_policy: 3, + modestbranding: 1, + playsinline: 1, + start: provider.startTime || 0, + end: provider.endTime || 0, + hd: provider.quality === 'hd720' || provider.quality === 'hd1080' ? 1 : 0 + }, + events: { + onReady: (event: any) => { + this.youtubeReady.set(true); + if (provider.autoplay !== false) { + event.target.playVideo(); + } + this.onVideoLoaded(); + }, + onStateChange: (event: any) => { + // Loop the video if needed + if (event.data === window.YT.PlayerState.ENDED && provider.loop !== false) { + event.target.playVideo(); + } + }, + onError: () => { + this.onVideoError(); + } + } + }); + } + + private createVimeoPlayer(): void { + if (!this.vimeoPlayerElement || !this.config.videoProvider?.videoId) return; + + const provider = this.config.videoProvider; + + this.vimeoPlayer = new window.Vimeo.Player(this.vimeoPlayerElement.nativeElement, { + id: provider.videoId, + width: '100%', + height: '100%', + autoplay: provider.autoplay !== false, + muted: provider.muted !== false, + loop: provider.loop !== false, + controls: provider.controls || false, + background: true + }); + + this.vimeoPlayer.ready().then(() => { + this.vimeoReady.set(true); + this.onVideoLoaded(); + }).catch(() => { + this.onVideoError(); + }); + + if (provider.startTime) { + this.vimeoPlayer.setCurrentTime(provider.startTime); + } + } + + @HostListener('window:scroll', ['$event']) + onScroll(): void { + if (this.enableParallax) { + this.scrollY.set(window.pageYOffset); + this.updateParallax(); + } + } + + @HostListener('window:resize', ['$event']) + onResize(): void { + this.windowHeight.set(window.innerHeight); + } + + private initializeParallaxElements(): void { + this.parallaxElements.forEach(element => { + if (!element.direction) { + element.direction = 'vertical'; + } + if (!element.initialOffset) { + element.initialOffset = {x: 0, y: 0}; + } + }); + } + + private setupParallaxListeners(): void { + window.addEventListener('scroll', this.onScroll.bind(this), {passive: true}); + window.addEventListener('resize', this.onResize.bind(this), {passive: true}); + } + + private setupResizeObserver(): void { + if (this.parallaxContainer) { + this.resizeObserver = new ResizeObserver(() => { + this.updateParallax(); + }); + this.resizeObserver.observe(this.parallaxContainer.nativeElement); + } + } + + private updateParallax(): void { + if (this.animationFrame) { + cancelAnimationFrame(this.animationFrame); + } + + this.animationFrame = requestAnimationFrame(() => { + this.parallaxElements.forEach(parallaxElement => { + if (parallaxElement.element) { + this.applyParallaxTransform(parallaxElement); + } else { + const element = document.getElementById(parallaxElement.id); + if (element) { + parallaxElement.element = element; + this.applyParallaxTransform(parallaxElement); + } + } + }); + }); + } + + private applyParallaxTransform(parallaxElement: ParallaxElement): void { + if (!parallaxElement.element) return; + + const scrollY = this.scrollY(); + const speed = parallaxElement.speed * this.parallaxIntensity; + const {x: initialX, y: initialY} = parallaxElement.initialOffset!; + + let transformX = initialX; + let transformY = initialY; + + switch (parallaxElement.direction) { + case 'vertical': + transformY = initialY + (scrollY * speed); + break; + case 'horizontal': + transformX = initialX + (scrollY * speed); + break; + case 'both': + transformX = initialX + (scrollY * speed * 0.5); + transformY = initialY + (scrollY * speed); + break; + } + + parallaxElement.element.style.transform = + `translate3d(${transformX}px, ${transformY}px, 0)`; + } + + // Video-specific methods + onVideoLoaded(): void { + console.log('Video background loaded successfully'); + } + + onVideoError(): void { + console.warn('Video background failed to load, falling back to image'); + } + + // Public methods for external control + playVideo(): void { + if (this.videoElement?.nativeElement) { + this.videoElement.nativeElement.play().catch(console.error); + } else if (this.youtubePlayer) { + this.youtubePlayer.playVideo(); + } else if (this.vimeoPlayer) { + this.vimeoPlayer.play(); + } + } + + pauseVideo(): void { + if (this.videoElement?.nativeElement) { + this.videoElement.nativeElement.pause(); + } else if (this.youtubePlayer) { + this.youtubePlayer.pauseVideo(); + } else if (this.vimeoPlayer) { + this.vimeoPlayer.pause(); + } + } + + setVideoVolume(volume: number): void { + const normalizedVolume = Math.max(0, Math.min(1, volume)); + + if (this.videoElement?.nativeElement) { + this.videoElement.nativeElement.volume = normalizedVolume; + } else if (this.youtubePlayer) { + this.youtubePlayer.setVolume(normalizedVolume * 100); + } else if (this.vimeoPlayer) { + this.vimeoPlayer.setVolume(normalizedVolume); + } + } + + muteVideo(): void { + if (this.videoElement?.nativeElement) { + this.videoElement.nativeElement.muted = true; + } else if (this.youtubePlayer) { + this.youtubePlayer.mute(); + } else if (this.vimeoPlayer) { + this.vimeoPlayer.setVolume(0); + } + } + + unmuteVideo(): void { + if (this.videoElement?.nativeElement) { + this.videoElement.nativeElement.muted = false; + } else if (this.youtubePlayer) { + this.youtubePlayer.unMute(); + } else if (this.vimeoPlayer) { + this.vimeoPlayer.setVolume(1); + } + } + + seekTo(timeInSeconds: number): void { + if (this.videoElement?.nativeElement) { + this.videoElement.nativeElement.currentTime = timeInSeconds; + } else if (this.youtubePlayer) { + this.youtubePlayer.seekTo(timeInSeconds); + } else if (this.vimeoPlayer) { + this.vimeoPlayer.setCurrentTime(timeInSeconds); + } + } + + // Parallax methods + addParallaxElement(element: ParallaxElement): void { + this.parallaxElements.push(element); + this.initializeParallaxElements(); + } + + removeParallaxElement(id: string): void { + const index = this.parallaxElements.findIndex(el => el.id === id); + if (index > -1) { + this.parallaxElements.splice(index, 1); + } + } + + updateParallaxIntensity(intensity: number): void { + this.parallaxIntensity = intensity; + this.updateParallax(); + } + + // Utility methods for creating video configs + static createYouTubeConfig(videoId: string, options?: Partial): VideoProvider { + return { + type: 'youtube', + videoId, + autoplay: true, + muted: true, + loop: true, + controls: false, + ...options + }; + } + + static createVimeoConfig(videoId: string, options?: Partial): VideoProvider { + return { + type: 'vimeo', + videoId, + autoplay: true, + muted: true, + loop: true, + controls: false, + ...options + }; + } + + static createDirectVideoConfig(url: string, options?: Partial): VideoProvider { + return { + type: 'direct', + url, + autoplay: true, + muted: true, + loop: true, + controls: false, + ...options + }; + } +} diff --git a/src/app/services/firebase/firestore.service.spec.ts b/src/app/services/firebase/firestore.service.spec.ts new file mode 100644 index 0000000..b182f59 --- /dev/null +++ b/src/app/services/firebase/firestore.service.spec.ts @@ -0,0 +1,16 @@ +import {TestBed} from '@angular/core/testing'; + +import {FirestoreService} from './firestore.service'; + +describe('FirestoreService', () => { + let service: FirestoreService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(FirestoreService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/services/firebase/firestore.service.ts b/src/app/services/firebase/firestore.service.ts new file mode 100644 index 0000000..b7f6890 --- /dev/null +++ b/src/app/services/firebase/firestore.service.ts @@ -0,0 +1,481 @@ +import {Injectable} from '@angular/core'; +import { + collection, + deleteDoc, + doc, + Firestore, + getDoc, + getDocs, + limit, + onSnapshot, + orderBy, + query, + serverTimestamp, + setDoc, + Timestamp, + updateDoc, + where, writeBatch +} from '@angular/fire/firestore'; +import { + deleteObject, + getDownloadURL, + ref, + Storage, + uploadBytes, + uploadBytesResumable, + uploadString +} from '@angular/fire/storage'; +import {from, Observable, throwError, of} from 'rxjs'; +import {catchError, map, switchMap} from 'rxjs/operators'; +import {v4 as uuidv4} from 'uuid'; + + +export interface FirestoreDocument { + id?: string; + createdAt?: Timestamp; + updatedAt?: Timestamp; + + [key: string]: any; +} + +@Injectable({ + providedIn: 'root' +}) +export class FirestoreService { + constructor( + private firestore: Firestore, + private storage: Storage + ) { + } + + /** + * Creates or updates a document in Firestore + * @param collectionPath - Path to the collection + * @param data - Document data + * @param id - Optional document ID (will be generated if not provided) + * @returns Observable of the document reference + */ + saveDocument( + collectionPath: string, + data: T, + id?: string + ): Observable { + const docId = id || data.id || uuidv4(); + const docRef = doc(this.firestore, collectionPath, docId); + + // Add timestamps + const documentData = { + ...data, + updatedAt: serverTimestamp(), + createdAt: data.createdAt || serverTimestamp(), + id: docId + }; + + return from(setDoc(docRef, documentData, {merge: true})).pipe( + map(() => docId), + catchError(error => { + console.error(`Error saving document to ${collectionPath}:`, error); + return throwError(() => new Error(`Failed to save document: ${error.message}`)); + }) + ); + } + + /** + * Retrieves a document from Firestore + * @param collectionPath - Path to the collection + * @param id - Document ID + * @returns Observable of the document data + */ + getDocument(collectionPath: string, id: string): Observable { + const docRef = doc(this.firestore, collectionPath, id); + + return from(getDoc(docRef)).pipe( + map(snapshot => { + if (snapshot.exists()) { + return {id: snapshot.id, ...snapshot.data()} as T; + } else { + return null; + } + }), + catchError(error => { + console.error(`Error getting document from ${collectionPath}:`, error); + return throwError(() => new Error(`Failed to get document: ${error.message}`)); + }) + ); + } + + /** + * Updates an existing document in Firestore + * @param collectionPath - Path to the collection + * @param id - Document ID + * @param data - Partial data to update + * @returns Observable of void + */ + updateDocument>( + collectionPath: string, + id: string, + data: T + ): Observable { + const docRef = doc(this.firestore, collectionPath, id); + + // Add updated timestamp + const updateData = { + ...data, + updatedAt: serverTimestamp() + }; + + return from(updateDoc(docRef, updateData)).pipe( + catchError(error => { + console.error(`Error updating document in ${collectionPath}:`, error); + return throwError(() => new Error(`Failed to update document: ${error.message}`)); + }) + ); + } + + /** + * Deletes a document from Firestore + * @param collectionPath - Path to the collection + * @param id - Document ID + * @returns Observable of void + */ + deleteDocument(collectionPath: string, id: string): Observable { + const docRef = doc(this.firestore, collectionPath, id); + + return from(deleteDoc(docRef)).pipe( + catchError(error => { + console.error(`Error deleting document from ${collectionPath}:`, error); + return throwError(() => new Error(`Failed to delete document: ${error.message}`)); + }) + ); + } + + /** + * Queries documents from a collection + * @param collectionPath - Path to the collection + * @param filters - Array of where conditions [field, operator, value] + * @param sortField - Optional field to sort by + * @param sortDirection - Optional sort direction ('asc' or 'desc') + * @param limitCount - Optional limit on number of results + * @returns Observable of array of documents + */ + queryDocuments( + collectionPath: string, + filters?: [string, any, any][], + sortField?: string, + sortDirection: 'asc' | 'desc' = 'desc', + limitCount?: number + ): Observable { + const collectionRef = collection(this.firestore, collectionPath); + + let q = query(collectionRef); + + // Apply filters if provided + if (filters && filters.length > 0) { + filters.forEach(filter => { + q = query(q, where(filter[0], filter[1], filter[2])); + }); + } + + // Apply sorting if provided + if (sortField) { + q = query(q, orderBy(sortField, sortDirection)); + } + + // Apply limit if provided + if (limitCount) { + q = query(q, limit(limitCount)); + } + + return from(getDocs(q)).pipe( + map(snapshot => { + return snapshot.docs.map(doc => ({id: doc.id, ...doc.data()} as T)); + }), + catchError(error => { + console.error(`Error querying documents from ${collectionPath}:`, error); + return throwError(() => new Error(`Failed to query documents: ${error.message}`)); + }) + ); + } + + /** + * Listen to real-time updates on a document + * @param collectionPath - Path to the collection + * @param id - Document ID + * @returns Observable that emits the document data on changes + */ + listenToDocument(collectionPath: string, id: string): Observable { + const docRef = doc(this.firestore, collectionPath, id); + + return new Observable(observer => { + // Return the unsubscribe function to clean up when the observable is unsubscribed + return onSnapshot(docRef, + (snapshot) => { + if (snapshot.exists()) { + observer.next({id: snapshot.id, ...snapshot.data()} as T); + } else { + observer.next(null); + } + }, + (error) => { + console.error(`Error listening to document in ${collectionPath}:`, error); + observer.error(error); + } + ); + }); + } + + /** + * Listen to real-time updates on a collection + * @param collectionPath - Path to the collection + * @param filters - Optional array of where conditions + * @param sortField + * @param sortDirection + * @returns Observable that emits the collection data on changes + */ + listenToCollection( + collectionPath: string, + filters?: [string, any, any][], + sortField?: string, + sortDirection: 'asc' | 'desc' = 'desc' + ): Observable { + const collectionRef = collection(this.firestore, collectionPath); + + let q = query(collectionRef); + + // Apply filters if provided + if (filters && filters.length > 0) { + filters.forEach(filter => { + q = query(q, where(filter[0], filter[1], filter[2])); + }); + } + + // Apply sorting if provided + if (sortField) { + q = query(q, orderBy(sortField, sortDirection)); + } + + return new Observable(observer => { + // Return the unsubscribe function to clean up when the observable is unsubscribed + return onSnapshot(q, + (snapshot) => { + const documents = snapshot.docs.map(doc => ({id: doc.id, ...doc.data()} as T)); + observer.next(documents); + }, + (error) => { + console.error(`Error listening to collection ${collectionPath}:`, error); + observer.error(error); + } + ); + }); + } + + /** + * Uploads a file to Firebase Storage + * @param path - Storage path + * @param file - File to upload + * @param metadata - Optional metadata + * @returns Observable of the download URL + */ + uploadFile(path: string, file: File | Blob, metadata?: any): Observable { + const storageRef = ref(this.storage, path); + + return from(uploadBytes(storageRef, file, metadata)).pipe( + switchMap(() => from(getDownloadURL(storageRef))), + catchError(error => { + console.error(`Error uploading file to ${path}:`, error); + return throwError(() => new Error(`Failed to upload file: ${error.message}`)); + }) + ); + } + + /** + * Uploads a file with progress tracking + * @param path - Storage path + * @param file - File to upload + * @param metadata - Optional metadata + * @returns Observable that emits upload progress and final URL + */ + uploadFileWithProgress(path: string, file: File | Blob, metadata?: any): Observable<{ + progress: number, + downloadUrl?: string + }> { + const storageRef = ref(this.storage, path); + const uploadTask = uploadBytesResumable(storageRef, file, metadata); + + return new Observable<{ progress: number, downloadUrl?: string }>(observer => { + uploadTask.on( + 'state_changed', + (snapshot) => { + const progress = (snapshot.bytesTransferred / snapshot.totalBytes) * 100; + observer.next({progress}); + }, + (error) => { + console.error(`Error uploading file to ${path}:`, error); + observer.error(error); + }, + async () => { + try { + const downloadUrl = await getDownloadURL(uploadTask.snapshot.ref); + observer.next({progress: 100, downloadUrl}); + observer.complete(); + } catch (error) { + observer.error(error); + } + } + ); + }); + } + + /** + * Uploads a base64 string as a file + * @param path - Storage path + * @param dataUrl - Data URL string + * @param metadata - Optional metadata + * @returns Observable of the download URL + */ + uploadBase64(path: string, dataUrl: string, metadata?: any): Observable { + const storageRef = ref(this.storage, path); + + return from(uploadString(storageRef, dataUrl, 'data_url', metadata)).pipe( + switchMap(() => from(getDownloadURL(storageRef))), + catchError(error => { + console.error(`Error uploading base64 to ${path}:`, error); + return throwError(() => new Error(`Failed to upload base64: ${error.message}`)); + }) + ); + } + + /** + * Deletes a file from Firebase Storage + * @param path - Storage path + * @returns Observable of void + */ + deleteFile(path: string): Observable { + const storageRef = ref(this.storage, path); + + return from(deleteObject(storageRef)).pipe( + catchError(error => { + console.error(`Error deleting file from ${path}:`, error); + return throwError(() => new Error(`Failed to delete file: ${error.message}`)); + }) + ); + } + + /** + * Saves user settings + * @param userId - User ID + * @param settings - Settings object + * @returns Observable of void + */ + saveUserSettings(userId: string, settings: any): Observable { + return this.updateDocument(`users/${userId}`, 'settings', {settings}); + } + + /** + * Gets user settings + * @param userId - User ID + * @returns Observable of settings object + */ + getUserSettings(userId: string): Observable { + return this.getDocument(`users/${userId}`, 'settings'); + } + + /** + * Saves a log entry + * @param level + * @param logEntry - Log entry object + * @param user + * @param p0 + * @returns Observable of the log entry ID + */ + saveLogEntry(level: string, logEntry: { + level: 'info' | 'warn' | 'error' | 'debug'; + message: string; + userId?: string; + metadata?: any; + }, user: unknown, p0: string): Observable { + return this.saveDocument('logs', { + ...logEntry, + timestamp: serverTimestamp() + }); + } + + /** + * Gets log entries + * @param userId - Optional user ID to filter by + * @param level - Optional log level to filter by + * @param limit - Optional limit on number of results + * @returns Observable of log entries + */ + getLogEntries( + userId?: string, + level?: 'info' | 'warn' | 'error' | 'debug', + limit?: number + ): Observable { + const filters: [string, any, any][] = []; + + if (userId) { + filters.push(['userId', '==', userId]); + } + + if (level) { + filters.push(['level', '==', level]); + } + + return this.queryDocuments( + 'logs', + filters, + 'timestamp', + 'desc', + limit + ); + } + + /** + * Saves user profile data + * @param userId - User ID + * @param profileData - Profile data + * @returns Observable of void + */ + saveUserProfile(userId: string, profileData: any): Observable { + return this.updateDocument('users', userId, profileData); + } + + /** + * Gets user profile data + * @param userId - User ID + * @returns Observable of user profile + */ + getUserProfile(userId: string): Observable { + return this.getDocument('users', userId); + } + + /** + * Creates or updates a user document + * @param userId - User ID + * @param userData - User data + * @returns Observable of void + */ + createOrUpdateUser(userId: string, userData: any): Observable { + return this.saveDocument('users', {...userData, id: userId}, userId).pipe( + map(() => void 0) + ); + } + + + /** + * Gets a storage file download URL + * @param path - Storage path + * @returns Observable of the download URL + */ + getFileUrl(path: string): Observable { + const storageRef = ref(this.storage, path); + + return from(getDownloadURL(storageRef)).pipe( + catchError(error => { + console.error(`Error getting download URL for ${path}:`, error); + return throwError(() => new Error(`Failed to get download URL: ${error.message}`)); + }) + ); + } +} diff --git a/src/app/services/firebase/realtime-db.service.spec.ts b/src/app/services/firebase/realtime-db.service.spec.ts new file mode 100644 index 0000000..bb1daa4 --- /dev/null +++ b/src/app/services/firebase/realtime-db.service.spec.ts @@ -0,0 +1,16 @@ +import {TestBed} from '@angular/core/testing'; + +import {RealtimeDbService} from './realtime-db.service'; + +describe('RealtimeDbService', () => { + let service: RealtimeDbService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(RealtimeDbService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/services/firebase/realtime-db.service.ts b/src/app/services/firebase/realtime-db.service.ts new file mode 100644 index 0000000..eaa3326 --- /dev/null +++ b/src/app/services/firebase/realtime-db.service.ts @@ -0,0 +1,338 @@ +import {Injectable} from '@angular/core'; +import { + Database, + ref, + push, + set, + get, + update, + remove, + onValue, + off, + query, + orderByChild, + orderByKey, + limitToFirst, + limitToLast, + startAt, + endAt, + getDatabase +} from '@angular/fire/database'; +import {Observable} from 'rxjs'; + +export interface DatabaseItem { + id?: string; + + [key: string]: any; +} + +@Injectable({ + providedIn: 'root' +}) +export class RealtimeDbService { + private readonly db: Database = getDatabase() as Database; + + constructor() { + console.warn('RealtimeDbService is deprecated. Please use FirebaseService instead.'); + try { + this.db = getDatabase(); + console.log('Database service initialized successfully'); + } catch (error) { + console.error('Error in database service:', error); + } + + + } + + // Create a new item + async create(path: string, data: Omit): Promise { + try { + const listRef = ref(this.db, path); + const newItemRef = push(listRef); + await set(newItemRef, { + ...data, + createdAt: Date.now(), + updatedAt: Date.now() + }); + return newItemRef.key!; + } catch (error) { + console.error('Error creating item:', error); + throw error; + } + } + + // Set item with custom ID + async setItem(path: string, id: string, data: Omit): Promise { + try { + const itemRef = ref(this.db, `${path}/${id}`); + await set(itemRef, { + ...data, + id, + createdAt: Date.now(), + updatedAt: Date.now() + }); + } catch (error) { + console.error('Error setting item:', error); + throw error; + } + } + + // Get a single item by ID + async getItem(path: string, id: string): Promise { + try { + const itemRef = ref(this.db, `${path}/${id}`); + const snapshot = await get(itemRef); + if (snapshot.exists()) { + return {id, ...snapshot.val()} as T; + } + return null; + } catch (error) { + console.error('Error getting item:', error); + throw error; + } + } + + // Get all items from a path + async getItems(path: string): Promise { + try { + const listRef = ref(this.db, path); + const snapshot = await get(listRef); + if (snapshot.exists()) { + const items: T[] = []; + snapshot.forEach((childSnapshot) => { + items.push({ + id: childSnapshot.key, + ...childSnapshot.val() + } as T); + }); + return items; + } + return []; + } catch (error) { + console.error('Error getting items:', error); + throw error; + } + } + + // Update an existing item + async updateItem(path: string, id: string, updates: Partial>): Promise { + try { + const itemRef = ref(this.db, `${path}/${id}`); + await update(itemRef, { + ...updates, + updatedAt: Date.now() + }); + } catch (error) { + console.error('Error updating item:', error); + throw error; + } + } + + // Delete an item + async deleteItem(path: string, id: string): Promise { + try { + const itemRef = ref(this.db, `${path}/${id}`); + await remove(itemRef); + } catch (error) { + console.error('Error deleting item:', error); + throw error; + } + } + + // Listen to real-time changes for a single item + watchItem(path: string, id: string): Observable { + return new Observable(observer => { + const itemRef = ref(this.db, `${path}/${id}`); + + const unsubscribe = onValue(itemRef, (snapshot) => { + if (snapshot.exists()) { + observer.next({id, ...snapshot.val()} as T); + } else { + observer.next(null); + } + }, (error) => { + observer.error(error); + }); + + // Return cleanup function + return () => off(itemRef, 'value', unsubscribe); + }); + } + + // Listen to real-time changes for a list of items + watchItems(path: string): Observable { + return new Observable(observer => { + const listRef = ref(this.db, path); + + const unsubscribe = onValue(listRef, (snapshot) => { + const items: T[] = []; + if (snapshot.exists()) { + snapshot.forEach((childSnapshot) => { + items.push({ + id: childSnapshot.key, + ...childSnapshot.val() + } as T); + }); + } + observer.next(items); + }, (error) => { + observer.error(error); + }); + + // Return cleanup function + return () => off(listRef, 'value', unsubscribe); + }); + } + + // Query items with filters + async queryItems( + path: string, + options: { + orderBy?: 'key' | string; + limitToFirst?: number; + limitToLast?: number; + startAt?: string | number; + endAt?: string | number; + equalTo?: string | number; + } = {} + ): Promise { + try { + let queryRef = ref(this.db, path); + let queryBuilder = query(queryRef); + + if (options.orderBy) { + if (options.orderBy === 'key') { + queryBuilder = query(queryBuilder, orderByKey()); + } else { + queryBuilder = query(queryBuilder, orderByChild(options.orderBy)); + } + } + + if (options.limitToFirst) { + queryBuilder = query(queryBuilder, limitToFirst(options.limitToFirst)); + } + + if (options.limitToLast) { + queryBuilder = query(queryBuilder, limitToLast(options.limitToLast)); + } + + if (options.startAt !== undefined) { + queryBuilder = query(queryBuilder, startAt(options.startAt)); + } + + if (options.endAt !== undefined) { + queryBuilder = query(queryBuilder, endAt(options.endAt)); + } + + const snapshot = await get(queryBuilder); + const items: T[] = []; + + if (snapshot.exists()) { + snapshot.forEach((childSnapshot) => { + items.push({ + id: childSnapshot.key, + ...childSnapshot.val() + } as T); + }); + } + + return items; + } catch (error) { + console.error('Error querying items:', error); + throw error; + } + } + + // Watch items with query filters + watchQuery( + path: string, + options: { + orderBy?: 'key' | string; + limitToFirst?: number; + limitToLast?: number; + startAt?: string | number; + endAt?: string | number; + } = {} + ): Observable { + return new Observable(observer => { + let queryRef = ref(this.db, path); + let queryBuilder = query(queryRef); + + if (options.orderBy) { + if (options.orderBy === 'key') { + queryBuilder = query(queryBuilder, orderByKey()); + } else { + queryBuilder = query(queryBuilder, orderByChild(options.orderBy)); + } + } + + if (options.limitToFirst) { + queryBuilder = query(queryBuilder, limitToFirst(options.limitToFirst)); + } + + if (options.limitToLast) { + queryBuilder = query(queryBuilder, limitToLast(options.limitToLast)); + } + + if (options.startAt !== undefined) { + queryBuilder = query(queryBuilder, startAt(options.startAt)); + } + + if (options.endAt !== undefined) { + queryBuilder = query(queryBuilder, endAt(options.endAt)); + } + + const unsubscribe = onValue(queryBuilder, (snapshot) => { + const items: T[] = []; + if (snapshot.exists()) { + snapshot.forEach((childSnapshot) => { + items.push({ + id: childSnapshot.key, + ...childSnapshot.val() + } as T); + }); + } + observer.next(items); + }, (error) => { + observer.error(error); + }); + + return () => off(queryBuilder, 'value', unsubscribe); + }); + } + + // Batch operations + async batchUpdate(updates: { [path: string]: any }): Promise { + try { + const dbRef = ref(this.db); + await update(dbRef, updates); + } catch (error) { + console.error('Error performing batch update:', error); + throw error; + } + } + + // Check if item exists + async exists(path: string, id: string): Promise { + try { + const itemRef = ref(this.db, `${path}/${id}`); + const snapshot = await get(itemRef); + return snapshot.exists(); + } catch (error) { + console.error('Error checking if item exists:', error); + throw error; + } + } + + // Get count of items + async getCount(path: string): Promise { + try { + const listRef = ref(this.db, path); + const snapshot = await get(listRef); + return snapshot.size; + } catch (error) { + console.error('Error getting count:', error); + throw error; + } + } +} From 58920cca0eb6515aa19db618d71be15d7df4ceed Mon Sep 17 00:00:00 2001 From: Colin Michaels Date: Sat, 6 Sep 2025 15:46:15 -0400 Subject: [PATCH 06/14] ### Remove unused Firebase polyfill script - Deleted the `polyfills-FFHMD2TL.js` file from the Firebase hosting configuration as it is no longer required. - Cleaned up unnecessary assets to streamline the hosting setup and reduce potential maintenance overhead. Signed-off-by: Colin Michaels --- .firebase/colinmichaels/hosting/index.html | 6 +- .../hosting/polyfills-FFHMD2TL.js | 2 - ...ViYXNlL2NvbGlubWljaGFlbHMvaG9zdGluZw.cache | 248 ++++---- database.rules.json | 20 + package.json | 2 +- .../components/game/services/log.service.ts | 3 - .../services/firebase/FirestoreTestUtils.ts | 60 ++ .../firebase/firestore.service.spec.ts | 594 +++++++++++++++++- .../services/firebase/firestore.service.ts | 77 ++- 9 files changed, 872 insertions(+), 140 deletions(-) delete mode 100644 .firebase/colinmichaels/hosting/polyfills-FFHMD2TL.js create mode 100644 database.rules.json create mode 100644 src/app/services/firebase/FirestoreTestUtils.ts diff --git a/.firebase/colinmichaels/hosting/index.html b/.firebase/colinmichaels/hosting/index.html index 278bad1..d0ddb53 100644 --- a/.firebase/colinmichaels/hosting/index.html +++ b/.firebase/colinmichaels/hosting/index.html @@ -16,12 +16,12 @@ - + Colin Michaels - + - + diff --git a/.firebase/colinmichaels/hosting/polyfills-FFHMD2TL.js b/.firebase/colinmichaels/hosting/polyfills-FFHMD2TL.js deleted file mode 100644 index b01b791..0000000 --- a/.firebase/colinmichaels/hosting/polyfills-FFHMD2TL.js +++ /dev/null @@ -1,2 +0,0 @@ -var ce=globalThis;function te(e){return(ce.__Zone_symbol_prefix||"__zone_symbol__")+e}function dt(){let e=ce.performance;function n(M){e&&e.mark&&e.mark(M)}function a(M,s){e&&e.measure&&e.measure(M,s)}n("Zone");class t{static{this.__symbol__=te}static assertZonePatched(){if(ce.Promise!==S.ZoneAwarePromise)throw new Error("Zone.js has detected that ZoneAwarePromise `(window|global).Promise` has been overwritten.\nMost likely cause is that a Promise polyfill has been loaded after Zone.js (Polyfilling Promise api is not necessary when zone.js is loaded. If you must load one, do so before loading zone.js.)")}static get root(){let s=t.current;for(;s.parent;)s=s.parent;return s}static get current(){return b.zone}static get currentTask(){return D}static __load_patch(s,i,o=!1){if(S.hasOwnProperty(s)){let g=ce[te("forceDuplicateZoneCheck")]===!0;if(!o&&g)throw Error("Already loaded patch: "+s)}else if(!ce["__Zone_disable_"+s]){let g="Zone:"+s;n(g),S[s]=i(ce,t,w),a(g,g)}}get parent(){return this._parent}get name(){return this._name}constructor(s,i){this._parent=s,this._name=i?i.name||"unnamed":"",this._properties=i&&i.properties||{},this._zoneDelegate=new f(this,this._parent&&this._parent._zoneDelegate,i)}get(s){let i=this.getZoneWith(s);if(i)return i._properties[s]}getZoneWith(s){let i=this;for(;i;){if(i._properties.hasOwnProperty(s))return i;i=i._parent}return null}fork(s){if(!s)throw new Error("ZoneSpec required!");return this._zoneDelegate.fork(this,s)}wrap(s,i){if(typeof s!="function")throw new Error("Expecting function got: "+s);let o=this._zoneDelegate.intercept(this,s,i),g=this;return function(){return g.runGuarded(o,this,arguments,i)}}run(s,i,o,g){b={parent:b,zone:this};try{return this._zoneDelegate.invoke(this,s,i,o,g)}finally{b=b.parent}}runGuarded(s,i=null,o,g){b={parent:b,zone:this};try{try{return this._zoneDelegate.invoke(this,s,i,o,g)}catch(V){if(this._zoneDelegate.handleError(this,V))throw V}}finally{b=b.parent}}runTask(s,i,o){if(s.zone!=this)throw new Error("A task can only be run in the zone of creation! (Creation: "+(s.zone||J).name+"; Execution: "+this.name+")");let g=s,{type:V,data:{isPeriodic:ee=!1,isRefreshable:Z=!1}={}}=s;if(s.state===q&&(V===z||V===y))return;let he=s.state!=A;he&&g._transitionTo(A,d);let _e=D;D=g,b={parent:b,zone:this};try{V==y&&s.data&&!ee&&!Z&&(s.cancelFn=void 0);try{return this._zoneDelegate.invokeTask(this,g,i,o)}catch(Q){if(this._zoneDelegate.handleError(this,Q))throw Q}}finally{let Q=s.state;if(Q!==q&&Q!==X)if(V==z||ee||Z&&Q===k)he&&g._transitionTo(d,A,k);else{let Ee=g._zoneDelegates;this._updateTaskCount(g,-1),he&&g._transitionTo(q,A,q),Z&&(g._zoneDelegates=Ee)}b=b.parent,D=_e}}scheduleTask(s){if(s.zone&&s.zone!==this){let o=this;for(;o;){if(o===s.zone)throw Error(`can not reschedule task to ${this.name} which is descendants of the original zone ${s.zone.name}`);o=o.parent}}s._transitionTo(k,q);let i=[];s._zoneDelegates=i,s._zone=this;try{s=this._zoneDelegate.scheduleTask(this,s)}catch(o){throw s._transitionTo(X,k,q),this._zoneDelegate.handleError(this,o),o}return s._zoneDelegates===i&&this._updateTaskCount(s,1),s.state==k&&s._transitionTo(d,k),s}scheduleMicroTask(s,i,o,g){return this.scheduleTask(new E(G,s,i,o,g,void 0))}scheduleMacroTask(s,i,o,g,V){return this.scheduleTask(new E(y,s,i,o,g,V))}scheduleEventTask(s,i,o,g,V){return this.scheduleTask(new E(z,s,i,o,g,V))}cancelTask(s){if(s.zone!=this)throw new Error("A task can only be cancelled in the zone of creation! (Creation: "+(s.zone||J).name+"; Execution: "+this.name+")");if(!(s.state!==d&&s.state!==A)){s._transitionTo(x,d,A);try{this._zoneDelegate.cancelTask(this,s)}catch(i){throw s._transitionTo(X,x),this._zoneDelegate.handleError(this,i),i}return this._updateTaskCount(s,-1),s._transitionTo(q,x),s.runCount=-1,s}}_updateTaskCount(s,i){let o=s._zoneDelegates;i==-1&&(s._zoneDelegates=null);for(let g=0;gM.hasTask(i,o),onScheduleTask:(M,s,i,o)=>M.scheduleTask(i,o),onInvokeTask:(M,s,i,o,g,V)=>M.invokeTask(i,o,g,V),onCancelTask:(M,s,i,o)=>M.cancelTask(i,o)};class f{get zone(){return this._zone}constructor(s,i,o){this._taskCounts={microTask:0,macroTask:0,eventTask:0},this._zone=s,this._parentDelegate=i,this._forkZS=o&&(o&&o.onFork?o:i._forkZS),this._forkDlgt=o&&(o.onFork?i:i._forkDlgt),this._forkCurrZone=o&&(o.onFork?this._zone:i._forkCurrZone),this._interceptZS=o&&(o.onIntercept?o:i._interceptZS),this._interceptDlgt=o&&(o.onIntercept?i:i._interceptDlgt),this._interceptCurrZone=o&&(o.onIntercept?this._zone:i._interceptCurrZone),this._invokeZS=o&&(o.onInvoke?o:i._invokeZS),this._invokeDlgt=o&&(o.onInvoke?i:i._invokeDlgt),this._invokeCurrZone=o&&(o.onInvoke?this._zone:i._invokeCurrZone),this._handleErrorZS=o&&(o.onHandleError?o:i._handleErrorZS),this._handleErrorDlgt=o&&(o.onHandleError?i:i._handleErrorDlgt),this._handleErrorCurrZone=o&&(o.onHandleError?this._zone:i._handleErrorCurrZone),this._scheduleTaskZS=o&&(o.onScheduleTask?o:i._scheduleTaskZS),this._scheduleTaskDlgt=o&&(o.onScheduleTask?i:i._scheduleTaskDlgt),this._scheduleTaskCurrZone=o&&(o.onScheduleTask?this._zone:i._scheduleTaskCurrZone),this._invokeTaskZS=o&&(o.onInvokeTask?o:i._invokeTaskZS),this._invokeTaskDlgt=o&&(o.onInvokeTask?i:i._invokeTaskDlgt),this._invokeTaskCurrZone=o&&(o.onInvokeTask?this._zone:i._invokeTaskCurrZone),this._cancelTaskZS=o&&(o.onCancelTask?o:i._cancelTaskZS),this._cancelTaskDlgt=o&&(o.onCancelTask?i:i._cancelTaskDlgt),this._cancelTaskCurrZone=o&&(o.onCancelTask?this._zone:i._cancelTaskCurrZone),this._hasTaskZS=null,this._hasTaskDlgt=null,this._hasTaskDlgtOwner=null,this._hasTaskCurrZone=null;let g=o&&o.onHasTask,V=i&&i._hasTaskZS;(g||V)&&(this._hasTaskZS=g?o:c,this._hasTaskDlgt=i,this._hasTaskDlgtOwner=this,this._hasTaskCurrZone=this._zone,o.onScheduleTask||(this._scheduleTaskZS=c,this._scheduleTaskDlgt=i,this._scheduleTaskCurrZone=this._zone),o.onInvokeTask||(this._invokeTaskZS=c,this._invokeTaskDlgt=i,this._invokeTaskCurrZone=this._zone),o.onCancelTask||(this._cancelTaskZS=c,this._cancelTaskDlgt=i,this._cancelTaskCurrZone=this._zone))}fork(s,i){return this._forkZS?this._forkZS.onFork(this._forkDlgt,this.zone,s,i):new t(s,i)}intercept(s,i,o){return this._interceptZS?this._interceptZS.onIntercept(this._interceptDlgt,this._interceptCurrZone,s,i,o):i}invoke(s,i,o,g,V){return this._invokeZS?this._invokeZS.onInvoke(this._invokeDlgt,this._invokeCurrZone,s,i,o,g,V):i.apply(o,g)}handleError(s,i){return this._handleErrorZS?this._handleErrorZS.onHandleError(this._handleErrorDlgt,this._handleErrorCurrZone,s,i):!0}scheduleTask(s,i){let o=i;if(this._scheduleTaskZS)this._hasTaskZS&&o._zoneDelegates.push(this._hasTaskDlgtOwner),o=this._scheduleTaskZS.onScheduleTask(this._scheduleTaskDlgt,this._scheduleTaskCurrZone,s,i),o||(o=i);else if(i.scheduleFn)i.scheduleFn(i);else if(i.type==G)U(i);else throw new Error("Task is missing scheduleFn.");return o}invokeTask(s,i,o,g){return this._invokeTaskZS?this._invokeTaskZS.onInvokeTask(this._invokeTaskDlgt,this._invokeTaskCurrZone,s,i,o,g):i.callback.apply(o,g)}cancelTask(s,i){let o;if(this._cancelTaskZS)o=this._cancelTaskZS.onCancelTask(this._cancelTaskDlgt,this._cancelTaskCurrZone,s,i);else{if(!i.cancelFn)throw Error("Task is not cancelable");o=i.cancelFn(i)}return o}hasTask(s,i){try{this._hasTaskZS&&this._hasTaskZS.onHasTask(this._hasTaskDlgt,this._hasTaskCurrZone,s,i)}catch(o){this.handleError(s,o)}}_updateTaskCount(s,i){let o=this._taskCounts,g=o[s],V=o[s]=g+i;if(V<0)throw new Error("More tasks executed then were scheduled.");if(g==0||V==0){let ee={microTask:o.microTask>0,macroTask:o.macroTask>0,eventTask:o.eventTask>0,change:s};this.hasTask(this._zone,ee)}}}class E{constructor(s,i,o,g,V,ee){if(this._zone=null,this.runCount=0,this._zoneDelegates=null,this._state="notScheduled",this.type=s,this.source=i,this.data=g,this.scheduleFn=V,this.cancelFn=ee,!o)throw new Error("callback is not defined");this.callback=o;let Z=this;s===z&&g&&g.useG?this.invoke=E.invokeTask:this.invoke=function(){return E.invokeTask.call(ce,Z,this,arguments)}}static invokeTask(s,i,o){s||(s=this),K++;try{return s.runCount++,s.zone.runTask(s,i,o)}finally{K==1&&$(),K--}}get zone(){return this._zone}get state(){return this._state}cancelScheduleRequest(){this._transitionTo(q,k)}_transitionTo(s,i,o){if(this._state===i||this._state===o)this._state=s,s==q&&(this._zoneDelegates=null);else throw new Error(`${this.type} '${this.source}': can not transition to '${s}', expecting state '${i}'${o?" or '"+o+"'":""}, was '${this._state}'.`)}toString(){return this.data&&typeof this.data.handleId<"u"?this.data.handleId.toString():Object.prototype.toString.call(this)}toJSON(){return{type:this.type,state:this.state,source:this.source,zone:this.zone.name,runCount:this.runCount}}}let T=te("setTimeout"),p=te("Promise"),C=te("then"),_=[],P=!1,I;function H(M){if(I||ce[p]&&(I=ce[p].resolve(0)),I){let s=I[C];s||(s=I.then),s.call(I,M)}else ce[T](M,0)}function U(M){K===0&&_.length===0&&H($),M&&_.push(M)}function $(){if(!P){for(P=!0;_.length;){let M=_;_=[];for(let s=0;sb,onUnhandledError:W,microtaskDrainDone:W,scheduleMicroTask:U,showUncaughtError:()=>!t[te("ignoreConsoleErrorUncaughtError")],patchEventTarget:()=>[],patchOnProperties:W,patchMethod:()=>W,bindArguments:()=>[],patchThen:()=>W,patchMacroTask:()=>W,patchEventPrototype:()=>W,isIEOrEdge:()=>!1,getGlobalObjects:()=>{},ObjectDefineProperty:()=>W,ObjectGetOwnPropertyDescriptor:()=>{},ObjectCreate:()=>{},ArraySlice:()=>[],patchClass:()=>W,wrapWithCurrentZone:()=>W,filterProperties:()=>[],attachOriginToPatched:()=>W,_redefineProperty:()=>W,patchCallbacks:()=>W,nativeScheduleMicroTask:H},b={parent:null,zone:new t(null,null)},D=null,K=0;function W(){}return a("Zone","Zone"),t}function _t(){let e=globalThis,n=e[te("forceDuplicateZoneCheck")]===!0;if(e.Zone&&(n||typeof e.Zone.__symbol__!="function"))throw new Error("Zone already loaded.");return e.Zone??=dt(),e.Zone}var be=Object.getOwnPropertyDescriptor,Ae=Object.defineProperty,je=Object.getPrototypeOf,Et=Object.create,Tt=Array.prototype.slice,He="addEventListener",xe="removeEventListener",Le=te(He),Ie=te(xe),ae="true",le="false",Pe=te("");function Ve(e,n){return Zone.current.wrap(e,n)}function Ge(e,n,a,t,c){return Zone.current.scheduleMacroTask(e,n,a,t,c)}var j=te,De=typeof window<"u",pe=De?window:void 0,Y=De&&pe||globalThis,gt="removeAttribute";function Fe(e,n){for(let a=e.length-1;a>=0;a--)typeof e[a]=="function"&&(e[a]=Ve(e[a],n+"_"+a));return e}function yt(e,n){let a=e.constructor.name;for(let t=0;t{let p=function(){return T.apply(this,Fe(arguments,a+"."+c))};return fe(p,T),p})(f)}}}function tt(e){return e?e.writable===!1?!1:!(typeof e.get=="function"&&typeof e.set>"u"):!0}var nt=typeof WorkerGlobalScope<"u"&&self instanceof WorkerGlobalScope,Se=!("nw"in Y)&&typeof Y.process<"u"&&Y.process.toString()==="[object process]",Be=!Se&&!nt&&!!(De&&pe.HTMLElement),rt=typeof Y.process<"u"&&Y.process.toString()==="[object process]"&&!nt&&!!(De&&pe.HTMLElement),Ce={},mt=j("enable_beforeunload"),Ye=function(e){if(e=e||Y.event,!e)return;let n=Ce[e.type];n||(n=Ce[e.type]=j("ON_PROPERTY"+e.type));let a=this||e.target||Y,t=a[n],c;if(Be&&a===pe&&e.type==="error"){let f=e;c=t&&t.call(this,f.message,f.filename,f.lineno,f.colno,f.error),c===!0&&e.preventDefault()}else c=t&&t.apply(this,arguments),e.type==="beforeunload"&&Y[mt]&&typeof c=="string"?e.returnValue=c:c!=null&&!c&&e.preventDefault();return c};function $e(e,n,a){let t=be(e,n);if(!t&&a&&be(a,n)&&(t={enumerable:!0,configurable:!0}),!t||!t.configurable)return;let c=j("on"+n+"patched");if(e.hasOwnProperty(c)&&e[c])return;delete t.writable,delete t.value;let f=t.get,E=t.set,T=n.slice(2),p=Ce[T];p||(p=Ce[T]=j("ON_PROPERTY"+T)),t.set=function(C){let _=this;if(!_&&e===Y&&(_=Y),!_)return;typeof _[p]=="function"&&_.removeEventListener(T,Ye),E&&E.call(_,null),_[p]=C,typeof C=="function"&&_.addEventListener(T,Ye,!1)},t.get=function(){let C=this;if(!C&&e===Y&&(C=Y),!C)return null;let _=C[p];if(_)return _;if(f){let P=f.call(this);if(P)return t.set.call(this,P),typeof C[gt]=="function"&&C.removeAttribute(n),P}return null},Ae(e,n,t),e[c]=!0}function ot(e,n,a){if(n)for(let t=0;tfunction(E,T){let p=a(E,T);return p.cbIdx>=0&&typeof T[p.cbIdx]=="function"?Ge(p.name,T[p.cbIdx],p,c):f.apply(E,T)})}function fe(e,n){e[j("OriginalDelegate")]=n}var Je=!1,Me=!1;function kt(){try{let e=pe.navigator.userAgent;if(e.indexOf("MSIE ")!==-1||e.indexOf("Trident/")!==-1)return!0}catch{}return!1}function vt(){if(Je)return Me;Je=!0;try{let e=pe.navigator.userAgent;(e.indexOf("MSIE ")!==-1||e.indexOf("Trident/")!==-1||e.indexOf("Edge/")!==-1)&&(Me=!0)}catch{}return Me}function Ke(e){return typeof e=="function"}function Qe(e){return typeof e=="number"}var me=!1;if(typeof window<"u")try{let e=Object.defineProperty({},"passive",{get:function(){me=!0}});window.addEventListener("test",e,e),window.removeEventListener("test",e,e)}catch{me=!1}var bt={useG:!0},ne={},st={},it=new RegExp("^"+Pe+"(\\w+)(true|false)$"),ct=j("propagationStopped");function at(e,n){let a=(n?n(e):e)+le,t=(n?n(e):e)+ae,c=Pe+a,f=Pe+t;ne[e]={},ne[e][le]=c,ne[e][ae]=f}function Pt(e,n,a,t){let c=t&&t.add||He,f=t&&t.rm||xe,E=t&&t.listeners||"eventListeners",T=t&&t.rmAll||"removeAllListeners",p=j(c),C="."+c+":",_="prependListener",P="."+_+":",I=function(k,d,A){if(k.isRemoved)return;let x=k.callback;typeof x=="object"&&x.handleEvent&&(k.callback=y=>x.handleEvent(y),k.originalDelegate=x);let X;try{k.invoke(k,d,[A])}catch(y){X=y}let G=k.options;if(G&&typeof G=="object"&&G.once){let y=k.originalDelegate?k.originalDelegate:k.callback;d[f].call(d,A.type,y,G)}return X};function H(k,d,A){if(d=d||e.event,!d)return;let x=k||d.target||e,X=x[ne[d.type][A?ae:le]];if(X){let G=[];if(X.length===1){let y=I(X[0],x,d);y&&G.push(y)}else{let y=X.slice();for(let z=0;z{throw z})}}}let U=function(k){return H(this,k,!1)},$=function(k){return H(this,k,!0)};function J(k,d){if(!k)return!1;let A=!0;d&&d.useG!==void 0&&(A=d.useG);let x=d&&d.vh,X=!0;d&&d.chkDup!==void 0&&(X=d.chkDup);let G=!1;d&&d.rt!==void 0&&(G=d.rt);let y=k;for(;y&&!y.hasOwnProperty(c);)y=je(y);if(!y&&k[c]&&(y=k),!y||y[p])return!1;let z=d&&d.eventNameToString,S={},w=y[p]=y[c],b=y[j(f)]=y[f],D=y[j(E)]=y[E],K=y[j(T)]=y[T],W;d&&d.prepend&&(W=y[j(d.prepend)]=y[d.prepend]);function M(r,u){return!me&&typeof r=="object"&&r?!!r.capture:!me||!u?r:typeof r=="boolean"?{capture:r,passive:!0}:r?typeof r=="object"&&r.passive!==!1?{...r,passive:!0}:r:{passive:!0}}let s=function(r){if(!S.isExisting)return w.call(S.target,S.eventName,S.capture?$:U,S.options)},i=function(r){if(!r.isRemoved){let u=ne[r.eventName],v;u&&(v=u[r.capture?ae:le]);let R=v&&r.target[v];if(R){for(let m=0;mre.zone.cancelTask(re);r.call(Te,"abort",ie,{once:!0}),re.removeAbortListener=()=>Te.removeEventListener("abort",ie)}if(S.target=null,ke&&(ke.taskData=null),Ue&&(S.options.once=!0),!me&&typeof re.options=="boolean"||(re.options=se),re.target=N,re.capture=Oe,re.eventName=L,B&&(re.originalDelegate=F),O?ge.unshift(re):ge.push(re),m)return N}};return y[c]=l(w,C,ee,Z,G),W&&(y[_]=l(W,P,g,Z,G,!0)),y[f]=function(){let r=this||e,u=arguments[0];d&&d.transferEventName&&(u=d.transferEventName(u));let v=arguments[2],R=v?typeof v=="boolean"?!0:v.capture:!1,m=arguments[1];if(!m)return b.apply(this,arguments);if(x&&!x(b,m,r,arguments))return;let O=ne[u],N;O&&(N=O[R?ae:le]);let L=N&&r[N];if(L)for(let F=0;Ffunction(c,f){c[ct]=!0,t&&t.apply(c,f)})}function Rt(e,n){n.patchMethod(e,"queueMicrotask",a=>function(t,c){Zone.current.scheduleMicroTask("queueMicrotask",c[0])})}var Re=j("zoneTask");function ye(e,n,a,t){let c=null,f=null;n+=t,a+=t;let E={};function T(C){let _=C.data;_.args[0]=function(){return C.invoke.apply(this,arguments)};let P=c.apply(e,_.args);return Qe(P)?_.handleId=P:(_.handle=P,_.isRefreshable=Ke(P.refresh)),C}function p(C){let{handle:_,handleId:P}=C.data;return f.call(e,_??P)}c=ue(e,n,C=>function(_,P){if(Ke(P[0])){let I={isRefreshable:!1,isPeriodic:t==="Interval",delay:t==="Timeout"||t==="Interval"?P[1]||0:void 0,args:P},H=P[0];P[0]=function(){try{return H.apply(this,arguments)}finally{let{handle:A,handleId:x,isPeriodic:X,isRefreshable:G}=I;!X&&!G&&(x?delete E[x]:A&&(A[Re]=null))}};let U=Ge(n,P[0],I,T,p);if(!U)return U;let{handleId:$,handle:J,isRefreshable:q,isPeriodic:k}=U.data;if($)E[$]=U;else if(J&&(J[Re]=U,q&&!k)){let d=J.refresh;J.refresh=function(){let{zone:A,state:x}=U;return x==="notScheduled"?(U._state="scheduled",A._updateTaskCount(U,1)):x==="running"&&(U._state="scheduling"),d.call(this)}}return J??$??U}else return C.apply(e,P)}),f=ue(e,a,C=>function(_,P){let I=P[0],H;Qe(I)?(H=E[I],delete E[I]):(H=I?.[Re],H?I[Re]=null:H=I),H?.type?H.cancelFn&&H.zone.cancelTask(H):C.apply(e,P)})}function Ct(e,n){let{isBrowser:a,isMix:t}=n.getGlobalObjects();if(!a&&!t||!e.customElements||!("customElements"in e))return;let c=["connectedCallback","disconnectedCallback","adoptedCallback","attributeChangedCallback","formAssociatedCallback","formDisabledCallback","formResetCallback","formStateRestoreCallback"];n.patchCallbacks(n,e.customElements,"customElements","define",c)}function Dt(e,n){if(Zone[n.symbol("patchEventTarget")])return;let{eventNames:a,zoneSymbolEventNames:t,TRUE_STR:c,FALSE_STR:f,ZONE_SYMBOL_PREFIX:E}=n.getGlobalObjects();for(let p=0;pf.target===e);if(!t||t.length===0)return n;let c=t[0].ignoreProperties;return n.filter(f=>c.indexOf(f)===-1)}function et(e,n,a,t){if(!e)return;let c=ut(e,n,a);ot(e,c,t)}function Ze(e){return Object.getOwnPropertyNames(e).filter(n=>n.startsWith("on")&&n.length>2).map(n=>n.substring(2))}function Ot(e,n){if(Se&&!rt||Zone[e.symbol("patchEvents")])return;let a=n.__Zone_ignore_on_properties,t=[];if(Be){let c=window;t=t.concat(["Document","SVGElement","Element","HTMLElement","HTMLBodyElement","HTMLMediaElement","HTMLFrameSetElement","HTMLFrameElement","HTMLIFrameElement","HTMLMarqueeElement","Worker"]);let f=kt()?[{target:c,ignoreProperties:["error"]}]:[];et(c,Ze(c),a&&a.concat(f),je(c))}t=t.concat(["XMLHttpRequest","XMLHttpRequestEventTarget","IDBIndex","IDBRequest","IDBOpenDBRequest","IDBDatabase","IDBTransaction","IDBCursor","WebSocket"]);for(let c=0;c{let a=n[e.__symbol__("legacyPatch")];a&&a()}),e.__load_patch("timers",n=>{let a="set",t="clear";ye(n,a,t,"Timeout"),ye(n,a,t,"Interval"),ye(n,a,t,"Immediate")}),e.__load_patch("requestAnimationFrame",n=>{ye(n,"request","cancel","AnimationFrame"),ye(n,"mozRequest","mozCancel","AnimationFrame"),ye(n,"webkitRequest","webkitCancel","AnimationFrame")}),e.__load_patch("blocking",(n,a)=>{let t=["alert","prompt","confirm"];for(let c=0;cfunction(C,_){return a.current.run(E,n,_,p)})}}),e.__load_patch("EventTarget",(n,a,t)=>{St(n,t),Dt(n,t);let c=n.XMLHttpRequestEventTarget;c&&c.prototype&&t.patchEventTarget(n,t,[c.prototype])}),e.__load_patch("MutationObserver",(n,a,t)=>{ve("MutationObserver"),ve("WebKitMutationObserver")}),e.__load_patch("IntersectionObserver",(n,a,t)=>{ve("IntersectionObserver")}),e.__load_patch("FileReader",(n,a,t)=>{ve("FileReader")}),e.__load_patch("on_property",(n,a,t)=>{Ot(t,n)}),e.__load_patch("customElements",(n,a,t)=>{Ct(n,t)}),e.__load_patch("XHR",(n,a)=>{C(n);let t=j("xhrTask"),c=j("xhrSync"),f=j("xhrListener"),E=j("xhrScheduled"),T=j("xhrURL"),p=j("xhrErrorBeforeScheduled");function C(_){let P=_.XMLHttpRequest;if(!P)return;let I=P.prototype;function H(w){return w[t]}let U=I[Le],$=I[Ie];if(!U){let w=_.XMLHttpRequestEventTarget;if(w){let b=w.prototype;U=b[Le],$=b[Ie]}}let J="readystatechange",q="scheduled";function k(w){let b=w.data,D=b.target;D[E]=!1,D[p]=!1;let K=D[f];U||(U=D[Le],$=D[Ie]),K&&$.call(D,J,K);let W=D[f]=()=>{if(D.readyState===D.DONE)if(!b.aborted&&D[E]&&w.state===q){let s=D[a.__symbol__("loadfalse")];if(D.status!==0&&s&&s.length>0){let i=w.invoke;w.invoke=function(){let o=D[a.__symbol__("loadfalse")];for(let g=0;gfunction(w,b){return w[c]=b[2]==!1,w[T]=b[1],x.apply(w,b)}),X="XMLHttpRequest.send",G=j("fetchTaskAborting"),y=j("fetchTaskScheduling"),z=ue(I,"send",()=>function(w,b){if(a.current[y]===!0||w[c])return z.apply(w,b);{let D={target:w,url:w[T],isPeriodic:!1,args:b,aborted:!1},K=Ge(X,d,D,k,A);w&&w[p]===!0&&!D.aborted&&K.state===q&&K.invoke()}}),S=ue(I,"abort",()=>function(w,b){let D=H(w);if(D&&typeof D.type=="string"){if(D.cancelFn==null||D.data&&D.data.aborted)return;D.zone.cancelTask(D)}else if(a.current[G]===!0)return S.apply(w,b)})}}),e.__load_patch("geolocation",n=>{n.navigator&&n.navigator.geolocation&&yt(n.navigator.geolocation,["getCurrentPosition","watchPosition"])}),e.__load_patch("PromiseRejectionEvent",(n,a)=>{function t(c){return function(f){lt(n,c).forEach(T=>{let p=n.PromiseRejectionEvent;if(p){let C=new p(c,{promise:f.promise,reason:f.rejection});T.invoke(C)}})}}n.PromiseRejectionEvent&&(a[j("unhandledPromiseRejectionHandler")]=t("unhandledrejection"),a[j("rejectionHandledHandler")]=t("rejectionhandled"))}),e.__load_patch("queueMicrotask",(n,a,t)=>{Rt(n,t)})}function Lt(e){e.__load_patch("ZoneAwarePromise",(n,a,t)=>{let c=Object.getOwnPropertyDescriptor,f=Object.defineProperty;function E(h){if(h&&h.toString===Object.prototype.toString){let l=h.constructor&&h.constructor.name;return(l||"")+": "+JSON.stringify(h)}return h?h.toString():Object.prototype.toString.call(h)}let T=t.symbol,p=[],C=n[T("DISABLE_WRAPPING_UNCAUGHT_PROMISE_REJECTION")]!==!1,_=T("Promise"),P=T("then"),I="__creationTrace__";t.onUnhandledError=h=>{if(t.showUncaughtError()){let l=h&&h.rejection;l?console.error("Unhandled Promise rejection:",l instanceof Error?l.message:l,"; Zone:",h.zone.name,"; Task:",h.task&&h.task.source,"; Value:",l,l instanceof Error?l.stack:void 0):console.error(h)}},t.microtaskDrainDone=()=>{for(;p.length;){let h=p.shift();try{h.zone.runGuarded(()=>{throw h.throwOriginal?h.rejection:h})}catch(l){U(l)}}};let H=T("unhandledPromiseRejectionHandler");function U(h){t.onUnhandledError(h);try{let l=a[H];typeof l=="function"&&l.call(this,h)}catch{}}function $(h){return h&&h.then}function J(h){return h}function q(h){return Z.reject(h)}let k=T("state"),d=T("value"),A=T("finally"),x=T("parentPromiseValue"),X=T("parentPromiseState"),G="Promise.then",y=null,z=!0,S=!1,w=0;function b(h,l){return r=>{try{M(h,l,r)}catch(u){M(h,!1,u)}}}let D=function(){let h=!1;return function(r){return function(){h||(h=!0,r.apply(null,arguments))}}},K="Promise resolved with itself",W=T("currentTaskTrace");function M(h,l,r){let u=D();if(h===r)throw new TypeError(K);if(h[k]===y){let v=null;try{(typeof r=="object"||typeof r=="function")&&(v=r&&r.then)}catch(R){return u(()=>{M(h,!1,R)})(),h}if(l!==S&&r instanceof Z&&r.hasOwnProperty(k)&&r.hasOwnProperty(d)&&r[k]!==y)i(r),M(h,r[k],r[d]);else if(l!==S&&typeof v=="function")try{v.call(r,u(b(h,l)),u(b(h,!1)))}catch(R){u(()=>{M(h,!1,R)})()}else{h[k]=l;let R=h[d];if(h[d]=r,h[A]===A&&l===z&&(h[k]=h[X],h[d]=h[x]),l===S&&r instanceof Error){let m=a.currentTask&&a.currentTask.data&&a.currentTask.data[I];m&&f(r,W,{configurable:!0,enumerable:!1,writable:!0,value:m})}for(let m=0;m{try{let O=h[d],N=!!r&&A===r[A];N&&(r[x]=O,r[X]=R);let L=l.run(m,void 0,N&&m!==q&&m!==J?[]:[O]);M(r,!0,L)}catch(O){M(r,!1,O)}},r)}let g="function ZoneAwarePromise() { [native code] }",V=function(){},ee=n.AggregateError;class Z{static toString(){return g}static resolve(l){return l instanceof Z?l:M(new this(null),z,l)}static reject(l){return M(new this(null),S,l)}static withResolvers(){let l={};return l.promise=new Z((r,u)=>{l.resolve=r,l.reject=u}),l}static any(l){if(!l||typeof l[Symbol.iterator]!="function")return Promise.reject(new ee([],"All promises were rejected"));let r=[],u=0;try{for(let m of l)u++,r.push(Z.resolve(m))}catch{return Promise.reject(new ee([],"All promises were rejected"))}if(u===0)return Promise.reject(new ee([],"All promises were rejected"));let v=!1,R=[];return new Z((m,O)=>{for(let N=0;N{v||(v=!0,m(L))},L=>{R.push(L),u--,u===0&&(v=!0,O(new ee(R,"All promises were rejected")))})})}static race(l){let r,u,v=new this((O,N)=>{r=O,u=N});function R(O){r(O)}function m(O){u(O)}for(let O of l)$(O)||(O=this.resolve(O)),O.then(R,m);return v}static all(l){return Z.allWithCallback(l)}static allSettled(l){return(this&&this.prototype instanceof Z?this:Z).allWithCallback(l,{thenCallback:u=>({status:"fulfilled",value:u}),errorCallback:u=>({status:"rejected",reason:u})})}static allWithCallback(l,r){let u,v,R=new this((L,F)=>{u=L,v=F}),m=2,O=0,N=[];for(let L of l){$(L)||(L=this.resolve(L));let F=O;try{L.then(B=>{N[F]=r?r.thenCallback(B):B,m--,m===0&&u(N)},B=>{r?(N[F]=r.errorCallback(B),m--,m===0&&u(N)):v(B)})}catch(B){v(B)}m++,O++}return m-=2,m===0&&u(N),R}constructor(l){let r=this;if(!(r instanceof Z))throw new Error("Must be an instanceof Promise.");r[k]=y,r[d]=[];try{let u=D();l&&l(u(b(r,z)),u(b(r,S)))}catch(u){M(r,!1,u)}}get[Symbol.toStringTag](){return"Promise"}get[Symbol.species](){return Z}then(l,r){let u=this.constructor?.[Symbol.species];(!u||typeof u!="function")&&(u=this.constructor||Z);let v=new u(V),R=a.current;return this[k]==y?this[d].push(R,v,l,r):o(this,R,v,l,r),v}catch(l){return this.then(null,l)}finally(l){let r=this.constructor?.[Symbol.species];(!r||typeof r!="function")&&(r=Z);let u=new r(V);u[A]=A;let v=a.current;return this[k]==y?this[d].push(v,u,l,l):o(this,v,u,l,l),u}}Z.resolve=Z.resolve,Z.reject=Z.reject,Z.race=Z.race,Z.all=Z.all;let he=n[_]=n.Promise;n.Promise=Z;let _e=T("thenPatched");function Q(h){let l=h.prototype,r=c(l,"then");if(r&&(r.writable===!1||!r.configurable))return;let u=l.then;l[P]=u,h.prototype.then=function(v,R){return new Z((O,N)=>{u.call(this,O,N)}).then(v,R)},h[_e]=!0}t.patchThen=Q;function Ee(h){return function(l,r){let u=h.apply(l,r);if(u instanceof Z)return u;let v=u.constructor;return v[_e]||Q(v),u}}return he&&(Q(he),ue(n,"fetch",h=>Ee(h))),Promise[a.__symbol__("uncaughtPromiseErrors")]=p,Z})}function It(e){e.__load_patch("toString",n=>{let a=Function.prototype.toString,t=j("OriginalDelegate"),c=j("Promise"),f=j("Error"),E=function(){if(typeof this=="function"){let _=this[t];if(_)return typeof _=="function"?a.call(_):Object.prototype.toString.call(_);if(this===Promise){let P=n[c];if(P)return a.call(P)}if(this===Error){let P=n[f];if(P)return a.call(P)}}return a.call(this)};E[t]=a,Function.prototype.toString=E;let T=Object.prototype.toString,p="[object Promise]";Object.prototype.toString=function(){return typeof Promise=="function"&&this instanceof Promise?p:T.call(this)}})}function Mt(e,n,a,t,c){let f=Zone.__symbol__(t);if(n[f])return;let E=n[f]=n[t];n[t]=function(T,p,C){return p&&p.prototype&&c.forEach(function(_){let P=`${a}.${t}::`+_,I=p.prototype;try{if(I.hasOwnProperty(_)){let H=e.ObjectGetOwnPropertyDescriptor(I,_);H&&H.value?(H.value=e.wrapWithCurrentZone(H.value,P),e._redefineProperty(p.prototype,_,H)):I[_]&&(I[_]=e.wrapWithCurrentZone(I[_],P))}else I[_]&&(I[_]=e.wrapWithCurrentZone(I[_],P))}catch{}}),E.call(n,T,p,C)},e.attachOriginToPatched(n[t],E)}function Zt(e){e.__load_patch("util",(n,a,t)=>{let c=Ze(n);t.patchOnProperties=ot,t.patchMethod=ue,t.bindArguments=Fe,t.patchMacroTask=pt;let f=a.__symbol__("BLACK_LISTED_EVENTS"),E=a.__symbol__("UNPATCHED_EVENTS");n[E]&&(n[f]=n[E]),n[f]&&(a[f]=a[E]=n[f]),t.patchEventPrototype=wt,t.patchEventTarget=Pt,t.isIEOrEdge=vt,t.ObjectDefineProperty=Ae,t.ObjectGetOwnPropertyDescriptor=be,t.ObjectCreate=Et,t.ArraySlice=Tt,t.patchClass=ve,t.wrapWithCurrentZone=Ve,t.filterProperties=ut,t.attachOriginToPatched=fe,t._redefineProperty=Object.defineProperty,t.patchCallbacks=Mt,t.getGlobalObjects=()=>({globalSources:st,zoneSymbolEventNames:ne,eventNames:c,isBrowser:Be,isMix:rt,isNode:Se,TRUE_STR:ae,FALSE_STR:le,ZONE_SYMBOL_PREFIX:Pe,ADD_EVENT_LISTENER_STR:He,REMOVE_EVENT_LISTENER_STR:xe})})}function At(e){Lt(e),It(e),Zt(e)}var ft=_t();At(ft);Nt(ft); diff --git a/.firebase/hosting.LmZpcmViYXNlL2NvbGlubWljaGFlbHMvaG9zdGluZw.cache b/.firebase/hosting.LmZpcmViYXNlL2NvbGlubWljaGFlbHMvaG9zdGluZw.cache index 9bcc427..3dc0bf1 100644 --- a/.firebase/hosting.LmZpcmViYXNlL2NvbGlubWljaGFlbHMvaG9zdGluZw.cache +++ b/.firebase/hosting.LmZpcmViYXNlL2NvbGlubWljaGFlbHMvaG9zdGluZw.cache @@ -1,121 +1,127 @@ -chunk-ZGUKRA6E.js,1748572479730,0efabd2c10497ecc7eac94808e95ef33c6090b864769b2712860dcb59ad69ddb -index.html,1748572479729,17ed4364c2ea4c2f7e80a1ca8b03ba945336fe52c954faaea2a1b2e6ea15e0a7 -scripts-V4VO4MR6.js,1748572479727,38591f32e5f3ae992f35e023a4328f6e9aa886f35c5728b06ab3efbc447e09fa -polyfills-FFHMD2TL.js,1748572479728,02522c25699ab93e99a93914c3fc1158c874090dc123472be90c6746e6cf2d13 -chunk-ZWATAFO6.js,1748572479729,8b3e8a77c3db1a7f0d4749fa8a3f9ca1d9d58efe5104a8d3650b0169f4c272e9 -chunk-YDJWLCK6.js,1748572479731,a56be79910746e6f017db1b829d6587f4eb9fbbaf0592d28b937e2f2ce647650 -chunk-WWDJYTTK.js,1748572479733,ce13b8b4314236a5a4c29fc2e0605c3f24134b07bdb4d523268c23de5dbba3bd -chunk-UBVQATGU.js,1748572479733,5db064dacca072c9104bd338d5ae966b614bc9e5b3b9fb9704a93f4e024e7e37 -favicon.ico,1748572479729,8f1fc0f94ae643bb8757c3cb49f69bf6cb2228f632cb77c7e47d8ee3995d88a7 -chunk-RQSW5DVD.js,1748572479733,ccdc57525d3b99216fc05e83e624f87a06e5315c2ace14e50fca0402051a1c36 -chunk-ODE4NBXK.js,1748572479735,ee70bbb44719f8b0469f06129abaed0e3885ad541beb18a820996e3cb1333d38 -chunk-LNTGOE3M.js,1748572479735,9b5d93b9b1de3e560675ea6eb49508f6bdc4ba31a6d24b0ee74c906d6668ecb5 -chunk-RE5V77OY.js,1748572479734,a9a9f264718aab4aaf9f1f258bb860df131984c399404b763575fa58dc6d7b79 -chunk-IJ5MGM3Y.js,1748572479736,d3cf56c854e19155cf49094f8bb22ec4d4f648489b80c2e8e4c4aaf5f3d2a3e4 -chunk-J2B6NU2D.js,1748572479736,f6e60a67e02d30ccd9c1b8ee47e10499fdb92c8da34d5cb2ebc0ed615ca383ef -chunk-FKXIWFMO.js,1748572479736,1ec03de231ba51d8c18443c7467a8de2752de6e0fa2bd4a511091b292cefb664 -assets/files.json,1748572479753,f1c5c147a48e1507ad4e45d5da26441543c6969d92afef0a3fb2eeb42b2e3c9b -assets/site.webmanifest,1748572479737,1070ac375a6fc0a08c7f761891ce7f24862dd5693be284dec2fde12e3df1368a -assets/browserconfig.xml,1748572479754,6d3864fc7eef2280f3c1149bf074e00ea8ade65feeae32d65ddaa1b5b80e8412 -chunk-GITJRJ46.js,1748572479736,126e509960224ca7acd35dfc857651b7c31611fd20a7703bdbf9ae754fd59f9c -chunk-6GTWYHP4.js,1748572479736,5f1ea1633b5963af866f32c28484c17cb6bdf41e1802c51b850b5a0a0265bc20 -assets/icons/safari-pinned-tab.svg,1748572479740,65729c2333f30b56eae5e1d0bc0a8fa2e749d9348d7d640ad58ef196d366416b -assets/icons/mstile-70x70.png,1748572479741,f32880d2a737263aeb5d3ed9054f34939544ba8e05644cb85794b2096ff30075 -assets/icons/mstile-310x310.png,1748572479741,8a93720e944a5e58190b31552553edcdc7df5fdc50c47d2c14190ef64cef159a -assets/icons/mstile-310x150.png,1748572479741,44c484a4768aef97bb462d163649b71b0cc25fe62f39070c9c338ef587caa02e -assets/icons/mstile-150x150.png,1748572479741,44fc3c79497dd7fff2cce4f337a3e4b05ae3a794c3b34614e76076eafe3ce997 -assets/icons/mstile-144x144.png,1748572479741,2a3d79be470997f72cae96f9fd14e7215f82b0e6c8e0ffa4411cddf1f966da59 -assets/icons/favicon-16x16.png,1748572479742,58a3341209281a404cde68dd52f41f7056a76441b150e0b867b6023c00cc1581 -assets/icons/favicon-32x32.png,1748572479742,78cba0e271812a5a050fb832a76d166733da17dca4f76e0e3a7966aed40ad57a -assets/icons/apple-touch-icon.png,1748572479750,69f1be179ce863d8f1cd53c544d0bbfa2e75b8fb91bc6c46c2c79f24e553cc6a -assets/icons/cm-rect-320X132.png,1748572479750,4acaf18c9fa8d964e0a8e373b9f536acb4829d6010f010d99515915e94ba98b6 -assets/icons/android-chrome-192x192.png,1748572479750,7a3c2852d7d4a2ba28a46ed9f11eff9a0cbd8561dc42a5dff057a1a5e0423e75 -assets/icons/favicon.ico,1748572479741,178c96afd2edf1e231b8d3a09e6a4af3325d17dc8a9e47bc432977610f278f28 -assets/icons/android-chrome-512x512.png,1748572479750,4a926301c700ddfe7d4687e4510d20e85504090efc5caeba27ff9b1b8a3f44f8 -assets/icons/cm-rect-320_132.jpg,1748572479749,81d5239eff004740e648ca3c8b942ebccfb90ec570fb3653a44c2883534feac1 -chunk-UORSHSI3.js,1748572479733,c18b760eddcf83227b0088114fb630c274545d055161a2bcf0fb2f9d4eed8f68 -main-UABJFXNS.js,1748572479728,e8ffa4e152c558006b975819a4ec1f3ceda00629ea8bd99b9cc7fc52ef46c719 -chunk-ROD7CKOQ.js,1748572479734,abae34e8c4bbad1910e82abc16f574c6a5dc8779ac3508bbc0e6830594d7d604 -assets/images/backgrounds/night.webp,1748572479739,8f34835ea8f6edc7a9bd75e311c9c1151db34ce298113f34790ad02110f7ecec -assets/images/overlays/cracked_corner.webp,1748572479738,6744ea79435170154610db67b14f4dc96452022917d5c8238324dd2f12a8df3e -assets/icons/custom/system/weather.svg,1748572479742,4e06cec7930f015e8ec91c5810e6575abcd86878af36b1e6360115a03c236456 -assets/icons/custom/system/safari.svg,1748572479743,b6399638755d8df2673ec628ac4ec2630bb8e7e2f8065e2fb657d7ecf9eba39c -assets/icons/custom/system/photos.svg,1748572479743,62d0ac0f8ae434bbe612c341c5ae893fb8f5c8640d553646b7285c72b4983908 -assets/icons/custom/system/phone.svg,1748572479743,8b82224a2bc8cbdaa2b4fd2bb1c89345b4974498a4e8b2ab54190a71145aa3a3 -assets/icons/custom/system/mail.svg,1748572479744,d61f13a08c0df8f47649cfbb385d0598f6758036fc186728ddf3e341d13cf609 -assets/icons/custom/system/notes.svg,1748572479743,5ed1516f03c3c530892207832dd4e5721a1bbafb832a9485cdcb537e82d28482 -assets/icons/custom/system/itunes.svg,1748572479744,c95c71399e16d3e1c1175e5b56f4082560d2eaa54f2a27e2c67facaee43a6f0a -assets/icons/custom/system/gif.svg,1748572479744,99bc4100505b7ec5207484c6a6ab58ec68e30dc41256aa5041b7e77cb5e825b6 -assets/icons/custom/system/imessage.svg,1748572479744,462c33b9374830efb9501285cf3aa3466c756681c0e1dca7961976ba72ab111a -assets/icons/custom/system/clock.svg,1748572479745,263230d2e0681eff0fab2ad9ed1d10c3b85d27c29a262f82fa13aa6c52821fce -assets/icons/custom/system/camera.svg,1748572479745,5f0069f2f83de435958b13da81f621ffd800a02f44aaa17eec466ac6ac9648ef -assets/icons/custom/system/calendar.svg,1748572479745,7650e279882307a4a52fb1028ee90015dd81eec9ed0d9b4da4cd7edf9060ee65 -assets/icons/custom/system/calculator.svg,1748572479745,f858554fdcc3a0e54301d59bf56e18014bd294ef285cdc72f9ab2af2d4c1c138 -assets/icons/custom/filetypes/zip.svg,1748572479746,57679a088049582c9e995ea9d23186144d0f7242b61ccc362920ebd0a830f9f3 -assets/icons/custom/filetypes/txt.svg,1748572479746,cf9fd38dff3bc1558108521feb99888d6ff41f3591910460d28e3ff2c11bf183 -assets/icons/custom/filetypes/xls.svg,1748572479746,cdb36e073ca4ac8d98611cd4e81a2ca9c6565ed03bc616af19920ce3d3dc3c9f -assets/icons/custom/filetypes/svg.svg,1748572479747,43743525967e95b45d77f1b0a4ccaadfac1de43f082cc7ebf125bedbbc1091a5 -assets/icons/custom/filetypes/ppt.svg,1748572479747,bb6e10a985dcac1a9e0f0f130ee35b1286d94ea42f1392c77699504bc73be169 -assets/icons/custom/filetypes/png.svg,1748572479747,916c7997f077d27f89e758262740fe6bea2fe93ec9214bfdefc4211ec940c0bc -assets/icons/custom/filetypes/rar.svg,1748572479747,1310c8f408b4a3fcaaf393a55ef6f5292eabb0ce9bbaac0e890a6dc5901fca0c -assets/icons/custom/filetypes/pdf.svg,1748572479748,683cde37b87dda6acb8c871992111d5194cd29aa6b20cb3aeb2baf8d2e111ed9 -assets/icons/custom/filetypes/mp3.svg,1748572479748,8cf7540423a91b659a28d62036227879dd4b9422e722d8f65c05db107aebbb55 -assets/icons/custom/filetypes/mov.svg,1748572479748,6404455d3c88dd0ce68ec7f42bf29aa9e6e628f226940bd20cfe0b2a7de695e1 -chunk-YP32UGNH.js,1748572479730,b09a94bbc67c3b5ceb91b0ba42757c755a597b996b4d984ef693dce7351309b8 -assets/icons/custom/filetypes/jpg.svg,1748572479748,fbf5ca14b64eb9448a06e5615b9a4dc266df969262f306eccbb48dd8710f41ff -assets/icons/custom/filetypes/htm.svg,1748572479748,5ff44e456dc5efa6e375a796cd88159f4a7be3c379bfd6e0ed16f1802f817f97 -assets/icons/custom/filetypes/gif.svg,1748572479749,a214f5bdda315c3970e0e0bee1baab13fa0aa5a740d210dc2ebde99a19cb6198 -assets/icons/custom/filetypes/doc.svg,1748572479749,5e2e01abf6aff5084726023a9020984aa174e804a41a75b773282def3240cc7a -assets/icons/custom/filetypes/css.svg,1748572479749,fbeaa48d0bd23c234793ba8f646763621fceec341d42b758f22a355b31a391d9 -assets/icons/custom/filetypes/avi.svg,1748572479749,311552c79de10828cd02d9243360795a7da07084b927c353651cdded7ac8c6d1 -assets/game/levels/level-4.json,1748572479751,8407fbd53874f356c2372c1368cfca333a071867bd249110fa91f78719a55b85 -assets/game/levels/level-3.json,1748572479751,8407fbd53874f356c2372c1368cfca333a071867bd249110fa91f78719a55b85 -assets/game/levels/level-2.json,1748572479751,b0b51f4b1b3b4c05f74c149f3b72f30e7042b4e1396201fdb96f771423f286b0 -assets/game/levels/level-1.json,1748572479751,a04ee7432f0db2a24775907b51bfbf28364b2da5210da194b5ecf1bb4fcedbd0 -assets/game/commands/level-4.commands.ts,1748572479752,f66e88d478cf577e0de090f419de63ce031c9b101d61fa5b1e3c9a8848b89272 -assets/game/commands/level-3.commands.ts,1748572479752,f66e88d478cf577e0de090f419de63ce031c9b101d61fa5b1e3c9a8848b89272 -assets/game/commands/level-2.commands.ts,1748572479752,f66e88d478cf577e0de090f419de63ce031c9b101d61fa5b1e3c9a8848b89272 -assets/images/backgrounds/night.jpg,1748572479739,0318dcbc1b3de1d390d30c70909d36a15e6befba0a9b6a2c4ca118ba482ff53e -assets/game/commands/level-1.commands.ts,1748572479752,f66e88d478cf577e0de090f419de63ce031c9b101d61fa5b1e3c9a8848b89272 -assets/docs/tooltip.doc.md,1748572479753,58bd9d691eb866e54db725c1d42751fae00df9f163e0f02ebb50202716fe2fb3 -assets/docs/gameplay.doc.md,1748572479753,206eebe2a53128796afdc3abae60503abf548653461500d5e7d134da301223b2 -assets/docs/colinos-demo.doc.md,1748572479753,42b83da2243d95a17996f8a4cdca7a46d5f7f1274a66861b53c12b521c23866a -assets/docs/cipher.md,1748572479753,9837101837279dd8b9c5b794236e1572c086cd6de70346a8792b135ad07ca7dd -assets/config/game-levels.json,1748572479754,b780536a8230944ac695522d8943a148faa5145dd4612c401794c0129b9166b4 -assets/audio/efx/tada.mp3,1748572479771,7315f1ff418743154af5c6e178c6394d0a14c2b3dcf07b292300947f68bd1601 -assets/audio/efx/glitch-2.mp3,1748572479773,9ae0a5e5e2abceb20245864c4f4a9f18442c88031a8e31d283d8289d7609aeba -assets/audio/efx/glitch-3.mp3,1748572479772,b6985b6593afca9f2cfa7efd1b5d1bcf26f773c2d5c3efeb14d285ba55ea13de -assets/audio/efx/glitch-4.mp3,1748572479772,14f7a834fcbf7d36014f1dd3ef37e679267d49cd759561dd1196e170967923fa -assets/audio/efx/glitch-1.mp3,1748572479773,dd4af4ef693dd863a4dcb3cf84b6bb94aa1a43e225407c944c4e2739a391dae5 -assets/audio/efx/drum-5.mp3,1748572479773,1e98c4879c40c4a2b7e65ab6ba003a9d44896c067ac151db5d4434fefd24ebd8 -assets/audio/efx/digital-beep-2.mp3,1748572479775,2e22c97a131ea52eb92a1093fd3b4e6f8bf75a959f894b5f2a7eeb206bbedc7e -assets/audio/efx/digital-beep-1.mp3,1748572479775,3bbe90bc19f11e5e0bdd1b2f0cdd228556f8b764a8a0331494bffc79a3a781fe -assets/audio/efx/click-2.mp3,1748572479776,5bfca51e3998410c2f589b900257be323896d9f14baa4de677ad35f40b5dd55d -assets/audio/efx/click-1.mp3,1748572479776,d38579cf84b3ee4bf978099b9e4ea97f58df6bc3dc0313edd6bd92aeb44af81a -chunk-KGNW53ZE.js,1748572479735,3be20408682bad947b2dde88205dbfa6df1485a0759f7761e8329f88ab2e6deb -assets/audio/efx/startup.mp3,1748572479771,ffb2fe1ec722ed91745826198ed886bd1fcbe681f53c70c8a6d1b3e3995df5eb -assets/audio/efx/drum-4.mp3,1748572479774,ac109de6eebae5a0c9e1254d2d6572efde47332571698a922c99608656d2bb6a -assets/audio/efx/drum-3.mp3,1748572479774,67f1a984344de0e4e058404e2e492061144cabda5b45b5e409bd2b8036b0a31b -assets/audio/efx/drum-1.mp3,1748572479774,493c442755378fb423af30f0ed426c69c0b7b6e9e77ead49d1748bc6f273da18 -assets/audio/efx/drum-2.mp3,1748572479774,94b4a2c246a88d9dd77c47057a2918b46d2fc97fd8a4f356c1a7fdb2b2391ff9 -assets/audio/efx/wawa.mp3,1748572479771,be58f038fcef57ef69a5faf7149efd44292a846ef48624309d709506bf45bc40 -assets/audio/efx/response_good.mp3,1748572479772,86c003eca9769ff5517711833b7e7a7af7c4855d1f5e07877e0ac5da02001285 -assets/audio/efx/response_bad.mp3,1748572479772,51337646c0a4a84f8aadc727bec438ac99ee78c7da274e87084efe47a51ca880 -assets/audio/efx/count-beep.mp3,1748572479775,29a8856c1fb0a841a034ddfd39af24317a9ac1764b9ce428c1e767c10a97a453 -assets/audio/efx/feedback-glitch.mp3,1748572479773,260a8fecf314dd9a3307c2c091293de9b3e9d0aa6c6433395391ca4d4cee8015 -assets/audio/efx/click-3.mp3,1748572479775,45a55543d3324c253dc5911f04e44ef8b054f9186ee189b23b3e4a4b9acab5cc -assets/audio/efx/beep-warning.mp3,1748572479776,a472f7670641b352130b455b2aec042fd7f964965dd27ee44faeb2e48b685cf9 -chunk-Y4BUDMDQ.js,1748572479731,2db91255e385ef476204b807fedd18f7397178f45f05dfee9e373545c5d4994e -assets/audio/efx/bootup.mp3,1748572479776,4fac68d098db1957664d548202185786883148c0d2da3d033e479e855f164e1e -assets/images/backgrounds/day.webp,1748572479739,c51f3e78acb9db88bf1909818bd6b9690eba7de786ef6d7e0ffcb6b187248a18 -assets/audio/efx/ambient.mp3,1748572479777,23914af46bf99953fe8621cf57b1896c14add6afe688b68dfc49853e7e5a8e85 -assets/images/backgrounds/day.jpg,1748572479740,26a62ca72544e77d7e3762c10d84291d4335bde0660f81df0beffd396a0ee5e2 -chunk-PHZMN5UB.js,1748572479734,ac15286cfa9a30b2f6268e12daae2cfef5764d834bdf0701f19ca39eba0ce482 -assets/images/overlays/cracked_full.webp,1748572479738,10326386ca2a86528a771a6c6b015b1813b92b09bfb8fef1f50dfefd3967c3ac -assets/audio/music/game_loop_1.mp3,1748572479755,bb75cc68119b2d9bcd166c8634a8a6ca8e56077bd392d2d1647050db60a8be48 -styles-WHOFTCPY.css,1748572479711,d66886ad2159f8737767f74959562d8c28aed094fd048977361a10018173c0a8 -assets/audio/music/ambient_3.mp3,1748572479760,a3c3186f18c3cffd986fb1b9b7021442f9a8612b74e6fdeae8b649882ceea702 -assets/audio/music/ambient_4.mp3,1748572479758,485184f819943e4f817c22edcff2bdaa55bb8e376d3dab9199d2069522181b82 -assets/audio/music/ambient_1.mp3,1748572479770,ec4d8c9263bfbb162210576b1f123464f8b872adfb58e95a4b8fd86962f55848 -chunk-X5NZQJRD.js,1748572479732,9ab3e8d05fdf88774777022fd9a1edad2c3e6df1adf0d14fe71a7f37ba5775c4 -assets/audio/music/ambient_2.mp3,1748572479766,e023f2af13b83b61fa29d6facaf04deff94c7f03d4aed2c442f708732838cff8 +scripts-V4VO4MR6.js,1757187150841,38591f32e5f3ae992f35e023a4328f6e9aa886f35c5728b06ab3efbc447e09fa +polyfills-B6TNHZQ6.js,1757187150841,0d470d11ef45790298a0211b78859f53935f24a7d05c357a0bce142e2e12a925 +chunk-ZOCWA4ED.js,1757187150843,61afa67f588cfe3911964bf283317eee1965f705e413e1569c2749117c893e2a +chunk-XT5AIZD2.js,1757187150844,5ba05955a04ee2af46077458fa6bf7c512780f58bc006ef950858eceeada1e09 +chunk-YIQKWPT5.js,1757187150844,83b9c62878419c2e6e5173ea738110fc8307f9aa5278e8ac28eae52c0656e916 +chunk-RHXNB347.js,1757187150844,a393aa92bdaec7c0f77d3331cdbb4ca94667b77c301d18d98ebf8239837c9b57 +index.html,1757187150842,1ae941799f4c905034b306431178418a916ac0f1546ceff1701c0745a2ea1d5a +chunk-P3F2VVNI.js,1757187150846,2e7d39737627a4b105f82986eb7fa9361866bc8bd417ece8a4e2f58326d4f1fb +chunk-UZ5S3YEG.js,1757187150844,7671e73f1b8a8626cbe476791e2baecdda0fe33992c94577d5e11ffeb9835ae9 +chunk-LQKPBLYP.js,1757187150848,4c675ef993afe4208a239e64ef0d0a86f81aef569185ca0b1d03c44ede23d7e7 +favicon.ico,1757187150843,8f1fc0f94ae643bb8757c3cb49f69bf6cb2228f632cb77c7e47d8ee3995d88a7 +chunk-N7ZX7E3B.js,1757187150846,016b3107ed1e67ec40f326928d70f53bb9349a3ee7ce4609db1a892f378f3a06 +chunk-KIJZZIYW.js,1757187150848,787a0c53776139217e00a8e55b43975f5a6fed5414ab875c503ad69a5d284dbd +chunk-IK4IJ7P6.js,1757187150848,213b0350d9e2430ac36aeddd43e55040b8dc3943f2b25bbf8860895d8b61abe8 +chunk-GXSJH6RE.js,1757187150848,081ab3e765c00d542730c5e08155389a90141eef14fc88b534f9a816e596d46e +chunk-BNKGFPS2.js,1757187150850,a6b5de469c2a17567264ea79064a11b9fd047b44fad04ed7e7d9b743425fdb8e +chunk-6OJJVJXD.js,1757187150854,bbd47713d2eb4cedf60f18fa3f24a68756803f4a450d8de91b7f3b81aeac7c80 +chunk-6GSGN5NW.js,1757187150854,b90f40fb9ce0b0c69cd551dc909321ed0433fbab04ba1877ebf6dac60123b957 +chunk-54LBGYYD.js,1757187150854,224d045361c165356b02eb5fc0e5d59bb85ee8cb0dcba535d95b21e888e01ef5 +chunk-4VSPGYCZ.js,1757187150854,bb338a2753ea25b80e8533e441ae69656b859d478e13047ebfbdf03d3b951f57 +assets/files.json,1757187150871,f1c5c147a48e1507ad4e45d5da26441543c6969d92afef0a3fb2eeb42b2e3c9b +chunk-452WUMRF.js,1757187150854,c2bef2d3505dfe733b31cc742886e2682aed102b97c6888de8c0581a1fb92b52 +assets/browserconfig.xml,1757187150873,6d3864fc7eef2280f3c1149bf074e00ea8ade65feeae32d65ddaa1b5b80e8412 +assets/site.webmanifest,1757187150855,1070ac375a6fc0a08c7f761891ce7f24862dd5693be284dec2fde12e3df1368a +chunk-72CIPDHL.js,1757187150853,896179b7800f56a5aa47fafd4c329f05a0188c358dbb07499af8a93171d6774d +assets/icons/safari-pinned-tab.svg,1757187150858,65729c2333f30b56eae5e1d0bc0a8fa2e749d9348d7d640ad58ef196d366416b +assets/icons/mstile-70x70.png,1757187150859,f32880d2a737263aeb5d3ed9054f34939544ba8e05644cb85794b2096ff30075 +assets/icons/mstile-310x310.png,1757187150859,8a93720e944a5e58190b31552553edcdc7df5fdc50c47d2c14190ef64cef159a +assets/icons/mstile-310x150.png,1757187150859,44c484a4768aef97bb462d163649b71b0cc25fe62f39070c9c338ef587caa02e +assets/icons/mstile-150x150.png,1757187150859,44fc3c79497dd7fff2cce4f337a3e4b05ae3a794c3b34614e76076eafe3ce997 +assets/icons/favicon-32x32.png,1757187150860,78cba0e271812a5a050fb832a76d166733da17dca4f76e0e3a7966aed40ad57a +assets/icons/mstile-144x144.png,1757187150859,2a3d79be470997f72cae96f9fd14e7215f82b0e6c8e0ffa4411cddf1f966da59 +assets/icons/favicon.ico,1757187150859,178c96afd2edf1e231b8d3a09e6a4af3325d17dc8a9e47bc432977610f278f28 +main-555JKOLT.js,1757187150842,cb8a5923a68283baac087cd0d7f8568c63487426da1f4ba10ecd6204555ed520 +chunk-NXOWNF6Z.js,1757187150846,af58a7cc4f23515983aca27517a138bc2013cbce79e71daec40ff1915dc4c1e3 +assets/images/backgrounds/night.webp,1757187150857,8f34835ea8f6edc7a9bd75e311c9c1151db34ce298113f34790ad02110f7ecec +assets/images/overlays/cracked_corner.webp,1757187150856,6744ea79435170154610db67b14f4dc96452022917d5c8238324dd2f12a8df3e +assets/icons/favicon-16x16.png,1757187150860,58a3341209281a404cde68dd52f41f7056a76441b150e0b867b6023c00cc1581 +assets/icons/cm-rect-320X132.png,1757187150868,4acaf18c9fa8d964e0a8e373b9f536acb4829d6010f010d99515915e94ba98b6 +assets/icons/apple-touch-icon.png,1757187150868,69f1be179ce863d8f1cd53c544d0bbfa2e75b8fb91bc6c46c2c79f24e553cc6a +assets/icons/android-chrome-512x512.png,1757187150869,4a926301c700ddfe7d4687e4510d20e85504090efc5caeba27ff9b1b8a3f44f8 +assets/icons/android-chrome-192x192.png,1757187150869,7a3c2852d7d4a2ba28a46ed9f11eff9a0cbd8561dc42a5dff057a1a5e0423e75 +assets/icons/custom/system/weather.svg,1757187150861,4e06cec7930f015e8ec91c5810e6575abcd86878af36b1e6360115a03c236456 +assets/icons/custom/system/safari.svg,1757187150861,b6399638755d8df2673ec628ac4ec2630bb8e7e2f8065e2fb657d7ecf9eba39c +assets/icons/cm-rect-320_132.jpg,1757187150868,81d5239eff004740e648ca3c8b942ebccfb90ec570fb3653a44c2883534feac1 +assets/icons/custom/system/photos.svg,1757187150861,62d0ac0f8ae434bbe612c341c5ae893fb8f5c8640d553646b7285c72b4983908 +assets/icons/custom/system/phone.svg,1757187150861,8b82224a2bc8cbdaa2b4fd2bb1c89345b4974498a4e8b2ab54190a71145aa3a3 +assets/icons/custom/system/notes.svg,1757187150861,5ed1516f03c3c530892207832dd4e5721a1bbafb832a9485cdcb537e82d28482 +assets/icons/custom/system/mail.svg,1757187150862,d61f13a08c0df8f47649cfbb385d0598f6758036fc186728ddf3e341d13cf609 +assets/icons/custom/system/itunes.svg,1757187150862,c95c71399e16d3e1c1175e5b56f4082560d2eaa54f2a27e2c67facaee43a6f0a +assets/icons/custom/system/imessage.svg,1757187150862,462c33b9374830efb9501285cf3aa3466c756681c0e1dca7961976ba72ab111a +assets/icons/custom/system/gif.svg,1757187150862,99bc4100505b7ec5207484c6a6ab58ec68e30dc41256aa5041b7e77cb5e825b6 +assets/icons/custom/system/clock.svg,1757187150862,263230d2e0681eff0fab2ad9ed1d10c3b85d27c29a262f82fa13aa6c52821fce +assets/icons/custom/system/camera.svg,1757187150863,5f0069f2f83de435958b13da81f621ffd800a02f44aaa17eec466ac6ac9648ef +assets/icons/custom/system/calendar.svg,1757187150863,7650e279882307a4a52fb1028ee90015dd81eec9ed0d9b4da4cd7edf9060ee65 +assets/icons/custom/system/calculator.svg,1757187150863,f858554fdcc3a0e54301d59bf56e18014bd294ef285cdc72f9ab2af2d4c1c138 +assets/icons/custom/filetypes/zip.svg,1757187150863,57679a088049582c9e995ea9d23186144d0f7242b61ccc362920ebd0a830f9f3 +assets/icons/custom/filetypes/xls.svg,1757187150864,cdb36e073ca4ac8d98611cd4e81a2ca9c6565ed03bc616af19920ce3d3dc3c9f +assets/icons/custom/filetypes/txt.svg,1757187150864,cf9fd38dff3bc1558108521feb99888d6ff41f3591910460d28e3ff2c11bf183 +assets/icons/custom/filetypes/svg.svg,1757187150864,43743525967e95b45d77f1b0a4ccaadfac1de43f082cc7ebf125bedbbc1091a5 +assets/icons/custom/filetypes/rar.svg,1757187150864,1310c8f408b4a3fcaaf393a55ef6f5292eabb0ce9bbaac0e890a6dc5901fca0c +chunk-CMKSZQDC.js,1757187150849,5670a74340acbc0257cc5f446be25e15f9cc50770040caf6fc75e4b4d98e60af +assets/icons/custom/filetypes/ppt.svg,1757187150865,bb6e10a985dcac1a9e0f0f130ee35b1286d94ea42f1392c77699504bc73be169 +assets/icons/custom/filetypes/png.svg,1757187150865,916c7997f077d27f89e758262740fe6bea2fe93ec9214bfdefc4211ec940c0bc +chunk-C265U2LU.js,1757187150849,045d04745ddb0c128e249cb956bd27ce859edfa08a61031b8ec9514d28315f1f +assets/icons/custom/filetypes/pdf.svg,1757187150865,683cde37b87dda6acb8c871992111d5194cd29aa6b20cb3aeb2baf8d2e111ed9 +assets/icons/custom/filetypes/mp3.svg,1757187150866,8cf7540423a91b659a28d62036227879dd4b9422e722d8f65c05db107aebbb55 +assets/icons/custom/filetypes/mov.svg,1757187150866,6404455d3c88dd0ce68ec7f42bf29aa9e6e628f226940bd20cfe0b2a7de695e1 +assets/icons/custom/filetypes/jpg.svg,1757187150866,fbf5ca14b64eb9448a06e5615b9a4dc266df969262f306eccbb48dd8710f41ff +assets/icons/custom/filetypes/htm.svg,1757187150866,5ff44e456dc5efa6e375a796cd88159f4a7be3c379bfd6e0ed16f1802f817f97 +assets/icons/custom/filetypes/gif.svg,1757187150867,a214f5bdda315c3970e0e0bee1baab13fa0aa5a740d210dc2ebde99a19cb6198 +assets/icons/custom/filetypes/doc.svg,1757187150867,5e2e01abf6aff5084726023a9020984aa174e804a41a75b773282def3240cc7a +assets/icons/custom/filetypes/css.svg,1757187150867,fbeaa48d0bd23c234793ba8f646763621fceec341d42b758f22a355b31a391d9 +assets/images/backgrounds/night.jpg,1757187150857,0318dcbc1b3de1d390d30c70909d36a15e6befba0a9b6a2c4ca118ba482ff53e +assets/icons/custom/filetypes/avi.svg,1757187150867,311552c79de10828cd02d9243360795a7da07084b927c353651cdded7ac8c6d1 +assets/game/levels/level-4.json,1757187150869,8407fbd53874f356c2372c1368cfca333a071867bd249110fa91f78719a55b85 +chunk-BCXQU5SJ.js,1757187150850,53df1490c4cb5eb0208ec4ba210f64621dc9b539b1aa9d27c8d3eb7a78d193cb +assets/game/levels/level-3.json,1757187150870,8407fbd53874f356c2372c1368cfca333a071867bd249110fa91f78719a55b85 +assets/game/levels/level-2.json,1757187150870,b0b51f4b1b3b4c05f74c149f3b72f30e7042b4e1396201fdb96f771423f286b0 +assets/game/levels/level-1.json,1757187150870,a04ee7432f0db2a24775907b51bfbf28364b2da5210da194b5ecf1bb4fcedbd0 +assets/game/commands/level-4.commands.ts,1757187150871,f66e88d478cf577e0de090f419de63ce031c9b101d61fa5b1e3c9a8848b89272 +assets/game/commands/level-2.commands.ts,1757187150871,f66e88d478cf577e0de090f419de63ce031c9b101d61fa5b1e3c9a8848b89272 +assets/game/commands/level-3.commands.ts,1757187150871,f66e88d478cf577e0de090f419de63ce031c9b101d61fa5b1e3c9a8848b89272 +assets/game/commands/level-1.commands.ts,1757187150871,f66e88d478cf577e0de090f419de63ce031c9b101d61fa5b1e3c9a8848b89272 +assets/docs/tooltip.doc.md,1757187150872,58bd9d691eb866e54db725c1d42751fae00df9f163e0f02ebb50202716fe2fb3 +assets/docs/gameplay.doc.md,1757187150872,206eebe2a53128796afdc3abae60503abf548653461500d5e7d134da301223b2 +assets/docs/colinos-demo.doc.md,1757187150872,42b83da2243d95a17996f8a4cdca7a46d5f7f1274a66861b53c12b521c23866a +assets/config/game-levels.json,1757187150873,b780536a8230944ac695522d8943a148faa5145dd4612c401794c0129b9166b4 +chunk-Q42YD6Y3.js,1757187150845,9c69db1bc91fbef59182c89e6ee9afb048be5857d6613aa41d19d25858d22c91 +assets/docs/cipher.md,1757187150873,9837101837279dd8b9c5b794236e1572c086cd6de70346a8792b135ad07ca7dd +assets/audio/efx/tada.mp3,1757187150891,7315f1ff418743154af5c6e178c6394d0a14c2b3dcf07b292300947f68bd1601 +assets/audio/efx/glitch-3.mp3,1757187150892,b6985b6593afca9f2cfa7efd1b5d1bcf26f773c2d5c3efeb14d285ba55ea13de +assets/audio/efx/glitch-4.mp3,1757187150892,14f7a834fcbf7d36014f1dd3ef37e679267d49cd759561dd1196e170967923fa +assets/audio/efx/glitch-2.mp3,1757187150893,9ae0a5e5e2abceb20245864c4f4a9f18442c88031a8e31d283d8289d7609aeba +assets/audio/efx/glitch-1.mp3,1757187150893,dd4af4ef693dd863a4dcb3cf84b6bb94aa1a43e225407c944c4e2739a391dae5 +assets/audio/efx/drum-5.mp3,1757187150893,1e98c4879c40c4a2b7e65ab6ba003a9d44896c067ac151db5d4434fefd24ebd8 +assets/audio/efx/digital-beep-2.mp3,1757187150895,2e22c97a131ea52eb92a1093fd3b4e6f8bf75a959f894b5f2a7eeb206bbedc7e +assets/audio/efx/digital-beep-1.mp3,1757187150895,3bbe90bc19f11e5e0bdd1b2f0cdd228556f8b764a8a0331494bffc79a3a781fe +assets/audio/efx/startup.mp3,1757187150891,ffb2fe1ec722ed91745826198ed886bd1fcbe681f53c70c8a6d1b3e3995df5eb +assets/audio/efx/drum-4.mp3,1757187150894,ac109de6eebae5a0c9e1254d2d6572efde47332571698a922c99608656d2bb6a +assets/audio/efx/drum-3.mp3,1757187150894,67f1a984344de0e4e058404e2e492061144cabda5b45b5e409bd2b8036b0a31b +assets/audio/efx/drum-2.mp3,1757187150894,94b4a2c246a88d9dd77c47057a2918b46d2fc97fd8a4f356c1a7fdb2b2391ff9 +assets/audio/efx/drum-1.mp3,1757187150894,493c442755378fb423af30f0ed426c69c0b7b6e9e77ead49d1748bc6f273da18 +assets/audio/efx/click-1.mp3,1757187150896,d38579cf84b3ee4bf978099b9e4ea97f58df6bc3dc0313edd6bd92aeb44af81a +assets/audio/efx/click-2.mp3,1757187150896,5bfca51e3998410c2f589b900257be323896d9f14baa4de677ad35f40b5dd55d +assets/audio/efx/wawa.mp3,1757187150891,be58f038fcef57ef69a5faf7149efd44292a846ef48624309d709506bf45bc40 +assets/audio/efx/response_good.mp3,1757187150891,86c003eca9769ff5517711833b7e7a7af7c4855d1f5e07877e0ac5da02001285 +assets/audio/efx/response_bad.mp3,1757187150892,51337646c0a4a84f8aadc727bec438ac99ee78c7da274e87084efe47a51ca880 +assets/audio/efx/click-3.mp3,1757187150896,45a55543d3324c253dc5911f04e44ef8b054f9186ee189b23b3e4a4b9acab5cc +assets/audio/efx/feedback-glitch.mp3,1757187150893,260a8fecf314dd9a3307c2c091293de9b3e9d0aa6c6433395391ca4d4cee8015 +assets/audio/efx/count-beep.mp3,1757187150895,29a8856c1fb0a841a034ddfd39af24317a9ac1764b9ce428c1e767c10a97a453 +chunk-A74NXCP5.js,1757187150851,b242254c3ed9fbc7dc89570588ea97458f18e51428aa2a1b4b5b872e422f5c23 +assets/audio/efx/beep-warning.mp3,1757187150896,a472f7670641b352130b455b2aec042fd7f964965dd27ee44faeb2e48b685cf9 +chunk-PJL6QLWO.js,1757187150845,46ea222855f2204976749c991d6b257a9f4e845d38003f41068ffe1714434778 +assets/audio/efx/bootup.mp3,1757187150896,4fac68d098db1957664d548202185786883148c0d2da3d033e479e855f164e1e +assets/images/backgrounds/day.webp,1757187150857,c51f3e78acb9db88bf1909818bd6b9690eba7de786ef6d7e0ffcb6b187248a18 +assets/audio/efx/ambient.mp3,1757187150897,23914af46bf99953fe8621cf57b1896c14add6afe688b68dfc49853e7e5a8e85 +chunk-BAC7DBFE.js,1757187150851,e29c687c759822e48382cb44b157f23338d9535dd73737941d82023a7dd2569c +assets/images/backgrounds/day.jpg,1757187150858,26a62ca72544e77d7e3762c10d84291d4335bde0660f81df0beffd396a0ee5e2 +assets/images/overlays/cracked_full.webp,1757187150856,10326386ca2a86528a771a6c6b015b1813b92b09bfb8fef1f50dfefd3967c3ac +assets/audio/music/game_loop_1.mp3,1757187150874,bb75cc68119b2d9bcd166c8634a8a6ca8e56077bd392d2d1647050db60a8be48 +styles-YSYK66JW.css,1757187150828,95118d84f18919528657d1aad9e893a543ada6a958c769e18495865fef7238c9 +assets/audio/music/ambient_3.mp3,1757187150879,a3c3186f18c3cffd986fb1b9b7021442f9a8612b74e6fdeae8b649882ceea702 +assets/audio/music/ambient_4.mp3,1757187150877,485184f819943e4f817c22edcff2bdaa55bb8e376d3dab9199d2069522181b82 +assets/audio/music/ambient_1.mp3,1757187150890,ec4d8c9263bfbb162210576b1f123464f8b872adfb58e95a4b8fd86962f55848 +assets/audio/music/ambient_2.mp3,1757187150886,e023f2af13b83b61fa29d6facaf04deff94c7f03d4aed2c442f708732838cff8 +chunk-M5UIXMUN.js,1757187150847,c88252f3f4f3be0413c9d3c6fcc428ddf711b13048ac6d24da66f0a98c1b73f6 diff --git a/database.rules.json b/database.rules.json new file mode 100644 index 0000000..541d78f --- /dev/null +++ b/database.rules.json @@ -0,0 +1,20 @@ +{ + "rules": { + ".read": "auth != null", + ".write": "auth != null", + "users": { + "$uid": { + ".read": "$uid === auth.uid", + ".write": "$uid === auth.uid" + } + }, + "logs": { + ".read": "auth != null", + ".write": "auth != null" + }, + "public": { + ".read": true, + ".write": "auth != null" + } + } +} diff --git a/package.json b/package.json index c791884..d240a6a 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "build": "ng build", "build:css": "npx tailwindcss -o ./dist/output.css --minify", "watch": "ng build --watch --configuration development", - "test": "ng test", + "test": "ng test --include='**/'", "lint": "ng lint" }, "private": true, diff --git a/src/app/components/game/services/log.service.ts b/src/app/components/game/services/log.service.ts index 55a263c..b16f5b3 100644 --- a/src/app/components/game/services/log.service.ts +++ b/src/app/components/game/services/log.service.ts @@ -92,15 +92,12 @@ export class LogService { const entry: LogEntry = {level, message, timestamp: new Date()}; this.firestore.saveLogEntry( - entry.level, { level: entry.level, message: typeof entry.message === 'string' ? entry.message : '', userId: user.name, metadata: 'log entry' }, - 'test',// Use the user ID, not the user object - entry.timestamp.toISOString() ).subscribe({ next: () => { // Successfully saved log to Firestore diff --git a/src/app/services/firebase/FirestoreTestUtils.ts b/src/app/services/firebase/FirestoreTestUtils.ts new file mode 100644 index 0000000..1f26ba3 --- /dev/null +++ b/src/app/services/firebase/FirestoreTestUtils.ts @@ -0,0 +1,60 @@ +import {FirestoreService} from './firestore.service'; + +/** + * Test utilities for Firestore service integration tests + */ +export class FirestoreTestUtils { + constructor(private firestoreService: FirestoreService) { + } + + /** + * Creates test data for documents + */ + createTestDocument(overrides: any = {}) { + return { + id: `test-${Date.now()}`, + name: 'Test Document', + status: 'active', + createdAt: new Date(), + ...overrides + }; + } + + /** + * Creates multiple test documents + */ + createTestDocuments(count: number, baseData: any = {}) { + return Array.from({length: count}, (_, index) => + this.createTestDocument({ + ...baseData, + name: `Test Document ${index + 1}`, + index: index + }) + ); + } + + /** + * Cleans up test data by deleting documents + */ + async cleanupTestDocuments(collectionPath: string, documentIds: string[]) { + const deletePromises = documentIds.map(id => + this.firestoreService.deleteDocument(collectionPath, id).toPromise() + ); + + await Promise.all(deletePromises); + } + + /** + * Creates a test file for storage operations + */ + createTestFile(filename: string = 'test.txt', content: string = 'test content') { + return new File([content], filename, {type: 'text/plain'}); + } + + /** + * Generates a unique collection name for tests + */ + getTestCollectionName(baseName: string = 'test') { + return `${baseName}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + } +} diff --git a/src/app/services/firebase/firestore.service.spec.ts b/src/app/services/firebase/firestore.service.spec.ts index b182f59..095f74a 100644 --- a/src/app/services/firebase/firestore.service.spec.ts +++ b/src/app/services/firebase/firestore.service.spec.ts @@ -1,16 +1,604 @@ import {TestBed} from '@angular/core/testing'; - -import {FirestoreService} from './firestore.service'; +import {Firestore} from '@angular/fire/firestore'; +import {Storage} from '@angular/fire/storage'; +import {FirestoreService, FirestoreDocument} from './firestore.service'; +import {of} from 'rxjs'; describe('FirestoreService', () => { let service: FirestoreService; + let mockFirestore: jasmine.SpyObj; + let mockStorage: jasmine.SpyObj; + + // Mock implementations for Firebase functions + let mockDoc: jasmine.Spy; + let mockCollection: jasmine.Spy; + let mockSetDoc: jasmine.Spy; + let mockGetDoc: jasmine.Spy; + let mockUpdateDoc: jasmine.Spy; + let mockDeleteDoc: jasmine.Spy; + let mockGetDocs: jasmine.Spy; + let mockQuery: jasmine.Spy; + let mockWhere: jasmine.Spy; + let mockOrderBy: jasmine.Spy; + let mockLimit: jasmine.Spy; + let mockOnSnapshot: jasmine.Spy; + let mockServerTimestamp: jasmine.Spy; + let mockWriteBatch: jasmine.Spy; + + // Storage mocks + let mockRef: jasmine.Spy; + let mockUploadBytes: jasmine.Spy; + let mockGetDownloadURL: jasmine.Spy; + let mockDeleteObject: jasmine.Spy; + let mockUploadString: jasmine.Spy; + let mockUploadBytesResumable: jasmine.Spy; beforeEach(() => { - TestBed.configureTestingModule({}); + const firestoreSpy = jasmine.createSpyObj('Firestore', ['app']); + const storageSpy = jasmine.createSpyObj('Storage', ['app']); + + TestBed.configureTestingModule({ + providers: [ + FirestoreService, + {provide: Firestore, useValue: firestoreSpy}, + {provide: Storage, useValue: storageSpy} + ] + }); + service = TestBed.inject(FirestoreService); + mockFirestore = TestBed.inject(Firestore) as jasmine.SpyObj; + mockStorage = TestBed.inject(Storage) as jasmine.SpyObj; + + // Setup Firebase function mocks + setupFirebaseMocks(); }); + function setupFirebaseMocks() { + // Mock Firestore functions + mockDoc = jasmine.createSpy('doc').and.returnValue({id: 'mock-ref'}); + mockCollection = jasmine.createSpy('collection').and.returnValue({id: 'mock-collection'}); + mockSetDoc = jasmine.createSpy('setDoc').and.returnValue(Promise.resolve()); + mockGetDoc = jasmine.createSpy('getDoc').and.returnValue(Promise.resolve({ + exists: () => true, + id: 'test-id', + data: () => ({name: 'Test Document'}) + })); + mockUpdateDoc = jasmine.createSpy('updateDoc').and.returnValue(Promise.resolve()); + mockDeleteDoc = jasmine.createSpy('deleteDoc').and.returnValue(Promise.resolve()); + mockGetDocs = jasmine.createSpy('getDocs').and.returnValue(Promise.resolve({ + docs: [ + {id: '1', data: () => ({name: 'Doc 1'})}, + {id: '2', data: () => ({name: 'Doc 2'})} + ] + })); + mockQuery = jasmine.createSpy('query').and.returnValue({id: 'mock-query'}); + mockWhere = jasmine.createSpy('where').and.returnValue({id: 'mock-where'}); + mockOrderBy = jasmine.createSpy('orderBy').and.returnValue({id: 'mock-orderby'}); + mockLimit = jasmine.createSpy('limit').and.returnValue({id: 'mock-limit'}); + mockOnSnapshot = jasmine.createSpy('onSnapshot').and.callFake((ref: any, callback: any) => { + setTimeout(() => callback({ + exists: () => true, + id: 'test-id', + data: () => ({name: 'Test Document'}) + }), 0); + return () => { + }; // unsubscribe function + }); + mockServerTimestamp = jasmine.createSpy('serverTimestamp').and.returnValue('mock-timestamp'); + mockWriteBatch = jasmine.createSpy('writeBatch').and.returnValue({ + set: jasmine.createSpy('set'), + update: jasmine.createSpy('update'), + delete: jasmine.createSpy('delete'), + commit: jasmine.createSpy('commit').and.returnValue(Promise.resolve()) + }); + + // Mock Storage functions + mockRef = jasmine.createSpy('ref').and.returnValue({id: 'mock-storage-ref'}); + mockUploadBytes = jasmine.createSpy('uploadBytes').and.returnValue(Promise.resolve({})); + mockGetDownloadURL = jasmine.createSpy('getDownloadURL').and.returnValue( + Promise.resolve('https://example.com/file.txt') + ); + mockDeleteObject = jasmine.createSpy('deleteObject').and.returnValue(Promise.resolve()); + mockUploadString = jasmine.createSpy('uploadString').and.returnValue(Promise.resolve({})); + mockUploadBytesResumable = jasmine.createSpy('uploadBytesResumable').and.returnValue({ + on: jasmine.createSpy('on').and.callFake((event: string, progress: any, error: any, complete: any) => { + setTimeout(() => progress({bytesTransferred: 50, totalBytes: 100}), 0); + setTimeout(() => complete(), 10); + }), + snapshot: {ref: {}} + }); + + // Replace the Firebase functions in the service + (service as any).doc = mockDoc; + (service as any).collection = mockCollection; + (service as any).setDoc = mockSetDoc; + (service as any).getDoc = mockGetDoc; + (service as any).updateDoc = mockUpdateDoc; + (service as any).deleteDoc = mockDeleteDoc; + (service as any).getDocs = mockGetDocs; + (service as any).query = mockQuery; + (service as any).where = mockWhere; + (service as any).orderBy = mockOrderBy; + (service as any).limit = mockLimit; + (service as any).onSnapshot = mockOnSnapshot; + (service as any).serverTimestamp = mockServerTimestamp; + (service as any).writeBatch = mockWriteBatch; + (service as any).ref = mockRef; + (service as any).uploadBytes = mockUploadBytes; + (service as any).getDownloadURL = mockGetDownloadURL; + (service as any).deleteObject = mockDeleteObject; + (service as any).uploadString = mockUploadString; + (service as any).uploadBytesResumable = mockUploadBytesResumable; + } + it('should be created', () => { expect(service).toBeTruthy(); }); + + describe('Document Operations', () => { + describe('saveDocument', () => { + beforeEach(() => { + spyOn(service as any, 'doc').and.returnValue({id: 'mock-ref'}); + spyOn(service as any, 'setDoc').and.returnValue(Promise.resolve()); + spyOn(service as any, 'serverTimestamp').and.returnValue('mock-timestamp'); + }); + + it('should save a document with generated ID', (done) => { + const testData: FirestoreDocument = { + name: 'Test Document' + }; + + service.saveDocument('test-collection', testData).subscribe({ + next: (docId) => { + expect(docId).toBeDefined(); + expect(typeof docId).toBe('string'); + done(); + }, + error: done.fail + }); + }); + + it('should save a document with provided ID', (done) => { + const testData: FirestoreDocument = { + name: 'Test Document' + }; + + service.saveDocument('test-collection', testData, 'custom-id').subscribe({ + next: (docId) => { + expect(docId).toBe('custom-id'); + done(); + }, + error: done.fail + }); + }); + + it('should handle save errors', (done) => { + spyOn(service as any, 'setDoc').and.returnValue( + Promise.reject(new Error('Save failed')) + ); + + const testData: FirestoreDocument = { + name: 'Test Document' + }; + + service.saveDocument('test-collection', testData).subscribe({ + next: () => done.fail('Should have failed'), + error: (error) => { + expect(error.message).toContain('Failed to save document'); + done(); + } + }); + }); + }); + + describe('getDocument', () => { + beforeEach(() => { + spyOn(service as any, 'doc').and.returnValue({id: 'mock-ref'}); + }); + + it('should retrieve an existing document', (done) => { + const mockSnapshot = { + exists: () => true, + id: 'test-id', + data: () => ({name: 'Test Document'}) + }; + + spyOn(service as any, 'getDoc').and.returnValue(Promise.resolve(mockSnapshot)); + + service.getDocument('test-collection', 'test-id').subscribe({ + next: (doc: any) => { + expect(doc).toBeDefined(); + expect(doc?.id).toBe('test-id'); + expect((doc as any)?.name).toBe('Test Document'); + done(); + }, + error: done.fail + }); + }); + + it('should return null for non-existent document', (done) => { + const mockSnapshot = { + exists: () => false + }; + + spyOn(service as any, 'getDoc').and.returnValue(Promise.resolve(mockSnapshot)); + + service.getDocument('test-collection', 'non-existent').subscribe({ + next: (doc) => { + expect(doc).toBeNull(); + done(); + }, + error: done.fail + }); + }); + + it('should handle get errors', (done) => { + spyOn(service as any, 'getDoc').and.returnValue( + Promise.reject(new Error('Get failed')) + ); + + service.getDocument('test-collection', 'test-id').subscribe({ + next: () => done.fail('Should have failed'), + error: (error) => { + expect(error.message).toContain('Failed to get document'); + done(); + } + }); + }); + }); + + describe('updateDocument', () => { + beforeEach(() => { + spyOn(service as any, 'doc').and.returnValue({id: 'mock-ref'}); + spyOn(service as any, 'serverTimestamp').and.returnValue('mock-timestamp'); + }); + + it('should update a document', (done) => { + spyOn(service as any, 'updateDoc').and.returnValue(Promise.resolve()); + + const updateData = {name: 'Updated Name'}; + + service.updateDocument('test-collection', 'test-id', updateData).subscribe({ + next: () => { + done(); + }, + error: done.fail + }); + }); + + it('should handle update errors', (done) => { + spyOn(service as any, 'updateDoc').and.returnValue( + Promise.reject(new Error('Update failed')) + ); + + service.updateDocument('test-collection', 'test-id', {name: 'Updated'}).subscribe({ + next: () => done.fail('Should have failed'), + error: (error) => { + expect(error.message).toContain('Failed to update document'); + done(); + } + }); + }); + }); + + describe('deleteDocument', () => { + beforeEach(() => { + spyOn(service as any, 'doc').and.returnValue({id: 'mock-ref'}); + }); + + it('should delete a document', (done) => { + spyOn(service as any, 'deleteDoc').and.returnValue(Promise.resolve()); + + service.deleteDocument('test-collection', 'test-id').subscribe({ + next: () => { + done(); + }, + error: done.fail + }); + }); + + it('should handle delete errors', (done) => { + spyOn(service as any, 'deleteDoc').and.returnValue( + Promise.reject(new Error('Delete failed')) + ); + + service.deleteDocument('test-collection', 'test-id').subscribe({ + next: () => done.fail('Should have failed'), + error: (error) => { + expect(error.message).toContain('Failed to delete document'); + done(); + } + }); + }); + }); + }); + + describe('Query Operations', () => { + describe('queryDocuments', () => { + beforeEach(() => { + spyOn(service as any, 'collection').and.returnValue({id: 'mock-collection'}); + spyOn(service as any, 'query').and.returnValue({id: 'mock-query'}); + }); + + it('should query documents without filters', (done) => { + const mockDocs = [ + {id: '1', data: () => ({name: 'Doc 1'})}, + {id: '2', data: () => ({name: 'Doc 2'})} + ]; + const mockSnapshot = {docs: mockDocs}; + + spyOn(service as any, 'getDocs').and.returnValue(Promise.resolve(mockSnapshot)); + + service.queryDocuments('test-collection').subscribe({ + next: (docs: any) => { + expect(docs.length).toBe(2); + expect(docs[0].id).toBe('1'); + expect(docs[1].id).toBe('2'); + done(); + }, + error: done.fail + }); + }); + + it('should query documents with filters', (done) => { + const mockDocs = [ + {id: '1', data: () => ({name: 'Doc 1', status: 'active'})} + ]; + const mockSnapshot = {docs: mockDocs}; + + spyOn(service as any, 'where').and.returnValue({id: 'mock-where'}); + spyOn(service as any, 'getDocs').and.returnValue(Promise.resolve(mockSnapshot)); + + const filters: [string, any, any][] = [['status', '==', 'active']]; + + service.queryDocuments('test-collection', filters).subscribe({ + next: (docs) => { + expect(docs.length).toBe(1); + expect((docs[0] as any).status).toBe('active'); + done(); + }, + error: done.fail + }); + }); + + it('should handle query errors', (done) => { + spyOn(service as any, 'getDocs').and.returnValue( + Promise.reject(new Error('Query failed')) + ); + + service.queryDocuments('test-collection').subscribe({ + next: () => done.fail('Should have failed'), + error: (error) => { + expect(error.message).toContain('Failed to query documents'); + done(); + } + }); + }); + }); + }); + + describe('Storage Operations', () => { + describe('uploadFile', () => { + beforeEach(() => { + spyOn(service as any, 'ref').and.returnValue({id: 'mock-storage-ref'}); + }); + + it('should upload a file and return download URL', (done) => { + const mockFile = new File(['test content'], 'test.txt', {type: 'text/plain'}); + const mockDownloadUrl = 'https://example.com/test.txt'; + + spyOn(service as any, 'uploadBytes').and.returnValue(Promise.resolve({})); + spyOn(service as any, 'getDownloadURL').and.returnValue(Promise.resolve(mockDownloadUrl)); + + service.uploadFile('test/path', mockFile).subscribe({ + next: (url) => { + expect(url).toBe(mockDownloadUrl); + done(); + }, + error: done.fail + }); + }); + + it('should handle upload errors', (done) => { + const mockFile = new File(['test content'], 'test.txt'); + + spyOn(service as any, 'uploadBytes').and.returnValue( + Promise.reject(new Error('Upload failed')) + ); + + service.uploadFile('test/path', mockFile).subscribe({ + next: () => done.fail('Should have failed'), + error: (error) => { + expect(error.message).toContain('Failed to upload file'); + done(); + } + }); + }); + }); + + describe('deleteFile', () => { + beforeEach(() => { + spyOn(service as any, 'ref').and.returnValue({id: 'mock-storage-ref'}); + }); + + it('should delete a file', (done) => { + spyOn(service as any, 'deleteObject').and.returnValue(Promise.resolve()); + + service.deleteFile('test/path').subscribe({ + next: () => { + done(); + }, + error: done.fail + }); + }); + + it('should handle delete file errors', (done) => { + spyOn(service as any, 'deleteObject').and.returnValue( + Promise.reject(new Error('Delete failed')) + ); + + service.deleteFile('test/path').subscribe({ + next: () => done.fail('Should have failed'), + error: (error) => { + expect(error.message).toContain('Failed to delete file'); + done(); + } + }); + }); + }); + + describe('uploadBase64', () => { + beforeEach(() => { + spyOn(service as any, 'ref').and.returnValue({id: 'mock-storage-ref'}); + }); + + it('should upload base64 string', (done) => { + const dataUrl = 'data:text/plain;base64,dGVzdCBjb250ZW50'; + const mockDownloadUrl = 'https://example.com/test.txt'; + + spyOn(service as any, 'uploadString').and.returnValue(Promise.resolve({})); + spyOn(service as any, 'getDownloadURL').and.returnValue(Promise.resolve(mockDownloadUrl)); + + service.uploadBase64('test/path', dataUrl).subscribe({ + next: (url) => { + expect(url).toBe(mockDownloadUrl); + done(); + }, + error: done.fail + }); + }); + }); + }); + + describe('User Operations', () => { + describe('saveUserSettings', () => { + it('should save user settings', (done) => { + spyOn(service, 'updateDocument').and.returnValue(of(void 0)); + + const settings = {theme: 'dark', notifications: true}; + + service.saveUserSettings('user-123', settings).subscribe({ + next: () => { + expect(service.updateDocument).toHaveBeenCalledWith( + 'users', 'user-123', {settings} + ); + done(); + }, + error: done.fail + }); + }); + }); + + describe('getUserSettings', () => { + it('should get user settings', (done) => { + const mockUser = { + id: 'user-123', + settings: {theme: 'dark', notifications: true} + }; + + spyOn(service, 'getDocument').and.returnValue(of(mockUser)); + + service.getUserSettings('user-123').subscribe({ + next: (settings) => { + expect(settings).toEqual(mockUser.settings); + done(); + }, + error: done.fail + }); + }); + + it('should return null if user has no settings', (done) => { + spyOn(service, 'getDocument').and.returnValue(of({id: 'user-123'})); + + service.getUserSettings('user-123').subscribe({ + next: (settings) => { + expect(settings).toBeNull(); + done(); + }, + error: done.fail + }); + }); + }); + + describe('createOrUpdateUser', () => { + it('should create or update a user', (done) => { + const userData = {name: 'John Doe', email: 'john@example.com'}; + + spyOn(service, 'saveDocument').and.returnValue(of('user-123')); + + service.createOrUpdateUser('user-123', userData).subscribe({ + next: () => { + expect(service.saveDocument).toHaveBeenCalledWith( + 'users', + {...userData, id: 'user-123'}, + 'user-123' + ); + done(); + }, + error: done.fail + }); + }); + }); + }); + + describe('Real-time Operations', () => { + describe('listenToDocument', () => { + it('should listen to document changes', (done) => { + const mockSnapshot = { + exists: () => true, + id: 'test-id', + data: () => ({name: 'Test Document'}) + }; + + spyOn(service as any, 'doc').and.returnValue({id: 'mock-ref'}); + spyOn(service as any, 'onSnapshot').and.callFake( + (docRef: any, callback: any) => { + setTimeout(() => callback(mockSnapshot), 0); + return () => { + }; // Return unsubscribe function + } + ); + + service.listenToDocument('test-collection', 'test-id').subscribe({ + next: (doc: any) => { + expect(doc).toBeDefined(); + expect(doc?.id).toBe('test-id'); + expect((doc as any)?.name).toBe('Test Document'); + done(); + }, + error: done.fail + }); + }); + }); + + describe('listenToCollection', () => { + it('should listen to collection changes', (done) => { + const mockDocs = [ + {id: '1', data: () => ({name: 'Doc 1'})}, + {id: '2', data: () => ({name: 'Doc 2'})} + ]; + const mockSnapshot = {docs: mockDocs}; + + spyOn(service as any, 'collection').and.returnValue({id: 'mock-collection'}); + spyOn(service as any, 'query').and.returnValue({id: 'mock-query'}); + spyOn(service as any, 'onSnapshot').and.callFake( + (query: any, callback: any) => { + setTimeout(() => callback(mockSnapshot), 0); + return () => { + }; + } + ); + + service.listenToCollection('test-collection').subscribe({ + next: (docs: any) => { + expect(docs.length).toBe(2); + expect(docs[0].id).toBe('1'); + expect(docs[1].id).toBe('2'); + done(); + }, + error: done.fail + }); + }); + }); + }); }); diff --git a/src/app/services/firebase/firestore.service.ts b/src/app/services/firebase/firestore.service.ts index b7f6890..6c79ec2 100644 --- a/src/app/services/firebase/firestore.service.ts +++ b/src/app/services/firebase/firestore.service.ts @@ -368,7 +368,9 @@ export class FirestoreService { * @returns Observable of void */ saveUserSettings(userId: string, settings: any): Observable { - return this.updateDocument(`users/${userId}`, 'settings', {settings}); + return this.updateDocument('users', userId, {settings}).pipe( + map(() => void 0) + ); } /** @@ -377,23 +379,22 @@ export class FirestoreService { * @returns Observable of settings object */ getUserSettings(userId: string): Observable { - return this.getDocument(`users/${userId}`, 'settings'); + return this.getDocument('users', userId).pipe( + map(user => user?.settings || null) + ); } /** * Saves a log entry - * @param level * @param logEntry - Log entry object - * @param user - * @param p0 * @returns Observable of the log entry ID */ - saveLogEntry(level: string, logEntry: { + saveLogEntry(logEntry: { level: 'info' | 'warn' | 'error' | 'debug'; message: string; userId?: string; metadata?: any; - }, user: unknown, p0: string): Observable { + }): Observable { return this.saveDocument('logs', { ...logEntry, timestamp: serverTimestamp() @@ -478,4 +479,66 @@ export class FirestoreService { }) ); } + + /** + * Executes multiple write operations as a batch + * @param operations - Array of batch operations + * @returns Observable of void + */ + executeBatch(operations: Array<{ + type: 'set' | 'update' | 'delete'; + collection: string; + id: string; + data?: any; + }>): Observable { + const batch = writeBatch(this.firestore); + + operations.forEach(operation => { + const docRef = doc(this.firestore, operation.collection, operation.id); + + switch (operation.type) { + case 'set': + batch.set(docRef, { + ...operation.data, + updatedAt: serverTimestamp(), + createdAt: operation.data?.createdAt || serverTimestamp() + }); + break; + case 'update': + batch.update(docRef, { + ...operation.data, + updatedAt: serverTimestamp() + }); + break; + case 'delete': + batch.delete(docRef); + break; + } + }); + + return from(batch.commit()).pipe( + catchError(error => { + console.error('Error executing batch operations:', error); + return throwError(() => new Error(`Failed to execute batch operations: ${error.message}`)); + }) + ); + } + + /** + * Checks if a document exists + * @param collectionPath - Path to the collection + * @param id - Document ID + * @returns Observable of boolean + */ + documentExists(collectionPath: string, id: string): Observable { + const docRef = doc(this.firestore, collectionPath, id); + + return from(getDoc(docRef)).pipe( + map(snapshot => snapshot.exists()), + catchError(error => { + console.error(`Error checking document existence in ${collectionPath}:`, error); + return of(false); + }) + ); + } } From ff817a501f6d312ef38e39ac9f5d7bd22a2922e2 Mon Sep 17 00:00:00 2001 From: Colin Michaels Date: Sat, 6 Sep 2025 15:54:37 -0400 Subject: [PATCH 07/14] ### Add automated Firebase deployment workflow and environment variable handling - Created a GitHub Actions workflow (`firebase_deployment_workflow.yml`) to automatically deploy the application to Firebase Hosting on pushes to the `main` branch. - Includes steps for Node.js setup, dependency installation, build process, and deployment. - Added secure integration via GitHub Secrets for sensitive environment variables. - Updated `environment.ts` to use environment variables for Firebase and third-party API configurations, removing hard-coded sensitive data. - Refined `.gitignore` to exclude `.env` files and environment-specific configuration files for better security and maintainability. **Signed-off-by:** Colin Michaels Signed-off-by: Colin Michaels --- .../firebase_deployment_workflow.yml | 43 +++++++++++++++++++ .gitignore | 7 ++- src/environments/environment.ts | 24 +++++------ 3 files changed, 60 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/firebase_deployment_workflow.yml diff --git a/.github/workflows/firebase_deployment_workflow.yml b/.github/workflows/firebase_deployment_workflow.yml new file mode 100644 index 0000000..8de7de5 --- /dev/null +++ b/.github/workflows/firebase_deployment_workflow.yml @@ -0,0 +1,43 @@ +name: Deploy to Production + +on: + push: + branches: [ main ] + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build:prod + env: + APP_TITLE: ${{ secrets.APP_TITLE }} + API_URL: ${{ secrets.API_URL }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + OPEN_WEATHER_MAP_API_KEY: ${{ secrets.OPEN_WEATHER_MAP_API_KEY }} + FIREBASE_API_KEY: ${{ secrets.FIREBASE_API_KEY }} + FIREBASE_AUTH_DOMAIN: ${{ secrets.FIREBASE_AUTH_DOMAIN }} + FIREBASE_DATABASE_URL: ${{ secrets.FIREBASE_DATABASE_URL }} + FIREBASE_PROJECT_ID: ${{ secrets.FIREBASE_PROJECT_ID }} + FIREBASE_STORAGE_BUCKET: ${{ secrets.FIREBASE_STORAGE_BUCKET }} + FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.FIREBASE_MESSAGING_SENDER_ID }} + FIREBASE_APP_ID: ${{ secrets.FIREBASE_APP_ID }} + FIREBASE_MEASUREMENT_ID: ${{ secrets.FIREBASE_MEASUREMENT_ID }} + + - name: Deploy to Firebase + uses: FirebaseExtended/action-hosting-deploy@v0 + with: + repoToken: ${{ secrets.GITHUB_TOKEN }} + firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT }} + projectId: ${{ secrets.FIREBASE_PROJECT_ID }} diff --git a/.gitignore b/.gitignore index 4fdc722..51ad70d 100644 --- a/.gitignore +++ b/.gitignore @@ -44,9 +44,12 @@ Thumbs.db # Firebase local config .firebase/* -# env configs +# Environment files +.env +.env.local +.env.*.local src/environments/environment.dev.ts src/environments/environment.local.ts src/environments/environment.prod.ts -src/environments/environment.ts + diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 39f1388..d05a99d 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -1,17 +1,17 @@ export const environment = { production: false, - title: 'Colin Michaels - LOCAL', - apiUrl: 'http://localhost:3000', // your NestJS backend - openAiApiKey: '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', - openWeatherMapApiKey: '94280521bd9339761dcf9ad73816cb64', + title: process.env['APP_TITLE'] || 'Colin Michaels', + apiUrl: process.env['API_URL'] || 'http://localhost:3000', + openAiApiKey: process.env['OPENAI_API_KEY'] || '', + openWeatherMapApiKey: process.env['OPEN_WEATHER_MAP_API_KEY'] || '', firebaseConfig: { - apiKey: "AIzaSyDxAfhvfwY2g3jLHHRPewj8NMgj9P0PP_Y", - authDomain: "colinmichaels.firebaseapp.com", - databaseURL: "https://colinmichaels-default-rtdb.firebaseio.com/", - projectId: "colinmichaels", - storageBucket: "colinmichaels.firebasestorage.app", - messagingSenderId: "695739708994", - appId: "1:695739708994:web:0a44e9b2d8d614b617dfdb", - measurementId: "G-MWYBVKZ7KE" + apiKey: process.env['FIREBASE_API_KEY'] || '', + authDomain: process.env['FIREBASE_AUTH_DOMAIN'] || '', + databaseURL: process.env['FIREBASE_DATABASE_URL'] || '', + projectId: process.env['FIREBASE_PROJECT_ID'] || '', + storageBucket: process.env['FIREBASE_STORAGE_BUCKET'] || '', + messagingSenderId: process.env['FIREBASE_MESSAGING_SENDER_ID'] || '', + appId: process.env['FIREBASE_APP_ID'] || '', + measurementId: process.env['FIREBASE_MEASUREMENT_ID'] || '' } }; From 90c3710ebb86bb4babc925d45b90f2626fedfea8 Mon Sep 17 00:00:00 2001 From: Colin Michaels Date: Sat, 6 Sep 2025 19:06:06 -0400 Subject: [PATCH 08/14] ### Introduce separate environment configuration files for development and production - Added `environment.dev.ts` and `environment.prod.ts` to segregate environment-specific configurations. - Defined placeholders for `APP_TITLE`, `API_URL`, Firebase credentials, and API keys to enhance security and flexibility. - Updated `.gitignore` to exclude environment configuration files (`environment.dev.ts` and `environment.prod.ts`) to avoid committing sensitive information. This change improves maintainability, aligns with best practices, and supports better environment handling for deployments. **Signed-off-by:** Colin Michaels Signed-off-by: Colin Michaels --- .gitignore | 4 ---- src/environments/environment.dev.ts | 17 +++++++++++++++++ src/environments/environment.prod.ts | 17 +++++++++++++++++ 3 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 src/environments/environment.dev.ts create mode 100644 src/environments/environment.prod.ts diff --git a/.gitignore b/.gitignore index 51ad70d..89171de 100644 --- a/.gitignore +++ b/.gitignore @@ -46,10 +46,6 @@ Thumbs.db # Environment files .env -.env.local -.env.*.local -src/environments/environment.dev.ts src/environments/environment.local.ts -src/environments/environment.prod.ts diff --git a/src/environments/environment.dev.ts b/src/environments/environment.dev.ts new file mode 100644 index 0000000..d05a99d --- /dev/null +++ b/src/environments/environment.dev.ts @@ -0,0 +1,17 @@ +export const environment = { + production: false, + title: process.env['APP_TITLE'] || 'Colin Michaels', + apiUrl: process.env['API_URL'] || 'http://localhost:3000', + openAiApiKey: process.env['OPENAI_API_KEY'] || '', + openWeatherMapApiKey: process.env['OPEN_WEATHER_MAP_API_KEY'] || '', + firebaseConfig: { + apiKey: process.env['FIREBASE_API_KEY'] || '', + authDomain: process.env['FIREBASE_AUTH_DOMAIN'] || '', + databaseURL: process.env['FIREBASE_DATABASE_URL'] || '', + projectId: process.env['FIREBASE_PROJECT_ID'] || '', + storageBucket: process.env['FIREBASE_STORAGE_BUCKET'] || '', + messagingSenderId: process.env['FIREBASE_MESSAGING_SENDER_ID'] || '', + appId: process.env['FIREBASE_APP_ID'] || '', + measurementId: process.env['FIREBASE_MEASUREMENT_ID'] || '' + } +}; diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts new file mode 100644 index 0000000..fa43593 --- /dev/null +++ b/src/environments/environment.prod.ts @@ -0,0 +1,17 @@ +export const environment = { + production: true, + title: process.env['APP_TITLE'] || 'Colin Michaels', + apiUrl: process.env['API_URL'] || 'http://localhost:3000', + openAiApiKey: process.env['OPENAI_API_KEY'] || '', + openWeatherMapApiKey: process.env['OPEN_WEATHER_MAP_API_KEY'] || '', + firebaseConfig: { + apiKey: process.env['FIREBASE_API_KEY'] || '', + authDomain: process.env['FIREBASE_AUTH_DOMAIN'] || '', + databaseURL: process.env['FIREBASE_DATABASE_URL'] || '', + projectId: process.env['FIREBASE_PROJECT_ID'] || '', + storageBucket: process.env['FIREBASE_STORAGE_BUCKET'] || '', + messagingSenderId: process.env['FIREBASE_MESSAGING_SENDER_ID'] || '', + appId: process.env['FIREBASE_APP_ID'] || '', + measurementId: process.env['FIREBASE_MEASUREMENT_ID'] || '' + } +}; From f54e590353b7ae16c6f2551fe4dbd1d4b838fdfa Mon Sep 17 00:00:00 2001 From: Colin Michaels Date: Sat, 6 Sep 2025 19:13:46 -0400 Subject: [PATCH 09/14] ### Replace environment variable bindings with placeholder values - Updated `environment.ts`, `environment.dev.ts`, and `environment.prod.ts` to replace `process.env` bindings with placeholder values for configurations like `APP_TITLE`, `APP_API_URL`, Firebase credentials, and API keys. - Removed reliance on dynamic environment variables for streamlined configuration setup and improved clarity in local, development, and production environments. - Included comments to guide users in properly configuring environment values during setup. This change enhances security by removing hardcoded fallback values and simplifies the onboarding process for developers. Signed-off-by: Colin Michaels --- src/environments/environment.dev.ts | 24 ++++++++++++------------ src/environments/environment.prod.ts | 24 ++++++++++++------------ src/environments/environment.ts | 26 +++++++++++++------------- 3 files changed, 37 insertions(+), 37 deletions(-) diff --git a/src/environments/environment.dev.ts b/src/environments/environment.dev.ts index d05a99d..a56af2a 100644 --- a/src/environments/environment.dev.ts +++ b/src/environments/environment.dev.ts @@ -1,17 +1,17 @@ export const environment = { production: false, - title: process.env['APP_TITLE'] || 'Colin Michaels', - apiUrl: process.env['API_URL'] || 'http://localhost:3000', - openAiApiKey: process.env['OPENAI_API_KEY'] || '', - openWeatherMapApiKey: process.env['OPEN_WEATHER_MAP_API_KEY'] || '', + title: '', + apiUrl: '', // your NestJS backend + openAiApiKey: '', + openWeatherMapApiKey: '', firebaseConfig: { - apiKey: process.env['FIREBASE_API_KEY'] || '', - authDomain: process.env['FIREBASE_AUTH_DOMAIN'] || '', - databaseURL: process.env['FIREBASE_DATABASE_URL'] || '', - projectId: process.env['FIREBASE_PROJECT_ID'] || '', - storageBucket: process.env['FIREBASE_STORAGE_BUCKET'] || '', - messagingSenderId: process.env['FIREBASE_MESSAGING_SENDER_ID'] || '', - appId: process.env['FIREBASE_APP_ID'] || '', - measurementId: process.env['FIREBASE_MEASUREMENT_ID'] || '' + apiKey: "", + authDomain: "", + projectId: "", + databaseURL: "", + storageBucket: "", + messagingSenderId: "", + appId: "", + measurementId: "" } }; diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts index fa43593..cf62637 100644 --- a/src/environments/environment.prod.ts +++ b/src/environments/environment.prod.ts @@ -1,17 +1,17 @@ export const environment = { production: true, - title: process.env['APP_TITLE'] || 'Colin Michaels', - apiUrl: process.env['API_URL'] || 'http://localhost:3000', - openAiApiKey: process.env['OPENAI_API_KEY'] || '', - openWeatherMapApiKey: process.env['OPEN_WEATHER_MAP_API_KEY'] || '', + title: '', + apiUrl: '', // your NestJS backend + openAiApiKey: '', + openWeatherMapApiKey: '', firebaseConfig: { - apiKey: process.env['FIREBASE_API_KEY'] || '', - authDomain: process.env['FIREBASE_AUTH_DOMAIN'] || '', - databaseURL: process.env['FIREBASE_DATABASE_URL'] || '', - projectId: process.env['FIREBASE_PROJECT_ID'] || '', - storageBucket: process.env['FIREBASE_STORAGE_BUCKET'] || '', - messagingSenderId: process.env['FIREBASE_MESSAGING_SENDER_ID'] || '', - appId: process.env['FIREBASE_APP_ID'] || '', - measurementId: process.env['FIREBASE_MEASUREMENT_ID'] || '' + apiKey: "", + authDomain: "", + projectId: "", + databaseURL: "", + storageBucket: "", + messagingSenderId: "", + appId: "", + measurementId: "" } }; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index fa43593..a56af2a 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -1,17 +1,17 @@ export const environment = { - production: true, - title: process.env['APP_TITLE'] || 'Colin Michaels', - apiUrl: process.env['API_URL'] || 'http://localhost:3000', - openAiApiKey: process.env['OPENAI_API_KEY'] || '', - openWeatherMapApiKey: process.env['OPEN_WEATHER_MAP_API_KEY'] || '', + production: false, + title: '', + apiUrl: '', // your NestJS backend + openAiApiKey: '', + openWeatherMapApiKey: '', firebaseConfig: { - apiKey: process.env['FIREBASE_API_KEY'] || '', - authDomain: process.env['FIREBASE_AUTH_DOMAIN'] || '', - databaseURL: process.env['FIREBASE_DATABASE_URL'] || '', - projectId: process.env['FIREBASE_PROJECT_ID'] || '', - storageBucket: process.env['FIREBASE_STORAGE_BUCKET'] || '', - messagingSenderId: process.env['FIREBASE_MESSAGING_SENDER_ID'] || '', - appId: process.env['FIREBASE_APP_ID'] || '', - measurementId: process.env['FIREBASE_MEASUREMENT_ID'] || '' + apiKey: "", + authDomain: "", + projectId: "", + databaseURL: "", + storageBucket: "", + messagingSenderId: "", + appId: "", + measurementId: "" } }; From 6bd21b14b48ca1b59c6c30fc5052423bc0bc332f Mon Sep 17 00:00:00 2001 From: Colin Michaels Date: Sat, 6 Sep 2025 19:45:16 -0400 Subject: [PATCH 10/14] ### Update GitHub URL logic in Projects Overview and Main components - Replaced hardcoded GitHub URL in `projects-overview.component.html` with dynamic binding using the `githubUrl` property for better reusability and maintainability. - Updated the `githubUrl` value in `main.component.html` to point to the appropriate repository (`ColinMichaels-Angular`). **Signed-off-by:** Colin Michaels Signed-off-by: Colin Michaels --- src/app/components/main/main.component.html | 2 +- .../main/projects-overview/projects-overview.component.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/components/main/main.component.html b/src/app/components/main/main.component.html index 6fa02e2..caa1c5d 100644 --- a/src/app/components/main/main.component.html +++ b/src/app/components/main/main.component.html @@ -42,7 +42,7 @@ } ]" description="Building a powerful operating system emulator with modern web technologies." - githubUrl="https://github.com/colinmichaels/" + githubUrl="https://github.com/ColinMichaels/ColinMichaels-Angular" title="Current Project(s)"> diff --git a/src/app/components/main/projects-overview/projects-overview.component.html b/src/app/components/main/projects-overview/projects-overview.component.html index 053af0d..f811a09 100644 --- a/src/app/components/main/projects-overview/projects-overview.component.html +++ b/src/app/components/main/projects-overview/projects-overview.component.html @@ -12,7 +12,7 @@

{{ title }}