From 4910f87a3eaf7f033b19ee0e2e8e52667903be96 Mon Sep 17 00:00:00 2001 From: Colin Michaels Date: Sat, 28 Feb 2026 13:06:43 -0500 Subject: [PATCH] chore: optimize components and harden env/secret deployment --- .github/workflows/firebase-hosting-merge.yml | 64 +++---- .../firebase-hosting-pull-request.yml | 21 ++- .../firebase_deployment_workflow.yml | 81 +++++---- .gitignore | 3 +- .nvmrc | 1 + docs/ARCHITECTURE/OVERVIEW.md | 65 +++++++ docs/ARCHITECTURE/SECURITY.md | 59 +++++++ docs/ARCHITECTURE/SERVICES.md | 147 ++++++++++++++++ docs/ARCHITECTURE/STATE_EVENTS.md | 49 ++++++ docs/FUTURE_FEATURES/ROADMAP.md | 51 ++++++ docs/README/DEVELOPMENT.md | 65 +++++++ docs/README/ENVIRONMENT_SECRETS.md | 61 +++++++ docs/README/INDEX.md | 21 +++ docs/README/PROJECT_OVERVIEW.md | 45 +++++ docs/TODOS/TECH_DEBT.md | 100 +++++++++++ package.json | 2 + scripts/generate-environment.mjs | 68 +++++++ .../patch-editor/patch-editor.component.html | 15 -- .../patch-editor/patch-editor.component.scss | 11 ++ .../patch-editor/patch-editor.component.ts | 3 +- src/app/components/main/main.constants.ts | 2 + .../scroll-class-toggle.directive.ts | 166 +++++++++++++----- src/environments/.env.example | 12 ++ 23 files changed, 973 insertions(+), 139 deletions(-) create mode 100644 .nvmrc create mode 100644 docs/ARCHITECTURE/OVERVIEW.md create mode 100644 docs/ARCHITECTURE/SECURITY.md create mode 100644 docs/ARCHITECTURE/SERVICES.md create mode 100644 docs/ARCHITECTURE/STATE_EVENTS.md create mode 100644 docs/FUTURE_FEATURES/ROADMAP.md create mode 100644 docs/README/DEVELOPMENT.md create mode 100644 docs/README/ENVIRONMENT_SECRETS.md create mode 100644 docs/README/INDEX.md create mode 100644 docs/README/PROJECT_OVERVIEW.md create mode 100644 docs/TODOS/TECH_DEBT.md create mode 100644 scripts/generate-environment.mjs create mode 100644 src/app/components/game/apps/music-apps/patch-editor/patch-editor.component.scss create mode 100644 src/app/components/main/main.constants.ts create mode 100644 src/environments/.env.example diff --git a/.github/workflows/firebase-hosting-merge.yml b/.github/workflows/firebase-hosting-merge.yml index bc482a0..02a81d7 100644 --- a/.github/workflows/firebase-hosting-merge.yml +++ b/.github/workflows/firebase-hosting-merge.yml @@ -2,26 +2,31 @@ # https://github.com/firebase/firebase-tools name: Deploy to Firebase Hosting on merge + on: push: branches: - master + +permissions: + contents: read + 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 }} + APP_TITLE: ${{ vars.APP_TITLE || secrets.APP_TITLE }} + APP_API_URL: ${{ vars.APP_API_URL || vars.API_URL || secrets.APP_API_URL || secrets.API_URL }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY || vars.OPENAI_API_KEY }} + OPEN_WEATHER_MAP_API_KEY: ${{ secrets.OPEN_WEATHER_MAP_API_KEY || vars.OPEN_WEATHER_MAP_API_KEY }} + FIREBASE_API_KEY: ${{ vars.FIREBASE_API_KEY || secrets.FIREBASE_API_KEY }} + FIREBASE_AUTH_DOMAIN: ${{ vars.FIREBASE_AUTH_DOMAIN || secrets.FIREBASE_AUTH_DOMAIN }} + FIREBASE_DATABASE_URL: ${{ vars.FIREBASE_DATABASE_URL || secrets.FIREBASE_DATABASE_URL }} + FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID || secrets.FIREBASE_PROJECT_ID }} + FIREBASE_STORAGE_BUCKET: ${{ vars.FIREBASE_STORAGE_BUCKET || secrets.FIREBASE_STORAGE_BUCKET }} + FIREBASE_MESSAGING_SENDER_ID: ${{ vars.FIREBASE_MESSAGING_SENDER_ID || secrets.FIREBASE_MESSAGING_SENDER_ID }} + FIREBASE_APP_ID: ${{ vars.FIREBASE_APP_ID || secrets.FIREBASE_APP_ID }} + FIREBASE_MEASUREMENT_ID: ${{ vars.FIREBASE_MEASUREMENT_ID || secrets.FIREBASE_MEASUREMENT_ID }} steps: - name: Checkout repository uses: actions/checkout@v4 @@ -29,41 +34,24 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 20 + cache: npm - name: Install dependencies - run: npm install + run: npm ci - - 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: Generate Angular environment files + run: npm run generate:env - name: Build Angular app run: npm run build - - uses: actions/checkout@v4 - - run: npm ci - - uses: FirebaseExtended/action-hosting-deploy@v0 + + - name: Deploy to Firebase Hosting + uses: FirebaseExtended/action-hosting-deploy@v0 with: repoToken: ${{ secrets.GITHUB_TOKEN }} firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_COLINMICHAELS }} channelId: live - projectId: colinmichaels + projectId: ${{ vars.FIREBASE_PROJECT_ID || secrets.FIREBASE_PROJECT_ID || 'colinmichaels' }} env: FIREBASE_CLI_EXPERIMENTS: webframeworks diff --git a/.github/workflows/firebase-hosting-pull-request.yml b/.github/workflows/firebase-hosting-pull-request.yml index ecb06ea..f8f2226 100644 --- a/.github/workflows/firebase-hosting-pull-request.yml +++ b/.github/workflows/firebase-hosting-pull-request.yml @@ -11,13 +11,32 @@ jobs: build_and_preview: if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} runs-on: ubuntu-latest + env: + APP_TITLE: ${{ vars.APP_TITLE || secrets.APP_TITLE }} + APP_API_URL: ${{ vars.APP_API_URL || vars.API_URL || secrets.APP_API_URL || secrets.API_URL }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY || vars.OPENAI_API_KEY }} + OPEN_WEATHER_MAP_API_KEY: ${{ secrets.OPEN_WEATHER_MAP_API_KEY || vars.OPEN_WEATHER_MAP_API_KEY }} + FIREBASE_API_KEY: ${{ vars.FIREBASE_API_KEY || secrets.FIREBASE_API_KEY }} + FIREBASE_AUTH_DOMAIN: ${{ vars.FIREBASE_AUTH_DOMAIN || secrets.FIREBASE_AUTH_DOMAIN }} + FIREBASE_DATABASE_URL: ${{ vars.FIREBASE_DATABASE_URL || secrets.FIREBASE_DATABASE_URL }} + FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID || secrets.FIREBASE_PROJECT_ID }} + FIREBASE_STORAGE_BUCKET: ${{ vars.FIREBASE_STORAGE_BUCKET || secrets.FIREBASE_STORAGE_BUCKET }} + FIREBASE_MESSAGING_SENDER_ID: ${{ vars.FIREBASE_MESSAGING_SENDER_ID || secrets.FIREBASE_MESSAGING_SENDER_ID }} + FIREBASE_APP_ID: ${{ vars.FIREBASE_APP_ID || secrets.FIREBASE_APP_ID }} + FIREBASE_MEASUREMENT_ID: ${{ vars.FIREBASE_MEASUREMENT_ID || secrets.FIREBASE_MEASUREMENT_ID }} steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm - run: npm ci + - run: npm run generate:env + - run: npm run build - uses: FirebaseExtended/action-hosting-deploy@v0 with: repoToken: ${{ secrets.GITHUB_TOKEN }} firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_COLINMICHAELS }} - projectId: colinmichaels + projectId: ${{ vars.FIREBASE_PROJECT_ID || secrets.FIREBASE_PROJECT_ID || 'colinmichaels' }} env: FIREBASE_CLI_EXPERIMENTS: webframeworks diff --git a/.github/workflows/firebase_deployment_workflow.yml b/.github/workflows/firebase_deployment_workflow.yml index 8de7de5..7342f58 100644 --- a/.github/workflows/firebase_deployment_workflow.yml +++ b/.github/workflows/firebase_deployment_workflow.yml @@ -1,43 +1,52 @@ -name: Deploy to Production +name: Manual Firebase Deploy on: - push: - branches: [ main ] + workflow_dispatch: + +permissions: + contents: read jobs: deploy: runs-on: ubuntu-latest - + env: + APP_TITLE: ${{ vars.APP_TITLE || secrets.APP_TITLE }} + APP_API_URL: ${{ vars.APP_API_URL || vars.API_URL || secrets.APP_API_URL || secrets.API_URL }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY || vars.OPENAI_API_KEY }} + OPEN_WEATHER_MAP_API_KEY: ${{ secrets.OPEN_WEATHER_MAP_API_KEY || vars.OPEN_WEATHER_MAP_API_KEY }} + FIREBASE_API_KEY: ${{ vars.FIREBASE_API_KEY || secrets.FIREBASE_API_KEY }} + FIREBASE_AUTH_DOMAIN: ${{ vars.FIREBASE_AUTH_DOMAIN || secrets.FIREBASE_AUTH_DOMAIN }} + FIREBASE_DATABASE_URL: ${{ vars.FIREBASE_DATABASE_URL || secrets.FIREBASE_DATABASE_URL }} + FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID || secrets.FIREBASE_PROJECT_ID }} + FIREBASE_STORAGE_BUCKET: ${{ vars.FIREBASE_STORAGE_BUCKET || secrets.FIREBASE_STORAGE_BUCKET }} + FIREBASE_MESSAGING_SENDER_ID: ${{ vars.FIREBASE_MESSAGING_SENDER_ID || secrets.FIREBASE_MESSAGING_SENDER_ID }} + FIREBASE_APP_ID: ${{ vars.FIREBASE_APP_ID || secrets.FIREBASE_APP_ID }} + FIREBASE_MEASUREMENT_ID: ${{ vars.FIREBASE_MEASUREMENT_ID || secrets.FIREBASE_MEASUREMENT_ID }} 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 }} + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Generate Angular environment files + run: npm run generate:env + + - name: Build + run: npm run build + + - name: Deploy to Firebase + uses: FirebaseExtended/action-hosting-deploy@v0 + with: + repoToken: ${{ secrets.GITHUB_TOKEN }} + firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_COLINMICHAELS || secrets.FIREBASE_SERVICE_ACCOUNT }} + channelId: live + projectId: ${{ vars.FIREBASE_PROJECT_ID || secrets.FIREBASE_PROJECT_ID || 'colinmichaels' }} + env: + FIREBASE_CLI_EXPERIMENTS: webframeworks diff --git a/.gitignore b/.gitignore index 89171de..47821c1 100644 --- a/.gitignore +++ b/.gitignore @@ -47,5 +47,6 @@ Thumbs.db # Environment files .env src/environments/environment.local.ts - +src/environments/.env.* +!src/environments/.env.example diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/docs/ARCHITECTURE/OVERVIEW.md b/docs/ARCHITECTURE/OVERVIEW.md new file mode 100644 index 0000000..648e644 --- /dev/null +++ b/docs/ARCHITECTURE/OVERVIEW.md @@ -0,0 +1,65 @@ +# Architecture Overview + +## Runtime Shape + +The app uses Angular standalone components with route-driven screens and service-centric state. + +- Router controls entry screens (home, login, desktop, boot, sleep). +- Desktop screen coordinates window lifecycle and system UI. +- Services hold long-lived state (apps, user, settings, storage, CLI, sound, files, notifications). +- Dynamic component loading is used for in-window apps. + +## Major Subsystems + +- Desktop shell: + desktop surface, tray, dock, route params, context menu. +- Window/app manager: + app registry, app launch, focus, close, persisted open apps. +- CLI gameplay: + command execution, typewriter output, user/level progression. +- Persistence: + settings/user/tasks/patches through storage strategy. +- Media/audio: + icon/media helpers, sound playback, music and effects. +- Overlay and notifications: + global overlays and in-app notification stream. + +## High-Level Diagram + +```mermaid +graph TD + A[AppComponent] --> B[Router] + B --> C[DesktopComponent] + B --> D[Login and Boot Screens] + + C --> E[ApplicationManagerService] + E --> F[AppWindowComponent Dynamic Apps] + E --> G[Dock and SystemTray] + + C --> H[LevelLoaderComponent] + H --> I[GameConfigService] + + F --> J[CliGameComponent] + J --> K[CLIService] + K --> I + K --> L[UserService] + + J --> M[TypewriterService] + M --> N[SoundService] + + L --> O[SettingsService] + O --> P[StorageService] + + C --> Q[OverlayService] + C --> R[NotificationService] + R --> S[NotificationServerComponent] + + G --> T[FileSystemService] +``` + +## Design Notes + +- This codebase favors behavior in services over local component state. +- The primary maintainability pressure points are large services with mixed responsibilities and untyped dynamic data flows. +- Behavior stability depends heavily on preserving service public APIs while tightening internals. + diff --git a/docs/ARCHITECTURE/SECURITY.md b/docs/ARCHITECTURE/SECURITY.md new file mode 100644 index 0000000..9205c2a --- /dev/null +++ b/docs/ARCHITECTURE/SECURITY.md @@ -0,0 +1,59 @@ +# Security Notes + +## Threat Model (Relevant to This App) + +- XSS via dynamic HTML rendering (`innerHTML`, dynamic tooltip/notification content, terminal output). +- Open redirect or unsafe external navigation from route-driven URL values. +- Client-side secret exposure (API keys in browser bundle/environment files). +- Unsafe parsing and persistence of local storage data. +- Supply-chain risk from dependency drift and failing quality gates. + +## Audit Findings + +## 1) XSS Surface + +Current sinks include: + +- CLI output rendering with `[innerHTML]` +- notification message rendering with `[innerHTML]` +- tooltip text rendering with `[innerHTML]` +- raw `innerHTML` writes in settings subpanel fallback +- SVG trust bypass via `bypassSecurityTrustHtml` + +Risk: +user-controlled or remotely controlled strings could execute markup/script payloads if not constrained. + +## 2) External URL Handling + +- Redirect guard opens decoded route param in a new tab with no allowlist. + +Risk: +malicious URLs or script schemes can be triggered by crafted routes. + +## 3) Secrets in Client + +- OpenAI and weather API keys are configured for client-side use. + +Risk: +keys are recoverable from browser context and can be abused. + +## 4) Storage Trust + +- App/session state read from local storage without robust schema validation. + +Risk: +tampered storage payloads can produce runtime errors or unintended behavior. + +## Recommended Mitigations (Planned) + +1. Replace unsafe HTML sinks with safe rendering primitives and explicit formatting tokens. +2. Validate external URLs with strict scheme/domain checks before `window.open`. +3. Move third-party API calls requiring secrets behind a backend proxy/function. +4. Add schema guards for storage rehydration and fail-safe defaults. +5. Reduce `bypassSecurityTrust*` usage to controlled, immutable asset paths only. + +## Operational Notes + +- Firebase database rules are present; keep auth checks strict and avoid widening `.read` scopes. +- Use supported Node LTS for reproducible builds and security patch coverage. + diff --git a/docs/ARCHITECTURE/SERVICES.md b/docs/ARCHITECTURE/SERVICES.md new file mode 100644 index 0000000..48d2711 --- /dev/null +++ b/docs/ARCHITECTURE/SERVICES.md @@ -0,0 +1,147 @@ +# Core Services + +This section focuses on the key game/runtime services prioritized in the cleanup audit. + +## `sound.service.ts` + +- Responsibility: + effect audio preload/playback/mute and variant pools. +- Dependencies: + `SettingsService`, `PatchService`, `LogService`, sound config token. +- Called by: + desktop flow, CLI/typewriter, login, intro overlays. +- Current risks: + service `OnInit` lifecycle not invoked by Angular DI, weak filename sanitization, cache key inconsistency. +- Planned cleanup: + move init to explicit constructor/init method, harden filename allowlist, normalize cache keys. + +## `user.service.ts` + +- Responsibility: + user profile state and persistence bridge. +- Dependencies: + `SettingsService`, `LogService`. +- Called by: + login, desktop, CLI, settings, game config, tray. +- Current risks: + `previousUserSubject` not updated, unnecessary async wrapper around sync settings call. +- Planned cleanup: + simplify update path, ensure previous/current snapshots are coherent. + +## `overlay.service.ts` + +- Responsibility: + global overlay image visibility/state. +- Dependencies: + none. +- Called by: + desktop and overlay component. +- Current risks: + temporary overlay timeout races and no timeout tracking. +- Planned cleanup: + track/cancel previous timers and provide deterministic hide behavior. + +## `cli.service.ts` + +- Responsibility: + command registry and command execution. +- Dependencies: + `GameConfigService`, `UserService`. +- Called by: + CLI game component. +- Current risks: + auth bug in `su` command (`isAuthorized` not invoked), direct `localStorage` reads, weak input validation. +- Planned cleanup: + fix auth branch, route identity through `UserService`, validate command parameters. + +## `typewriter.service.ts` + +- Responsibility: + queued typed output with mode-dependent sound behavior. +- Dependencies: + `SoundService`, `UserService`. +- Called by: + CLI, desktop intro, home terminal. +- Current risks: + loose typings (`any`), timer lifecycle concerns, `onBegin` called per char instead of per line. +- Planned cleanup: + strict event payload types, line-level hook semantics, safer timer teardown. + +## `settings.service.ts` + +- Responsibility: + register/get/update single settings and setting sets, form sync. +- Dependencies: + `StorageService`, `NotificationService`. +- Called by: + user, weather, sound player, appearance panel, patch/music features. +- Current risks: + broad `any` typing, untracked subscriptions, nested persistence flows. +- Planned cleanup: + type-safe setting models, explicit subscription lifecycle, flatten async logic. + +## `application-manager.service.ts` + +- Responsibility: + app registry, launch/close/focus, memory checks, persistence of open apps. +- Dependencies: + `ApplicationFactory`, `NotificationService`, `LogService`. +- Called by: + desktop, dock, tray, app window template, activity monitor, CLI. +- Current risks: + very large mixed-responsibility service, unsafe `localStorage` JSON parse, fragile instance counting. +- Planned cleanup: + extract persistence/registry helpers, guard JSON parse, fix instance limit accounting. + +## `media.service.ts` + +- Responsibility: + media item helpers and basic preload behavior. +- Dependencies: + none. +- Called by: + notification/media rendering pathways. +- Current risks: + weak typing around icon/svg data and inconsistent factory outputs. +- Planned cleanup: + normalize `MediaItem` factory return types and tighten icon interfaces. + +## `storage.service.ts` + +- Responsibility: + persistence abstraction (`IndexedDB` first, localStorage fallback). +- Dependencies: + browser storage APIs. +- Called by: + settings, tasks, patch editor. +- Current risks: + `getAllKeys()` bypasses strategy and always reads localStorage. +- Planned cleanup: + add strategy-level key enumeration and align behavior across storage backends. + +## `file-system.service.ts` + +- Responsibility: + virtual file tree, path navigation, finder data/view modes. +- Dependencies: + `HttpClient`, faker. +- Called by: + finder UI and tray view mode controls. +- Current risks: + startup faker generation cost, nondeterministic tree shape, duplicate favorites. +- Planned cleanup: + deterministic seed or static mock loading in prod, dedupe favorites, lazy/mock gate. + +## `game-config.service.ts` + +- Responsibility: + level loading, current level state, unlocked commands, log file content lookup. +- Dependencies: + `HttpClient`, `UserService`. +- Called by: + CLI service, level loader, CLI component. +- Current risks: + awkward `Promise>` API, unused parameters, fragile level load expectations. +- Planned cleanup: + return clean observables/promises (one async model), remove unused args, tighten level indexing. + diff --git a/docs/ARCHITECTURE/STATE_EVENTS.md b/docs/ARCHITECTURE/STATE_EVENTS.md new file mode 100644 index 0000000..bbdb10e --- /dev/null +++ b/docs/ARCHITECTURE/STATE_EVENTS.md @@ -0,0 +1,49 @@ +# State and Event Flow + +## State Management Model + +This codebase uses service-local reactive state (mostly `BehaviorSubject`) instead of a central state library. + +- Long-lived state: + app registry/open apps/focus, user profile, settings, file system, notifications. +- Component-local state: + UI toggles, view pagination, current selected items. +- Persistence: + settings/user/tasks/patches via `StorageService`. + +## Core Event Flows + +## App and Window Lifecycle + +1. Desktop requests open app via `ApplicationManagerService.openApplication(id)`. +2. Manager validates registry, memory, and instance limits. +3. `ApplicationFactory` creates window instance metadata. +4. `AppWindowComponent` dynamically creates embedded component. +5. Focus updates reorder open apps list and tray state. +6. Open app list is persisted to local storage key `applications`. + +## CLI Command Flow + +1. User enters command in CLI app. +2. `CliGameComponent` normalizes input and invokes `CLIService.executeInput`. +3. Command output is routed to `TypewriterService`. +4. Follow-up actions can mutate user/level state through `UserService` and `GameConfigService`. + +## User and Settings Persistence Flow + +1. `UserService` registers/reads `user` setting set. +2. Updates flow through `SettingsService.updateSettingSet`. +3. `SettingsService` persists through `StorageService` strategy. + +## Overlay and Notification Flow + +1. Any producer calls `OverlayService` or `NotificationService`. +2. Overlay and notification renderer components subscribe and update UI. +3. Notifications auto-dismiss on timeout unless duration is zero. + +## Hotspots for Cleanup + +- Unbounded subscriptions in services/components that do not use `takeUntilDestroyed`. +- Dynamic window/component lifecycle depends on mutable shared objects. +- App persistence uses raw object snapshots and should be narrowed to safe fields. + diff --git a/docs/FUTURE_FEATURES/ROADMAP.md b/docs/FUTURE_FEATURES/ROADMAP.md new file mode 100644 index 0000000..08d3ff5 --- /dev/null +++ b/docs/FUTURE_FEATURES/ROADMAP.md @@ -0,0 +1,51 @@ +# Roadmap + +## Short-Term (1-2 Sprints) + +- Re-enable trustworthy quality gates (lint, real test execution, stable build environment). +- Fix high-risk correctness and security bugs identified in audit. +- Add targeted tests around CLI parsing/auth, app manager lifecycle, and storage rehydration. + +Dependencies: + +- Supported Node runtime for reproducible builds. +- Stable lint config path. + +Risks: + +- Existing script/config drift may hide real code issues until gates are repaired. + +## Medium-Term (2-4 Sprints) + +- Service decomposition: + split `ApplicationManagerService` and simplify `SettingsService`. +- Strong typing pass: + remove high-impact `any` usage in service contracts and dynamic payloads. +- Performance cleanup: + reduce startup random generation and avoid avoidable subscription churn. + +Dependencies: + +- Baseline tests in key services/components. + +Risks: + +- Refactor can affect runtime sequencing in desktop/window lifecycle if done too broadly. + +## Long-Term (4+ Sprints) + +- Security hardening: + migrate secret-bearing API calls to backend proxy. +- Rendering safety: + remove risky `innerHTML` patterns in terminal/tooltip/notification systems. +- Product evolution: + richer app ecosystem, saved desktop sessions, advanced window tiling/layout presets. + +Dependencies: + +- Backend support for proxy endpoints and auth/rate-limiting. + +Risks: + +- UI behavior drift during renderer hardening unless covered by tests and visual checks. + diff --git a/docs/README/DEVELOPMENT.md b/docs/README/DEVELOPMENT.md new file mode 100644 index 0000000..0ba783e --- /dev/null +++ b/docs/README/DEVELOPMENT.md @@ -0,0 +1,65 @@ +# Development + +## Prerequisites + +- Node.js LTS (recommended: Node 20 or Node 22) +- npm 10+ +- Chrome/Chromium for Karma tests + +Current environment note: Node `23.11.1` is unsupported by Angular 19 and currently causes unstable build behavior in this repository. + +## Install + +```bash +npm ci +``` + +## Environment Setup (Local) + +1. Copy `src/environments/.env.example` to `src/environments/.env.local`. +2. Fill in your local values. +3. Keep `src/environments/.env.local` and `src/environments/environment.local.ts` uncommitted. + +## Run Locally + +```bash +npm start +``` + +## Build + +```bash +npm run build +``` + +## Test + +```bash +npm run test -- --watch=false --browsers=ChromeHeadless +``` + +## Lint + +```bash +npm run lint +``` + +## Current Quality Gate Status (Audit Baseline) + +- `npm ci`: not re-run in this pass (existing `node_modules` reused) +- `npm run lint`: runs and reports real issues (`372` current errors) +- `npm run test -- --watch=false --browsers=ChromeHeadless`: passing (`88/88`) +- `npm run build`: still aborts early under unsupported Node `23.11.1` + +## Recommended Local Tooling Alignment + +1. Use Node `22` (or any version matching `package.json#engines`). +2. Run `nvm use` (project now includes `.nvmrc`). +3. Continue reducing lint backlog from current baseline. + +## Troubleshooting + +- If test fails with port binding in sandboxed environments, run with elevated permissions or locally in non-sandbox shell. +- If Angular CLI prompts for analytics in CI/local automation, set: + - `NG_CLI_ANALYTICS=false` +- If build crashes with memory allocator errors, switch to supported LTS Node first before code-level debugging. diff --git a/docs/README/ENVIRONMENT_SECRETS.md b/docs/README/ENVIRONMENT_SECRETS.md new file mode 100644 index 0000000..df55a6f --- /dev/null +++ b/docs/README/ENVIRONMENT_SECRETS.md @@ -0,0 +1,61 @@ +# Environment and Secrets Setup (GitHub Actions) + +This project builds Angular environment files during CI from GitHub Actions settings, not from committed local secrets. + +## Required GitHub Variables + +Add these under: `Settings -> Secrets and variables -> Actions -> Variables` + +| Name | Description | Example Value | +| --- | --- | --- | +| `APP_TITLE` | App title shown in the UI. | `Colin Michaels - Production` | +| `APP_API_URL` | Backend API base URL used by the frontend. | `https://api.example.com` | +| `FIREBASE_API_KEY` | Firebase Web API key from Firebase project settings. | `example_firebase_web_api_key` | +| `FIREBASE_AUTH_DOMAIN` | Firebase Auth domain for the project. | `your-project.firebaseapp.com` | +| `FIREBASE_DATABASE_URL` | Firebase Realtime Database URL. | `https://your-project-default-rtdb.firebaseio.com/` | +| `FIREBASE_PROJECT_ID` | Firebase project id used by SDK and deploy. | `your-project` | +| `FIREBASE_STORAGE_BUCKET` | Firebase storage bucket host. | `your-project.firebasestorage.app` | +| `FIREBASE_MESSAGING_SENDER_ID` | Firebase messaging sender id. | `123456789012` | +| `FIREBASE_APP_ID` | Firebase web app id. | `1:123456789012:web:abcdef1234567890` | +| `FIREBASE_MEASUREMENT_ID` | GA4 measurement id for Firebase Analytics. | `G-ABCDEFGH12` | + +## Required GitHub Secrets + +Add these under: `Settings -> Secrets and variables -> Actions -> Secrets` + +| Name | Description | Example Value | +| --- | --- | --- | +| `OPENAI_API_KEY` | API key used by the AI chat service. | `example_openai_api_key_value` | +| `OPEN_WEATHER_MAP_API_KEY` | API key used by weather data calls. | `xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` | +| `FIREBASE_SERVICE_ACCOUNT_COLINMICHAELS` | Firebase service account JSON used by GitHub Action deploy. | `{"type":"service_account","project_id":"your-project",...}` | + +## Optional Compatibility Keys + +Workflows support fallbacks for legacy names: + +- `API_URL` (legacy fallback for `APP_API_URL`) +- `APP_TITLE`/`APP_API_URL` can be provided as either Variables or Secrets +- `FIREBASE_SERVICE_ACCOUNT` can be used as fallback for manual deploy workflow + +## Local Development Files + +- Keep local-only values in ignored files: + - `src/environments/environment.local.ts` + - `src/environments/.env.local` +- Do not commit those files. +- Use `src/environments/.env.example` as the safe template. + +## CI Environment Generation + +CI runs: + +```bash +npm run generate:env +``` + +This script writes: + +- `src/environments/environment.ts` +- `src/environments/environment.prod.ts` + +Both are generated from CI environment values before `npm run build`. diff --git a/docs/README/INDEX.md b/docs/README/INDEX.md new file mode 100644 index 0000000..39bf00d --- /dev/null +++ b/docs/README/INDEX.md @@ -0,0 +1,21 @@ +# Documentation Index + +This folder is the entry point for project documentation. + +## Read First + +- [Project Overview](./PROJECT_OVERVIEW.md) +- [Development Setup](./DEVELOPMENT.md) +- [Environment and Secrets Setup](./ENVIRONMENT_SECRETS.md) + +## Architecture + +- [Architecture Overview](../ARCHITECTURE/OVERVIEW.md) +- [Core Services](../ARCHITECTURE/SERVICES.md) +- [State and Event Flow](../ARCHITECTURE/STATE_EVENTS.md) +- [Security Notes](../ARCHITECTURE/SECURITY.md) + +## Planning + +- [Tech Debt TODOs](../TODOS/TECH_DEBT.md) +- [Future Roadmap](../FUTURE_FEATURES/ROADMAP.md) diff --git a/docs/README/PROJECT_OVERVIEW.md b/docs/README/PROJECT_OVERVIEW.md new file mode 100644 index 0000000..8701646 --- /dev/null +++ b/docs/README/PROJECT_OVERVIEW.md @@ -0,0 +1,45 @@ +# Project Overview + +## What This Project Is + +This project recreates a macOS-inspired desktop experience in the browser using Angular and Tailwind CSS. It mixes a portfolio-style landing experience with an interactive in-browser "OS" that includes movable windows, app launching, CLI-like gameplay, notifications, overlays, and media tools. + +## Core Experience + +- Desktop shell with app windows and focus management. +- App registry and launcher behavior (dock/system tray/menu). +- CLI-driven game flow with typed output and level progression. +- Settings, storage, and user profile persistence. +- Audio, media rendering, and visual overlay systems. + +## Tech Stack + +- Angular 19 standalone components +- TypeScript (strict mode enabled) +- Tailwind CSS 3 +- RxJS for reactive state/event streams +- AngularFire/Firebase for backend integration points +- FontAwesome and CDK helpers in UI components + +## High-Level Module Map + +- `src/app/components/main`: + public/portfolio-facing experience. +- `src/app/components/game`: + desktop simulation, apps, system UI, and most game services. +- `src/app/components/game/services`: + core runtime logic (app manager, CLI, settings, storage, media, sound, user). +- `src/app/services`: + shared Firebase/auth services. +- `src/app/guards`, `src/app/pipes`, `src/app/modules`: + route guards, pipe helpers, feature modules. + +## Suggested Reading Order + +1. `src/app/app.routes.ts` +2. `src/app/app.config.ts` +3. `src/app/components/game/desktop/desktop.component.ts` +4. `src/app/components/game/services/application-manager.service.ts` +5. `src/app/components/game/apps/cli-game/cli-game.component.ts` +6. `src/app/components/game/services/*` for subsystem behavior + diff --git a/docs/TODOS/TECH_DEBT.md b/docs/TODOS/TECH_DEBT.md new file mode 100644 index 0000000..afd3238 --- /dev/null +++ b/docs/TODOS/TECH_DEBT.md @@ -0,0 +1,100 @@ +# Tech Debt TODOs + +Status legend: + +- `[ ]` not started +- `[~]` in progress +- `[x]` complete + +## Quick Wins (Do First) + +- [x] Fix lint gate wiring (`npm run lint` currently non-functional). + - Impact: High + - Effort: S + - Validation: lint command runs and reports real issues. + +- [x] Fix test script include pattern so specs are discovered (currently 0 tests executed). + - Impact: High + - Effort: S + - Validation: test command executes existing specs. + +- [x] Fix `CLIService` auth bug in `su` command path. + - Impact: High + - Effort: S + - Validation: CLI command behavior tests/manual checks. + +- [x] Fix notification dismiss/click behavior to use notification `id` consistently. + - Impact: Medium + - Effort: S + - Validation: notification component spec/manual UX check. + +- [x] Add URL validation allowlist in redirect guard. + - Impact: High + - Effort: S + - Validation: guard unit tests for allowed and blocked URLs. + +- [x] Stabilize baseline unit tests to full pass (`88/88` in CI-like headless run). + - Impact: High + - Effort: M + - Validation: `npm run test -- --watch=false --browsers=ChromeHeadless`. + +## Medium Refactors + +- [~] Refactor `SettingsService` for typed models and safe subscription lifecycle. + - Impact: High + - Effort: M + - Validation: lint/test/build + settings UI regression check. + +- [x] Optimize `ScrollClassToggleDirective` scroll handling by batching with `requestAnimationFrame` and caching class lists. + - Impact: Medium + - Effort: S + - Validation: manual scroll regression across main page header transitions. + +- [x] Move `PatchEditorComponent` inline template styles into component stylesheet. + - Impact: Low + - Effort: S + - Validation: visual regression check for patch envelope controls. + +- [x] Align `StorageService` strategy behavior, including `getAllKeys`. + - Impact: Medium + - Effort: M + - Validation: storage-focused unit tests across strategy paths. + +- [~] Break `ApplicationManagerService` into smaller responsibilities (registry, persistence, lifecycle). + - Impact: High + - Effort: M + - Validation: app launch/focus/close regression tests. + +- [~] Stabilize `TypewriterService` timer and callback semantics. + - Impact: Medium + - Effort: M + - Validation: CLI typing flow checks and queue behavior tests. + +- [~] Reduce startup randomness/cost in `FileSystemService`. + - Impact: Medium + - Effort: M + - Validation: finder behavior and startup responsiveness. + +## Larger Changes (Riskier, Stage Later) + +- [ ] Move OpenAI and weather calls behind backend proxy/functions. + - Impact: High (security) + - Effort: L + - Validation: integration tests and production key removal. + +- [ ] Replace `innerHTML` rendering paths with safe renderers. + - Impact: High (security) + - Effort: L + - Validation: XSS regression tests + UI snapshot/manual checks. + +- [~] Enforce supported Node LTS through `.nvmrc`/`engines` and CI checks. + - Impact: Medium + - Effort: S + - Validation: consistent local/CI build success. + +## Suggested Execution Order + +1. Restore quality gates (lint/test/build reliability). +2. Patch clear correctness/security bugs. +3. Refactor high-impact services incrementally. +4. Address secret-handling and HTML-rendering hardening. diff --git a/package.json b/package.json index e029a66..45bc192 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,9 @@ "scripts": { "ng": "ng", "start": "ng serve", + "generate:env": "node scripts/generate-environment.mjs", "build": "ng build", + "build:prod": "ng build --configuration production", "build:css": "npx tailwindcss -o ./dist/output.css --minify", "watch": "ng build --watch --configuration development", "test": "ng test", diff --git a/scripts/generate-environment.mjs b/scripts/generate-environment.mjs new file mode 100644 index 0000000..c34a49f --- /dev/null +++ b/scripts/generate-environment.mjs @@ -0,0 +1,68 @@ +import {mkdirSync, writeFileSync} from 'node:fs'; +import {dirname, resolve} from 'node:path'; + +const readEnv = (name) => (process.env[name] ?? '').trim(); + +const appTitle = readEnv('APP_TITLE'); +const apiUrl = readEnv('APP_API_URL') || readEnv('API_URL'); +const openAiApiKey = readEnv('OPENAI_API_KEY'); +const openWeatherMapApiKey = readEnv('OPEN_WEATHER_MAP_API_KEY'); +const firebaseApiKey = readEnv('FIREBASE_API_KEY'); +const firebaseAuthDomain = readEnv('FIREBASE_AUTH_DOMAIN'); +const firebaseDatabaseUrl = readEnv('FIREBASE_DATABASE_URL'); +const firebaseProjectId = readEnv('FIREBASE_PROJECT_ID'); +const firebaseStorageBucket = readEnv('FIREBASE_STORAGE_BUCKET'); +const firebaseMessagingSenderId = readEnv('FIREBASE_MESSAGING_SENDER_ID'); +const firebaseAppId = readEnv('FIREBASE_APP_ID'); +const firebaseMeasurementId = readEnv('FIREBASE_MEASUREMENT_ID'); + +const missing = []; +if (!appTitle) missing.push('APP_TITLE'); +if (!apiUrl) missing.push('APP_API_URL (or API_URL)'); +if (!openAiApiKey) missing.push('OPENAI_API_KEY'); +if (!openWeatherMapApiKey) missing.push('OPEN_WEATHER_MAP_API_KEY'); +if (!firebaseApiKey) missing.push('FIREBASE_API_KEY'); +if (!firebaseAuthDomain) missing.push('FIREBASE_AUTH_DOMAIN'); +if (!firebaseDatabaseUrl) missing.push('FIREBASE_DATABASE_URL'); +if (!firebaseProjectId) missing.push('FIREBASE_PROJECT_ID'); +if (!firebaseStorageBucket) missing.push('FIREBASE_STORAGE_BUCKET'); +if (!firebaseMessagingSenderId) missing.push('FIREBASE_MESSAGING_SENDER_ID'); +if (!firebaseAppId) missing.push('FIREBASE_APP_ID'); +if (!firebaseMeasurementId) missing.push('FIREBASE_MEASUREMENT_ID'); + +if (missing.length > 0) { + console.error('Missing required environment variables:'); + missing.forEach((name) => console.error(`- ${name}`)); + process.exit(1); +} + +const environmentConfig = { + production: true, + title: appTitle, + apiUrl, + openAiApiKey, + openWeatherMapApiKey, + firebaseConfig: { + apiKey: firebaseApiKey, + authDomain: firebaseAuthDomain, + databaseURL: firebaseDatabaseUrl, + projectId: firebaseProjectId, + storageBucket: firebaseStorageBucket, + messagingSenderId: firebaseMessagingSenderId, + appId: firebaseAppId, + measurementId: firebaseMeasurementId + } +}; + +const fileContent = `export const environment = ${JSON.stringify(environmentConfig, null, 2)};\n`; +const outputFiles = [ + resolve('src/environments/environment.ts'), + resolve('src/environments/environment.prod.ts') +]; + +for (const filePath of outputFiles) { + mkdirSync(dirname(filePath), {recursive: true}); + writeFileSync(filePath, fileContent, 'utf8'); +} + +console.log(`Generated ${outputFiles.length} environment file(s).`); diff --git a/src/app/components/game/apps/music-apps/patch-editor/patch-editor.component.html b/src/app/components/game/apps/music-apps/patch-editor/patch-editor.component.html index 802ffca..a0dfdfa 100644 --- a/src/app/components/game/apps/music-apps/patch-editor/patch-editor.component.html +++ b/src/app/components/game/apps/music-apps/patch-editor/patch-editor.component.html @@ -39,21 +39,6 @@

🎹 Load Saved Patch

-
diff --git a/src/app/components/game/apps/music-apps/patch-editor/patch-editor.component.scss b/src/app/components/game/apps/music-apps/patch-editor/patch-editor.component.scss new file mode 100644 index 0000000..cb4bf56 --- /dev/null +++ b/src/app/components/game/apps/music-apps/patch-editor/patch-editor.component.scss @@ -0,0 +1,11 @@ +.env-container { + @apply grid grid-cols-4 gap-2; +} + +.env-container label { + @apply text-sm font-light; +} + +.env-container input { + @apply w-full text-xs bg-black text-white/90 py-1 px-2 rounded-lg; +} diff --git a/src/app/components/game/apps/music-apps/patch-editor/patch-editor.component.ts b/src/app/components/game/apps/music-apps/patch-editor/patch-editor.component.ts index 7a80be1..d29809b 100644 --- a/src/app/components/game/apps/music-apps/patch-editor/patch-editor.component.ts +++ b/src/app/components/game/apps/music-apps/patch-editor/patch-editor.component.ts @@ -19,7 +19,8 @@ import {TooltipDirective} from '../../../directives/tooltip.directive'; NgSwitch, NgSwitchCase ], - templateUrl: './patch-editor.component.html' + templateUrl: './patch-editor.component.html', + styleUrls: ['./patch-editor.component.scss'] }) export class PatchEditorComponent implements OnInit { defaultPatch: SynthPatch = { diff --git a/src/app/components/main/main.constants.ts b/src/app/components/main/main.constants.ts new file mode 100644 index 0000000..911cebc --- /dev/null +++ b/src/app/components/main/main.constants.ts @@ -0,0 +1,2 @@ +export const HOME_NOTIFY_CLASSES = 'bg-black/80 text-green-500 border-2 border-green-500'; + diff --git a/src/app/modules/scroll/directives/scroll-class-toggle.directive.ts b/src/app/modules/scroll/directives/scroll-class-toggle.directive.ts index 1f0d2e2..31f2720 100644 --- a/src/app/modules/scroll/directives/scroll-class-toggle.directive.ts +++ b/src/app/modules/scroll/directives/scroll-class-toggle.directive.ts @@ -7,6 +7,8 @@ import { AfterViewInit, } from '@angular/core'; +type ScrollDirection = 'left' | 'right' | 'top' | 'bottom'; + /** * A directive that applies or removes specified CSS classes to an element based on the user's scroll position * relative to a defined scroll threshold. Supports additional animation effects for entry and exit behavior. @@ -51,84 +53,154 @@ import { selector: '[appScrollClassToggle]', standalone: false }) -/** - * TODO: need to test more and fix - */ export class ScrollClassToggleDirective implements AfterViewInit { @Input() scrollThreshold = 100; - @Input() enterClasses = ''; // class names to add when the threshold passed - @Input() exitClasses = ''; // class names to add when below the threshold @Input() applyTransition = true; @Input() duration = 'duration-500'; - @Input() flyIn: 'left' | 'right' | 'top' | 'bottom' | null = null; - @Input() leaveTo: 'left' | 'right' | 'top' | 'bottom' | null = null; + + private _enterClasses = ''; + @Input() + set enterClasses(value: string) { + this._enterClasses = value ?? ''; + this.enterClassList = this.toClassList(this._enterClasses); + } + + get enterClasses(): string { + return this._enterClasses; + } + + private _exitClasses = ''; + @Input() + set exitClasses(value: string) { + this._exitClasses = value ?? ''; + this.exitClassList = this.toClassList(this._exitClasses); + } + + get exitClasses(): string { + return this._exitClasses; + } + + private _flyIn: ScrollDirection | null = null; + @Input() + set flyIn(value: ScrollDirection | null) { + this._flyIn = value; + this.flyInClassList = value ? this.toClassList(this.getFlyInPreset(value)) : []; + } + + get flyIn(): ScrollDirection | null { + return this._flyIn; + } + + private _leaveTo: ScrollDirection | null = null; + @Input() + set leaveTo(value: ScrollDirection | null) { + this._leaveTo = value; + this.leaveToClassList = value ? this.toClassList(this.getLeaveToPreset(value)) : []; + } + + get leaveTo(): ScrollDirection | null { + return this._leaveTo; + } private hasEntered = false; + private scrollTicking = false; + private enterClassList: string[] = []; + private exitClassList: string[] = []; + private flyInClassList: string[] = []; + private leaveToClassList: string[] = []; constructor( - private readonly el: ElementRef, + private readonly el: ElementRef, private readonly renderer: Renderer2) { } ngAfterViewInit(): void { - // Apply initial state - this.applyClasses(this.exitClasses); - if (this.flyIn) { - this.applyClasses(this.getFlyInPreset(this.flyIn)); - } // Ensure transition classes are present if (this.applyTransition) { this.addIfMissing(this.duration); this.addIfMissing('transition-all'); this.addIfMissing('ease-in-out'); } + + this.initializeState(); } @HostListener('window:scroll', []) - onWindowScroll() { - const scrollTop = window.pageYOffset || document.documentElement.scrollTop; - const passedThreshold = scrollTop > this.scrollThreshold; - - const enterClassList = this.enterClasses.split(' ').filter(c => c); - const exitClassList = this.exitClasses.split(' ').filter(c => c); - - if (passedThreshold && !this.hasEntered) { - // Add enter classes, remove exit - enterClassList.forEach(cls => this.renderer.addClass(this.el.nativeElement, cls)); - exitClassList.forEach(cls => this.renderer.removeClass(this.el.nativeElement, cls)); - if (this.flyIn) { - this.getFlyInPreset(this.flyIn).split(' ').forEach(cls => - this.renderer.removeClass(this.el.nativeElement, cls) - ); - } - this.hasEntered = true; - } else if (!passedThreshold && this.hasEntered) { - // Revert to exit classes - exitClassList.forEach(cls => this.renderer.addClass(this.el.nativeElement, cls)); - enterClassList.forEach(cls => this.renderer.removeClass(this.el.nativeElement, cls)); - if (this.leaveTo) { - this.getLeaveToPreset(this.leaveTo).split(' ').forEach(cls => - this.renderer.addClass(this.el.nativeElement, cls) - ); - } - this.hasEntered = false; + onWindowScroll(): void { + if (this.scrollTicking) { + return; } + + this.scrollTicking = true; + requestAnimationFrame(() => { + this.scrollTicking = false; + this.updateStateFromScroll(); + }); } - private addIfMissing(className: string) { + private addIfMissing(className: string): void { const el = this.el.nativeElement; if (!el.classList.contains(className)) { this.renderer.addClass(el, className); } } - private applyClasses(classString: string) { - classString.split(' ').filter(c => c).forEach(cls => - this.renderer.addClass(this.el.nativeElement, cls) - ); + private initializeState(): void { + if (this.isThresholdPassed()) { + this.applyEnteredState(); + this.hasEntered = true; + return; + } + + this.addClasses(this.exitClassList); + this.addClasses(this.flyInClassList); + } + + private updateStateFromScroll(): void { + const passedThreshold = this.isThresholdPassed(); + if (passedThreshold === this.hasEntered) { + return; + } + + if (passedThreshold) { + this.applyEnteredState(); + } else { + this.applyExitedState(); + } + this.hasEntered = passedThreshold; + } + + private applyEnteredState(): void { + this.addClasses(this.enterClassList); + this.removeClasses(this.exitClassList); + this.removeClasses(this.flyInClassList); + this.removeClasses(this.leaveToClassList); + } + + private applyExitedState(): void { + this.addClasses(this.exitClassList); + this.removeClasses(this.enterClassList); + this.addClasses(this.leaveToClassList); + } + + private addClasses(classes: string[]): void { + classes.forEach((cssClass) => this.renderer.addClass(this.el.nativeElement, cssClass)); + } + + private removeClasses(classes: string[]): void { + classes.forEach((cssClass) => this.renderer.removeClass(this.el.nativeElement, cssClass)); + } + + private toClassList(classString: string): string[] { + return classString.split(/\s+/).filter(Boolean); + } + + private isThresholdPassed(): boolean { + const scrollTop = window.pageYOffset || document.documentElement.scrollTop || 0; + return scrollTop > this.scrollThreshold; } - private getFlyInPreset(direction: string): string { + private getFlyInPreset(direction: ScrollDirection): string { switch (direction) { case 'left': return '-translate-x-full opacity-0'; @@ -143,7 +215,7 @@ export class ScrollClassToggleDirective implements AfterViewInit { } } - private getLeaveToPreset(direction: string): string { + private getLeaveToPreset(direction: ScrollDirection): string { return this.getFlyInPreset(direction); } } diff --git a/src/environments/.env.example b/src/environments/.env.example new file mode 100644 index 0000000..e54722e --- /dev/null +++ b/src/environments/.env.example @@ -0,0 +1,12 @@ +APP_TITLE=Colin Michaels - Production +APP_API_URL=https://api.example.com +OPENAI_API_KEY=example_openai_api_key_value +OPEN_WEATHER_MAP_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +FIREBASE_API_KEY=example_firebase_web_api_key +FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com +FIREBASE_DATABASE_URL=https://your-project-default-rtdb.firebaseio.com/ +FIREBASE_PROJECT_ID=your-project +FIREBASE_STORAGE_BUCKET=your-project.firebasestorage.app +FIREBASE_MESSAGING_SENDER_ID=123456789012 +FIREBASE_APP_ID=1:123456789012:web:abcdef1234567890 +FIREBASE_MEASUREMENT_ID=G-ABCDEFGH12